Skip to content

Store credit is a payment, not a discount: import, push and invoice refusal (o3d-zvec.28) - #760

Merged
OneTwo3D merged 22 commits into
developmentfrom
o3d-zvec28-store-credit-payment-2
Oct 16, 2026
Merged

OneTwo3D merged 22 commits into
developmentfrom
o3d-zvec28-store-credit-payment-2

Conversation

@OneTwo3D

@OneTwo3D OneTwo3D commented Oct 9, 2026 •

Copy link
Copy Markdown
Owner

Supersedes #758 (closed; same content, migration directory renamed on a fresh branch, history not rewritten).

What this does

Smart Coupons store credit is a payment, not a discount. WooCommerce takes it off order.total without reducing any line, so the old importer (which summed every coupon_lines[] entry) stored the whole credit as SalesOrder.discountAmount; the Mintsoft push then sent it as DiscountTotalExVat (reduced goods value for customs/IOSS) and the accounting document would have been understated in revenue and VAT.

Decision D-A1 = option (b): the Xero 813/816 payment posting is not built here. Instead IMS fails closed for an order that carries store credit until o3d-iklv lands.

Design

Piece Change
Classifier (lib/connectors/woocommerce/sync/coupon-classification.ts, pure) Reads the type from the order item's coupon_info ([id, code, discount_type, amount], WC 8.7+) or legacy coupon_data.discount_type, and from the order's smart_coupons_contribution map. Read from the code of the production woo-mintsoft sync plugin (read-only, via ssh): it identifies store credit ONLY by smart_coupons_contribution, never by discount_type. Both signals are honoured; a disagreement is a conflict, not a pick. smart_coupon = credit; fixed_cart/percent/fixed_product/percent_product/acfw_bogo = genuine discount; anything else or nothing recorded = UNKNOWN. Credit amount is gross (discount + discount_tax).
Unknown type UNKNOWN money that Woo put on the lines is harmless (carried as before). UNKNOWN money not on the lines (could be a discount or a payment) refuses the import: nothing is created, ERROR wc_coupon_import_refused, and the import returns the same text. Also refused: an unreadable credit amount, and a credit order whose lines + tax + shipping - genuine order discount - credit do not equal the Woo total (credit also inside the lines, e.g. apply-before-tax).
Schema SalesOrder.storeCreditForeign Decimal(18,4) NOT NULL DEFAULT 0 (gross, order currency) + migration 20261009090000_sales_order_store_credit (additive, metadata-only default, no backfill). A Payment row was rejected: every payment reader would read it as cash.
Importer discountAmount carries only the genuine-coupon residual (today's y14 logic, unchanged); credit goes to storeCreditForeign. _registerPayment/_paymentAmount are withheld for a credit order (its document is at full value, not the order total). WARNING wc_store_credit_invoice_withheld when the invoice is queued.
Poster refusal guardCancelledSalesOrderInvoice (already runs under the order row lock for SALES_INVOICE and SALES_INVOICE_UPDATE) now also refuses when storeCreditForeign is non-zero or unreadable (cancellation still outranks it); new guardStoreCreditCreditNote refuses the refund CREDIT_NOTE for such an order (an unreadable order fails closed). Text single-sourced in lib/domain/accounting/store-credit-invoice-refusal.ts and carries the repo's ledger-check preamble. Read from the order, not a payload marker, so it covers import, held release, manual re-queue and update.
Mintsoft push buildPushInput/buildPushPayload unchanged: with the credit out of discountAmount, DiscountTotalExVat is genuine discount only. Matches the plugin: it strips the credit coupon out of DiscountTotalExVat and sends no payment field at all (the credit settles the invoice, Mintsoft is told the full value). The push-time total check (orderTotalDriftPence) now subtracts the credit, so an honest credit order is not flagged as drift.
Coupon backfill Unchanged, documented. It reads only stored columns, so it cannot classify; it only ever CLEARS duplicated line-allocated discount, so it can never restate credit into a discount, and new credit orders are LINE_ALLOCATED-stamped (POST_FIX). A legacy row that stored credit as discount stays as it was (production is unused).
Docs help-docs/woocommerce.md (symlinked from docs/woocommerce.md), docs/mintsoft.md, docs/todo/woo-mintsoft-plugin-parity-gap.md (its "already handled" claim corrected). No new env vars.

Tests (each arm prints its precondition)

tests/connectors/wc-store-credit-payment.test.ts (pure credit, credit + percentage, credit + fixed with remainder, unknown unallocated, no-type unallocated, unknown allocated, zero credit, legacy coupon_data, conflict, contribution-only, unreadable amount, reconciliation ok / double-counted / rounding, refusal text, importer wiring order), tests/accounting/store-credit-invoice-refusal.test.ts (control posts, credit order refused, cancelled outranks, unreadable fails closed, refund of a credit order, create/update/credit-note guard-before-post wiring, single-sourced conditional text), tests/wms-order-push-store-credit.test.ts (push discount, genuine-discount control, drift with/without credit, Mintsoft payload has no credit/payment key), tests/connectors/wc-store-credit-backfill.test.ts (characterisation: unchanged module, cannot be red on trunk).

Mutation table (final head; each shown red, restored from a copy, md5 verified equal, match count 1)

# Mutation Red arms
M1 classifier treats smart_coupon as a discount (sum-everything) 5 (pure credit, credit+percent, credit+fixed, legacy coupon_data, contribution-only)
M2 unknown type with unallocated money falls back to discount 3
M3 zero credit counts as credit 2 (control posts; refund control)
M4 reconciliation always ok 1 (double-counted)
M5 invoice guard ignores store credit 2
M6 credit-note guard never refuses 1 (refund of credit order)
M7 push total check ignores credit 1
M8 importer does not persist the credit 1 (wiring)
M9 credit refusal outranks cancellation 1
M10 payment still registered for a credit order 1 (wiring)
M11 unreadable credit allowed through 1
M12 old mapWcOrderDiscount (sum every coupon) restored 1 (the exact bead repro; this is the trunk behaviour, i.e. the failing-first arm)

What is NOT done

  • The Xero 813/816 payment posting (o3d-iklv) is NOT built. A store-credit order gets no sales invoice, no credit note and no payment registration from IMS until it is. Follow-up: o3d-effm1 (removes these refusals when iklv lands; depends on iklv).
  • No end-to-end importWcOrder test (no harness runs it without a DB): importer wiring is pinned by source order-of-operations scan and the planner is tested purely. Follow-up o3d-3qlyw (DB-tier test).
  • Classification and the reconciliation tolerance are derived from the plugin's notes and synthetic fixtures; there is no real store-credit order payload in the repo. A wrong tolerance fails closed (order refused, not misstated). Follow-up o3d-6ai1r.
  • Legacy rows that stored credit as discount are not restated.
  • next build not run locally.

Rebase notes

Based on development 6a2d905. PRs #751/#753 also touch lib/domain/wms/order-push-sweep.ts (exported buildPushInput, one word); my edits there are the storeCreditForeign select/type/drift lines, so expect a trivial rebase. This PR deliberately does not export buildPushInput and tests the push through runWmsOrderPushSweepCore. #753's penny guard, if it re-derives the total, must also subtract storeCreditForeign.

Gate (exact head 484bb5f; CI env incl. CRON_SECRET, SETTINGS_ENCRYPTION_KEY, AUTH_*, CI=true; heavy tiers under /var/tmp/ims-gate-heavy.lock)

Step Exit Notes
prisma generate 0 first
tsc --noEmit (6144 MB) 0
check:all 0
db:schema:scope origin/development HEAD 0
npm run validate 1 17 of 18 steps PASS; unit 13029 tests, 12514 pass; only not ok = # TODO o3d-qf6q and the known local-only o3d-t2lvg pair (CI validate is green); the migration-deletion guard passes here
prisma migrate deploy (scratch PG17, scram, private port; current_database verified) 0
db:schema:drift 0
test:db (CI env only) 0 173 tests, 171 pass, 0 fail, 2 skipped
db:stamp-scratch + test:concurrency 0 489 tests, 487 pass, 0 fail, 2 skipped
run-migration-verifications --strict (separate DB) 0
app-db-object-access 0
tsc LAST, git status --porcelain empty 0
Dev-DB write census NOT PROVEN dev DB is live-served; no connection of this work pointed at it

CI on 484bb5f: all checks green (validate, build, fresh-db-drift, db-backed-regressions, migration-scope, migration-conventions, producer-census, and the rest). next build not run locally (CI build green).

Codex round 1 (carried over from #758)

  • Reconciliation tolerance capped at two minor units whatever the line count (201-line credit-already-in-lines case refused, honest 201-line case accepted).
  • INVOICE_PAYMENT, INVOICE_EMAIL and WC_INVOICE_NOTE refused for a store-credit order at the posting boundary (read from the order; revived/manual entries included). tests/accounting/store-credit-posting-boundaries.test.ts classifies all 32 processor cases (6 GUARDED, 26 EXEMPT with a reason) and fails on an unclassified one; one mutation per boundary.
  • Update path: a re-read order with credit and a stored credit of 0 gets the credit recorded from the payload (no restatement), ERROR wc_store_credit_recorded_on_update.
  • Refusal retry: no new queue; webhook answers 500 and the sweep holds its cursor. Bead o3d-80k0y for a durable by-id row.
  • Merged origin/development (conflicts only in docs).
  • Migration renamed to the single later name 20261009210000_sales_order_store_credit on this fresh branch (the old branch's history carried two names, which the repo's migration-deletion guard forbids).

Codex round 2

  • Exact reconciliation for a credit order (strict, a quarter of a minor unit; no allowance). A credit of 1 or 2 minor units already in the lines is refused; 201-line honest order accepted, a whole penny out refused for review. Printed drift table: 0 and 0.002 accepted; 0.004, 0.01, -0.01 refused.
  • Signal matrix (15 printed rows): any disagreement involving a credit signal across coupon_info / coupon_data / smart_coupons_contribution (including an unreadable record) refuses the import even when fully allocated (CREDIT_SIGNAL_CONFLICT).
  • Push withheld for an order with credit whose discount is not proven credit-free (no LINE_ALLOCATED stamp, e.g. recorded on update): buildPushInput throws the single-sourced reason, so the create pass parks it VALIDATION_FAILED before anything is claimed or sent. Tested through the push sweep with a printed matrix.
  • Update path: order row lock first, then a conditional UPDATE ... WHERE storeCreditForeign = 0; log only when it wrote. Mocked race (40 rounds) and a real-DB concurrency test (15 rounds, both winners observed).
  • Mutations M20-M26 (and M1/M2/M5 re-run) red, restored by copy with md5 equal. Not mutation-tested against the real-DB concurrency test (UNPROVEN beyond the mocked arm); no rebase needed (development unchanged at 6a2d905).

Codex round 3 (head a52111f) - the update path is simplified

  • Credit is set once, at creation, and never changed. The record-on-update behaviour (and its lock/conditional credit write) is removed; storeCreditForeign is written only by the credit-aware import's creating write, so no code path can change it after an invoice guard read it.
  • Review hold. New nullable enum column SalesOrder.storeCreditAssessment (ASSESSED | REVIEW_REQUIRED), in the same single migration. A later delivery is re-classified; credit or a credit CONFLICT the row does not account for (unassessed order, changed credit, conflict) sets REVIEW_REQUIRED (order row lock, conditional write, idempotent, one ERROR wc_store_credit_review_required entry). Every posting boundary (invoice create/update, credit note, payment, email, note) and the warehouse push refuse REVIEW_REQUIRED with a review-specific single-sourced text. No existing hold field fit (the only SalesOrder hold is the withdrawal hold, with unrelated semantics), hence the one column.
  • Provenance. The push guard requires storeCreditAssessment = ASSESSED (written only in the creating write) for any credit order; NULL (any row older than the column, native orders) fails closed whenever credit is present.
  • Reconciliation is computed from WooCommerce's own line, fee and shipping figures (wcReportedOrderAmounts), not IMS's six-decimal unit-price rebuild; fixture: 3 lines x quantity 997 (rebuild drifts -0.000638, Woo's figures reconcile, accepted). The 1-2 minor unit credit-in-lines refusals stay.
  • Sweep stall (medium): left to o3d-80k0y (note added).
  • Mutations M27-M34 plus M1/M2/M5/M14/M20-M23 re-run: all red, restored by copy with md5 equal. Real-DB concurrency test (15 rounds, both delivery orders): one flag, one entry, credit stays 0; mutation-tested only through the mocked arms.
  • Merged origin/development (e15dde3, Writer ownership map: apply the 2026-10-09 owner rulings (o3d-4syxv) #761) cleanly. Gate: generate, tsc, check:all, schema-scope 0; validate 17/18 (only TODO o3d-qf6q and the known o3d-t2lvg pair); migrate/drift/test:db (173, 0 fail)/test:concurrency (489, 0 fail)/migration-verifications --strict/object-access 0; tsc last 0, porcelain clean.

Codex round 4 (head 304c56d) - customer-facing invoice documents

  • Refused for an order with store credit or held for review, with one single-sourced NOTHING WAS GENERATED OR SENT text: the manual invoice email (sendInvoiceEmail via getInvoiceQueueData), the accounting invoice email (getAccountingInvoiceQueueData), the outbox preparation of INVOICE / ACCOUNTING_INVOICE rows (so queued or retried rows are covered), and the on-the-fly invoice download (409; the stored accounting PDF branch is unchanged).
  • Decision for order documents that show a total (order confirmation, sales-order PDF): small and clearly correct, so they show the credit as its own Store credit line before the Total (goods + tax + shipping - credit = Total). They are not refused.
  • Census (tests/accounting/store-credit-customer-documents.test.ts, printed counts): every file that renders a PDF (8: 3 guarded, 5 exempt with a reason), every place a stored invoice PDF is served (5; all the accounting system's own document, never fetched for a credit order), every queued/prepared email kind (SALES_ORDER_CONFIRMATION, INVOICE, ACCOUNTING_INVOICE, SHIPMENT_DISPATCHED - no totals). A new renderer or kind fails the census.
  • Merged origin/development (Order push: VAT penny-precision guard (o3d-zvec.5) #753 push-total-guard etc.). Its payload total check would have flagged every credit order (it expects the order total to equal the pushed full goods value): PushTotalsInput.storeCreditGross is added to the expected gross, tested both ways. orderTotalDriftPence keeps the credit subtraction in its Decimal form.
  • Mutations M35-M40 (+ M5/M14/M23/M31 re-run): all red, restored by copy with md5 equal. Two were first NOT red (M38/M39) because the census checked presence only; the assertions now bind the guard conditions.
  • Gate on the exact head: generate, tsc, check:all, schema-scope 0; validate 17/18 (TODO o3d-qf6q and the known o3d-t2lvg pair only); migrate/drift/test:db (173)/test:concurrency (489)/migration-verifications --strict/object-access 0; tsc last 0, porcelain clean. CI 28/28 green.

Codex round 5 (head 484bb5f) - a credit signal with no coupon line is never dropped

  • The hole: smart_coupons_contribution was used only to classify MATCHING coupon lines; a contribution whose line WooCommerce omitted recorded zero credit and skipped the reconciliation. Now classifyWcCouponLines returns signalProblems: every non-zero (or unreadable) contribution with no coupon line (codes matched ignoring case and surrounding whitespace; list form has no amounts so is unreadable; multiple codes handled; a zero amount is no credit). Any of them refuses the import (nothing created, CREDIT_SIGNAL_CONFLICT, single-sourced via describeWcCouponRefusal) and, on a later delivery, holds the order (REVIEW_REQUIRED).
  • Other silently-ignored credit signals found by re-scanning the classifier (listed as asked):
    1. contribution with no coupon line - fixed (above).
    2. contribution key differing from the line code in case/whitespace - fixed (matching is normalised on both sides; previously exact-case).
    3. contribution amount unreadable - fixed (refused when orphaned).
    4. wallet / gift-card order meta that reduces the total with no coupon line (_wc_store_credit_used, _used_wallet_amount, _ywgc_applied_gift_cards_total, ... the legacy sync plugin's default key list) - fixed: a non-zero or unreadable value is refused.
    5. contribution AMOUNT versus the coupon line's gross amount - NOT compared: the credit amount is taken from the line, and a wrong amount is caught by the exact total reconciliation (credit orders only); stated so it is not read as covered by a separate check.
    6. the legacy plugin's name-pattern fallback for unlisted wallet meta keys - NOT replicated (it guesses); an unlisted wallet plugin's credit with no coupon line, no contribution and none of the listed keys is undetectable here, since no signal exists to ignore.
  • Tests (printed 12-row orphan-signal table): missing line => refused, matching (also case/space-differing, and an untyped line identified only by its contribution) => credit, zero-amount orphan => nothing, unreadable / list-form / two-code / wallet / object-valued meta => refused, extra credit line without a contribution and a plain percent coupon => unchanged. Update path: orphan contribution and wallet meta on a later delivery => REVIEW_REQUIRED. The importer wiring test (refusal precedes the order create) covers 'no order created'.
  • Mutations M41 (orphan check removed), M42 (case-sensitive matching; first NOT red until the fixture used a differently-cased line code), M43 (wallet meta ignored), M44 (update ignores signal problems), M45 (zero-amount orphan refused) plus M1/M21: all red, restored by copy with md5 equal.
  • Gate on the exact head: tsc, check:all, schema-scope 0; validate 17/18 (only the TODO and the known o3d-t2lvg pair); migrate/drift/test:db (173)/test:concurrency (489)/migration-verifications --strict/object-access 0; tsc last 0, porcelain clean. CI 28/28 green. origin/development had not moved.

Codex round 6 (head 5dbf9b1) - one invariant; session parked, state as of now

  • Done: the classifier is restructured around ONE invariant: only a clean, fully parsed, fully reconciled credit is accepted; any other evidence of store credit is refused on import and holds the order for review on update (wcUpdateNeedsStoreCreditReview, one pure function). Covers a present-but-unparseable smart_coupons_contribution in any shape, credit lines with missing / unreadable / negative amounts, missing or unreadable discount_type, disagreeing sources, credit words on a not-cleanly-typed line, wallet / gift-card meta. Generated test (wc-store-credit-evidence-invariant.test.ts, 201 shapes: 49 clean, 152 evidence, explicit oracle, printed table). Mutations M1, M42, M46-M58, M49/M51 (after widening the generator) red, restored by copy.
  • Behaviour change to know about: a coupon line with NO recorded type is now refused (not provably a discount); the synthetic Woo import rehearsal fixture was updated to carry coupon_info like a real order. A genuine but exotic untyped coupon would be refused for operator review (fail closed).
  • Merged origin/development (Payment hold: ledger-check remedy bound to unmeasurable records and tenant generation (o3d-llyw) #757) cleanly.
  • Verified: CI on 5dbf9b1: 28/28 green. Locally, on the previous head (3350a90) the heavy gate was green apart from three rehearsal tests that the untyped-coupon strictness broke; those are fixed in 5dbf9b1 and pass locally (rehearse-woo-import, 18/18).
  • NOT verified locally on the exact head 5dbf9b1: the full local heavy gate (validate, DB tiers) was queued behind the shared lock when the session was parked and was cancelled before it started. CI on that head is the only full-suite evidence for it. Dev-DB write census still not proven.
  • No scratch database or background job of mine remains.

Codex round 7 (head 5dbf9b1) - the contribution AMOUNT is compared with its coupon line

  • The hole: a smart_coupon line with discount 0 / discount_tax 0 beside a POSITIVE smart_coupons_contribution for the same code was accepted with credit 0 (the import skips reconciliation when credit is 0), creating an ASSESSED order with no recorded credit and invoice/warehouse push unblocked.
  • Rule (explicit, in classifyWcCouponLines): per contribution code, sum the gross (discount + discount_tax) of every coupon line with that code and compare with the contribution amount. Equal within 0.0025 (a quarter of a two-decimal minor unit: storage noise only) => the credit is the line gross and the normal exact total reconciliation applies. ANY other difference is a signalProblem => import REFUSED (CREDIT_SIGNAL_CONFLICT, the single-sourced describeWcCouponRefusal text) and REVIEW_REQUIRED on update: zero line + positive contribution, line greater than contribution, contribution greater than line, positive line + zero contribution, a penny either way, 12.0025 (exactly the bound: refused), duplicate lines for one code (summed), several codes (per code, so a total that matches by accident does not hide a per-code mismatch). A contribution of 0 beside a 0 line is no credit and is clean. A credit line typed smart_coupon that the contribution does not list is still clean (the contribution is optional evidence).
  • Last systematic pass found two more uncovered fields, now refused: (a) fee lines: wallet plugins apply credit as a NEGATIVE fee named for the wallet; a fee named store credit / gift card / wallet with a negative (or unreadable) total or tax is refused on import and holds on update (a plainly named negative fee, a zero wallet fee and a positive wallet top-up fee are unchanged); (b) order total versus components for an order with NO credit: the exact reconciliation used to run only when credit > 0, so money taken off the total with no signal at all passed silently; it now also runs for a credit-free order whenever WooCommerce reported a total, SHORTFALL ONLY (a total below lines + tax + shipping - order-level discount by a quarter of a minor unit or more is refused; a higher total is not credit-shaped and is not refused here).
  • Docs: help-docs/woocommerce.md updated in the same change.

Payload field coverage (every field that can carry credit)

Payload field Rule Covered by
coupon_lines[].discount, .discount_tax credit line needs both present, finite, non-negative; unreadable genuine amount refused generator F2 (22 rows x 2 fields), wc-store-credit-payment unreadable-amount arms
coupon_lines[].meta_data coupon_info / coupon_data (type) all readable sources agree; unreadable / missing / disagreeing refused generator F3 (13 rows) + F4 (10 rows), signal matrix (15 rows)
coupon_lines[].code and meta (credit words on a not-cleanly-typed line) refused generator F6 (5 rows)
coupon line gross versus smart_coupons_contribution amount NEW: equal within 0.0025 per code or refused generator F7 (22 rows)
order meta smart_coupons_contribution (any shape; orphans; unreadable amounts) non-empty {code: amount} map, every code has a line generator F1 (24 rows), orphan table (12 rows)
order meta wallet / gift-card keys (all 11 legacy keys) absent or readable zero only generator F5 over the 11 keys written out in the test (oracle list, asserted equal to the classifier's), 16 shapes each
fee_lines[] negative / unreadable amount named for credit NEW: refused generator F8 (10 rows)
fee_lines[] ordinary names unchanged (carried as before) generator F8 clean rows
discount_total, discount_tax (order level) not read: any discount or credit that reaches the total but not the lines shows up in the total reconciliation below total reconciliation rows
total versus lines + fees + shipping + tax (credit order) exact, quarter minor unit drift table, 201-line arms, high-quantity arm
total versus the same (NO credit) NEW: shortfall refused NO-CREDIT order table (6 rows) + wiring arm
total_tax, shipping_total, shipping_tax, cart_tax enter via line and shipping total_tax in wcReportedOrderAmounts high-quantity and 201-line arms
update path (any of the above on a later delivery) wcUpdateNeedsStoreCreditReview => REVIEW_REQUIRED generator update column (499 shapes), wc-store-credit-update-path
Limits, stated: an unlisted wallet plugin's credit with no coupon line, no contribution, no listed meta key, no credit-named fee and no total shortfall is undetectable; a total HIGHER than the components is not refused (not credit-shaped); the total check runs on import only (an update re-checks the evidence, not the arithmetic); the 0.0025 contribution tolerance is fixed (two-decimal currencies; for a three-decimal currency it is looser than a quarter minor unit, and an honest figure is equal to the digit anyway).

Round 7 mutations (committed first; each restored from a copy, md5 verified equal; the generator run is one test so the first failing row is printed)

# Mutation Red on
MA1 trunk: no amount rule (failing-first) zero line + contribution 12 created credit 0
MA2 tolerance accepts anything same row
MA3 zero-gross line exempt from the comparison same row
MA4 only the first of duplicate lines is summed same code on two lines, sum equals contribution refused
MA5 tolerance a whole penny contribution 12.0025 (exactly the bound)
MA6 one-sided comparison zero line + contribution 12
MB1 fee lines ignored Store credit, -10.00
MB2 negative fee TAX ignored Gift card, tax only negative
MB3 unreadable fee amount ignored Wallet, unreadable total
MB4 no-credit shortfall never refused a penny short 129.99
MB5 no-credit check symmetric a penny over 130.01
MB6 importer reconciles only credit orders wiring arm
MB7 update path ignores fee lines EVIDENCE NOT HELD ON UPDATE
MB8 one wallet key dropped from the classifier (first NOT red: the generator read the code's own list; the test now carries its own oracle list) oracle list + _smart_coupon_credit_used = null

Codex round 8 (head cb454cb) - a posting-time order-state fence for the review transition

  • The hole: the invoice guard released the order lock before the invoice-number check and the remote post, and the credit-note and payment guards read the order without the lock, so a WooCommerce update that flagged REVIEW_REQUIRED between a guard's read and its remote call could still be followed by an invoice, credit note or payment.
  • Fix (the protocol cancellation already uses against an invoice in flight): the posting's own PROCESSING claim is the durable intent (committed by the runner before the entry is processed); the deciding reads serialise on the order row.
    1. Every posting guard now reads the store-credit state UNDER lockSalesOrder: sales invoice create/update (already did), credit note (guardStoreCreditCreditNote, locks the refund's order), payment / invoice email / WooCommerce note (guardStoreCreditFollowUp).
    2. The review transition (updateExistingWcOrderFromPayload), after taking the same lock and before writing, calls assertNoOrderPostingInFlightForReview (new lib/domain/accounting/store-credit-posting-fence.ts). A fresh (under 15 minutes) PROCESSING claim of SALES_INVOICE, SALES_INVOICE_UPDATE, INVOICE_PAYMENT, INVOICE_EMAIL or WC_INVOICE_NOTE for the order (directly, or via payload.referenceId), or a CREDIT_NOTE keyed by one of its refunds, makes it throw StoreCreditReviewDeferredError: the whole update rolls back and the delivery is retried, and the review is recorded once the posting has settled.
      Either the review commits first and the guard reads REVIEW_REQUIRED and refuses, or the posting was authorised first and the review waits. Lock order: order lock, then sync-log reads; no other lock.
  • Stated limits: a claim older than 15 minutes is not treated as live (the same bound the claim/reclaim scheme and the cancellation fence rest on); a posting that went out before the credit was visible is not recalled (the docs say to check the ledger); the retry relies on the webhook answering an error and the poll re-delivering (as for a refused import).
  • Tests (real PostgreSQL, tests/concurrency/store-credit-posting-fence.concurrent.test.ts, 9 arms; nothing ordered by sleeping: the parked side is observed in pg_stat_activity): REVIEW FIRST x3 (invoice guard, credit-note guard, payment guard: parked behind the transition's lock, then refuse), GUARD FIRST x5 (a live claim of each of SALES_INVOICE, SALES_INVOICE_UPDATE, INVOICE_PAYMENT, CREDIT_NOTE-by-refund, INVOICE_EMAIL-by-payload: the transition parks behind the guard's lock, then defers; after the claim settles the review commits), one isolating arm (stale 30-minute claim, settled claim with a fresh stamp, another order's claim: no deferral). Printed preconditions in every arm. Unit arms in wc-store-credit-update-path.test.ts (lock before the claim read, no claim read when no review is needed); existing mocks updated for the guards' new transaction.
  • Mutations (committed first, restored from a copy, md5 equal):
    | # | Mutation | Red |
    |---|---|---|
    | MC1 | transition does not look for the claim | all 5 guard-first arms |
    | MC2 | invoice guard reads without the order lock | review-first invoice (never parks) |
    | MC3 | credit-note guard reads without the lock | review-first credit note |
    | MC4 | follow-up guard reads without the lock | review-first payment |
    | MC5 | claim lookup ignores payload.referenceId | guard-first INVOICE_EMAIL |
    | MC6 | claim lookup ignores refunds | guard-first CREDIT_NOTE |
    | MC7 | claim lookup ignores staleness | isolating arm |
    | MC8 | claim lookup ignores status (first NOT red: the settled row had a null stamp; the arm now keeps a fresh stamp) | isolating arm |
  • Docs: help-docs/woocommerce.md. Merged origin/development.

Gate on the exact head 1890832 (CI env; heavy tiers under the lock; scratch PG17, scram, port 55760; stamp + concurrency on their own freshly migrated DB)

Step Exit Notes
prisma generate / tsc (6144 MB) 0 / 0
migrate deploy / db:schema:drift / db:schema:scope origin/development HEAD 0 / 0 / 0
test:unit (TMPDIR=/dev/shm) 0 13210 tests, 12687 pass, 0 fail, 522 skipped; only not ok is # TODO o3d-qf6q
check:all (incl. ledger-standing census guard) 0
test:db 0 181 tests, 179 pass, 0 fail, 2 skipped
app-db-object-access 0
db:stamp-scratch + test:concurrency 0 / 0 525 tests, 523 pass, 0 fail, 2 skipped (includes the 9 new arms)
migration verifications --strict (separate DB) 0
npm run validate 1 17 of 18 PASS; only the unit step fails, on the TODO and the known local-only o3d-t2lvg pair
tsc LAST, git status --porcelain empty 0 0 lines
Dev-DB write census NOT PROVEN no connection of this work pointed at it
Not run locally: next build (CI build). CI on 1890832: 28/28 check-runs green via the check-runs API.

🤖 Generated with Claude Code

https://claude.ai/code/session_01M2fZEeZ5fYPBhB3TEKv9vb

…efusal (o3d-zvec.28)

Supersedes #758 (same content; the migration directory carries its single later name
20261009210000_sales_order_store_credit, which the old branch's history could not do).

- Smart Coupons store credit is classified from the order item's coupon_info / coupon_data and the
  order's smart_coupons_contribution record, held in SalesOrder.storeCreditForeign (gross), and kept
  out of discountAmount, so the warehouse push carries the full goods value.
- An unknown coupon type with money off the lines, an unreadable credit, or a credit that does not
  reconcile (tolerance capped at two minor units) refuses the import.
- Sales invoice (create/update), credit note, payment registration, invoice email and WooCommerce
  invoice note are refused at the Xero posting boundary for a store-credit order until the 813/816
  payment posting exists; a census test classifies every processor case.
- A re-read order held before credit was recorded gets the credit recorded from the payload (no restatement).

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 9, 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-09T14:16:48.352998Z 9be9fe6 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: 9be9fe6cc1

ℹ️ 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".

}

// The residual that is not in the lines, with an UNKNOWN coupon in play, is the ambiguous case.
if (classified.unknown.length > 0 && unallocated > 0) {

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 Refuse unknown coupons even when line markdowns mask them

When an untyped £10 coupon is store credit that was not applied to any line, but the order also has at least £10 of unrelated per-line markdowns, resolveResidual clamps the aggregate residual to zero and this condition accepts the order. The credit is then neither recorded in storeCreditForeign nor represented as a discount, so the imported components exceed the Woo total; the WMS check is advisory and accounting can post the larger invoice with only the lower Woo payment. Allocation of an unknown coupon cannot safely be inferred from the aggregate line-discount total; refuse it or reconcile the resulting order total before import.

Useful? React with 👍 / 👎.

Comment thread lib/connectors/xero/sync-processor.ts Outdated
// the document rather than post one that exceeds what the customer was charged. Read from the
// order itself, under the same lock, so it covers every producer (import, hold release, manual
// re-queue, update) and not just payloads that happen to carry a marker.
if (orderCarriesStoreCredit(so.storeCreditForeign)) return { kind: 'storeCredit' as const }

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 Recheck store credit at the remote-write boundary

When a queued invoice is processed concurrently with updateExistingWcOrderFromPayload discovering credit on an existing order, this transaction can read zero and release its row lock before the updater commits a positive storeCreditForeign; the worker then continues through the number/lease fences and posts the invoice. The updater neither checks the processing claim nor shares a lock through the remote call, so the lock here does not provide the claimed coverage. Recheck credit in the immediate beforePost fence or otherwise serialize the updater with the posting attempt.

Useful? React with 👍 / 👎.

OneTwo3D and others added 21 commits October 9, 2026 16:33
…push withhold, locked conditional update)

- reconciliation of a credit order is exact (quarter minor unit), not an allowance
- every disagreement involving a credit signal (coupon_info / coupon_data / smart_coupons_contribution) is refused whatever the allocation residual
- an order with credit whose discount is not proven credit-free (no LINE_ALLOCATED stamp) is withheld from the warehouse push and parked with a reason
- update path takes the order row lock and records the credit with a conditional write (WHERE storeCreditForeign = 0)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M2fZEeZ5fYPBhB3TEKv9vb
…; provenance; Woo's own amounts)

- storeCreditForeign is set only by the credit-aware import's creating write; the record-on-update path is gone
- a later delivery that shows credit (or a credit conflict) the row does not account for sets
  StoreCreditAssessment.REVIEW_REQUIRED, which every posting boundary and the warehouse push refuse
- the push guard needs storeCreditAssessment = ASSESSED (written only by the creating write); NULL fails closed
- the credit reconciliation uses WooCommerce's own line / fee / shipping figures, not the 6dp unit-price rebuild

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M2fZEeZ5fYPBhB3TEKv9vb
…store credit)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M2fZEeZ5fYPBhB3TEKv9vb
- the manual invoice email, the accounting invoice email, the outbox preparation of INVOICE / ACCOUNTING_INVOICE rows and the
  on-the-fly invoice download refuse an order with store credit / held for review, with one single-sourced text
- the order confirmation and sales-order PDF show the credit as its own line so their figures add up
- a census lists every place that renders or sends a customer document (printed counts, GUARDED / EXEMPT-with-reason)
- the pushed-payload total check (push-total-guard) expects the full goods value: expected gross = order total + store credit

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M2fZEeZ5fYPBhB3TEKv9vb
…heir presence

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M2fZEeZ5fYPBhB3TEKv9vb
…ever dropped)

- a non-zero or unreadable smart_coupons_contribution entry with no matching coupon line (case / whitespace normalised) refuses the import
  and, on update, holds the order for review; so does a non-zero wallet / gift-card order meta value
- contribution codes are matched ignoring case and surrounding whitespace

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M2fZEeZ5fYPBhB3TEKv9vb
…ed line

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M2fZEeZ5fYPBhB3TEKv9vb
…ve path

Credit evidence that is not a clean, fully parsed, fully reconciled credit is refused on import and holds the
order for review on update. The classifier is restructured around a whitelist of clean shapes (credit line,
contribution record, genuine discount line, wallet meta); every other shape - missing, malformed, unreadable,
conflicting, orphaned, credit-worded, or an absent discount_type - falls to a problem.

- smart_coupons_contribution present in ANY value shape (null, '', 0, array, empty map, malformed JSON, nested) that is not a clean
  non-empty {code: amount} map with every code matched by a coupon line is a problem
- a credit line with a missing / unreadable / negative amount is a problem (not an UNKNOWN that sums to 0)
- the update decision is one pure function (wcUpdateNeedsStoreCreditReview)
- generated invariant test over ~195 payload shapes with an explicit oracle

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M2fZEeZ5fYPBhB3TEKv9vb
…eing-type branches; docs

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M2fZEeZ5fYPBhB3TEKv9vb
…e like a real order

A coupon line with no recorded discount_type is now refused (not provably a discount), so the synthetic store's coupon
lines carry coupon_info as WooCommerce 8.7+ reports them.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M2fZEeZ5fYPBhB3TEKv9vb
…he credit is refused

A smart_coupon line with discount 0 beside a positive smart_coupons_contribution was accepted
with credit 0 (no reconciliation, order ASSESSED with no recorded credit). The contribution
amount is now compared with the line gross (within 0.0025, per code, summed over duplicate
lines); any other difference is a signal problem: refused on import, review hold on update.
The generated invariant test gains the amount-mismatch dimension (22 shapes).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M2fZEeZ5fYPBhB3TEKv9vb
…al shortfall refused, every wallet key generated

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M2fZEeZ5fYPBhB3TEKv9vb
…the amount rule, fee lines and no-credit shortfall

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M2fZEeZ5fYPBhB3TEKv9vb
Every order-scoped posting guard (sales invoice create/update, credit note, payment and other follow-ups) now
reads the store-credit state under the order row lock, and the review transition, after taking the same lock,
defers (retryable, whole update rolled back) while a fresh PROCESSING claim of an order-scoped posting for
the order exists. Real-Postgres race arms in both directions, isolating arms for stale/settled/other-order.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M2fZEeZ5fYPBhB3TEKv9vb
…only status distinguishes it

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
@OneTwo3D
OneTwo3D merged commit 33ac6bf 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