Appearance
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-locatedx.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 defaultedshop(ยง5.5). - All Shopify calls through
server/services/shopifyAPI.js(rule 6); grep diff formyshopify.commust 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_SCOPESruntime 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.settlementsub-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/settlementundefined: 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)(throwsError(JSON.stringify(json.errors))on top-level GraphQL errors โ anACCESS_DENIEDextensions code lands in that message string).Produces (all instance methods on
ShopifyAPI):findCustomerByEmail(email)โ{ id, displayName, email } | nullcreateCustomer({ email, name })โ{ id, displayName, email }getShopCurrency()โ'USD'-style codecreditStoreCredit({ customerId, amount, currencyCode })โ{ transactionId }class MissingScopeError extends Errorwithcode = 'MISSING_SCOPES', exported asmodule.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, nearVariantThrottleExceededErroradd:
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
shopifyAPImethods +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.cashTotalwhenpayout.method === 'cash', elseorder.payout.creditTotal. Missing/<= 0amount โ{ error, status: 400 }(nothing to pay is a review-screen problem, not a settle).Fresh settle requires
status === 'accepted'; the claim isfindOneAndUpdate({ _id, shop, status: 'accepted' }, ...)โnullresult โ{ error: 'Order was already settled', status: 409 }.Cash: claim writes the full settlement (
method:'cash', amount, currencyCode: nullis NOT written โ cash has no currency conversion concern; writecurrencyCodefromgetShopCurrency()only on the credit path... decision: writecurrencyCodeon both paths for a uniform record; cash uses the same shop currency) and statuspaid. One DB write, no Shopify mutation beyondgetShopCurrency(). (Self-review note: cash callsgetShopCurrency()โ acceptable, it's a 1-cost query; if the shop is unreachable the cash settle still proceeds withcurrencyCode: nullvia try/catch, because a network hiccup must not block handing someone cash.)Store credit: claim writes settlement stub (no
storeCreditTransactionId) +paidโ resolve customer (findCustomerByEmail, elsecreateCustomerwithcustomerCreated: true) โcreditStoreCreditโ finalfindOneAndUpdate$setsettlement.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'+ nostoreCreditTransactionIdโ skip claim, re-run resolve/credit/confirm; on failure do NOT revert (the claim must survive).previewSettlementnever mutates: returns match orwillCreateCustomer: true.[ ] Step 1: Write failing tests โ cover, with a mocked
shopifyAPIobject ({ findCustomerByEmail: vi.fn(), createCustomer: vi.fn(), creditStoreCredit: vi.fn(), getShopCurrency: vi.fn().mockResolvedValue('USD') }) and_setDeps({ BuylistOrder: { findOne: vi.fn(), findOneAndUpdate: vi.fn() } }):- preview with an existing customer โ
{ customer, willCreateCustomer: false } - preview with no match โ
{ customer: null, willCreateCustomer: true } - cash settle: single
findOneAndUpdateclaim with full settlement, no credit calls - credit settle happy path: claim โ existing customer โ credit โ confirm write includes transactionId + shopifyCustomerId
- credit settle creates customer when lookup returns null (
customerCreated: truein confirm write) - lost race: claim returns null โ
{ status: 409 }, no Shopify calls - wrong state (
pending) โ{ status: 409 } - unknown order โ
{ status: 404 } - zero amount โ
{ status: 400 } creditStoreCreditrejects โ revert write happens (status back toaccepted, settlement unset) and the error propagates- retry path:
paidcredit order without transactionId โ no claim, credit re-issued, confirm written MissingScopeErrorfrom any shopifyAPI method propagates out untouched
- preview with an existing customer โ
[ ] 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 + exportsettleBuylistOrderSchema) - Modify:
server/routes/buylist.js(two routes + exported handlers) - Test:
server/routes/buylist.settle.test.js
Interfaces:
Consumes: Task 3 service;
MissingScopeErrorfrom Task 2;new ShopifyAPI(req.store.shop, req.accessToken)(same construction asroutes/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? }viavalidate(settleBuylistOrderSchema)โ 200{ order }, 4xx from service{ error }, 403{ error, code: 'MISSING_SCOPES' }- Exports
handleSettlementPreview,handleSettleOrderfor direct unit testing (same pattern ashandleUpdateOrder).
[ ] 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 themakeRes/mock structure ofbuylist.updateOrder.test.js. Mock../services/buylistSettlementServicewithvi.spyOn. Cases: 200 passthrough of{ order }; service{ error, status }โ that status;MissingScopeErrorrejection โ 403 withcode: '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(beforemodule.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.settleandnpm 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:347REQUIRED_PARTNER_SCOPES,server/scripts/onboardPartnerStoreCC.js:36,server/scripts/onboardPartnerStore.js:35(append the three new scopes) - Modify:
.env.example(document the expandedSHOPIFY_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.SCOPESto 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(',') || SCOPESinshopify.js;SHOPIFY_SCOPES || SCOPES.join(',')inauth.jsand bothshopifyConnect.jssites (require constants at top of each file). Append the three scopes to bothREQUIRED_PARTNER_SCOPESarrays and theREQUIRED_SCOPESstring. - [ ] Step 3:
npm test(auth/connect tests must still pass) andnpm 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_SCOPESin 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 existingcustomers/redact webhook logicdescribe)
Interfaces:
Consumes: Task 1 fields (
customer.shopifyCustomerId).Produces: redaction sets
customer.emailtoredacted@gdpr.invalidand$unsetscustomer.name+customer.shopifyCustomerIdon everybuylist_ordersdoc matching{ shop, 'customer.email': email }. Lines/totals stay (business records); only personal data goes.[ ] Step 1: Failing test: webhook with
customer: { email }callsBuylistOrder.updateManywith 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.postdirectly, matching the page's existing style); retroui components from the barrel; existing stateorder,acting,act(action)and flagsisPending/isOfferSentinBuylistReviewPage.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). Ifsettlement.method === 'store_credit'and nostoreCreditTransactionId: warning "Store credit not confirmed" + Retry button posting/settleagain.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 โ commitgit 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-onlyfiles contain nomyshopify.comoutsideshopifyAPI.js; no|| 'mtg'-style defaults; route lines containvalidate(. - [ ] Push branch, open PR to
maintitled "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.
