Skip to content

Buylist Shopify-Native Settlement (PR 1) Implementation Plan โ€‹

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: A merchant settling an accepted buylist order issues native Shopify store credit (or records an in-person cash payout), links the order to a real Shopify customer, and the order reaches paid with a durable settlement record.

Architecture: New buylistSettlementService owns the settle state machine (atomic claim โ†’ resolve/create customer โ†’ storeCreditAccountCredit โ†’ confirm; revert on Shopify failure; retriable crash window). Shopify calls live in three new shopifyAPI.js instance methods. Two new routes on routes/buylist.js. Client adds a settle modal to the review page.

Tech Stack: Express + Mongoose (CommonJS), Zod, Vitest (ESM test files), React 18 + retroui, Shopify Admin GraphQL 2026-01.

Spec: docs/superpowers/specs/2026-07-29-buylist-shopify-native-settlement-design.md

Global Constraints โ€‹

  • Server code CommonJS; test files ESM import, co-located x.test.js.
  • Every new persisted sub-doc field declared field-by-field + round-trip test (CLAUDE.md ยง5.2).
  • No identity defaults โ€” no || 'mtg', no defaulted shop (ยง5.5).
  • All Shopify calls through server/services/shopifyAPI.js (rule 6); grep diff for myshopify.com must show none elsewhere.
  • New endpoints: Zod schema in server/schemas/ + validate() middleware where the schema is static.
  • Services expose _setDeps()/_resetDeps() for DI in tests.
  • Game parity ยง5.1: this PR touches no plugin/per-game model/importer โ€” all three plugins (mtg, pokemon, riftbound) exempt-by-construction; state this in the PR body.
  • ยง5.7: SHOPIFY_SCOPES runtime env (Cloud Run + worker) AND the GH Actions secret must be updated in the same sitting as the code change โ€” manual checklist item, called out in the PR body.
  • Live testing on ufkes-dev deferred until the app is granted the new scopes; all tests here are mocked.
  • Branch: claude/shopify-customer-buylist-c4b7ca; commits are one imperative sentence, merchant-visible outcome.

Task 1: BuylistOrder settlement fields + indexes โ€‹

Files:

  • Modify: server/models/BuylistOrder.js
  • Test: server/models/BuylistOrder.test.js

Interfaces:

  • Produces: order.customer.shopifyCustomerId (String, Shopify GID), order.settlement sub-doc { method: 'store_credit'|'cash', amount: Number, currencyCode: String, settledAt: Date, note: String, storeCreditTransactionId: String, customerCreated: Boolean }. Indexes { shop, 'customer.shopifyCustomerId', createdAt } and { shop, 'customer.email' }.

  • [ ] Step 1: Write the failing round-trip test (append to server/models/BuylistOrder.test.js, matching its existing in-memory construction style):

js
describe('settlement fields (ยง5.2 round-trip)', () => {
    it('persists customer.shopifyCustomerId and every settlement field through construction', () => {
        const doc = new BuylistOrder({
            shop: 'test.myshopify.com',
            source: 'merchant',
            customer: {
                email: 'seller@example.com',
                name: 'Seller One',
                shopifyCustomerId: 'gid://shopify/Customer/123'
            },
            settlement: {
                method: 'store_credit',
                amount: 42.5,
                currencyCode: 'USD',
                settledAt: new Date('2026-07-29T00:00:00Z'),
                note: 'register 1',
                storeCreditTransactionId: 'gid://shopify/StoreCreditAccountTransaction/9',
                customerCreated: true
            }
        });
        const obj = doc.toObject();
        expect(obj.customer.shopifyCustomerId).toBe('gid://shopify/Customer/123');
        expect(obj.settlement).toMatchObject({
            method: 'store_credit',
            amount: 42.5,
            currencyCode: 'USD',
            note: 'register 1',
            storeCreditTransactionId: 'gid://shopify/StoreCreditAccountTransaction/9',
            customerCreated: true
        });
        expect(obj.settlement.settledAt).toBeInstanceOf(Date);
    });

    it('rejects a settlement method outside the enum', () => {
        const doc = new BuylistOrder({
            shop: 'test.myshopify.com',
            source: 'merchant',
            customer: { email: 'seller@example.com' },
            settlement: { method: 'paypal' }
        });
        expect(doc.validateSync().errors['settlement.method']).toBeTruthy();
    });
});
  • [ ] Step 2: Run to verify failure โ€” npm test -- BuylistOrder.test โ†’ new tests FAIL (shopifyCustomerId/settlement undefined: strict mode drops undeclared fields).

  • [ ] Step 3: Declare the fields. In server/models/BuylistOrder.js:

