Skip to content

Payment identity: several payments per invoice, told apart by the receipt; cancelled_at; importer receipts - #765

Merged
OneTwo3D merged 8 commits into
developmentfrom
payid-identity
Oct 16, 2026
Merged

OneTwo3D merged 8 commits into
developmentfrom
payid-identity

Conversation

@OneTwo3D

@OneTwo3D OneTwo3D commented Oct 16, 2026 •

Copy link
Copy Markdown
Owner

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

  • One pure partition (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's Payment row 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).
  • Fence: a different live receipt never refuses by its own verdict; its ledger record is attributed by mark (never by amount/date, never by recorded ledger id) and set aside from this payment's matching. A mark on two records withholds; a record carrying this row's own mark is never set aside; the undescribable-attempt arms still read the unfiltered ledger. A 50/50 same-day split now posts both.
  • Registration: a different receipt's FAILED row no longer refuses, its amount still counts toward capacity (freed only when the ledger reads clear). Registration now also refuses when a registration that may be the same payment has already posted, so register => proceed (agreement test).
  • Capacity guard: AMBIGUOUS_FAILED_REGISTRATION only for the same identity; a different receipt's FAILED row is counted (or LEDGER_AMOUNT_UNKNOWN).
  • Retry planner: tokens/settled contenders/live-sibling rule over the same partition; retry of a failed deposit after the balance posted is allowed when the ledger check is clear.
  • sales_orders.cancelled_at: DB-stamped by trigger (database clock, preserved while cancelled, cleared on reopen, NULL = unknown, no backfill). addPayment now 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.
  • Importer creates a Payment row for paid Woo orders (create path) in the same transaction; its id rides on the INVOICE_PAYMENT as paymentId.
  • Two local receipts for one real payment both post (bounded by the existing order-total cap in 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_OVERPAY is 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 per Payment.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 live AmountDue (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_at trigger).
  • Rehearsal (tests/scripts/rehearse-woo-import.test.ts) now asserts every paid imported order has a receipt.
  • Mutations shown red (restored, md5 verified): partition all-same; null=different; drop live-receipt check; drop same-order check; skip attribution; attribute by elimination; marker attributes many; key on row id; registration refuses different; planner unpartitioned; undescribable reads filtered; capacity unnarrowed / not counted; tie=after; unknown=after; registration/guard ignore cancellation; agreement block off; importer writes no receipt; trigger disabled.

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

  • Receipt paidAt is operator-entered, so a backdated receipt reads as paid while active.
  • A registration with no recorded receipt, or whose receipt was deleted, still blocks a hand-recorded one (by design; the importer now removes the main source).
  • Importer creates the receipt on the create path only; an order paid in Woo after import is not given a Payment row here.
  • The operator ledger-check preview still refuses when any registration has posted (receipt not chosen yet).
  • Not run against live Xero (no network writes).

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. postMoneyUnderLedgerFence now takes recheckUnderLock, run after the ledger fence authorises and before the remote call; the Xero INVOICE_PAYMENT branch passes the capacity guard with countInFlight: 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.

OneTwo3D and others added 4 commits October 16, 2026 13:12
…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
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-16T15:06:49.189458Z c81bf93 PR opened
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

OneTwo3D and others added 4 commits October 16, 2026 16:26
…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
@OneTwo3D
OneTwo3D merged commit 46418b8 into development Oct 16, 2026
28 checks passed
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.

1 participant