Skip to content

feat(returns): Customer Returns Intake + QC (sub-project F2)#147

Open
ecommeanblvd wants to merge 12 commits into
feat/order-ops-warehouse-transfersfrom
feat/order-ops-customer-returns
Open

feat(returns): Customer Returns Intake + QC (sub-project F2)#147
ecommeanblvd wants to merge 12 commits into
feat/order-ops-warehouse-transfersfrom
feat/order-ops-customer-returns

Conversation

@ecommeanblvd

Copy link
Copy Markdown
Owner

What this changes

Internal customer returns intake + QC module (/f/returns). Records returned goods against a Shopify order, QC per-line with pass/fail quantities, restocks QC-passed units into warehouseInventory, and displays Shopify refund status with a reconciliation flag. Does not push refunds to Shopify — refund status is read-only from the already-synced shopifyOrderRefunds.

  • Schema: customer_returns + customer_return_lines (+ customer_return_status enum), migration 0048.
  • Pure logic (features/returns/logic.ts, 17 tests): code format, draft/QC validation, restock delta, refund reconcile flag.
  • Queries (queries.ts): list / detail / order picker / over-return guard.
  • Actions (actions.ts): createReturn, submitReturnQc (transactional restock, idempotent via status guard), cancelReturn. Server-side over-return + line-membership guards.
  • RBAC: new warehouse.returns permission (operator+admin); nav entry /f/returns.
  • UI: list + create flow + per-line QC detail with refund panel.

Stacked on C1 (feat/order-ops-warehouse-transfers) — merge after C1. Includes the F2 spec + plan docs.

Deploy steps (need DATABASE_URL): run npm run db:migrate (applies 0048) and npx tsx scripts/seed-roles.ts (materializes warehouse.returns for operators) before use.

Shopify impact

  • Scopes touched: none (no Shopify OAuth scopes added; warehouse.returns is an internal RBAC permission)
  • Stores affected: none (read-only consumption of synced refund data)
  • Contains write operations to a store? no

Checklist

  • Tests added/updated (17 new pure-logic tests; full suite 735 passing)
  • npm run typecheck passes (tsc --noEmit clean; npm run build succeeds)
  • No store write operations introduced

🤖 Generated with Claude Code

@taro-fu

taro-fu commented Jun 30, 2026

Copy link
Copy Markdown

Code review

Found 2 issues:

  1. submitReturnQc marks a return as completed even when only a subset of lines are submitted in the QC payload. Lines absent from input.lines keep their default passQty=0/failQty=0/restockedQty=0 and are permanently unprocessable once the status changes (because ret.status !== 'open' blocks re-QC). There is no guard requiring input.lines.length === lines.length (lines fetched from the DB).

.where(eq(schema.customerReturnLines.returnId, input.returnId));
const merged = input.lines.map((qc) => {
const line = lines.find((l) => l.id === qc.lineId);
if (!line) throw new Error('Dòng QC không thuộc phiếu');
return { line, qc };
});
const v = validateReturnQc(merged.map((m) => ({
returnedQty: m.line.returnedQty, passQty: m.qc.passQty, failQty: m.qc.failQty,
})));
if (!v.ok) throw new Error(v.error);
for (const { line, qc } of merged) {
const eff = restockEffect({ sku: line.sku, passQty: qc.passQty });
let inventoryRowId: string | null = line.warehouseInventoryId;
if (line.sku && eff.onHandDelta > 0) {
const [inv] = await tx.insert(schema.warehouseInventory)
.values({
sku: line.sku, productTitle: line.productTitle, variantTitle: line.variantTitle,
qtyOnHand: eff.onHandDelta, updatedBy: userId,
})
.onConflictDoUpdate({
target: schema.warehouseInventory.sku,
set: {
qtyOnHand: sql`${schema.warehouseInventory.qtyOnHand} + ${eff.onHandDelta}`,
updatedBy: userId, updatedAt: sql`now()`,
},
})
.returning({ id: schema.warehouseInventory.id });
inventoryRowId = inv.id;
}
await tx.update(schema.customerReturnLines).set({
passQty: qc.passQty, failQty: qc.failQty, failReason: qc.failReason ?? null,
restockedQty: eff.onHandDelta, warehouseInventoryId: inventoryRowId, updatedAt: sql`now()`,
}).where(eq(schema.customerReturnLines.id, line.id));
}
await tx.update(schema.customerReturns).set({
status: 'completed', qcDoneAt: sql`now()`, qcDoneBy: userId, updatedAt: sql`now()`,
}).where(eq(schema.customerReturns.id, input.returnId));
});

  1. The inline server actions passed to ReturnsPanel (searchOrders, loadOrderLines) do not have their own permission check. In Next.js, server actions are independent HTTP POST endpoints — a client can invoke them directly without going through the page render that contains the hasPermission(role, 'view_returns') guard. searchOrdersForReturn and getOrderLinesForReturn in features/returns/queries.ts contain no auth check of their own, exposing order number, financial status, SKU, and line quantity to any authenticated session regardless of role.

}))}
canManage={hasPermission(role, 'manage_returns')}
searchOrders={async (q: string) => { 'use server'; return searchOrdersForReturn(q); }}
loadOrderLines={async (orderId: string) => { 'use server'; return getOrderLinesForReturn(orderId); }}
/>

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants