Skip to content

Buylist Shopify-Native Settlement + Customer Profile Integration โ€‹

Date: 2026-07-29 Status: Approved Supersedes/extends: Issue #495 item 1 (customer-profile view) โ€” reframed around native settlement first. Related: 2026-07-28-buylist-independent-cash-credit-design.md (payout tracks this builds on)

Problem โ€‹

Buylist orders live entirely in buylist_orders (Mongo), keyed by a free-typed email. The paid status exists in the enum but no endpoint produces it โ€” "payout" has no representation anywhere, on our side or Shopify's. Meanwhile Shopify now has first-class primitives for exactly this: native store credit attached to the customer account (rendered on the admin customer profile, redeemable at POS and online checkout) and an Admin UI extension surface on the customer detail page.

Decision (Brent, 2026-07-29): use the platform. Store-credit payouts are issued as native Shopify store credit (not gift cards โ€” those are the legacy workaround). Cash payouts settle in person and get a durable settlement record in our app.

Goal โ€‹

When a merchant settles an accepted buylist order:

  • Store credit: the amount lands on the customer's Shopify store-credit balance via storeCreditAccountCredit, visible natively on their admin profile and spendable at checkout/POS.
  • Cash: the order carries a settlement record (amount, timestamp, note) as the audit trail for in-person payment.
  • Either way the order is linked to a real Shopify customer, and the customer's buylist history appears on their admin customer page via a read-only Admin UI extension.

Non-Goals โ€‹

  • Inventory intake of purchased cards into Shopify.
  • Cash reconciliation reporting (fast-follow candidate, not this design).
  • Store-credit expiration configuration.
  • Gift-card payout vehicle.
  • Any change to quoting/offer logic or payout-track math.
  • Settling from the extension itself (read-only; actions deep-link into the app).

Decisions Made โ€‹

DecisionChoice
Payout vehicleNative Shopify store credit (storeCreditAccountCredit)
Customer linkingResolve by email at settlement; create the customer (with portal disclosure) if missing
Cash trackingSettlement record: amount, timestamp, optional note
Extension scopeRead-only order list on the customer profile, deep-link to app
SequencingPR 1 settlement + linking, PR 2 admin extension

Design โ€‹

1. Data model (BuylistOrder) โ€‹

Both additions declared field-by-field (CLAUDE.md ยง5.2) with a schema-shape round-trip test:

  • customer.shopifyCustomerId โ€” String, Shopify customer GID. Set at settlement when the customer is resolved or created. This is the join key the PR 2 extension queries on.
  • settlement sub-doc:
    • method: 'store_credit' | 'cash'
    • amount: Number โ€” the order's creditTotal/cashTotal for its payout method at settle time
    • currencyCode: String (shop currency)
    • settledAt: Date
    • note: String, optional (e.g. "paid from register 1")
    • storeCreditTransactionId: String โ€” store-credit settlements only; absence on a paid store-credit order means the credit was never confirmed (see ยง3 failure handling)
    • customerCreated: Boolean โ€” whether settlement created the Shopify customer record

New indexes:

  • { shop: 1, 'customer.shopifyCustomerId': 1, createdAt: -1 } โ€” extension's primary query
  • { shop: 1, 'customer.email': 1 } โ€” extension's fallback for pre-settlement orders (closes the missing-index note from issue #495)

Orders paid without a settlement sub-doc read as "settled before tracking existed."

2. Settlement endpoints โ€‹

GET /api/buylist/orders/:id/settlement-preview Resolves the order's email against Shopify (customers search โ€” emails are unique per customer, so 0 or 1 match). Returns matched customer { id, displayName } or willCreateCustomer: true, plus the settle amount and method. The settle modal shows this so the merchant confirms the identity match before any credit is issued โ€” wrong-email orders are caught here, since store credit is only spendable by someone checking out with that email.

