OctoWiki

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 clients table (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_active soft-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).
TableModelNotes
shopify_customersshopify_customer.goguest-safe
shopify_ordersshopify_order.gostatus/sub-status enums, 2026-04 fields
shopify_order_itemsshopify_order_item.govendor_order_id → Octopus orders.id; mapping_error
shopify_order_item_vouchersshopify_order_item_voucher.goorphaned — see dead code
shopify_product_mappingsin shopify_order_item.goSKU→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).

  1. Fetch/ingest (fetch-shopify-orders, * * * * *) — per client: FetchOrders with 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.
  2. Line item → voucher order (fetch_shopify_orders_job.go:492) — SKU mapping (no map ⇒ mapping_error on the item, surfaced in admin, no silent numeric fallback); deterministic client_reference = SHOPIFY_{orderID}_{lineItemID}; recipient email prefers order.email then customer.email; calls handler.CreateVoucherOrderForJob (qty 1, denomination = line-item unit price).
  3. Deliver/fulfil (process-pending-shopify-orders, * * * * *) — reads each linked Octopus order; aggregates allDelivered / anyDelivered / allFailed. All delivered → markOrderAsFulfilledInShopify; all failed → cancelShopifyOrderOnFailure (orderCancel + card refund); some → PARTIALLY_DELIVERED.
  4. Fulfil in Shopify — takes the first fulfillment order (assumption), fulfillmentCreate with notifyCustomer:false and tracking company:"Email Delivery", number:"DIGITAL".
  5. 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 via client.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.
  • orderCancel is async in 2026-04 — returns a Job {id, done} and takes structured refundMethod: 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 checkoutfixed. OrderNode.Customer is a pointer; guests return nil and delivery falls back to order.email. Migration …16 dropped customer_id NOT NULL, added email.
  • GraphQL 2026-04 type mismatch — resolved in code (structured refundMethod, fulfillmentCreate replacing fulfillmentCreateV2, async orderCancel, typed user-errors).
  • Double-email — mitigated: fulfilment uses notifyCustomer:false so 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 usedCreateVoucherOrder 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 (VerifyCredentialsquery { shop { name } }) but saves even if verification fails (warn-only). Requires a myshopify.com domain.
  • 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):

  1. client_id=0 ingest bug — blocks all multi-tenant ingest.
  2. Source mis-tag — wire SourceShopify through.
  3. Fulfilment failure marks order DELIVERED anyway (see below) — Shopify order stuck unfulfilled forever.
  4. Rate-limit/cost handling absent — add before high-volume stores.
  5. DB default API version '2025-01' diverges from DefaultAPIVersion = "2026-04" — a client row without an explicit version can silently run on 2025-01.

Dead code & smells

IssueWhere
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; routes http/routes/admin.go:198
  • Order entry: http/handler/create_voucher_order.go:495

On this page