In customer:

js
    customer: {
        email: { type: String, required: true },
        name: String,
        // Shopify customer GID, linked at settlement time (spec 2026-07-29).
        // Absent until the order settles; the PR-2 customer-page extension
        // queries on it, with email as the fallback join.
        shopifyCustomerId: String
    },

After payout (each field declared โ€” ยง5.2):

js
    // Written once, when the merchant settles the order (status -> 'paid').
    // A 'paid' store_credit settlement WITHOUT storeCreditTransactionId means
    // the claim succeeded but the Shopify credit was never confirmed โ€” the
    // retry path in buylistSettlementService keys off exactly that shape.
    settlement: {
        method: { type: String, enum: ['store_credit', 'cash'] },
        amount: Number,
        currencyCode: String,
        settledAt: Date,
        note: String,
        storeCreditTransactionId: String,
        customerCreated: Boolean
    }

After the existing index lines:

js
buylistOrderSchema.index({ shop: 1, 'customer.shopifyCustomerId': 1, createdAt: -1 });
buylistOrderSchema.index({ shop: 1, 'customer.email': 1 });
  • [ ] Step 4: Run to verify pass โ€” npm test -- BuylistOrder.test โ†’ PASS, no other model tests broken.

  • [ ] Step 5: Commit โ€” git add server/models/BuylistOrder.js server/models/BuylistOrder.test.js && git commit -m "Record who was paid and how on settled buylist orders"


Task 2: shopifyAPI customer + store-credit methods โ€‹

Files:

  • Modify: server/services/shopifyAPI.js
  • Test: server/services/shopifyAPI.test.js