POST /api/buylist/orders/:id/settle โ€” Zod schema in server/schemas/, wired via validate(). Body: { note? }. Allowed only from accepted (409 otherwise). Amount is not overridable in v1; adjustments happen in the existing review/edit flow before accepting.

  • Cash path: stamp settlement, set status paid. No Shopify call.
  • Store-credit path:
    1. Atomically claim: findOneAndUpdate({ _id, shop, status: 'accepted' }, โ†’ status 'paid' + settlement stub). A concurrent settle loses the race and 409s โ€” this is the double-issue guard.
    2. Resolve customer by email; customerCreate if none.
    3. storeCreditAccountCredit (account auto-created on first credit).
    4. Write storeCreditTransactionId and customer.shopifyCustomerId.
    • Shopify failure: revert status to accepted, clear the stub, surface the error.
    • Crash window (claimed, no transaction id): detectable โ€” the review UI flags "credit not confirmed" and offers retry; the retry re-issues safely because the claim (status paid, settlement.method: 'store_credit', no transaction id) survives and the retry path only runs the Shopify steps.

The walk-in register flow ("customer here with cards, pay now") is the client chaining accept โ†’ settle; no combined endpoint.

3. Shopify integration โ€‹

  • Three new methods on server/services/shopifyAPI.js (rule 6 โ€” nothing else calls Shopify): findCustomerByEmail, createCustomer, creditStoreCredit. API version is the shared constants.js API_VERSION.
  • New scopes: read_customers, write_customers, write_store_credit_account_transactions. Updated in the same sitting (ยง5.7 split-brain rule) in: server/config/shopify.js fallback, server/config/constants.jsSCOPES, the runtime SHOPIFY_SCOPES env on Cloud Run/worker, and the GH Actions secret consumed by deploy workflows.
  • Re-consent rollout: existing installs lack the new scopes. The settle endpoint detects Shopify's missing-scope error and returns a distinct error code the client renders as a "re-authorize LGS Forge" prompt (not a raw 500). Buylist is beta, so the affected-store count is small.
  • GDPR: customers/redact handling must extend to buylist_orders for the shop+email. Verify current webhook behavior during implementation โ€” likely a pre-existing gap, fix rides along in PR 1.

4. Client (review page) โ€‹

Settle button on accepted orders opens a modal: customer match from the preview endpoint (or "a new customer record will be created"), amount, method, optional note โ†’ confirm. Success links to the customer's Shopify admin page. The public portal gains a one-line disclosure that completing a sale may create a customer record at the store.

5. PR 2 โ€” customer-page Admin UI extension โ€‹

  • New extensions/buylist-customer-orders/ workspace: shopify.extension.toml targeting admin.customer-details.block.render, Shopify CLI build step, app-config change so it registers on deploy. First extension in this app.
  • Read-only block: the customer's buylist orders (status, totals, settlement summary), each deep-linking to the app's review screen.
  • Backed by GET /api/buylist/customer-orders (Zod-validated customerId and/or email): queries shopifyCustomerId first, email fallback for orders that predate settlement linking. Auth via session token through dualModeAuth; confirm the extension origin passes the CORS allowlist.
  • The block renders nothing for customers with no buylist history and for stores without buylist enabled โ€” the extension deploys app-wide but must stay invisible where the beta feature is off.

6. Testing โ€‹

  • Settlement route/service tests via _setDeps injection (mock shopifyAPI): cash path, credit path, double-settle race (second call 409s), Shopify failure โ†’ revert, crash-window retry, missing-scope โ†’ distinct error code, customer-create path.
  • ยง5.2 round-trip test for settlement and customer.shopifyCustomerId.
  • customer-orders endpoint tests for both join keys and the empty case.
  • Game parity (ยง5.1): settlement is order-level and game-agnostic; no plugin, per-game model, or importer is touched. All three plugins (mtg, pokemon, riftbound) exempt-by-construction โ€” stated in each PR.

Open Items Deliberately Left Behind โ€‹

  • Issue #495 items 2โ€“4 (payout migration run, retroui Input cn() fix, ManaBox vocabulary) are untouched by this design and remain tracked there.
  • Cash reconciliation view โ€” revisit after settlement records accumulate.