When a concept campaign is cancelled, all Shopify orders containing the concept's SKU must be refunded. The system bulk-processes these through a chain of Oban workers, each responsible for a single Shopify API call. Idempotency is enforced via timestamp fields on concept_pre_order_line_items — if a step's timestamp is already set, the worker exits early.
flowchart TD
%% ── Entry ──────────────────────────────────────────────
API["POST /api/concept-pre-order/refund-concept-pre-order"]
API --> TX["Controller Transaction"]
subgraph TX["Ecto.Multi — Controller Transaction"]
LOCK["Lock concept_pre_order"]
LOCK --> VALIDATE{"refund_count\nalready set?"}
VALIDATE -- "Yes" --> REJECT["422 — already triggered"]
VALIDATE -- "No" --> QUERY["Query ETL: order lines\nmatching SKU\n(non-cancelled orders)"]
QUERY --> UPDATE_CPO["Update concept_pre_order\nset refund_count, refunded_by_id"]
UPDATE_CPO --> INSERT_LI["Insert concept_pre_order_line_items\n(one per order line)"]
INSERT_LI --> FANOUT["Fan-out: enqueue one\nRefundLineItemWorker per line item"]
end
FANOUT --> W1
%% ── Worker 1: Refund ───────────────────────────────────
subgraph W1["RefundConceptPreOrderLineItemWorker"]
W1_LOCK["Lock line_item"] --> W1_IDEM{"refund_created_at OR\norder_cancelled_at OR\npayment_terms_deleted_at\nalready set?"}
W1_IDEM -- "Yes" --> W1_SKIP["Exit: already_processed"]
W1_IDEM -- "No" --> W1_QUERY["Shopify GQL:\norderPaymentState"]
W1_QUERY --> W1_STATUS{"displayFinancialStatus?"}
W1_STATUS -- "REFUNDED / VOIDED" --> W1_ALREADY["Set order_cancelled_at\n+ order_cancel_response"]
W1_ALREADY --> W1_ENQ_C["Enqueue PaymentTerms\n(no cancel_chain)"]
W1_STATUS -- "Other" --> W1_LINES{"Other active line items\non this order?"}
W1_LINES -- "Yes (multi-item)" --> W1_REFUND["Shopify GQL:\nsuggestedRefund → refundCreate"]
W1_REFUND --> W1_SET_R["Set refund_created_at\n+ refund_create_response"]
W1_SET_R --> W1_ENQ_A["Enqueue PaymentTerms\n(no cancel_chain)"]
W1_LINES -- "No (single-item)" --> W1_ENQ_B["Enqueue PaymentTerms\n(cancel_chain: true)"]
end
W1_ENQ_A --> W2
W1_ENQ_B --> W2
W1_ENQ_C --> W2
%% ── Worker 2: Payment Terms ────────────────────────────
subgraph W2["CancelConceptPreOrderLineItemPaymentTermsWorker"]
W2_LOCK["Lock line_item"] --> W2_IDEM{"payment_terms_deleted_at\nalready set?"}
W2_IDEM -- "Yes" --> W2_SKIP["Exit: already_processed"]
W2_IDEM -- "No" --> W2_QUERY["Shopify GQL:\norderPaymentState"]
W2_QUERY --> W2_PT{"paymentTerms\nexist?"}
W2_PT -- "No terms / none pending" --> W2_SKIP2["Set payment_terms_deleted_at\n(skipped response)"]
W2_PT -- "Has pending schedules" --> W2_DELETE["Shopify GQL:\npaymentTermsDelete"]
W2_DELETE --> W2_SET["Set payment_terms_deleted_at\n+ payment_terms_delete_response"]
W2_SKIP2 --> W2_CHAIN{"cancel_chain\narg set?"}
W2_SET --> W2_CHAIN
W2_CHAIN -- "Yes" --> W2_ENQ["Enqueue\nCancelOrderWorker"]
W2_CHAIN -- "No" --> W2_DONE["DONE"]
end
W2_ENQ --> W3
%% ── Worker 3: Cancel Order ─────────────────────────────
subgraph W3["CancelConceptPreOrderLineItemOrderWorker"]
W3_LOCK["Lock line_item"] --> W3_IDEM{"order_cancelled_at\nalready set?"}
W3_IDEM -- "Yes" --> W3_SKIP["Exit: already_processed"]
W3_IDEM -- "No" --> W3_JOB{"order_cancel_shopify_job_id\nalready set?"}
W3_JOB -- "Yes (resuming)" --> W3_ENQ_POLL["Enqueue PollWorker"]
W3_JOB -- "No" --> W3_CANCEL["Shopify GQL:\norderCancel"]
W3_CANCEL --> W3_SET["Set order_cancel_shopify_job_id\n+ order_cancel_response"]
W3_SET --> W3_ENQ_POLL2["Enqueue PollWorker"]
end
W3_ENQ_POLL --> W4
W3_ENQ_POLL2 --> W4
%% ── Worker 4: Poll ─────────────────────────────────────
subgraph W4["PollConceptPreOrderLineItemOrderCancelWorker"]
W4_LOCK["Lock line_item"] --> W4_IDEM{"order_cancelled_at\nalready set?"}
W4_IDEM -- "Yes" --> W4_SKIP["Exit: already_processed"]
W4_IDEM -- "No" --> W4_MAX{"poll_count ≥ 30?"}
W4_MAX -- "Yes" --> W4_ERR["Error: max polls exceeded"]
W4_MAX -- "No" --> W4_POLL["Shopify GQL:\njobPoll"]
W4_POLL --> W4_DONE{"job.done?"}
W4_DONE -- "Yes" --> W4_SET["Set order_cancelled_at"]
W4_SET --> W4_FIN["DONE"]
W4_DONE -- "No" --> W4_INC["Increment poll_count"]
W4_INC --> W4_SNOOZE["Snooze 30s\n(re-run this worker)"]
end
W4_SNOOZE -.->|"re-enqueue"| W4_LOCK
%% ── Scenario styling ──────────────────────────────────
style W1_REFUND fill:#4a9,stroke:#333,color:#fff
style W1_SET_R fill:#4a9,stroke:#333,color:#fff
style W1_ENQ_A fill:#4a9,stroke:#333,color:#fff
style W1_ENQ_B fill:#49a,stroke:#333,color:#fff
style W1_ALREADY fill:#a94,stroke:#333,color:#fff
style W1_ENQ_C fill:#a94,stroke:#333,color:#fff
| Color | Scenario | Path |
|---|---|---|
| Green | A: Multi-item order | Refund line item → delete payment terms → DONE |
| Blue | B: Single-item order | Skip refund → delete payment terms → cancel order → poll → DONE |
| Orange | C: Already refunded/voided | Mark cancelled → delete payment terms → DONE |
| Worker | Shopify API Call | DB Fields Set | Enqueues Next |
|---|---|---|---|
RefundConceptPreOrderLineItemWorker |
orderPaymentState, suggestedRefund, refundCreate |
refund_created_at, refund_create_response (scenario A) / order_cancelled_at, order_cancel_response (scenario C) |
CancelConceptPreOrderLineItemPaymentTermsWorker |
CancelConceptPreOrderLineItemPaymentTermsWorker |
orderPaymentState, paymentTermsDelete |
payment_terms_deleted_at, payment_terms_delete_response |
CancelConceptPreOrderLineItemOrderWorker (only if cancel_chain) |
CancelConceptPreOrderLineItemOrderWorker |
orderCancel |
order_cancel_shopify_job_id, order_cancel_response |
PollConceptPreOrderLineItemOrderCancelWorker |
PollConceptPreOrderLineItemOrderCancelWorker |
jobPoll |
order_cancelled_at, order_cancel_poll_count |
Self (snooze) or none |
Each worker gates on a timestamp field before making any Shopify API call:
| Timestamp Field | Checked By | Meaning |
|---|---|---|
refund_created_at |
RefundWorker | Refund already issued for this line item |
order_cancelled_at |
RefundWorker, CancelOrderWorker, PollWorker | Order already cancelled (via refund path or cancel path) |
payment_terms_deleted_at |
RefundWorker, PaymentTermsWorker | Payment terms already removed |
order_cancel_shopify_job_id |
CancelOrderWorker | Cancel already submitted, skip to polling |
order_cancel_poll_count |
PollWorker | Caps at 30 iterations (15 min total) before erroring |
If a worker crashes or the system restarts, Oban retries the job. The timestamp check ensures no Shopify mutation is repeated — the worker picks up where it left off and enqueues the next step.