OctoWiki

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:

SourceEntry pointNotes
Client APIPOST /api/v1/ordersCreateVoucherOrderJWT + IP whitelist
Client portalPOST /client/api/orders (perm create_orders)cookie auth
Cart checkoutClientCartCheckoutHandlercreateSingleOrdersource="client_portal"
AdminPOST /admin/orders/create → marshals a request, calls the same handlerno separate logic
Shopifyingest job → CreateVoucherOrderForJobsource="API"
G2AG2ACreateOrderHandlerCreateVoucherOrderForJob per itemidempotent 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 → run processOrder inline.
  • Vendor-level async (vendor.IsAsync): whether the vendor returns codes on CreateOrder (sync) or only a vendor_order_id to poll later (async). The branch that matters is whether orderResp.Data.Vouchers is 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").

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 audit InwardLog; item → DELIVERED/COMPLETED. Read paths decrypt via DecryptVoucherData.
  • Claim links (GV links): createVoucherLinks mints a UUID token, encrypted token, SHA-256 token_hash, optional 6-digit PIN, 1-year expiry, Status=UNCLAIMED, in voucher_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. UpdateWalletBalance is 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=1 marks 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. ClaimOrderItemForRedemption atomically stamps redeem_resource_id; the race-loser gets ErrVoucherAlreadyRedeemed. Refunding a prepaid order releases the voucher for re-redemption instead of crediting a wallet.

Idempotency (three mechanisms)

  1. 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-minted reference_code stays internal.
  2. Inbound webhook IdempotencyKey — stored on vendor_webhook_log; a duplicate returns already_processed (200). Key = normalizer output (Svix svix-id overrides for Runa) or vendorOrderID: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.
  3. Outbound webhook event_id (evt_…) — one per event, shared across all client URLs, stable across retries; the client's idempotency key (also X-Event-ID).

Inbound vendor webhooks

JWT-less routes, authenticated by signature or path-token:

RouteHandlerAuth
POST /webhooks/vendor/:codevoucher vendorsverifier (Svix/HMAC/Noop)
POST /webhooks/orders/:code · /direct-topups/:codetopupper-vendor VerifyWebhookSignature (if header present)
POST /webhooks/esims/:code/:tokeneSIMURL 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.

On this page