Shopify Integration
The inbound Shopify sales channel — per-tenant order polling, SKU→product mapping, voucher provisioning, fulfilment and cancellation on the 2026-04 GraphQL API. Includes the critical client_id=0 ingest bug.
Shopify is an inbound sales channel: Octopus polls each tenant's store for paid/unfulfilled orders, provisions an Octopus voucher order per line item, lets the voucher engine email the buyer, then marks the Shopify order fulfilled (or cancels + refunds on total failure). Everything is per-client (multi-tenant) and driven by cron, not webhooks.
Critical — every ingest insert writes client_id = 0
The fetch job builds ShopifyOrder / ShopifyCustomer / ShopifyOrderItem structs without ever setting ClientID (fetch_shopify_orders_job.go:258,368,448), yet the insert repos read client_id from the struct (repo/shopify_order.go:219 etc.). j.clientID is in scope but never propagated, so every insert sends client_id = 0. Against the NOT NULL REFERENCES clients(id) FK this either fails the insert or (if an id-0 client existed) mis-attributes the whole order tree to the wrong tenant.
This is the "multi-tenancy bypassed" defect — highest-priority fix. Reads are correctly scoped everywhere; this is purely a write-path omission. Fix: set ClientID: j.clientID on all three structs.
Architecture & data model
Package services/shopify/: client.go (per-tenant GraphQL client + all API calls), models.go (GraphQL structs), factory.go (ClientFactory, per-tenant sync.Map client cache), queries.go/mutations.go, helpers.go, errors.go.
- Store credentials live on the
clientstable (one store per client):ShopifyEnabled,ShopifyShopName,ShopifyAccessToken(AES-GCM encrypted),ShopifyAPIVersion,ShopifyLastSyncAt,ShopifyLastCancelSyncAt,ShopifySyncEnabled(models/client.go:31). - Product mapping:
shopify_product_mappings—(client_id, shopify_sku)unique →octopus_product_id,is_activesoft-delete (repo/shopify_product_mapping.go, 5-min cache with negative caching). Replaces a legacy "SKU is the numeric product_id" convention (backfilled by a seeder).
| Table | Model | Notes |
|---|---|---|
shopify_customers | shopify_customer.go | guest-safe |
shopify_orders | shopify_order.go | status/sub-status enums, 2026-04 fields |
shopify_order_items | shopify_order_item.go | vendor_order_id → Octopus orders.id; mapping_error |
shopify_order_item_vouchers | shopify_order_item_voucher.go | orphaned — see dead code |
shopify_product_mappings | in shopify_order_item.go | SKU→product |
Multi-tenancy was retrofitted (migration …0006) — pre-existing rows backfilled to the first client, unique keys made (client_id, shopify_*_id).
The full flow
Three cron tasks, all registered in main.go:218 only when FeatureFlags.IsShopifyEnabled() (which requires vouchers enabled).
- Fetch/ingest (
fetch-shopify-orders,* * * * *) — per client:FetchOrderswith a fixed 10-min lookback (not the stored watermark, which is written but unread), cursor-paginated. Dedup order → upsert customer (guest-safe) → insert item →createVoucherOrderForLineItem. - Line item → voucher order (
fetch_shopify_orders_job.go:492) — SKU mapping (no map ⇒mapping_erroron the item, surfaced in admin, no silent numeric fallback); deterministicclient_reference = SHOPIFY_{orderID}_{lineItemID}; recipient email prefersorder.emailthencustomer.email; callshandler.CreateVoucherOrderForJob(qty 1, denomination = line-item unit price). - Deliver/fulfil (
process-pending-shopify-orders,* * * * *) — reads each linked Octopus order; aggregatesallDelivered/anyDelivered/allFailed. All delivered →markOrderAsFulfilledInShopify; all failed →cancelShopifyOrderOnFailure(orderCancel + card refund); some → PARTIALLY_DELIVERED. - Fulfil in Shopify — takes the first fulfillment order (assumption),
fulfillmentCreatewithnotifyCustomer:falseand trackingcompany:"Email Delivery", number:"DIGITAL". - Inbound cancellation (
detect-shopify-cancellations,*/5 * * * *) — per-client watermark,FetchCancelledOrders; delivered vouchers are NOT revoked — flagged for manual review with a loud warning.
Shopify API interaction
- GraphQL only (no REST). Endpoint
https://{shop}/admin/api/{version}/graphql.json. - API version: single source of truth
DefaultAPIVersion = "2026-04"(client.go:24); per-client override viaclient.ShopifyAPIVersion. - Auth: private-app access token in
X-Shopify-Access-Token. No OAuth handshake in-repo — token pasted into the admin form, AES-GCM encrypted. orderCancelis async in 2026-04 — returns aJob {id, done}and takes structuredrefundMethod: OrderCancelRefundMethodInput!(not the legacy boolean). The job is fire-and-forget — logged, not polled; final state confirmed by the cancellation poller (stage 5).
Rate-limit / query-cost handling is absent
executeGraphQL only logs X-Shopify-Shop-Api-Call-Limit at debug. It does not read extensions.cost.throttleStatus, has no backoff, and no retry on THROTTLED. Add cost-aware throttling before onboarding high-volume stores.
Multi-tenancy
Attribution is pull-based, not webhook-based: ClientFactory caches one *shopify.Client per clientID (decrypting the token); tasks iterate GetShopifyEnabledClients and pass client.ID into each job. Reads are correctly scoped by client_id everywhere. The one break is the client_id=0 write bug above.
The other three initiative-flagged issues are in better shape:
- Guest checkout — fixed.
OrderNode.Customeris a pointer; guests returnniland delivery falls back toorder.email. Migration…16droppedcustomer_id NOT NULL, addedemail. - GraphQL 2026-04 type mismatch — resolved in code (structured
refundMethod,fulfillmentCreatereplacingfulfillmentCreateV2, asyncorderCancel, typed user-errors). - Double-email — mitigated: fulfilment uses
notifyCustomer:falseso Shopify doesn't duplicate Octopus's delivery email; the failure path lets Shopify send only its refund email.
Idempotency
Layered dedup so overlapping minute-polls are safe: order (GetShopifyOrderByShopifyID + unique (client_id, shopify_order_id)), customer (update-or-insert), line item (skip-if-exists + unique key), voucher order (deterministic client_reference + pre-check + the engine's own duplicate-400 guard), email (sent once by the engine on the delivered transition), cancellation (skip if already CANCELLED). Pre-checks are read-then-write without a tx, but the scheduler is single-instance per task and DB unique constraints are the backstop.
Order-creation path
CreateVoucherOrderForJob (create_voucher_order.go:495) is the programmatic entry — it spins up a throwaway Fiber context, sets TenantIDKey, marshals the request into the body, and calls the HTTP handler. The comment admits this fake-request boilerplate is a tracked follow-up to extract into a real service.
Shopify orders are mis-tagged as source API
models.SourceShopify = "SHOPIFY" exists but is never used — CreateVoucherOrder hardcodes Source = SourceApi (create_voucher_order.go:804). Every Shopify-originated order is tagged API, breaking source attribution/reporting. Wire SourceShopify through the job path. (Same root cause as the OrderSource constants noted in Order Lifecycle.)
Admin controls
- Store connection on the client create/edit forms (
admin_client.go,admin_ui.go): fields for shop name / token / API version; token AES-GCM encrypted; on save it probes credentials (VerifyCredentials→query { shop { name } }) but saves even if verification fails (warn-only). Requires amyshopify.comdomain. - Product mappings (JSON API,
admin_shopify_mappings.go): list / create (validates product exists, 409 on conflict) / toggle (soft-delete flip, never hard-delete).
The 2026-04 upgrade
Already pinned to 2026-04 in code — mutations, models, and the extend migration are all in place. docs/shopify/2026-04.md is a 512 KB dump of the full Shopify Admin GraphQL reference (for schema validation, not an internal memo); there is no in-repo TODO in the Shopify package.
Critical bugs to fix first (ranked):
client_id=0ingest bug — blocks all multi-tenant ingest.- Source mis-tag — wire
SourceShopifythrough. - Fulfilment failure marks order DELIVERED anyway (see below) — Shopify order stuck unfulfilled forever.
- Rate-limit/cost handling absent — add before high-volume stores.
- DB default API version
'2025-01'diverges fromDefaultAPIVersion = "2026-04"— a client row without an explicit version can silently run on 2025-01.
Dead code & smells
| Issue | Where |
|---|---|
Fulfilment failure still persists status DELIVERED — status set to DELIVERED before markOrderAsFulfilledInShopify; on failure only result.Error is set, DELIVERED is persisted, order excluded from re-fetch → Shopify order stuck unfulfilled forever. (The allFailed branch correctly leaves status untouched to retry.) | process_pending_shopify_orders_job.go:291,335 |
shopify_order_item_vouchers table orphaned — full repo incl. InsertShopifyOrderItemVoucher, no non-test caller. Codes live in the voucher engine's own tables. | repo/shopify_order_item_voucher.go |
shopify_last_sync_at written but never read — fetch uses fixed 10-min lookback. (Cancel poller does use its watermark.) | fetch_shopify_orders_job.go:114 |
| Legacy plaintext-token fallback if decryption fails ("insecure!" warning). | factory.go:81 |
| Credential verification non-blocking — bad creds saved anyway. | admin_client.go:259 |
| Fake-Fiber-context in jobs — background job builds a synthetic HTTP request. | create_voucher_order.go:495 |
| "First fulfillment order only" assumption — multi-fulfillment orders partially fulfilled. | process_pending_shopify_orders_job.go:478 |
Vendor-topology leak check: clean
No vendor/supplier identity reaches the Shopify buyer: fulfilment tracking is generic (Email Delivery / DIGITAL, no notify), the failure staffNote ("Octopus voucher provisioning failed…") is internal-only, and mapping_error is admin-only. Just keep staffNote internal and don't move that text into a customer-visible field. See no-vendor-in-customer-facing.
Key files
- Service:
services/shopify/{client,models,factory,queries,mutations,helpers,errors}.go - Jobs:
jobs/{fetch_shopify_orders_job,process_pending_shopify_orders_job,detect_shopify_cancellations_job}.go - Scheduler:
scheduler/{fetch_shopify_orders_task,process_pending_shopify_orders_task,detect_shopify_cancellations_task}.go - Models/repo:
database/models/shopify_*.go,database/repo/shopify_*.go - Admin:
http/handler/{admin_shopify_mappings,admin_client,admin_ui}.go; routeshttp/routes/admin.go:198 - Order entry:
http/handler/create_voucher_order.go:495
Webhook Payloads
The exact JSON for every outbound client webhook — the envelope, all 15 event types with field tables and example bodies, the HMAC-SHA256 signing + verification, and the quirks (wallet amount is a string, activation_code always omitted).
G2A Import Channel
The inbound G2A marketplace channel — Octopus is the server. OAuth2 token issuance, the reserve → order → pull-codes lifecycle, mint-on-demand GH stock, per-tenant scoping, and its strong test coverage.