Repository navigation
Payment identity: several payments per invoice, told apart by the receipt; cancelled_at; importer receipts - #765
Conversation
…rd and retry planner A deposit and a balance are different payments (Payment.id); only a repeat of the same payment is refused. Different live receipts never refuse by their own verdict; their ledger records are attributed by mark and set aside. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M2fZEeZ5fYPBhB3TEKv9vb
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M2fZEeZ5fYPBhB3TEKv9vb
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M2fZEeZ5fYPBhB3TEKv9vb
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M2fZEeZ5fYPBhB3TEKv9vb
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c81bf93162
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| orderId: created.id, | ||
| amount: totalForeign, | ||
| currency, | ||
| method: wcOrder.payment_method_title || wcOrder.payment_method || null, |
There was a problem hiding this comment.
Store the WooCommerce gateway ID on imported receipts
When a paid order is imported without immediately posting its invoice (for example, the initial import's skipAccounting path), the later deferred-receipt flow passes this stored Payment.method to lookupPaymentAccount. Payment mappings are populated and keyed by WooCommerce gateway IDs such as stripe, while this line preferentially stores the display title such as Credit card; whenever those differ, the deferred registration reports NO_BANK_ACCOUNT and leaves the imported payment unregistered in the ledger. Store wcOrder.payment_method here, consistent with SalesOrder.paymentMethod and _paymentMethod.
Useful? React with 👍 / 👎.
…hosen receipt The recorder passed an empty receipt set before any receipt was chosen, so a posted deposit blocked the check a balance needed. It is now asked at record time against the chosen receipt, through the shared identity partition. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M2fZEeZ5fYPBhB3TEKv9vb
…nting in-flight payments Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M2fZEeZ5fYPBhB3TEKv9vb
# Conflicts: # lib/connectors/woocommerce/sync/order-import.ts
What
Several payments per invoice are allowed when they are different payments; the same payment is never sent twice. Identity is
Payment.id(the receipt a registration was queued for).Design
lib/domain/accounting/payment-identity.ts) used by the money-post fence, the registration decision, the post-site capacity guard and the manual retry planner (and the revival gate). A rival is a different payment only if both receipts are known, unequal, and the rival'sPaymentrow still exists on the same order (and is not a refund payment). Null identity, deleted receipt, equal id, unreadable receipts = "possibly the same", judged exactly as before (fail closed).AMBIGUOUS_FAILED_REGISTRATIONonly for the same identity; a different receipt's FAILED row is counted (orLEDGER_AMOUNT_UNKNOWN).sales_orders.cancelled_at: DB-stamped by trigger (database clock, preserved while cancelled, cleared on reopen, NULL = unknown, no backfill).addPaymentnow accepts receipts on cancelled orders. A receipt dated strictly after the cancellation is held at registration and at the post-site guard (PAID_AFTER_CANCELLATION: nothing sent, visible warning); ties/unknown take the credit-note path. Overpayment modelling is out of scope.Paymentrow for paid Woo orders (create path) in the same transaction; its id rides on the INVOICE_PAYMENT aspaymentId.addPayment).Xero demo findings (coordinator, 2026-10-16) and how this fits
POST /Payments over outstanding is refused 400, outstanding is net of credit-note allocations, Idempotency-Key replay returns the same PaymentID while a different/no key with the same Reference double-posts, several payments per invoice are fine. So
WOULD_OVERPAYis kept at registration and at the post site (no excess is ever pushed as a payment). Idempotency: the live-unique index and enqueue key are already perPayment.id+ invoice, revivals pin the original token, and the fence refuses a second row for the same receipt. Not done: the post-site cap uses IMS's own ledger total minus posted rows, not the liveAmountDue(net of credit notes); a credit note allocated outside IMS is caught by Xero's 400, not pre-empted. Follow-up recommended.Tests
tests/accounting/payment-identity-fence.test.ts(P1-P5, D1-D10, M10 arms; failing-first: P1/P2/P3/A5 red on trunk, the D rows pass on trunk as controls).tests/accounting/payment-identity-gates.test.ts(partition, attribution, registration, capacity, planner, cancellation timing, agreement table).tests/concurrency/payment-identity.concurrent.test.ts(real DB: same-receipt race posts once, replacement row refused; deposit+balance both post; 50/50 split;cancelled_attrigger).tests/scripts/rehearse-woo-import.test.ts) now asserts every paid imported order has a receipt.Gate (local, final head)
prisma generate, tsc, migrate deploy, unit, concurrency (516/516), db (171/171), drift/scope, check:all, validate (all steps pass except unit's known failures), eslint, tsc last. Known local-only failures only: o3d-secops r20 pair and the
# TODO o3d-qf6q.Not proven / known limits
paidAtis operator-entered, so a backdated receipt reads as paid while active.Round 1 fix (Codex HIGH)
The operator ledger-check recorder asked the posted-registration gate with an empty receipt set before any receipt was chosen, so a posted deposit blocked the check a balance needed. The gate is now asked at record time against the chosen receipt through the shared partition and live-receipt read (preview no longer asks it). Real-DB test 7b (posted deposit of a live receipt beside an unresolved legacy attempt: check records, balance registers); mutation (empty receipt set at record) shown red, restored. Merged origin/development (clean); full gate re-run on the new head (same known local failures only).
Round 2 fix (Codex HIGH)
Capacity was only checked before the per-document money-post lock, so two receipts for different payments could both pass it and the fence (which sets a different receipt's payment aside) would not re-measure.
postMoneyUnderLedgerFencenow takesrecheckUnderLock, run after the ledger fence authorises and before the remote call; the Xero INVOICE_PAYMENT branch passes the capacity guard withcountInFlight: true, which counts rows that passed the fence and are still PROCESSING (not yet SYNCED). A refusal is a failed outcome ("Not sent"), nothing goes on the wire. Real-DB test 5 (two receipts of 60 on a 100 invoice, both pass the unlocked guard, A returns from the fence still PROCESSING, B is refused; control with in-flight rows not counted lets B through); mutations (fence skips recheck, processor omits it) shown red. Merged origin/development (one conflict in order-import_registerPayment, resolved keeping both). Local db tier run on a fresh database (179/179) because the shared scratch db held other tests' leftover rows. Residual: the recheck refusal leaves the row FAILED/attempted (visible), where the unlocked guard retires it CANCELLED; ordering in the test is forced by awaiting A's fence call, the fence itself never waits for the lock.