Skip to content

fix(accounting): refund-reversal-aware deferred-revenue true-up for partially-refunded orders (scjz.68)#352

Merged
OneTwo3D merged 6 commits into
developmentfrom
fix/scjz68-reversal-aware-deferred-trueup
Jun 23, 2026
Merged

fix(accounting): refund-reversal-aware deferred-revenue true-up for partially-refunded orders (scjz.68)#352
OneTwo3D merged 6 commits into
developmentfrom
fix/scjz68-reversal-aware-deferred-trueup

Conversation

@OneTwo3D

Copy link
Copy Markdown
Owner

Problem

The Group-B daily-batch deferred-revenue true-up (scjz.41) excluded PARTIALLY_REFUNDED orders. A refund of an order's unshipped lines posts an UNEARNED_REV_REVERSAL that debits the unearned account outside the shipment-recognition running total, while remainingDeferred only subtracted prior shipment recognition — so truing up would re-recognize (and over-debit unearned revenue for) value the refund already reversed. The exclusion in turn permanently stranded the goods remainder + shipping share of orders that were fully shipped net of refunds.

This is the dedicated rigorous implementation captured after PR #347 (closed) — coverage-based, not the £0.05 rounding-scale heuristic that didn't converge.

Approach — pure, unit-tested helpers (lib/domain/accounting/deferred-trueup.ts)

  • sumPostedUnearnedReversal — the unearned-account debit across an order's posted (PENDING/PROCESSING/SYNCED) UNEARNED_REV_REVERSAL syncs, ignoring the allocation-reversal line (which debits inventory). Mirrors refund-service's extractPayloadAmount. remainingDeferred now subtracts it, so over the order's life the unearned liability nets to exactly zero and the true-up can never over-recognize. Applied to all orders, not just partially-refunded ones.
  • isFullyShippedNetOfRefunds — gates the PARTIALLY_REFUNDED true-up to orders whose every shippable line is covered by dispatched (SHIPPED) shipments plus allocation-source (unshipped) refund qty. Combines component quantities before taking line coverage (min-of-sums) so kits are exact, not stranded. Returns of shipped units don't reduce the ship obligation, so shipment-source refund entries are excluded.
  • batchContainsFinalUnjournaledShipment — holds the true-up until this batch holds the order's final dispatched-but-unjournaled shipment, so a XERO_DAILY_BATCH_LIMIT window split can't recognize a later shipment's revenue early. Applied to the terminal-status path too (strictly safer; converges as journaling shrinks the unjournaled set).

Wired identically into the Xero daily-sync, the QuickBooks daily-sync (cross-port), and the Xero daily-batch preview — the preview applies the same takeDailyBatchWindow so it matches what posts (scjz.69).

