Repository navigation
Returns money: refund to the original payment or to store credit, restocking fees, and a return that books as its own order - #33
Merged
Conversation
…redit, a return can keep a restocking fee, and a native return books as its own order so the day's journal reverses the sale with no plug Ledger plan PR 2b (docs/domains/returns.md "Returns money"; DECISIONS #20). - lib/returns/: bookReturn (the return's own RETURNED order: a negative line for what came back, the fee line, an invoice when the original was invoiced), refundReturn (pays what the original owes first, then to the original payment or to store credit, under the return's cap), returnMoney (pure rules), returnDetail + returnDetailContract (one shape for the API and the screen, which crashed on load). - processRefund in three steps: reserve under the payment's lock, call the processor outside any transaction with the key holt-refund-<id>, settle. A processor error leaves the refund PENDING and counted; retry reuses the key. No method override: a card is never refunded "to store credit". - lib/customerCredit: one writer per credit movement, in the caller's transaction, under a per-customer lock, only on the customer's own order; CHECK Customer_creditBalance_nonnegative. A register tender and "Apply store credit" on an order spend it; the tender route is payment.take (it refused the Register role). - Journal: return orders recognised with their own lines, the fee to POS_TRANSACTIONS/"Restocking Fee", refunds on them settle AR; PENDING, FAILED and VOIDED payments left out of the journal and the till. - A return's order is locked against order and line edits; a booked return cannot be cancelled. - Migration 20261019120000_returns_money: returnOrderId, restockingFeeLineItemId, Payment.returnId (refundPaymentId carried over and dropped), the fee mapping row, the credit CHECK. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
PR 2b of the stock-ledger plan: returns money, as you directed on 2026-10-16. A refund goes back the way it was paid, or onto the customer's account as store credit toward a new sale or a rewrite, and a return can keep a restocking fee. The customer never waits on the piece being put back or written off (#31's returned-stock queue).
Behind that, it fixes the money bugs listed in
docs/domains/returns.md→ "Returns money", each closed there:What changed
A return books as its own order (DECISIONS The README shows the project's health at a glance: CI, Security, CodeQL and license badges #20,
lib/returns/bookReturn.ts). The first time money moves, the return becomes a RETURNED order:The original order is never rewritten. Once booked, the return can't be cancelled, and its order can't be edited or deleted through any order or line route (
returnOrderEditProblem, one shared check).Paying it out (
lib/returns/refundReturn.ts):processRefund, refunded the way it was taken.processRefundin three steps:holt-refund-<id>(Stripe and Square both take it now).A processor error leaves the row PENDING: still counted by the cap, out of balances and the journal. Retry reuses the same key. The method override is gone. The stale-payment sweeper and the open-checkout check skip PENDING refunds.
Store credit (
lib/customerCredit.ts):Customer_creditBalance_nonnegative, mirrored indb-guards.sql).It's offered as a register tender, and as "Apply store credit" on an order's screen for rewrites and exchanges. The register's tender route is now
payment.take: the MANAGER/ADMIN role list it had refused the Register role (a static read ofresolveRoleListAccess). Its line is removed fromrole-array-api-routes.txt.Restocking fee: a percent or an amount, typed per return, 0 by default, fixed by the first refund. A nonzero fee needs
POS_TRANSACTIONS/"Restocking Fee" mapped. The migration adds that row unmapped; the demo seed maps it to a new4-4950.Journal:
reversesPaymentIdskip now applies only off a return order).Return detail:
lib/returns/returnDetailContract.ts, for the API and the screen.Migration
20261019120000_returns_money:Return.returnOrderId,Return.restockingFeeLineItemIdandPayment.returnId;refundPaymentIdonto its refund row, then dropsrefundPaymentIdandrefundAmount;Docs:
returns.md: "Returns money", with the findings table closed and what's left tracked;accounting.md: "Since PR 2b", and the correctedREFUND_ISSUEDsign (+1, as the code always had it);inventory.md: 2b marked built.Trace / Verification
npm run setup -- --scale=ci(all migrations, then the seed).prisma migrate diff --exit-codeprinted No difference detected. All 21 seeded returns name their line, and all 21 seeded refunds point at their payment.returnMoney.integration.test.ts): $1,000 net, $63.50 tax, $400 cost, a 15% fee, refunded in cash. It came out Dr Sales 1,000, Dr Tax 63.50, Dr Inventory 400 / Cr COGS 400, Cr Restocking Fee 150, Cr Cash 913.50, with AR netting to zero and no Over/Short line. A written-off piece books to shrinkage instead.returnMoney3/12 failed;generateSalesJournal1/20;pendingPaymentLifecycle1/16;returnMoney(unit) 1/9.grep -rn):processRefund: the two refund routes.recordPayment: the order payments route;recordPaymentTxis new and used byrefundReturn.customerCredit's oldissueCredit/applyCredithad no callers.getBalanceandadjustCreditkeep their signatures.refund()is called only frompaymentService.stripeProvider.refundspied on: errors once, then succeeds. Idempotency keys are honoured by the processors, not by Holt; the retry window is set to Stripe's 24 hours, and Square's key lifetime is [NEEDS VERIFICATION].Scan / gate state
npm run check:localon the branch:SONAR_TOKENlocallyAfter that run, one test was added: the edit-guard case in
returnMoney.integration.test.ts, run on its own (12/12). The rebase ontomainchanged nothing in the tree:mainis #32, byte-identical to the branch this was built on.Your decisions (defaults built in, easy to change)
Not in this PR, and why
Tracked in
docs/domains/returns.md→ "Not done, tracked here":Test plan
🤖 Generated with Claude Code