Admin Order Actions
A step-by-step trace of every admin order action — cancel, refund, retry, reset, resend-email, create, and the eSIM/recharge equivalents. The RESET-vs-RETRY distinction, the cancel→refund→inventory chain, and the inventory-leak gap.
The admin order actions are the operator's manual controls over a stuck or disputed order. This page traces each one to the DB write — most importantly the RESET vs RETRY distinction, which are opposite tools that both require a PENDING order.
Routes: admin.go:87-92 (voucher), :304-347 (eSIM/recharge), all writeMiddleware-gated. Handlers in admin_ui.go + admin_api_order.go (voucher), admin_esim_actions.go, admin_recharge_actions.go. See Order Lifecycle for the underlying engine and Admin Panel for the route inventory.
Two systemic gaps across all of these
- No inventory release. None of cancel/reset touch the
inventoriestable — allocated stock staysALLOCATEDforever (a leak), and reset makes it worse by hard-deleting theorder_itemsthe inventory points at, orphaning it beyond recovery. 2. No row-lock or idempotency key on cancel/refund — two concurrent submits that both readPENDINGcan double-credit the wallet before the status flips. Neither is guarded today.
RESET vs RETRY — the one to understand
Both require Status == PENDING. RETRY pushes the order forward through the existing pipeline; RESET tears its work down to a clean slate and walks away (no re-dispatch — the cron or a follow-up Retry rebuilds it).
| Aspect | RETRY (AdminRetryOrderHandler) | RESET (AdminResetOrderHandler) |
|---|---|---|
| Intent | Nudge another attempt | Clear partial state, start over |
status | Recomputed by delivery (may become DELIVERED / PARTIAL / stay PENDING) | Forced → PENDING |
sub_status | Advances via checkpoints | Forced → INITIAL |
retry_count | +1 if still pending | → 0 |
retry_after | now + backoff (1/2/4/8/16 min) | → NULL |
delivered_quantity | unchanged | → 0 |
order_items | reused; created only if missing (idempotent) | HARD-DELETED |
order_item_responses (vendor logs) | appended | HARD-DELETED |
item vendor_order_id | preserved (idempotency) | gone with the items |
| checkpoints | resume where they left off | rebuilt fresh next attempt |
| vendor API calls | yes, synchronously in the handler | none — awaits cron/manual retry |
| wallet / webhooks | delivery-side only | none |
| use when | order is mid-flight and just needs a poke; vendor order exists and you want to recover/advance it | order is corrupted / wrong vendor binding / bad partial items and must be rebuilt |
Typical operator flow: Reset a broken order, then Retry it (or let the cron pick it up) to rebuild cleanly.
Cancel — POST /orders/:orderID/cancel
admin_ui.go:2668. Guard: status == PENDING only (FAILED/DELIVERED can't be cancelled here).
Single tx: order → CANCELLED/REFUNDED + RefundedAmount = order.Amount (full); all items → CANCELLED/REFUNDED; best-effort wallet credit (UpdateWalletBalance Credit → ledger row); voucher_links → CANCELLED if IsLink; commit. Async wallet.credited + order.cancelled webhooks. No email, no inventory release.
Cancel flags REFUNDED even if the credit fails
Step 2 sets sub_status=REFUNDED + RefundedAmount=full unconditionally, but the wallet credit (step 4) is best-effort (logged, not rolled back). If the credit fails or WalletID is invalid, the order still shows REFUNDED — which then blocks the Refund endpoint (its guard is sub_status != Refunded). Result: customer never gets money and the admin can't retry via Refund. Also: no row-lock → concurrent cancels can double-credit.
Refund — POST /orders/:orderID/refund
admin_ui.go:2922. Guards: status ∈ {CANCELLED, FAILED} AND sub_status != Refunded AND Amount > 0 AND valid WalletID.
Single tx: refund = Amount − RefundedAmount (delta; ≤0 → 400); wallet credit ledger row (hard-fails the request on error, unlike cancel); order sub_status=Refunded, RefundedAmount = refundAmount (overwrite with the delta, not cumulative); items → sub_status=Refunded; commit. Async wallet.credited webhook only.
The sub_status=Refunded guard is the primary double-refund protection. Storing the delta as RefundedAmount is only safe because that guard prevents re-entry — a latent bug if the guard is ever relaxed.
Retry — POST /orders/:orderID/retry
admin_ui.go:3150. Guard: status == PENDING. No wrapping tx (sub-steps manage their own).
Resolves the best-discount vendor product, then calls ProcessOrderRetry → processOrder — the same pipeline as initial creation. Checkpoints are idempotent and resumable (they advance, never clear):
- C1 (auth/availability) skips items already
VendorOrderPendingor with avendor_order_id. - C2 (create vendor order) skips items that already have a
vendor_order_id; onretry_count>0with reference support, callscheckExistingVendorOrderto recover an already-created order instead of duplicating; pre-persists the reference code beforeCreateOrder. - C3 (assign vouchers) only fetches for items still missing a code.
ErrShouldReCreateclearsvendor_order_id(budget 5);ErrVendorOrderTerminal→ items FAILED.
If still PENDING after processing → ScheduleOrderRetry (+1 retry_count, retry_after = now + backoff 1/2/4/8/16 min). Vendor API calls are logged to order_item_responses via logVendorAPICallBulk, which commits on its own nil-tx so the logs survive a rollback — the exact rows Reset then deletes.
Reset — POST /orders/:orderID/reset
admin_ui.go:3248. Guard: status == PENDING.
Single tx: hard DELETE FROM order_item_responses (all items), hard DELETE FROM order_items, then ResetOrderForRetry — one UPDATE setting status=PENDING, sub_status=INITIAL, retry_count=0, retry_after=NULL, delivered_quantity=0, remarks. Commit. No webhook, email, metric, wallet touch, or re-dispatch — the order waits for the cron or a manual Retry to rebuild items from scratch.
Reset is destructive and mislabeled
The handler comments say "soft-delete" but both repo calls issue a real DELETE (no deleted_at) — irreversible, no confirmation, no undo. It wipes the order_item_responses vendor-debug logs that the retry path deliberately preserves. And it orphans inventory: any row already ALLOCATED to a deleted item keeps status='ALLOCATED' with order_item_id pointing at a now-nonexistent row — permanently removed from the AVAILABLE pool. Treat Reset as a last resort.
Resend email — POST /orders/:id/resend-email
admin_api_order.go:16. Guards: valid non-empty order.Email AND status == DELIVERED. No DB writes — fires sendOrderDeliveryEmail async and returns 200 immediately ("initiated"), so it reports success regardless of the actual SMTP outcome (unlike the synchronous eSIM/recharge resends). No idempotency guard — resend is unlimited. (A near-duplicate Handler.ResendOrderEmail exists at create_voucher_order.go:3726 but isn't the routed one.)
Create — POST /orders/create
admin_ui.go:4699. Guards: ClientID, ProductID, Denomination>0, Quantity>0 required. Sets the tenant context to ClientID, rewrites the body, and delegates to the standard CreateVoucherOrder pipeline — i.e. it debits the client's wallet and runs the full purchase on their behalf, with no admin-specific confirmation. The delegated Handler is built with only Repository+Cache (:4763), so verify nil-safety of other deps (mail, vendor factory) for admin-created orders.
The cancel → refund → inventory chain
Inventory lifecycle: allocation flips a row to status='ALLOCATED' + order_item_id. The AVAILABLE pool query is strictly status='AVAILABLE' AND client_id IS NULL AND deleted_at IS NULL — and nothing flips ALLOCATED back. Compare G2AReturnInventoryHandler, which does return inventory; that logic is never invoked by admin cancel/reset. See Wallet & Ledger for the credit-ledger mechanics.
eSIM equivalents (admin_esim_actions.go)
| Action | Guard | Effect |
|---|---|---|
| Cancel | PENDING | tx: CANCELLED/REFUNDED, full credit ledger; commit; ClearEsimOrderRetryAfter; EsimNotifier.Notify (email + webhook) |
| Refund | ∈{CANCELLED,FAILED} & sub!=REFUNDED | tx: sub=REFUNDED, full credit; commit. No notifier |
| Retry | PENDING | services.DispatchEsimOrder (same as cron), synchronous |
| Reset | PENDING | ResetEsimOrder: PENDING/INITIAL, NULL vendor_order_id/activation_code/iccid/failure_*, retry cleared. No re-dispatch |
| Force-deliver | PENDING + activation_code+iccid in body | encrypts + stores them, DELIVERED/VENDOR_CODE_FETCHED; manual delivery when vendor/webhook failed |
Key contrast: eSIM Reset clears vendor fields via a NULL UPDATE on the single order row (no child items to delete), so it's non-destructive of logs — unlike voucher Reset. And eSIM has a force-deliver; voucher has no equivalent manual-delivery action.
Recharge equivalents (admin_recharge_actions.go)
Recharges have no sub_status column, so the "already refunded" flag rides on a [REFUNDED] prefix in status_text (rechargeIsRefunded):
| Action | Guard | Effect |
|---|---|---|
| Cancel | PENDING | tx: CANCELLED, status_text="[REFUNDED] …", full credit; ClearRechargeRetryAfter; RechargeNotifier.Notify |
| Refund | ∈{CANCELLED,FAILED} & !rechargeIsRefunded | tx: status_text="[REFUNDED] …", full credit |
| Retry | PENDING | ProcessTopupRechargeRetry (same as cron) |
| Reset | PENDING | ResetRecharge: PENDING, NULL vendor/failure fields, retry cleared. No re-dispatch |
| Resend | DELIVERED + email | synchronous — surfaces SMTP failure |
The recharge [REFUNDED] marker is a strings.HasPrefix on free-text status_text — any later write that doesn't preserve the prefix silently re-opens the double-refund window. Weaker than voucher/eSIM's column-based sub_status guards. See Top-up & eSIM.
Findings
| Severity | Finding | Where |
|---|---|---|
| High | No inventory release on cancel/reset → stock leak; reset orphans it by hard-deleting the referencing items | admin_ui.go:2668/3248, inventory.go:1485 |
| High | Cancel flags REFUNDED even when the wallet credit fails → un-refunded-but-flagged orders block the Refund endpoint | admin_ui.go:2745 vs :2825 |
| Medium | No row-lock/idempotency on cancel & refund → concurrent double-credit | admin_ui.go |
| Medium | Reset mislabeled "soft-delete" — it's a hard DELETE of items + vendor logs, no undo | order_item.go:879, order_item_response.go:217 |
| Medium | Recharge double-refund guard is a status_text string prefix (fragile) | admin_recharge_actions.go:27 |
| Low | Voucher resend-email is fire-and-forget, reports success regardless of SMTP | admin_api_order.go:63 |
| Low | Admin create-order runs the wallet-debiting path via a minimal Handler{Repository,Cache} — verify nil-safety | admin_ui.go:4763 |
| Low | Voucher has no force-deliver equivalent to eSIM | — |
Key files
- Voucher:
http/handler/admin_ui.go(cancel:2668, refund:2922, retry:3150, reset:3248, create:4699),admin_api_order.go(resend:16) - Engine:
create_voucher_order.go(processOrder,ProcessOrderRetry,ScheduleOrderRetry),bulk_vendor_order_checkpoints.go,individual_vendor_order_checkpoints.go - Repo:
order.go(ResetOrderForRetry:746),order_item.go(DeleteOrderItemsByOrderID:868),order_item_response.go(Delete…:206),inventory.go - eSIM/recharge:
admin_esim_actions.go,admin_recharge_actions.go
Admin Panel
The internal /admin operations console — server-rendered Jet templates, the three-tier RBAC (viewer/admin/super_admin) plus view-only mode, and a feature-by-feature inventory of every admin control from clients and catalog to orders, payouts, and vendor credentials.
Infrastructure & Deployment
The servers, systemd services, nginx topology, Docker data plane, deploy/update scripts, and the PROD vs SANDBOX split.