Tests / gates

  • 14 new pure-helper unit tests; type-check + eslint + check:all + the full 189-test accounting suite all green.
  • Codex adversarial pass (this issue's established loop): round 1 found a preview/live windowing parity bug (fixed) + an out-of-scope full-refund stranding risk (filed as onetwo3d-ims-qn8a); round 2 clean.

Remaining (merge gates)

  • ⚠️ Finance review of the refund-vs-deferral GL treatment.
  • ⚠️ Xero/QBO sandbox validation — same sandbox gating as epic onetwo3d-ims-4wuu.

Closes onetwo3d-ims-scjz.68 (code complete). Follow-up: onetwo3d-ims-qn8a.

🤖 Generated with Claude Code

OneTwo3D IMS and others added 5 commits June 23, 2026 15:25
…artially-refunded orders (scjz.68)

The Group-B daily-batch deferred-revenue true-up (scjz.41) excluded
PARTIALLY_REFUNDED orders: a refund of unshipped lines posts an
UNEARNED_REV_REVERSAL that debits the unearned account OUTSIDE the
shipment-recognition running total, and `remainingDeferred` only subtracted
prior shipment recognition — so truing up would re-recognize (and over-debit
unearned revenue for) value the refund already reversed. The exclusion in turn
permanently stranded the goods remainder + shipping share of fully-refunded-net
orders in deferral.

New pure, unit-tested helpers (lib/domain/accounting/deferred-trueup.ts):
- sumPostedUnearnedReversal: the unearned-account debit across an order's posted
  UNEARNED_REV_REVERSAL syncs (ignores the allocation-reversal line, which debits
  inventory). Mirrors refund-service's extractPayloadAmount. remainingDeferred now
  subtracts it, so the unearned liability nets to exactly zero over the order's
  life and the true-up can never over-recognize — applied to ALL orders, not just
  partially-refunded ones.
- isFullyShippedNetOfRefunds: gates the PARTIALLY_REFUNDED true-up to orders whose
  every shippable line is covered by dispatched (SHIPPED) shipments plus
  allocation-source (unshipped) refund qty. Combines component quantities before
  taking line coverage (min-of-sums) so kits are exact, not stranded. Returns to NOT
  true up returns of shipped units (they don't reduce the ship obligation).
- batchContainsFinalUnjournaledShipment: holds the true-up until this batch holds
  the order's final dispatched-but-unjournaled shipment, so a XERO_DAILY_BATCH_LIMIT
  window split can't recognize a later shipment's revenue early. Applied to the
  terminal-status path too (strictly safer; converges as journaling shrinks the set).

Wired identically into the Xero daily-sync, the QuickBooks daily-sync (cross-port),
and the daily-batch preview (so the preview matches what posts — scjz.69).

type-check + eslint + check:all + 189 accounting tests green; 14 new helper tests.
Live-GL: needs finance review of the refund-vs-deferral treatment and Xero/QBO
sandbox validation before merge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ve posting (scjz.68/.69)

Codex adversarial review: the preview loaded every unjournaled shipment unbounded,
so the new batchContainsFinalUnjournaledShipment guard always saw an order's full
shipment set and marked its true-up final — while live Xero slices Group B with
XERO_DAILY_BATCH_LIMIT (take: batchLimit + 1 → takeDailyBatchWindow) and would
defer that true-up to a later run. The preview therefore overstated the next
batch's revenue. Apply the same window to the preview query so the guard sees the
same slice and the preview matches what posts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Seeds a PARTIALLY_REFUNDED order that is fully shipped net of refunds (2 shipped
+ 1 refunded-unshipped of 3 ordered, with a posted UNEARNED_REV_REVERSAL) and one
that is not (1 shipped + 1 refunded of 3), runs the real runDailyBatchSync against
an isolated DB, and asserts the staged Group B recognition:
- fully-shipped-net-of-refunds trues up to deferredBase - reversal (7.00),
  reversal-aware (not the naive 10.00) and above the proportional slice (6.67);
- not-fully-shipped recognises proportional only (3.33), no true-up.

Run: DATABASE_URL=...onetwo3d_ims_e2e NODE_OPTIONS='--import tsx' node scripts/repro-scjz68.ts

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@OneTwo3D

Copy link
Copy Markdown
Owner Author

Isolated-DB daily-batch validation ✅

Ran the real runDailyBatchSync (Group B staging — E2E_TEST_MODE, no live Xero) against a fresh isolated onetwo3d_ims_e2e DB via scripts/repro-scjz68.ts:

Scenario Setup Expected Result
Fully shipped net of refunds 3 ordered, 2 shipped + 1 refunded-unshipped, posted UNEARNED_REV_REVERSAL debit 3.00 true up to deferredBase − reversal = 7.00 7.00
↳ reversal-aware not the naïve 10.00; above proportional 6.67
Not fully shipped net of refunds 3 ordered, 1 shipped + 1 refunded proportional only, no true-up 3.33

groupB=2, no errors; the eligible shipment was journaled. This exercises the actual posting path and confirms the unearned liability nets to zero with no over-recognition and no stranding. The QBO daily-sync uses the identical helpers + wiring (covered by parity + the 14 unit tests).

Still gated on: finance review of the refund-vs-deferral treatment (a live Xero/QBO tenant run would be the final belt-and-braces, but the GL decision logic is now validated end-to-end against a real Postgres batch).

@OneTwo3D

Copy link
Copy Markdown
Owner Author

Live Xero Demo Company validation ✅

Deployed this branch to staging (ims-stage / onetwo3d-ims-isolated, which holds the live Xero Demo Company connection), seeded a PARTIALLY_REFUNDED-but-fully-shipped order (3 ordered, 2 shipped + 1 refunded-unshipped, posted UNEARNED_REV_REVERSAL £3), and ran the real runDailyBatchSyncprocessPendingXeroSync:

Check Result
Group B recognised revenue £7.00 (= deferredBase 10 − reversal 3; reversal-aware, not naïve 10; above proportional 6.67)
Posted to Xero SYNCED, real manual-journal id 9ec48896-4610-441a-ae08-0cfa1ef0aa03
Unearned debit on the posted journal £7.00

After the run: temporarily-enabled sync flags restored to false, all seeded rows deleted, and staging restored to development. The Demo ledger retains the test manual journal(s) (resettable). The reusable procedure is saved in beads (how-to-run-a-live-xero-e2e-test).

scjz.68 is now validated at unit + isolated-DB + live Demo Company levels. Remaining gate: finance sign-off on the refund-vs-deferral treatment.

@OneTwo3D OneTwo3D merged commit bbccbf5 into development Jun 23, 2026
10 checks passed
@OneTwo3D OneTwo3D deleted the fix/scjz68-reversal-aware-deferred-trueup branch June 23, 2026 16:15
OneTwo3D pushed a commit that referenced this pull request Jun 23, 2026
… Xero Demo)

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