ProjectR Docs
Backend (Convex)

Vouchers

Templates, issuance, the Shopify campaign-node model, and revocation.

Two layers

  • voucherTemplates — the discount definition with Shopify-parity fields (targeting, eligibility, min requirement, usage limits, BXGY, combinesWith, allowedChannels, codePrefix, validityDays).
  • vouchers — the per-user instance (code, status, expiresAt, source, channel, revocation, batch fields).

The campaign-node model

One Shopify discount node exists per template, created by ensureCampaignDiscountNode with a deterministic never-distributed seed code LOYALTY-SEED-<templateId-last10> that doubles as the idempotency key. Node-level settings are deliberately open (all customers, no usage limit) because ownership, single-use, and expiry are enforced app-side — the voucher's in-app code is the checkout code, and it is removed from the node the moment the voucher is used, expires, or is cancelled.

Attachment uses discountRedeemCodeBulkAdd, which is asynchronous. bulkAdd can return empty userErrors even when a code is globally duplicated on another node — the read-back poll against codeDiscountNodeByCode exists only on the consolidation/migration path, so on the normal mint path a global duplicate surfaces only via Shopify's checkout-side validation.

Mint paths

Every path inserts the voucher, increments the activeVouchers:<userId> sharded counter, and enqueues createShopifyDiscountForVoucher on shopifySyncPool (maxParallelism 3 — sized down after Shopify's concurrent-request limit tripped):

  • Admin issue (issueVoucherToUser / bulkGenerateVouchers via mintDashboardVoucher)
  • Cycle completion (mobileRedeemReward, source cycle_completion)
  • Stamp reward (redeemStampReward) and shake win (source: "shake_reward")
  • Duit Raya claim (source duit_raya, code = the pre-assigned pool code)

Refusals (template missing/inactive, code collision) are structured failures ({ success: false, message }) returned to the caller, not throws — only a malformed id fails Convex argument validation.

Redemption

  • User-initiated: redeemVoucher / redeemVoucherByCode — auth, ownership, status, lazy expiry, and the isChannelAllowed gate; marks used, decrements the counter, removes the Shopify code.
  • Post-checkout: mobileMarkVoucherUsed / markVoucherUsedInternal — only active → used (a plain "used" check would double-decrement the counter).
  • Webhook truth: markVouchersUsedFromWebhook — orders/create passes discount_codes; each known code is marked used with usedChannel from Shopify's source_name. A channel mismatch is logged, never blocked — it means the code bypassed prepareCheckout (e.g. typed into the web storefront).

Revocation and lifecycle

  • revokeVoucher decrements the counter before patching status (the helper no-ops unless the doc still reads active — the ordering is load-bearing), then removes the code.
  • reissueVoucher cancels the original (keeping its Shopify code for admin handling) and mints a copy inheriting issuedChannel.
  • extendVoucherExpiry can reactivate a lazily-expired voucher (counter re-increment; the Shopify node lives ~10 years, so endsAt isn't updated).
  • Template delete is refused once any voucher was issued; a voucherType class change can't update the node in place — the link is cleared and the next voucher mints a fresh node.

The one delete you must never do

Never call deleteShopifyDiscount with a campaign-node GID — it deletes the whole node and every user's codes with it. Revocation removes only the redeem-code under the node.

On this page