Skip to content

[WIP] feat(sales): revenue-only chargeback mode (scjz.70/.71) — needs deferred-matrix + sandbox validation#343

Open
OneTwo3D wants to merge 25 commits into
developmentfrom
codex/cogs-scjz70-71-chargeback-mode
Open

[WIP] feat(sales): revenue-only chargeback mode (scjz.70/.71) — needs deferred-matrix + sandbox validation#343
OneTwo3D wants to merge 25 commits into
developmentfrom
codex/cogs-scjz70-71-chargeback-mode

Conversation

@OneTwo3D

Copy link
Copy Markdown
Owner

⚠️ DRAFT — DO NOT MERGE

Revenue-only chargeback mode (epic 4wuu / .42 design). 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)

  • chargeback flag through createSalesOrderRefund + createRefund + persisted on SalesOrderRefund (migration) so accounting retry that re-stages reproduces it.
  • Suppresses COGS_REVERSAL (cost kept as a loss) and inventory restock (goods kept) — effectiveReturnWarehouseId neutralised so ALL restock paths (pre-ship guard, fallback build, snapshot, inbound movement) skip.
  • Skips the shipment cost-consume entirely (avoids "Cannot reverse COGS…" stranding chargebacks in retry) BUT preserves the allocation reversal for unshipped allocated qty (still in stock, not a loss) and the unearned-revenue reversal for deferred portions.
  • Revenue/AR reversal is the existing CREDIT_NOTE sync (unchanged).
  • Unit test: chargeback emits no COGS_REVERSAL + no restock.

Remaining (the hard part — needs the sandbox loop)

  1. Invariant/reconciliation evidence semantics for the partial/deferred matrix (Codex round 5): the current chargeback exemption from refund_missing_reversal_sync / terminal_refunded_order_missing_reversal_evidence is correct for fully-shipped chargebacks (credit-note-only) but too blunt for partial/deferred chargebacks that DO stage UNEARNED_REV_REVERSAL — those must keep the evidence requirement. Needs the order's deferred/shipment state modelled in the checks.
  2. .71 payment-poller wiring: raise the chargeback (via createRefund(chargeback) + buildChargebackRefundLines) on a detected reversal of a revenue-posted manual order, idempotently; cross-port Xero + QBO.
  3. Sandbox validation: the recognised/deferred × full/partial × Xero/QBO matrix on the Demo Company — confirm the credit note posts (DR sales / CR AR), COGS is NOT reversed, no restock, deferral markers reset, allocation reversed for unshipped, idempotent on re-poll.

Full spec + step-by-step on epic onetwo3d-ims-4wuu.

🤖 Generated with Claude Code

OneTwo3D IMS and others added 5 commits June 22, 2026 11:48
…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>
…y (keeps only shipped COGS as loss) — Codex P2
…cks (invariants + reconciliation) — Codex P2
OneTwo3D IMS and others added 3 commits June 22, 2026 12:35
…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>
… + mark deferred-exemption durable-field TODO (Codex P2)
@OneTwo3D

Copy link
Copy Markdown
Owner Author

Update — .71 wiring + deferred-matrix + 7 Codex rounds

Now includes the deferred-matrix evidence handling, the .71 Xero payment-poller wiring (idempotent raiseChargebackForReversedOrder), retry-safe paidAt clearing, VOIDED-invoice exclusion, and a safe-skip for discounted orders. Still DRAFT — sandbox-gated.

Remaining for the sandbox-validated continuation (full detail on epic 4wuu):

  1. Durable staged-reversal field (Codex P2): the deferred-chargeback evidence exemption in invariants.ts/reconciliation.ts currently derives stagedReversal from accountingRetrySyncs, which is cleared once syncs queue — so a completed deferred chargeback reads credit-note-only. Needs a durable per-refund record of staged reversal types (TODOs marked in-code).
  2. Order-discount allocation: discounted-order chargebacks are safe-skipped (manual handling + warning) until the discount is allocated across lines to match the invoice (the credit note derives amounts from unitPriceBase).
  3. QBO sales-reversal detection: the QuickBooks poller has none, so there's nothing to cross-port .71 into yet — adding QBO reversal detection is a separate connector gap.
  4. Demo-ledger validation matrix: recognised/deferred × full/partial — confirm credit note posts DR sales/CR AR, no COGS reversal, no restock, deferral reset, allocation reversed for unshipped, idempotent re-poll, voided-invoice skip.

OneTwo3D IMS and others added 4 commits June 22, 2026 12:55
… 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)
@OneTwo3D OneTwo3D marked this pull request as ready for review June 22, 2026 13:54
@OneTwo3D OneTwo3D marked this pull request as draft June 22, 2026 13:54

@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: 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".

Comment thread lib/domain/sales/refund-service.ts Outdated
Comment on lines +172 to +176
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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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>
@OneTwo3D

Copy link
Copy Markdown
Owner Author

✅ End-to-end validated on the Xero Demo Company

Deployed 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:

  • ACCRECCREDIT credit note CN-2026-00001, £50 → Sales (200), TotalTax 0
  • No COGS reversal (cost kept as a loss), no restock
  • Order REFUNDED, deferral markers reset; customer AR nets to 0 (invoice 50 − credit 50)

Two real bugs caught + fixed by the validation (1f83f53)

  1. fetchReversedInvoiceIds Xero where URL — the raw && split into separate query params, so Xero dropped the Status filter and the VOIDED query returned every ACCREC invoice → every reversal looked voided → the .71 voided-skip suppressed all chargebacks. Fixed with encodeURIComponent.
  2. getNumberingFormats requireAuthNEXT_REDIRECT in the session-less cron poller → chargeback failed. Fixed via an internalBypassToken (also fixes the latent WC-refund webhook path).

Still before merge

  • Rebase the benign .beads conflict
  • Taxable chargebacks still safe-skipped (net/gross tax basis — next Demo pass)
  • QBO sales-reversal detection (separate connector gap)

OneTwo3D IMS and others added 3 commits June 22, 2026 14:41
…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>
@OneTwo3D OneTwo3D marked this pull request as ready for review June 22, 2026 14:57
…z70-71-chargeback-mode

# Conflicts:
#	.beads/issues.jsonl

@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: 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".

Comment thread app/actions/sales.ts
Comment on lines +1938 to +1940
const result = await createRefund(orderId, lines, 'Payment reversed (chargeback)', undefined, {
internalBypassToken: INTERNAL_ACTION_BYPASS,
chargeback: true,

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

OneTwo3D IMS and others added 5 commits June 22, 2026 15:05
…, 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>
OneTwo3D IMS and others added 3 commits June 22, 2026 15:43
…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>
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