Order Lifecycle
The end-to-end voucher order engine — single vs bulk, single- vs multi-vendor, sync vs async, the checkpoint state machine, retries, the recreate protocol, money, and webhooks.
How an order goes from request to delivered code. Topup/eSIM parallels are noted inline. This ties together Products & Catalog, the vendor adapters, and the cron jobs.
The one fact to hold onto
processOrder (http/handler/create_voucher_order.go:3347) is the whole engine. The synchronous create path calls it; the retry cron calls it (via ProcessOrderRetry). There is no separate "async worker" — async orders are just orders the create-path skips, leaving the every-minute cron to run the exact same processOrder. Every sub-step is idempotent/resumable.
Creation entrypoints
Every path funnels into the same CreateVoucherOrder handler / processOrder engine:
| Source | Entry point | Notes |
|---|---|---|
| Client API | POST /api/v1/orders → CreateVoucherOrder | JWT + IP whitelist |
| Client portal | POST /client/api/orders (perm create_orders) | cookie auth |
| Cart checkout | ClientCartCheckoutHandler → createSingleOrder | source="client_portal" |
| Admin | POST /admin/orders/create → marshals a request, calls the same handler | no separate logic |
| Shopify | ingest job → CreateVoucherOrderForJob | source="API" |
| G2A | G2ACreateOrderHandler → CreateVoucherOrderForJob per item | idempotent on client_reference |
Request (CreateVoucherOrderRequest): product_id, denomination (>0, ≤1e9), quantity (1..10000, vs client.BulkLimit), optional wallet_id, client_reference (≤255), email.
There is no delivery mode on the request. DeliveryMode/DeliveryType are catalog attributes. What actually branches delivery is the link-order flag — see Delivery. Also: the SHOPIFY/DASHBOARD OrderSource constants are defined but never assigned — every programmatic caller funnels through CreateVoucherOrder and persists source="API", so provenance can't be told apart by orders.source today.
The high-level flow
Single vs bulk (quantity)
One Order fans out into N order_items (one per unit) via GenerateOrderItems, which is idempotent — it counts existing items and only creates the difference, so retries never duplicate. Delivery accounting: delivered_quantity is set on the order; PARTIALLY_DELIVERED is only reached when an admin has explicitly cancelled the remaining items while some are delivered — plain "some pending, some delivered" stays PENDING so the cron keeps working (transient vendor errors must not terminalize the order). Topup/eSIM are single-unit resources (recharges/esim_orders) and don't fan out.
Single-vendor vs multi-vendor & inventory allocation
At creation every item is stamped with the single best vendor (FindBestDiscount). But before hitting external vendors, inventory allocation can re-vendor items across many vendors:
So an order can be filled from inventory (multiple vendors) + one-or-more bulk vendors + one-or-more individual vendors, all concurrently — each vendor group an independent checkpointed state machine. Allowed vendors = active minus the client's vendor blacklist.
Sync vs async
Two orthogonal "async" concepts:
- Order-level async (throughput gate), decided at creation:
isAsync = quantity > 5 && !isLink. If async → return the PENDING order immediately and let the cron process it. If sync → runprocessOrderinline. - Vendor-level async (
vendor.IsAsync): whether the vendor returns codes on CreateOrder (sync) or only avendor_order_idto poll later (async). The branch that matters is whetherorderResp.Data.Vouchersis populated.
The state machine
Order-level (orders.status × orders.sub_status) is a thin projection of the item states. The item-level 3-checkpoint machine (bulk: bulk_vendor_order_checkpoints.go; individual: individual_vendor_order_checkpoints.go) is the real workhorse — each checkpoint is idempotent and commits in its own tx, so a crash/retry resumes exactly where it stopped:
Order sub-statuses: INITIAL, PROCESSING, PARTIAL, COMPLETED, REFUNDED, VENDOR_ORDER_PENDING/CREATED/FAILED, VENDOR_VOUCHER_FETCHED, GV_LINK_PENDING/CLAIMED/UNCLAIMED, BULK_LIMIT_EXCEEDED. Note the auto-pipeline never self-fails an order — failures hold at PENDING; FAILED/CANCELLED order states are reached only via admin actions or explicit vendor-terminal marks.
The retry engine
Cron pending-order-retry — every minute, batch 50, MaxRetryCount = 10 (the inline "Max 5" comment is stale). Selects status=PENDING AND retry_after <= now() AND retry_count <= 10. ProcessOrderRetry is a one-line wrapper over processOrder, which safely resumes a half-finished order because every step is idempotent.
Backoff (ScheduleOrderRetry): retry_count++, backoff = 1 << (n-1) minutes capped at 16 → 1, 2, 4, 8, 16, 16, …. An order leaves the cron by becoming non-PENDING or exceeding retry_count > 10.
Prefetch is a separate system. The live-order flow above consumes inventory that the prefetch state machine produces into the Valkey pools. An order never runs the prefetch machine.
The recreate protocol
When a vendor's record of an accepted vendor_order_id will never deliver (terminal status, 404 on a known id, idempotency-cache eviction), the adapter signals ErrShouldReCreate (error channel) or ShouldReCreate=true (response flag). Handling: clear the item's vendor_order_id (magic "NULL") and vendor_reference_code (forces a fresh code, escaping the vendor's idempotency cache), commit, return a retryable error. The order stays PENDING — never auto-FAILED. Next cron pass, Checkpoint 2 sees a null id and runs a fresh CREATE.
Bounded by recreateBudget = 5: once retry_count >= 5, recreate stops and the order is parked at PENDING for an admin (POST /admin/orders/:id/retry). A 404 while polling a known id is promoted to ErrShouldReCreate; a not-found while looking up by reference before an id exists stays ErrOrderNotFound (= "proceed to CREATE").
Delivery: code/pin vs claim link
Chosen by isLinkOrder at creation (needs FEATURE_VOUCHER_LINKS_ENABLED and vendor.IsLinkEnabled and product.IsLinkEnabled). An order has EITHER items OR links, never both.
- Normal voucher: code / code+PIN / claim_url stored on
order_items, every field encrypted (EncryptVoucherData,is_encrypted=true), with SHA-3 hashes for lookup + a checksum + an auditInwardLog; item →DELIVERED/COMPLETED. Read paths decrypt viaDecryptVoucherData. - Claim links (GV links):
createVoucherLinksmints a UUID token, encrypted token, SHA-256token_hash, optional 6-digit PIN, 1-year expiry,Status=UNCLAIMED, invoucher_links. Public URL{VOUCHER_BASE_URL}/vouchers/claim/{token_hash}. Link lifecycle:UNCLAIMED → CLAIMED(both count as delivered).
Money
- Debited at create time, not delivery — money leaves up-front, refunded if fulfilment fails.
UpdateWalletBalanceis a Postgres stored procedure that atomically writes the wallet balance +ledgers+transactions(Go never touches those tables directly). Balance checked pre-debit (ErrInsufficientBalance). - Charges:
amount = round(face × qty × (1 − discount/100) × rate, 2)— discount before FX, single round at the wallet boundary, conversion fee additive (currently 0). See pricing. - Refunds are CREDIT transactions (
systemRefundUserID=1marks auto-refunds). eSIM auto-refunds on webhook-FAILED inside the locked tx (a failed credit rolls back the status change). - Prepaid ("burn a voucher"): funded by redeeming a previously-delivered voucher (validated by SHA-3 hash, must be terminal-delivered + not already redeemed) rather than a wallet debit.
ClaimOrderItemForRedemptionatomically stampsredeem_resource_id; the race-loser getsErrVoucherAlreadyRedeemed. Refunding a prepaid order releases the voucher for re-redemption instead of crediting a wallet.
Idempotency (three mechanisms)
client_reference— client-supplied write dedupe, backed by a unique partial index per(client_id, client_reference). A pre-insert check returns a clean 400 "Duplicate client_reference" (rejection, not silent replay). The server-mintedreference_codestays internal.- Inbound webhook
IdempotencyKey— stored onvendor_webhook_log; a duplicate returnsalready_processed(200). Key = normalizer output (Svixsvix-idoverrides for Runa) orvendorOrderID:merchantOrderID:status. ⚠️ The eSIM path logs every attempt with no idempotency key — it relies solely on a terminal-state row lock, so a non-terminal duplicate eSIM webhook could be processed twice. - Outbound webhook
event_id(evt_…) — one per event, shared across all client URLs, stable across retries; the client's idempotency key (alsoX-Event-ID).
Inbound vendor webhooks
JWT-less routes, authenticated by signature or path-token:
| Route | Handler | Auth |
|---|---|---|
POST /webhooks/vendor/:code | voucher vendors | verifier (Svix/HMAC/Noop) |
POST /webhooks/orders/:code · /direct-topups/:code | topup | per-vendor VerifyWebhookSignature (if header present) |
POST /webhooks/esims/:code/:token | eSIM | URL path-token (constant-time compare, 401 on mismatch) |
VendorWebhookService.ProcessWebhook routes on resource_type: order/recharge → find the item, and if still PENDING, set retry_after=now so the next cron tick delivers it. Webhooks never deliver directly — they just poke the retry engine. Verifier selection: SvixWebhookVerifier for Runa (with webhook_secret); HMACWebhookVerifier (X-Signature = HMAC-SHA256(body, secret)) for any vendor with webhook_secret; NoopWebhookVerifier otherwise. Every webhook returns 200 even on processing failure, to stop vendor retries.
Terminal-only notifications
Customer-facing webhooks/emails fire only at a terminal (status, sub_status) pair — enforced in the notifiers, which callers may invoke unconditionally after any status write. For vouchers only three pairs pass classifyOrderTransition: DELIVERED+Completed, PARTIALLY_DELIVERED+Partial, CANCELLED+Refunded. Everything else is a silent no-op. Resend re-sends email only — webhooks are never replayed. The eSIM activation code is never sent in a webhook (email only). See the Memory Appendix.
Outbound client webhooks
The webhook-delivery cron (every minute, batch 50) processes webhook_deliveries queued by TriggerOrderEvent/TriggerWalletEvent/TriggerTopupEvent/TriggerEsimEvent (one row per subscribed client URL, MaxAttempts=5). It reaps rows stuck in processing >3 min, claims pending rows under FOR UPDATE SKIP LOCKED (incrementing attempt_count at claim → no double-send), and fans out 10 concurrent. Retry backoff 1, 2, 4, 8, 16 min then markFailed. SendWebhook signs with X-Signature = HMAC-SHA256(payload, token) (token shown in the client panel, never transmitted) plus X-Event-ID/X-Event-Type/X-Timestamp. Event types: order.*, wallet.*, topup.*, esim.* (payouts use a separate system).
VendorManager
services/external_vendors/manager.go abstracts each adapter behind CreateVoucherOrder / GetVoucherOrderStatus / GetVouchers / CancelVoucherOrder, resolving the vendor + attributes, checking IsActive, building the adapter via the factory, and caching the instance. Auth + ProductAvailability are used directly by Checkpoint 1. See the per-vendor pages for how each maps the uniform CreateOrderRequest/FormattedOrderResponse/Voucher contract.
Topup & eSIM parallels
Analogous engines: services/topup_order.go + topup_order_retry_job.go (state on recharges), services/esim_order.go + esim_order_retry_job.go + esim_installation_poll_job.go (state on esim_orders, plus install/activation lifecycle). Same debit-at-create, refund-on-failure, client_reference idempotency, and terminal-only notifications.
Client Configuration
Every field on the client (tenant) record and what it actually controls, plus the per-client config tables — discounts, payout limits, IP whitelist, portal users, blacklists — and the create-client flow. Includes the dead fields and default mismatches.
Top-up & eSIM Flows
The two single-unit order resources alongside the voucher engine — recharges and esim_orders. Their sync/hybrid-sync flows, the recreate-budget recovery model, the delivered=PROCESSING nudge, retry crons, and where they diverge from vouchers.