OctoWiki

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

  1. No inventory release. None of cancel/reset touch the inventories table — allocated stock stays ALLOCATED forever (a leak), and reset makes it worse by hard-deleting the order_items the inventory points at, orphaning it beyond recovery. 2. No row-lock or idempotency key on cancel/refund — two concurrent submits that both read PENDING can 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).

AspectRETRY (AdminRetryOrderHandler)RESET (AdminResetOrderHandler)
IntentNudge another attemptClear partial state, start over
statusRecomputed by delivery (may become DELIVERED / PARTIAL / stay PENDING)Forced → PENDING
sub_statusAdvances via checkpointsForced → INITIAL
retry_count+1 if still pending→ 0
retry_afternow + backoff (1/2/4/8/16 min)→ NULL
delivered_quantityunchanged→ 0
order_itemsreused; created only if missing (idempotent)HARD-DELETED
order_item_responses (vendor logs)appendedHARD-DELETED
item vendor_order_idpreserved (idempotency)gone with the items
checkpointsresume where they left offrebuilt fresh next attempt
vendor API callsyes, synchronously in the handlernone — awaits cron/manual retry
wallet / webhooksdelivery-side onlynone
use whenorder is mid-flight and just needs a poke; vendor order exists and you want to recover/advance itorder 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 ProcessOrderRetryprocessOrder — the same pipeline as initial creation. Checkpoints are idempotent and resumable (they advance, never clear):

  • C1 (auth/availability) skips items already VendorOrderPending or with a vendor_order_id.
  • C2 (create vendor order) skips items that already have a vendor_order_id; on retry_count>0 with reference support, calls checkExistingVendorOrder to recover an already-created order instead of duplicating; pre-persists the reference code before CreateOrder.
  • C3 (assign vouchers) only fetches for items still missing a code. ErrShouldReCreate clears vendor_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)

ActionGuardEffect
CancelPENDINGtx: CANCELLED/REFUNDED, full credit ledger; commit; ClearEsimOrderRetryAfter; EsimNotifier.Notify (email + webhook)
Refund∈{CANCELLED,FAILED} & sub!=REFUNDEDtx: sub=REFUNDED, full credit; commit. No notifier
RetryPENDINGservices.DispatchEsimOrder (same as cron), synchronous
ResetPENDINGResetEsimOrder: PENDING/INITIAL, NULL vendor_order_id/activation_code/iccid/failure_*, retry cleared. No re-dispatch
Force-deliverPENDING + activation_code+iccid in bodyencrypts + 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):

ActionGuardEffect
CancelPENDINGtx: CANCELLED, status_text="[REFUNDED] …", full credit; ClearRechargeRetryAfter; RechargeNotifier.Notify
Refund∈{CANCELLED,FAILED} & !rechargeIsRefundedtx: status_text="[REFUNDED] …", full credit
RetryPENDINGProcessTopupRechargeRetry (same as cron)
ResetPENDINGResetRecharge: PENDING, NULL vendor/failure fields, retry cleared. No re-dispatch
ResendDELIVERED + emailsynchronous — surfaces SMTP failure

The recharge [REFUNDED] marker is a strings.HasPrefix on free-text status_textany 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

SeverityFindingWhere
HighNo inventory release on cancel/reset → stock leak; reset orphans it by hard-deleting the referencing itemsadmin_ui.go:2668/3248, inventory.go:1485
HighCancel flags REFUNDED even when the wallet credit fails → un-refunded-but-flagged orders block the Refund endpointadmin_ui.go:2745 vs :2825
MediumNo row-lock/idempotency on cancel & refund → concurrent double-creditadmin_ui.go
MediumReset mislabeled "soft-delete" — it's a hard DELETE of items + vendor logs, no undoorder_item.go:879, order_item_response.go:217
MediumRecharge double-refund guard is a status_text string prefix (fragile)admin_recharge_actions.go:27
LowVoucher resend-email is fire-and-forget, reports success regardless of SMTPadmin_api_order.go:63
LowAdmin create-order runs the wallet-debiting path via a minimal Handler{Repository,Cache} — verify nil-safetyadmin_ui.go:4763
LowVoucher 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

On this page