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/bulkGenerateVouchersviamintDashboardVoucher) - Cycle completion (
mobileRedeemReward, sourcecycle_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 theisChannelAllowedgate; marks used, decrements the counter, removes the Shopify code. - Post-checkout:
mobileMarkVoucherUsed/markVoucherUsedInternal— onlyactive → used(a plain "used" check would double-decrement the counter). - Webhook truth:
markVouchersUsedFromWebhook—orders/createpassesdiscount_codes; each known code is marked used withusedChannelfrom Shopify'ssource_name. A channel mismatch is logged, never blocked — it means the code bypassedprepareCheckout(e.g. typed into the web storefront).
Revocation and lifecycle
revokeVoucherdecrements the counter before patching status (the helper no-ops unless the doc still readsactive— the ordering is load-bearing), then removes the code.reissueVouchercancels the original (keeping its Shopify code for admin handling) and mints a copy inheritingissuedChannel.extendVoucherExpirycan reactivate a lazily-expired voucher (counter re-increment; the Shopify node lives ~10 years, soendsAtisn'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.