Skip to content

Instantly share code, notes, and snippets.

@coop
Created February 18, 2026 04:06
Show Gist options
  • Select an option

  • Save coop/d038d24bbc4e7193169d56280af0efcc to your computer and use it in GitHub Desktop.

Select an option

Save coop/d038d24bbc4e7193169d56280af0efcc to your computer and use it in GitHub Desktop.
Concept Pre-Order Cancel/Refund Flow — Oban worker chain diagram

Concept Pre-Order Cancel/Refund Flow

Overview

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.

Flow Diagram

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
Loading

Scenario Legend

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 Reference

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

Idempotency Model

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment