Memory Appendix
Shopify Upgrade 2026 04
Octopus Shopify integration upgrade plan to API 2026-04 — locked decisions for poll/ingest/email/fulfill/cancel scope
Source memory file:
project_shopify_upgrade_2026_04.md· Category: Project / investigation This is a verbatim dump of Claude's persistent memory for the Octopus project. Rendered inside a code block so nothing is altered.
---
name: project-shopify-upgrade-2026-04
description: Octopus Shopify integration upgrade plan to API 2026-04 — locked decisions for poll/ingest/email/fulfill/cancel scope
metadata:
node_type: memory
type: project
originSessionId: 34acf719-37fc-4dd1-9cf8-b14fd7fe8e0c
---
# Shopify Integration Upgrade — locked decisions (2026-05-15)
Reference spec: `docs/shopify/2026-04.md` (20MB, full Admin GraphQL schema).
**Why:** Current Shopify integration was scaffolded but has latent bugs (multi-tenancy bypassed, GraphQL variable type mismatch, guest checkouts rejected, deprecated `fulfillmentCreateV2`) and never had a real test pass. Upgrading to 2026-04 also forces a rewrite of `orderCancel` because of breaking changes.
**How to apply:** When working on any file under `services/shopify/`, `jobs/*shopify*`, `scheduler/*shopify*`, `database/migration/2026*shopify*`, `http/handler/admin_*shopify*`, or `database/repo/shopify_*`, treat the rules below as authoritative. When implementing, follow the change order in section 6.
## Scope
1. Poll Shopify for new orders.
2. Create matching Octopus order + generate vouchers.
3. Email customer (Octopus sends, NOT Shopify).
4. Mark Shopify order fulfilled when Octopus order reaches `OrderDelivered`.
5. Full cancellation support both directions (inbound poll-detect + outbound `orderCancel`).
Out of scope: webhooks, refunds beyond full-cancel-refund, order edits, inventory, bulk operations.
## Locked decisions
- **Q1 — mark fulfilled timing:** when Octopus order transitions to `OrderDelivered` (already the existing trigger in `process_pending_shopify_orders_job.go`).
- **Q2 — SKU→product:** new `shopify_product_mappings` table keyed `(client_id, shopify_sku)` → `octopus_product_id`. No silent numeric-parse fallback. Admin UI to manage. One-time seeder backfills mappings where existing SKU parses to a valid product_id. Cache lookups with 5-min TTL.
- **Q3 — cancellations both directions:**
- Inbound: separate cron task (5-min cadence) querying `status:cancelled updated_at:>=<last_sync>`. New sub-status `ShopifyOrderSubStatusShopifyCancelled`. Add `cancelled_at`, `cancel_reason` to `shopify_orders`; `shopify_last_cancel_sync_at` to `clients`.
- Outbound: when ALL items in a Shopify order land `VendorStatus=FAILED`, call `orderCancel(reason: INVENTORY, refundMethod: {originalPaymentMethodsRefund: true}, restock: false, notifyCustomer: true, staffNote: "...")`. Partial-fail uses `fulfillmentOrderCancel` only; partial refund is manual for now.
- **Q4 — refund method on outbound cancel:** `originalPaymentMethodsRefund: true` (refund to card, not store credit).
- **Q5 — post-delivery Shopify cancel arrives:** log loudly, flag for manual review, do NOT attempt upstream revocation. Most direct_topup vendors don't support post-issuance revocation, and the code is already in the customer's inbox.
## Critical bugs in current code (fix BEFORE any 2026-04 work)
1. **Multi-tenancy bypassed:** `jobs/fetch_shopify_orders_job.go:59` and `jobs/process_pending_shopify_orders_job.go:61` call `singleton.GetShopifyClient()` which reads env vars. The `services/shopify/factory.go` exists but is unused. → Delete singleton, wire `*shopify.ClientFactory` through scheduler → jobs.
2. **GraphQL variable type mismatch:** `services/shopify/queries.go:7` declares `$since: DateTime!` but `client.go:145` passes a Shopify search-string `"created_at:>='...'"`. `orders.query` arg is `String`. → Rename to `$query: String!`.
3. **Guest checkouts crash:** `fetch_shopify_orders_job.go:286-288` rejects orders with empty `customer.ID`. → Use `order.email` first, `customer.email` as fallback.
4. **Double-email:** `client.go:235` sets `notifyCustomer: true` while `OrderNotifier` also emails via `order_notifier.go:166`. → Set `notifyCustomer: false`.
5. **API version drift:** singleton=2025-01, admin_client.go=2024-01, target=2026-04. → Single constant `DefaultShopifyAPIVersion = "2026-04"`.
6. **Plaintext token fallback in `factory.go:82-89`:** silent security degradation. → Delete; fail fast on decryption error.
7. **Fake Fiber context for voucher creation** at `fetch_shopify_orders_job.go:540-558`. → Extract `CreateVoucherOrder` core into a service callable from both HTTP handler and job.
## 2026-04 breaking changes vs current code
- **`fulfillmentCreateV2` → `fulfillmentCreate`** — same `FulfillmentInput`, just rename. V2 explicitly marked deprecated in spec.
- **`orderCancel` is now async and breaking-changed:**
- Returns `Job { id done }` instead of synchronous result.
- `refund: Boolean!` REMOVED — replaced by `refundMethod: OrderCancelRefundMethodInput!` (structured: `originalPaymentMethodsRefund: Boolean` or `storeCreditRefund: { expiresAt }`).
- New optional `staffNote: String`.
- Strategy: fire-and-forget on the Job; next inbound-cancel poll cycle confirms via `cancelledAt`.
## Required scopes (final)
- `read_orders` (60-day window; `read_all_orders` only if a merchant needs backfill)
- `write_orders` (required by `orderCancel`)
- `write_merchant_managed_fulfillment_orders` (required by `fulfillmentCreate` for digital goods at merchant-managed locations)
Admin client-creation form should call `shop { name }` after saving and surface scope/auth failures to the user.
## Polling query shape (after upgrade)
Primary fetch (every minute):
```text
query: "updated_at:>='<since>' financial_status:paid fulfillment_status:unfulfilled test:false"
sortKey: UPDATED_AT
```
Inbound-cancel fetch (every 5 min):
```text
query: "updated_at:>='<last_cancel_sync>' status:cancelled"
sortKey: UPDATED_AT
```
Order fields to capture: `id, name, createdAt, updatedAt, processedAt, email, displayFinancialStatus, displayFulfillmentStatus, test, cancelledAt, cancelReason, currencyCode, presentmentCurrencyCode, totalPriceSet, customer{…}, lineItems{…}`. Use `order.email` (works for guest checkouts), fall back to `customer.email`.
## Status (as of 2026-05-15)
All 11 steps shipped in one branch on 2026-05-15. Build + vet + targeted Shopify tests green. **Outstanding follow-ups:**
- Jet template UI for the SKU mapping admin page (handlers exist at `/admin/api/clients/:id/shopify-mappings`, just no HTML form yet).
- Full extraction of `Handler.CreateVoucherOrder` core into `services/voucher_order.go`. The fake-fiber-context is now encapsulated in `Handler.CreateVoucherOrderForJob`, but the 367-line core still lives in the HTTP handler. Defer until there is regression test coverage on the order-creation path.
- Mocky-balboa Shopify route with chaos hooks + orchestration test parity with Runa (28-subtest reference at `test/clientapi/runa_orchestration_test.go`). Step 11 currently ships unit-level `httptest` round-trip tests covering field parsing, filter strings, the `fulfillmentCreate`/`orderCancel` 2026-04 wire shapes, and the credential probe.
- Migration default in `20251026000005_add_shopify_to_clients.sql` still hard-codes `'2025-01'` for `shopify_api_version`. Only affects clients created via raw SQL — handler defaults to `shopify.DefaultAPIVersion`. Bump if it ever causes drift.
- Run after deploy: `go run main.go migrate up` (applies the two new migrations) + `go run main.go seed shopify_product_mapping_backfill` (idempotent).
## Change order (mergeable steps, each behind `client.shopify_sync_enabled`)
1. Factory plumbing — delete singleton, wire factory.
2. Query fix — `$query: String!`, new field set.
3. Schema migration — add `email`, `updated_at`, status columns, `cancelled_at`, `cancel_reason` to `shopify_orders`; `shopify_last_cancel_sync_at` to `clients`.
4. Guest checkout + email source fix.
5. Mutation rewrite — `fulfillmentCreate` rename, `notifyCustomer: false`, `orderCancel` 2026-04 shape.
6. SKU mapping table — migration, repo, admin UI, backfill seeder.
7. Outbound cancellation — wire `orderCancel` into all-items-failed branch.
8. Inbound cancellation task — new scheduler task + job + sub-status.
9. Service extraction — pull `CreateVoucherOrder` core out of HTTP handler.
10. Admin form scope verification + single API version constant.
11. Test harness — mocky-balboa route + chaos hooks + contract tests against captured 2026-04 responses (per [[feedback_vendor_chaos_and_tests]] and [[project_orchestration_test_harness]]).
## Memory hygiene
- Rate-limit observability is deferred — log `extensions.cost.throttleStatus.currentlyAvailable` as a structured field, no bulk operations needed at current volume.
- Don't add backwards-compat for old SKU numeric-parse behavior in step 6 — seeder migrates existing rows.Runa Products Singular Vs Plural
Resolved — Runa docs are source of truth, all endpoints are SINGULAR. Both the Go client and the mock were wrong (plural) and have been corrected to match the O
Vendor Architecture
Octopus has TWO distinct vendor concepts — topup vendors (suppliers) and the Grasshopper vendor (sales surface). All gift card products live under vendor.code='