Interfaces:

  • Consumes: existing this.graphQL(query, variables, options) (throws Error(JSON.stringify(json.errors)) on top-level GraphQL errors โ€” an ACCESS_DENIED extensions code lands in that message string).

  • Produces (all instance methods on ShopifyAPI):

    • findCustomerByEmail(email) โ†’ { id, displayName, email } | null
    • createCustomer({ email, name }) โ†’ { id, displayName, email }
    • getShopCurrency() โ†’ 'USD'-style code
    • creditStoreCredit({ customerId, amount, currencyCode }) โ†’ { transactionId }
    • class MissingScopeError extends Error with code = 'MISSING_SCOPES', exported as module.exports.MissingScopeError.
  • [ ] Step 1: Write failing tests (append to shopifyAPI.test.js; follow the file's pattern โ€” api.graphQL = vi.fn()):

js
describe('customer + store credit methods', () => {
    let api, graphQLSpy;
    beforeEach(() => {
        api = new ShopifyAPI('test.myshopify.com', 'token');
        graphQLSpy = vi.fn();
        api.graphQL = graphQLSpy;
    });

    it('findCustomerByEmail returns the exact-email match', async () => {
        graphQLSpy.mockResolvedValue({
            customers: { edges: [{ node: { id: 'gid://shopify/Customer/1', displayName: 'Ann B', email: 'ann@example.com' } }] }
        });
        const c = await api.findCustomerByEmail('Ann@Example.com');
        expect(c).toMatchObject({ id: 'gid://shopify/Customer/1' });
        expect(graphQLSpy.mock.calls[0][1].query).toContain('ann@example.com');
    });

    it('findCustomerByEmail returns null when search matches a different email', async () => {
        graphQLSpy.mockResolvedValue({
            customers: { edges: [{ node: { id: 'gid://shopify/Customer/2', displayName: 'Other', email: 'other@example.com' } }] }
        });
        expect(await api.findCustomerByEmail('ann@example.com')).toBeNull();
    });

    it('createCustomer splits the portal name into first/last and returns the customer', async () => {
        graphQLSpy.mockResolvedValue({
            customerCreate: { customer: { id: 'gid://shopify/Customer/3', displayName: 'Ann Van Berg', email: 'ann@example.com' }, userErrors: [] }
        });
        const c = await api.createCustomer({ email: 'ann@example.com', name: 'Ann Van Berg' });
        expect(c.id).toBe('gid://shopify/Customer/3');
        expect(graphQLSpy.mock.calls[0][1].input).toEqual({ email: 'ann@example.com', firstName: 'Ann', lastName: 'Van Berg' });
    });

    it('createCustomer throws on userErrors', async () => {
        graphQLSpy.mockResolvedValue({
            customerCreate: { customer: null, userErrors: [{ field: ['email'], message: 'Email has already been taken' }] }
        });
        await expect(api.createCustomer({ email: 'dupe@example.com' })).rejects.toThrow(/already been taken/);
    });

    it('creditStoreCredit sends a fixed-2 decimal amount and returns the transaction id', async () => {
        graphQLSpy.mockResolvedValue({
            storeCreditAccountCredit: { storeCreditAccountTransaction: { id: 'gid://shopify/StoreCreditAccountCreditTransaction/7' }, userErrors: [] }
        });
        const r = await api.creditStoreCredit({ customerId: 'gid://shopify/Customer/1', amount: 42.5, currencyCode: 'USD' });
        expect(r.transactionId).toBe('gid://shopify/StoreCreditAccountCreditTransaction/7');
        expect(graphQLSpy.mock.calls[0][1]).toEqual({
            id: 'gid://shopify/Customer/1',
            creditInput: { creditAmount: { amount: '42.50', currencyCode: 'USD' } }
        });
    });

    it('creditStoreCredit throws on userErrors', async () => {
        graphQLSpy.mockResolvedValue({
            storeCreditAccountCredit: { storeCreditAccountTransaction: null, userErrors: [{ field: null, message: 'Amount exceeds account limit' }] }
        });
        await expect(api.creditStoreCredit({ customerId: 'gid://shopify/Customer/1', amount: 1e9, currencyCode: 'USD' }))
            .rejects.toThrow(/exceeds account limit/);
    });

    it('maps ACCESS_DENIED graphQL failures to MissingScopeError', async () => {
        graphQLSpy.mockRejectedValue(new Error(JSON.stringify([{ message: 'Access denied', extensions: { code: 'ACCESS_DENIED' } }])));
        const { MissingScopeError } = await import('./shopifyAPI.js');
        await expect(api.creditStoreCredit({ customerId: 'gid://shopify/Customer/1', amount: 1, currencyCode: 'USD' }))
            .rejects.toBeInstanceOf(MissingScopeError);
        await expect(api.findCustomerByEmail('a@b.co')).rejects.toBeInstanceOf(MissingScopeError);
        await expect(api.createCustomer({ email: 'a@b.co' })).rejects.toBeInstanceOf(MissingScopeError);
    });

    it('getShopCurrency returns the shop currency code', async () => {
        graphQLSpy.mockResolvedValue({ shop: { currencyCode: 'CAD' } });
        expect(await api.getShopCurrency()).toBe('CAD');
    });
});
  • [ ] Step 2: Run to verify failure โ€” npm test -- shopifyAPI.test โ†’ new describe FAILS (methods undefined).

  • [ ] Step 3: Implement. In shopifyAPI.js, near VariantThrottleExceededError add:

js
// Thrown when the shop's token predates a scope this call needs (e.g. the
// store-credit scopes added 2026-07). Routes map it to a 403 with
// code MISSING_SCOPES so the client can prompt re-authorization instead of
// showing a raw failure.
class MissingScopeError extends Error {
    constructor(scope) {
        super(`Shopify access token is missing required scope: ${scope}`);
        this.name = 'MissingScopeError';
        this.code = 'MISSING_SCOPES';
        this.scope = scope;
    }
}

Instance methods on the class (place near other small query helpers):

js
    // graphQL() serialises top-level GraphQL errors into the thrown message,
    // so ACCESS_DENIED is only detectable as a substring.
    _rethrowScopeAware(error, scope) {
        if (typeof error?.message === 'string' && error.message.includes('ACCESS_DENIED')) {
            throw new MissingScopeError(scope);
        }
        throw error;
    }

    async findCustomerByEmail(email) {
        const query = `
            query findCustomerByEmail($query: String!) {
                customers(first: 1, query: $query) {
                    edges { node { id displayName email } }
                }
            }
        `;
        const normalized = String(email).trim().toLowerCase();
        try {
            const data = await this.graphQL(query, { query: `email:${JSON.stringify(normalized)}` });
            const node = data.customers?.edges?.[0]?.node;
            // Search is fuzzy; only an exact email match counts as "this customer".
            if (!node || (node.email || '').toLowerCase() !== normalized) return null;
            return node;
        } catch (error) {
            this._rethrowScopeAware(error, 'read_customers');
        }
    }

    async createCustomer({ email, name }) {
        const mutation = `
            mutation createCustomer($input: CustomerInput!) {
                customerCreate(input: $input) {
                    customer { id displayName email }
                    userErrors { field message }
                }
            }
        `;
        const input = { email };
        if (name && name.trim()) {
            const [firstName, ...rest] = name.trim().split(/\s+/);
            input.firstName = firstName;
            if (rest.length) input.lastName = rest.join(' ');
        }
        try {
            const data = await this.graphQL(mutation, { input });
            if (data.customerCreate.userErrors.length > 0) {
                throw new Error(JSON.stringify(data.customerCreate.userErrors));
            }
            return data.customerCreate.customer;
        } catch (error) {
            this._rethrowScopeAware(error, 'write_customers');
        }
    }

    async getShopCurrency() {
        const data = await this.graphQL('query shopCurrency { shop { currencyCode } }');
        return data.shop.currencyCode;
    }

    async creditStoreCredit({ customerId, amount, currencyCode }) {
        const mutation = `
            mutation creditStoreCredit($id: ID!, $creditInput: StoreCreditAccountCreditInput!) {
                storeCreditAccountCredit(id: $id, creditInput: $creditInput) {
                    storeCreditAccountTransaction { id }
                    userErrors { field message }
                }
            }
        `;
        try {
            const data = await this.graphQL(mutation, {
                id: customerId,
                creditInput: { creditAmount: { amount: amount.toFixed(2), currencyCode } }
            });
            if (data.storeCreditAccountCredit.userErrors.length > 0) {
                throw new Error(JSON.stringify(data.storeCreditAccountCredit.userErrors));
            }
            return { transactionId: data.storeCreditAccountCredit.storeCreditAccountTransaction.id };
        } catch (error) {
            this._rethrowScopeAware(error, 'write_store_credit_account_transactions');
        }
    }

Export: module.exports.MissingScopeError = MissingScopeError; next to the existing extra exports.

  • [ ] Step 4: Run to verify pass โ€” npm test -- shopifyAPI.test โ†’ PASS (all pre-existing tests too).

  • [ ] Step 5: Commit โ€” git commit -m "Teach the Shopify client to look up customers and issue store credit"


Task 3: buylistSettlementService โ€‹

Files:

  • Create: server/services/buylistSettlementService.js
  • Test: server/services/buylistSettlementService.test.js

Interfaces:

  • Consumes: Task 1 model shape; Task 2 shopifyAPI methods + MissingScopeError (propagates uncaught).
  • Produces:
    • previewSettlement({ order, shopifyAPI }) โ†’ { method, amount, customer: {id, displayName, email}|null, willCreateCustomer: boolean }
    • settleOrder({ shop, orderId, note, shopifyAPI }) โ†’ { order } on success or { error, status } (404 unknown, 409 wrong state/lost race, 400 zero/missing amount)
    • _setDeps({ BuylistOrder }) / _resetDeps()

Behavior contract (encode in tests):

  • Amount = order.payout.cashTotal when payout.method === 'cash', else order.payout.creditTotal. Missing/<= 0 amount โ†’ { error, status: 400 } (nothing to pay is a review-screen problem, not a settle).

  • Fresh settle requires status === 'accepted'; the claim is findOneAndUpdate({ _id, shop, status: 'accepted' }, ...) โ€” null result โ†’ { error: 'Order was already settled', status: 409 }.

  • Cash: claim writes the full settlement (method:'cash', amount, currencyCode: null is NOT written โ€” cash has no currency conversion concern; write currencyCode from getShopCurrency() only on the credit path... decision: write currencyCode on both paths for a uniform record; cash uses the same shop currency) and status paid. One DB write, no Shopify mutation beyond getShopCurrency(). (Self-review note: cash calls getShopCurrency() โ€” acceptable, it's a 1-cost query; if the shop is unreachable the cash settle still proceeds with currencyCode: null via try/catch, because a network hiccup must not block handing someone cash.)

  • Store credit: claim writes settlement stub (no storeCreditTransactionId) + paid โ†’ resolve customer (findCustomerByEmail, else createCustomer with customerCreated: true) โ†’ creditStoreCredit โ†’ final findOneAndUpdate $set settlement.storeCreditTransactionId, settlement.customerCreated, customer.shopifyCustomerId.

  • Shopify failure after a fresh claim โ†’ revert ($set status: 'accepted', $unset settlement) and rethrow.

  • Retry path: order already paid + settlement.method === 'store_credit' + no storeCreditTransactionId โ†’ skip claim, re-run resolve/credit/confirm; on failure do NOT revert (the claim must survive).

  • previewSettlement never mutates: returns match or willCreateCustomer: true.

  • [ ] Step 1: Write failing tests โ€” cover, with a mocked shopifyAPI object ({ findCustomerByEmail: vi.fn(), createCustomer: vi.fn(), creditStoreCredit: vi.fn(), getShopCurrency: vi.fn().mockResolvedValue('USD') }) and _setDeps({ BuylistOrder: { findOne: vi.fn(), findOneAndUpdate: vi.fn() } }):

    1. preview with an existing customer โ†’ { customer, willCreateCustomer: false }
    2. preview with no match โ†’ { customer: null, willCreateCustomer: true }
    3. cash settle: single findOneAndUpdate claim with full settlement, no credit calls
    4. credit settle happy path: claim โ†’ existing customer โ†’ credit โ†’ confirm write includes transactionId + shopifyCustomerId
    5. credit settle creates customer when lookup returns null (customerCreated: true in confirm write)
    6. lost race: claim returns null โ†’ { status: 409 }, no Shopify calls
    7. wrong state (pending) โ†’ { status: 409 }
    8. unknown order โ†’ { status: 404 }
    9. zero amount โ†’ { status: 400 }
    10. creditStoreCredit rejects โ†’ revert write happens (status back to accepted, settlement unset) and the error propagates
    11. retry path: paid credit order without transactionId โ†’ no claim, credit re-issued, confirm written
    12. MissingScopeError from any shopifyAPI method propagates out untouched
  • [ ] Step 2: Run to verify failure โ€” npm test -- buylistSettlementService โ†’ FAIL (module not found).

  • [ ] Step 3: Implement the service exactly per the behavior contract, DI pattern copied from buylistConfigService.js (let _deps = null; const getDeps = () => _deps || { BuylistOrder: require('../models/BuylistOrder') };).

  • [ ] Step 4: Run to verify pass โ€” npm test -- buylistSettlementService โ†’ PASS.

  • [ ] Step 5: Commit โ€” git commit -m "Settle buylist orders with native store credit or a tracked cash payout"


Task 4: Zod schema + settle routes โ€‹

Files:

  • Modify: server/schemas/buylistOrder.js (add + export settleBuylistOrderSchema)
  • Modify: server/routes/buylist.js (two routes + exported handlers)
  • Test: server/routes/buylist.settle.test.js

Interfaces:

  • Consumes: Task 3 service; MissingScopeError from Task 2; new ShopifyAPI(req.store.shop, req.accessToken) (same construction as routes/billing.js:119).

  • Produces:

    • GET /api/buylist/orders/:id/settlement-preview โ†’ 200 { preview } (service result), 404, 403 { error, code: 'MISSING_SCOPES' }
    • POST /api/buylist/orders/:id/settle โ€” body { note? } via validate(settleBuylistOrderSchema) โ†’ 200 { order }, 4xx from service { error }, 403 { error, code: 'MISSING_SCOPES' }
    • Exports handleSettlementPreview, handleSettleOrder for direct unit testing (same pattern as handleUpdateOrder).
  • [ ] Step 1: Schema. In server/schemas/buylistOrder.js:

js
const settleBuylistOrderSchema = z.object({
    note: z.string().max(500, 'note too long').optional()
}).strict();

Add to the file's module.exports.

  • [ ] Step 2: Write failing route tests in server/routes/buylist.settle.test.js, copying the makeRes/mock structure of buylist.updateOrder.test.js. Mock ../services/buylistSettlementService with vi.spyOn. Cases: 200 passthrough of { order }; service { error, status } โ†’ that status; MissingScopeError rejection โ†’ 403 with code: 'MISSING_SCOPES'; unexpected rejection โ†’ 500; preview 200 and preview-404 (order not found โ€” the preview handler loads the order itself and returns { error: 'Buy order not found' }).

  • [ ] Step 3: Run to verify failure โ€” handlers not exported yet.

  • [ ] Step 4: Implement routes at the bottom of routes/buylist.js (before module.exports):

js
/**
 * GET /api/buylist/orders/:id/settlement-preview
 * Dry-run identity resolution: who will receive this payout, and will a
 * Shopify customer record be created? Read-only โ€” the settle modal shows
 * this so the merchant confirms the match BEFORE any credit is issued.
 */
async function handleSettlementPreview(req, res) {
    try {
        const BuylistOrder = require('../models/BuylistOrder');
        const order = await BuylistOrder.findOne({ _id: req.params.id, shop: req.store.shop });
        if (!order) return res.status(404).json({ error: 'Buy order not found' });

        const ShopifyAPI = require('../services/shopifyAPI');
        const { previewSettlement } = require('../services/buylistSettlementService');
        const preview = await previewSettlement({
            order,
            shopifyAPI: new ShopifyAPI(req.store.shop, req.accessToken)
        });
        res.json({ preview });
    } catch (error) {
        if (error.code === 'MISSING_SCOPES') {
            return res.status(403).json({ error: error.message, code: 'MISSING_SCOPES' });
        }
        logger.error('Failed to preview buylist settlement', { error: error.message, orderId: req.params.id });
        res.status(500).json({ error: 'Failed to preview settlement' });
    }
}
router.get('/buylist/orders/:id/settlement-preview', handleSettlementPreview);

/**
 * POST /api/buylist/orders/:id/settle
 * Finalize the payout: store credit is issued to the linked/created Shopify
 * customer; cash is recorded as settled in person. Also the retry entry point
 * for a claimed-but-unconfirmed store credit settlement.
 */
async function handleSettleOrder(req, res) {
    try {
        const ShopifyAPI = require('../services/shopifyAPI');
        const { settleOrder } = require('../services/buylistSettlementService');
        const result = await settleOrder({
            shop: req.store.shop,
            orderId: req.params.id,
            note: req.body.note,
            shopifyAPI: new ShopifyAPI(req.store.shop, req.accessToken)
        });
        if (result.error) return res.status(result.status).json({ error: result.error });
        logger.info('Settled buylist order', { shop: req.store.shop, orderId: req.params.id, method: result.order.settlement.method });
        res.json({ order: result.order });
    } catch (error) {
        if (error.code === 'MISSING_SCOPES') {
            return res.status(403).json({ error: error.message, code: 'MISSING_SCOPES' });
        }
        logger.error('Failed to settle buylist order', { error: error.message, orderId: req.params.id });
        res.status(500).json({ error: 'Failed to settle buylist order' });
    }
}
const { validate } = require('../middleware/validate');
const { settleBuylistOrderSchema } = require('../schemas');
router.post('/buylist/orders/:id/settle', validate(settleBuylistOrderSchema), handleSettleOrder);

Export both handlers alongside handleUpdateOrder.

  • [ ] Step 5: Run to verify pass โ€” npm test -- buylist.settle and npm test -- schemas โ†’ PASS.

  • [ ] Step 6: Commit โ€” git commit -m "Add settlement preview and settle endpoints for buylist orders"


Task 5: Scope consolidation + config โ€‹

Files:

  • Modify: server/config/constants.js (SCOPES becomes the single source of truth)
  • Modify: server/config/shopify.js:9, server/routes/auth.js:30, server/routes/shopifyConnect.js:93,164 (fallback literals โ†’ constants)
  • Modify: server/routes/admin.js:347 REQUIRED_PARTNER_SCOPES, server/scripts/onboardPartnerStoreCC.js:36, server/scripts/onboardPartnerStore.js:35 (append the three new scopes)
  • Modify: .env.example (document the expanded SHOPIFY_SCOPES)

Interfaces:

  • Produces: constants.SCOPES = ['write_products','read_products','read_publications','write_publications','read_orders','read_locations','write_inventory','read_customers','write_customers','write_store_credit_account_transactions'] (union of the currently-divergent copies + the three new).

Note: the scope list is currently copy-pasted in five places with two different contents โ€” a live ยง5.8 duplicated guard. This task collapses the fallbacks onto constants.SCOPES. Partner-onboarding lists get the new scopes appended (they gate what NEW partner tokens must grant; existing tokens are unaffected).

  • [ ] Step 1: Update constants.SCOPES to the union list above, with a comment: the three customer/store-credit scopes landed with buylist settlement (spec 2026-07-29); existing installs must re-consent before settling with store credit.
  • [ ] Step 2: Replace each fallback literal: process.env.SHOPIFY_SCOPES?.split(',') || SCOPES in shopify.js; SHOPIFY_SCOPES || SCOPES.join(',') in auth.js and both shopifyConnect.js sites (require constants at top of each file). Append the three scopes to both REQUIRED_PARTNER_SCOPES arrays and the REQUIRED_SCOPES string.
  • [ ] Step 3: npm test (auth/connect tests must still pass) and npm run lint.
  • [ ] Step 4: Commit โ€” git commit -m "Request customer and store-credit scopes from one shared scope list"
  • [ ] Step 5 (manual, PR checklist item โ€” cannot be done from the repo): update SHOPIFY_SCOPES in Cloud Run env + worker env + the GH Actions deploy secret in the same sitting (ยง5.7), and re-authorize ufkes-dev before live testing.

Task 6: GDPR customers/redact anonymizes buylist orders โ€‹

Files:

  • Modify: server/routes/webhooks.js (POST /webhooks/customers/redact)
  • Test: server/routes/webhooks.test.js (extend the existing customers/redact webhook logic describe)

Interfaces:

  • Consumes: Task 1 fields (customer.shopifyCustomerId).

  • Produces: redaction sets customer.email to redacted@gdpr.invalid and $unsets customer.name + customer.shopifyCustomerId on every buylist_orders doc matching { shop, 'customer.email': email }. Lines/totals stay (business records); only personal data goes.

  • [ ] Step 1: Failing test: webhook with customer: { email } calls BuylistOrder.updateMany with exactly that filter/update; webhook without an email does not touch the model; model failure still returns 500 (existing error path).

  • [ ] Step 2: Implement inside the existing handler, replacing the "no customer data stored" comment block (that claim is stale โ€” buylist orders store emails):

js
        const email = customer?.email && String(customer.email).trim().toLowerCase();
        if (shop && email) {
            const BuylistOrder = require('../models/BuylistOrder');
            const result = await BuylistOrder.updateMany(
                { shop, 'customer.email': email },
                {
                    $set: { 'customer.email': 'redacted@gdpr.invalid' },
                    $unset: { 'customer.name': '', 'customer.shopifyCustomerId': '' }
                }
            );
            logger.info('Redacted buylist orders for customer', { shop, modified: result.modifiedCount });
        }

(Emails are stored as typed; if intake lowercases, match that โ€” check createBuylistOrderSchema handling during implementation and mirror it.)

  • [ ] Step 3: npm test -- webhooks โ†’ PASS.
  • [ ] Step 4: Commit โ€” git commit -m "Redact buylist customer data when Shopify requests GDPR erasure"

Task 7: Client โ€” settle modal, settlement display, portal disclosure โ€‹

Files:

  • Create: client/src/pages/buylist/SettleOrderModal.jsx
  • Modify: client/src/pages/buylist/BuylistReviewPage.jsx
  • Modify: client/src/pages/portal/BuylistPortalPage.jsx (one-line disclosure near submit)
  • Test: client/src/pages/buylist/BuylistReviewPage.test.jsx (+ modal behavior through it), client/src/pages/portal/BuylistPortalPage.test.jsx

Interfaces:

  • Consumes: Task 4 endpoints via utils/api.js (api.get/api.post directly, matching the page's existing style); retroui components from the barrel; existing state order, acting, act(action) and flags isPending/isOfferSent in BuylistReviewPage.jsx:180-186.
  • Produces: SettleOrderModal({ orderId, onSettled, onClose }).

Behavior:

  • order.status === 'accepted' โ†’ "Settle payout" button opens the modal.

  • Modal on mount: api.get(/buylist/orders/${orderId}/settlement-preview); shows method + amount, matched customer name/email or "A new customer record will be created for <email>", optional note input; Confirm โ†’ api.post(/buylist/orders/${orderId}/settle, { note }) โ†’ onSettled(order).

  • 403 with code === 'MISSING_SCOPES' (preview or settle) โ†’ modal swaps to an explanatory state: "LGS Forge needs new Shopify permissions to issue store credit โ€” re-authorize the app, then retry." (no broken raw error).

  • order.status === 'paid' โ†’ settlement summary panel (method, amount, settledAt, note). If settlement.method === 'store_credit' and no storeCreditTransactionId: warning "Store credit not confirmed" + Retry button posting /settle again.

  • Portal disclosure: single muted line by the submit button: "Completing a sale may create a customer record at this store." โ€” client-side text only; the server rule it mirrors is customer creation in buylistSettlementService (comment references it).

  • Tests (mock api): settle button renders only for accepted; preview shown; confirm posts and updates status; MISSING_SCOPES state; unconfirmed-credit retry appears for the crash-window shape; portal disclosure text renders.

  • [ ] Steps: failing tests โ†’ run (npm run test:client -- BuylistReviewPage) โ†’ implement โ†’ pass โ†’ commit git commit -m "Let merchants settle buylist payouts from the review screen"


Task 8: Full verification + PR โ€‹

  • [ ] npm test โ†’ 0 failures. npm run test:client โ†’ 0 failures.
  • [ ] npm run test:coverage โ†’ every modified file โ‰ฅ70% on all four metrics (note: flat-threshold gate is dead under Vitest 4 โ€” check the per-file table yourself).
  • [ ] npm run lint โ†’ clean; npm run build (client touched) โ†’ exits 0.
  • [ ] Greps: git diff main --name-only files contain no myshopify.com outside shopifyAPI.js; no || 'mtg'-style defaults; route lines contain validate(.
  • [ ] Push branch, open PR to main titled "Settle buylist orders with native Shopify store credit and tracked cash payouts". PR body includes: ยง5.1 parity statement (all three plugins exempt โ€” order-level change), the ยง5.7 manual env checklist (SHOPIFY_SCOPES: Cloud Run + worker + GH secret), the re-consent note, and "live verification on ufkes-dev deferred until scopes are granted".

Self-Review Notes โ€‹

  • Spec ยง3 crash-window retry โ†’ Task 3 case 11 + Task 7 retry button. Spec ยง2 preview โ†’ Tasks 3/4/7. Spec ยง3 scopes โ†’ Task 5. Spec ยง3 GDPR โ†’ Task 6. Spec ยง4 portal disclosure โ†’ Task 7. Spec ยง1 indexes โ†’ Task 1. Spec ยง5 (extension) is PR 2 โ€” separate plan.
  • Cash-path currency: resolved inline in Task 3 (currency recorded on both paths; cash tolerates lookup failure with null).
  • validate() used for the settle POST; preview GET has no body to validate.