[WIP] feat(sales): revenue-only chargeback mode (scjz.70/.71) — needs deferred-matrix + sandbox validation#343
Conversation
…scjz.70) A chargeback unwinds a payment-reversed order's recognised revenue WITHOUT returning goods: the credit note (emitted by the refund action as today) reverses recognised revenue against AR, but the COGS reversal and inventory restock are suppressed — the cost is kept as a loss and the customer keeps the goods (the .42 revenue-only-unwind design). - `chargeback?: boolean` threaded through createSalesOrderRefund + createRefund. - stageRefundAccountingReversals suppresses the COGS_REVERSAL sync when chargeback. - restock (applyReturnInboundStockTx) is skipped in both the create and re-stage retry paths even if a return warehouse is set. - New `chargeback` column on SalesOrderRefund (migration) persists the flag so an accounting retry that RE-STAGES (vs replays stored syncs) reproduces the revenue-only treatment. - Unit test proves chargeback emits no COGS_REVERSAL and performs no restock. UNMERGED / sandbox-gated: the payment-poller trigger (scjz.71) that raises this on a detected reversal POSTS a credit note to the live ledger and needs idempotency + prior-refund/shipping aggregation validated on the Xero Demo sandbox — full spec on epic 4wuu. This PR is the reusable mode; .71 wiring follows with the Demo loop. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ll restock paths) — Codex P2
…y (keeps only shipped COGS as loss) — Codex P2
…cks (invariants + reconciliation) — Codex P2
…ing (scjz.70/.71) Deferred-matrix fix (Codex round 5): a chargeback is exempt from the refund reversal-evidence checks ONLY when it staged no COGS/unearned reversal (fully-shipped, credit-note-only). accountingRetrySyncs records the staged service syncs even on success, so `stagedReversal` distinguishes a fully-shipped chargeback (no reversal expected) from a partial/deferred one that staged an UNEARNED_REV_REVERSAL and must still have that evidence — applied in both invariants.ts and reconciliation.ts. .71 payment-poller: raiseChargebackForReversedOrder (idempotent — one chargeback per order) builds the full remaining-order lines + shipping and runs the chargeback path. The Xero poller calls it on a detected payment reversal of a revenue-POSTED manual order (dynamic import breaks the lib→action cycle). NOTE: the QuickBooks poller has no sales-reversal detection at all yet, so there is nothing to wire there — adding QBO sales-reversal detection is a separate connector gap (recorded on epic 4wuu). Still DRAFT / sandbox-gated: the live credit-note postings + idempotency must be validated on the Xero Demo sandbox across the recognised/deferred × full/partial matrix before merge. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ted orders (Codex P1+P2)
… + mark deferred-exemption durable-field TODO (Codex P2)
Update —
|
…work on epic 4wuu
… allocation (scjz.71) Durable deferred-matrix signal (Codex P2): add SalesOrderRefund.reversalStaged (migration), set at staging time = whether a COGS/UNEARNED_REV_REVERSAL was staged. The invariant + reconciliation evidence checks now exempt a chargeback from the reversal-evidence requirement ONLY when reversalStaged is false (fully-shipped, credit-note-only) — a deferred chargeback that staged an unearned reversal still owes that evidence. Replaces the accountingRetrySyncs-derived signal, which is cleared once syncs queue. Order-discount allocation (Codex P2): buildChargebackRefundLines takes an optional targetNetTotalBase (the order's net remaining); when the un-discounted goods total exceeds (net − shipping) it scales the sale lines down proportionally so the credit note reverses the recognised NET revenue, not the gross — shipping left intact (order discounts apply to goods). The credit note derives line amounts from unitPriceBase (= totalBase/qty), so scaling totalBase flows through correctly. Replaces the prior safe-skip of discounted orders. raiseChargebackForReversedOrder now passes the net. Unit tests: reversalStaged=false on a fully-shipped chargeback; discount scaling to (net − shipping) with shipping intact; no scaling when net == gross. Still DRAFT — per-line tax allocation under discount + the full recognised/deferred × full/partial matrix validated on the Xero Demo sandbox before merge. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ot the tax-inclusive grand total (Codex P1)
…e-skip taxable pending sandbox tax-basis validation (Codex P1)
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 13821798e0
ℹ️ 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".
| const goodsGross = saleLines.reduce((sum, line) => addMoney(sum, line.totalBase), toDecimal(0)) | ||
| if (goodsGross.gt(0) && targetGoodsTotal.gte(0) && goodsGross.gt(targetGoodsTotal)) { | ||
| const factor = targetGoodsTotal.div(goodsGross) | ||
| for (const line of saleLines) { | ||
| line.totalBase = roundQuantity(toDecimal(line.totalBase).mul(factor), 4).toNumber() |
There was a problem hiding this comment.
Drop fully refunded discounted lines before scaling
When an order-level discount exists and a prior refund has already consumed a line's full quantity at its discounted value, the new scaling uses the line's pre-discount residual in saleLines, so the later chargeback can credit an already-refunded qty=0 line and under-credit the lines that are actually still outstanding. For example, with two £50 lines and a £20 order discount, after line A was fully refunded for £40, a chargeback should credit line B for £40; this scales the £10 residual on line A plus £50 on line B down to £40, producing about £6.67 on the already-refunded line and £33.33 on the remaining line.
Useful? React with 👍 / 👎.
…cron (Demo-validated, scjz.71) Two bugs caught by validating the chargeback against the Xero Demo Company: 1. fetchReversedInvoiceIds built `where=Type=="ACCREC"&&Status=="VOIDED"&Modified...` with the `&&` raw, so the `&` split it into separate query params and Xero dropped the Status filter — the VOIDED query returned every ACCREC invoice, so the .71 voided-skip treated every reversal as voided and suppressed all chargebacks. Now the where clause is a single encodeURIComponent'd param. 2. createRefund -> getNumberingFormats called requireAuth(), which redirects (NEXT_REDIRECT) in the session-less cron/poller context, so the chargeback failed. getNumberingFormats now accepts an internalBypassToken (non-sensitive settings) and createRefund passes it through for internal callers (also fixes the latent WC-refund webhook path, which already passes the bypass token). Validated on Demo: payment-reversal poll → chargeback → ACCRECCREDIT credit note posts (£50 to Sales, no tax), order REFUNDED, deferral markers reset, COGS NOT reversed, no restock, customer AR nets to zero. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
✅ End-to-end validated on the Xero Demo CompanyDeployed the branch to staging + Demo, seeded a non-taxable revenue-recognised order with a real ACCREC invoice, and the payment-poll raised the chargeback. Confirmed on the Demo ledger:
Two real bugs caught + fixed by the validation (1f83f53)
Still before merge
|
…fe-skip (scjz.71) Blocker A: createSalesOrderRefund compared the (net) chargeback refund total against the gross order total, so a full taxable unwind stuck at PARTIALLY_REFUNDED. Compare against the net order total (totalBase - taxBase) for chargebacks; non-taxable orders have taxBase 0 so behaviour is unchanged. Blocker B (credit-note gross-up) was already handled — the CREDIT_NOTE sync carries the order's per-line taxType with lineAmountsIncludeTax:false, so Xero grosses net lines back up to reverse the full tax-inclusive AR. Removes the taxable auto-chargeback safe-skip now both halves are wired. Pending: real-VAT Demo validation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…cy (scjz.71) P2a: an amount-only/ad-hoc prior refund can push priorRefundedBase above the order's shipping, making remainingShipping negative; subtracting it inflated targetGoodsTotal and could over-credit. Clamp remainingShipping to >= 0. P2b: the one-chargeback-per-order pre-check ran outside the refund lock, so two overlapping poller runs could both pass it and post duplicate credit notes. Re-check for an existing chargeback inside createSalesOrderRefund under the advisory + row lock and replay it (mirrors the externalRefundId path). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…en (Codex P1, scjz.71)
As an export of a 'use server' module, raiseChargebackForReversedOrder was
reachable as an unauthenticated Server Function via direct POST, and it calls
createRefund with INTERNAL_ACTION_BYPASS — so a network caller could forge a
chargeback/refund for any order ID, bypassing the sales.refund permission.
Gate it like createRefund: the in-process payment-poller passes the unforgeable
symbol token; any network caller falls through to requirePermission('sales.refund').
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…z70-71-chargeback-mode # Conflicts: # .beads/issues.jsonl
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 01baebc418
ℹ️ 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".
| const result = await createRefund(orderId, lines, 'Payment reversed (chargeback)', undefined, { | ||
| internalBypassToken: INTERNAL_ACTION_BYPASS, | ||
| chargeback: true, |
There was a problem hiding this comment.
Release allocations when raising pre-shipment chargebacks
When a reversed paid order is still ALLOCATED/PICKING/PACKING (or partially shipped with pending allocations), this path creates a full chargeback refund and can move the order to REFUNDED, but it never releases orderAllocation rows or decrements stockLevel.reservedQty/deletes pending shipments. The cancellation path uses cancelSalesOrderFulfillmentState for that cleanup, while this call passes no return warehouse and only handles refund/accounting, so stock remains reserved against a terminal refunded order after an automatic chargeback.
Useful? React with 👍 / 👎.
…, scjz.71) When createRefund succeeded but its credit-note/reversal staging failed (warning), raiseChargebackForReversedOrder returned success and dropped the warning. The poller then cleared paidAt and advanced the cursor as if fully recorded, while the existing-chargeback pre-check blocked any further automatic attempt — leaving manual retry as the only recovery. Propagate the warning as an error so the poller holds the reversal; accountingRetryRequired still drives the credit-note retry sweep. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ding (Codex P2, scjz.71) The one-chargeback pre-check returned a benign reason even when the existing chargeback still had accountingRetryRequired set, so a later poll cleared paidAt on an incomplete reversal. Surface an error while accounting retry is pending so the poller holds; once the refund-accounting retry sweep posts the credit note the flag clears and subsequent polls return the benign 'already exists'. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ex P2, scjz.71) A chargeback marks the order REFUNDED and keeps dispatched-stock COGS as a loss (no reversal). Group B excludes REFUNDED orders, so charging back a shipped order before its shipment is journaled would mean the COGS never posts and the allocation could be unwound as if stock were on hand. Gate the auto-chargeback to journaled shipments — defer (poller holds paidAt, retries after the next Group B run) when any SHIPPED shipment has no shipmentJournalDate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…dex P2, scjz.71) The chargeback absorbs an order-level discount by scaling each product line and applying its own tax type, but the invoice posts the discount as a separate line at the order-default tax type. For a discounted order spanning multiple tax rates (standard + zero), spreading the discount mis-reverses VAT/AR. Single-rate orders scale correctly. Safe-skip the mixed-tax discounted case with a manual-handling WARNING rather than post an incorrect VAT reversal. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nding accounting (Codex P2, scjz.71) P2a: the order-discount tax type can differ from the product lines' tax type even when all lines share one rate (e.g. zero-rated product, standard-rate discount), so spreading the discount across lines mis-reverses VAT/AR. Safe-skip ALL discounted orders for manual handling rather than reproduce the invoice's separate discount-line tax logic; undiscounted orders post an exact reversal. P2b: the atomic replay branch returned a clean success even when the existing chargeback still had accountingRetryRequired (pending/deferred reversal). Fail closed in that case so a racing poller holds paidAt instead of clearing it on an incomplete reversal. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…m (scjz.71) Refines the prior blanket discounted-order skip per review: an order discount and shipping post at the order-default tax code; the chargeback absorbs the discount by scaling the product lines. That is an EXACT VAT/AR reversal when the discount, shipping and every product line share one tax code, so process that (common) case; only safe-skip when a line's resolved tax code differs from the order default (mixed rates or a reverse-charged line), where spreading the discount would mis-state VAT. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… (scjz.71) Per review: the invoice never scales goods for an order discount — it posts full goods + a SEPARATE negative discount line to settings.discountAccount at the order- default tax code (and omits it entirely when no discount account is configured). The chargeback now mirrors that exactly: full goods lines + a negative 'discount'-kind line posted to the discount account, instead of scaling goods. This reverses sales, discount and VAT/AR accounts precisely for any tax mix (no uniform-tax-code restriction needed). - New 'discount' refund lineKind threads through the filter, persistence (distinguished on replay by null line + negative total), and credit-note account mapping. - raiseChargebackForReversedOrder passes the discount (base = amount / fxRateToBase); safe-skips only when no discount account is configured or prior partial refunds exist. - Removes the old goods-scaling path + its now-moot uniform-tax-code guard. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…lineKind (Codex P2, scjz.71) P2-1: discountAmount is GROSS for tax-inclusive orders, but credit-note lines post exclusive (net). Strip VAT (÷ 1+rate) when pricesIncludeVat before mirroring, so the discount line reverses the invoice exactly instead of over-stating. P2-2: a chargeback unwinds the whole remaining order; prior partial/amount-only refunds make the remaining balance ambiguous (the removed net-total path used to net them). Safe-skip ANY order with prior refunds to manual handling; drop the now-dead per-line prior-refund aggregation. P2-3: lineKind isn't persisted, so loadRefundAccountingQueueInput rebuilt the mirrored discount line as 'shipping' on accounting retry → wrong account. Re-infer null-product negative-total lines as 'discount' (matches the replay reconstruction). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Revenue-only chargeback mode (epic
4wuu/.42design). Built + Codex-reviewed through 5 rounds, each surfacing real downstream complexity. Not mergeable until the deferred-chargeback evidence semantics are refined AND the live postings are validated on the Xero Demo sandbox.What's done (correct + unit-tested)
chargebackflag throughcreateSalesOrderRefund+createRefund+ persisted onSalesOrderRefund(migration) so accounting retry that re-stages reproduces it.effectiveReturnWarehouseIdneutralised so ALL restock paths (pre-ship guard, fallback build, snapshot, inbound movement) skip.CREDIT_NOTEsync (unchanged).Remaining (the hard part — needs the sandbox loop)
refund_missing_reversal_sync/terminal_refunded_order_missing_reversal_evidenceis correct for fully-shipped chargebacks (credit-note-only) but too blunt for partial/deferred chargebacks that DO stageUNEARNED_REV_REVERSAL— those must keep the evidence requirement. Needs the order's deferred/shipment state modelled in the checks..71payment-poller wiring: raise the chargeback (viacreateRefund(chargeback)+buildChargebackRefundLines) on a detected reversal of a revenue-posted manual order, idempotently; cross-port Xero + QBO.Full spec + step-by-step on epic
onetwo3d-ims-4wuu.🤖 Generated with Claude Code