I1 refused any credits-funded order dated before 2026-01-09, storing
nothing at all — no transaction and no metadata, so the receipt was
discarded rather than kept as history.
Its reason was splits, not spend: before the cutover shared expenses
lived in SplitMyExpenses, and re-importing them would double-charge
against carryover transaction 2348. That reason expired with 788219b,
where ACTIVE_OBLIGATION became `settled = false AND transaction_date >=
'2026-01-09'`. A pre-cutover split can no longer assert a debt, so a
pre-cutover order cannot move a balance however it is recorded — and
ingestion writes no splits at any date, which now has a test of its own.
What the guard was still doing was hiding ordinary history: 275 orders,
$9,799.96 of meals and rides across 2020-2025, invisible only because the
money came from a gift-card balance instead of a card.
The funding side stays as it is, deliberately. Some of those orders were
paid from ShopBack gift cards that are themselves booked as expenses, so
that portion is counted twice. The exposure is bounded at $3,411.16 over
14 loads and is probably smaller: the descriptors name no brand — "ShopBack
Gift Cards SQ" is a batch code, and the card could be Amazon, Airbnb or
Shell as easily as DoorDash — and six are categorised `gifts`, which may
be real presents rather than self-funding. Reclassifying them on a guess
would corrupt correct data to fix a double-count that cannot be shown.
Only the ShopBack purchase emails can settle it, joined on total paid.
This commit is contained in:
@@ -0,0 +1,31 @@
|
|||||||
|
-- Let credits-funded orders exist before the cutover.
|
||||||
|
--
|
||||||
|
-- `chk_ingested_orders_after_cutover` (migration 0018) refused any row with
|
||||||
|
-- payment_method = 'credits' dated before 2026-01-09. It was written as a
|
||||||
|
-- database-level guard for invariant I1, whose stated reason was splits:
|
||||||
|
-- before the cutover, shared expenses lived in SplitMyExpenses, and
|
||||||
|
-- re-importing them would double-charge Sonu against carryover transaction
|
||||||
|
-- 2348.
|
||||||
|
--
|
||||||
|
-- That reason no longer holds. Since finance-app 788219b, ACTIVE_OBLIGATION is
|
||||||
|
-- `ts.settled = false AND t.transaction_date >= '2026-01-09'`, so a split on a
|
||||||
|
-- pre-cutover transaction cannot assert a debt at all. The guard now blocks
|
||||||
|
-- something it was never aimed at: the orders themselves, which are ordinary
|
||||||
|
-- historical spend. 275 of them — $9,799.96 of meals and rides across
|
||||||
|
-- 2020-2025 — were invisible because the receipt was the only record and the
|
||||||
|
-- money came from a gift-card balance rather than a card.
|
||||||
|
--
|
||||||
|
-- What is NOT resolved, and is accepted deliberately (user, 2026-07-28): some
|
||||||
|
-- of those orders were funded by ShopBack gift cards that are themselves
|
||||||
|
-- recorded as expenses, so that portion is counted twice. The exposure is
|
||||||
|
-- bounded at $3,411.16 (14 loads) and is probably smaller, because the
|
||||||
|
-- descriptors name no brand — "ShopBack Gift Cards SQ" is a batch code, and
|
||||||
|
-- the card could be Amazon, Airbnb or Shell as easily as DoorDash. Six are
|
||||||
|
-- categorised `gifts` and may be real presents rather than self-funding.
|
||||||
|
-- Reclassifying them on a guess would corrupt correct data to fix a
|
||||||
|
-- double-count that cannot be demonstrated, so they are left alone; only the
|
||||||
|
-- ShopBack purchase emails can settle it, joined on total paid.
|
||||||
|
--
|
||||||
|
-- The split guard is untouched: this changes what may exist, not what may be
|
||||||
|
-- owed.
|
||||||
|
ALTER TABLE transactions DROP CONSTRAINT IF EXISTS chk_ingested_orders_after_cutover;
|
||||||
@@ -182,16 +182,38 @@ describe("Order ingestion — invariants", () => {
|
|||||||
expect(Number(n!.c)).toBe(1);
|
expect(Number(n!.c)).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("I1: a credits order before the cutover is refused", async () => {
|
it("I1 retired: a credits order before the cutover is recorded, not refused", async () => {
|
||||||
|
// I1 used to store nothing at all for these — no transaction and no
|
||||||
|
// metadata — so the receipt was discarded. Its reason was splits (shared
|
||||||
|
// expenses lived in SplitMyExpenses before 2026-01-09), and that expired
|
||||||
|
// when ACTIVE_OBLIGATION gained its date bound: a pre-cutover split can no
|
||||||
|
// longer assert a debt, so a pre-cutover order cannot move a balance
|
||||||
|
// however it is recorded. All it was still doing was hiding history.
|
||||||
const p = parseOrderHTML(html("dd-01"), meta({ receivedAt: "2025-11-15T12:00:00Z" }));
|
const p = parseOrderHTML(html("dd-01"), meta({ receivedAt: "2025-11-15T12:00:00Z" }));
|
||||||
const res = await processOrderIngestion(p);
|
const res = await processOrderIngestion(p);
|
||||||
expect(res.skipped).toBe("pre_cutover");
|
expect(res.skipped).toBeUndefined();
|
||||||
expect(res.transactionId).toBeNull();
|
expect(res.transactionId).not.toBeNull();
|
||||||
await expect(
|
expect(res.flags).toContain("pre_cutover_credits_order");
|
||||||
queryRaw(
|
|
||||||
`INSERT INTO transactions (transaction_date, amount, payment_method) VALUES ('2025-11-15', 20.00, 'credits')`
|
const txn = await queryRow<{ transaction_date: string; payment_method: string; amount: string }>(
|
||||||
)
|
`SELECT transaction_date::text, payment_method, amount::text
|
||||||
).rejects.toThrow();
|
FROM transactions WHERE id = $1`,
|
||||||
|
[res.transactionId]
|
||||||
|
);
|
||||||
|
expect(txn!.transaction_date).toBe("2025-11-15");
|
||||||
|
expect(txn!.payment_method).toBe("credits");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a pre-cutover order still creates no split, so no balance moves", async () => {
|
||||||
|
// The whole safety argument for retiring I1. Ingestion writes no splits at
|
||||||
|
// any date; if that ever changes, this fails before a balance does.
|
||||||
|
const p = parseOrderHTML(html("dd-01"), meta({ receivedAt: "2025-11-15T12:00:00Z", messageId: "pre-cut-2" }));
|
||||||
|
const res = await processOrderIngestion(p);
|
||||||
|
const n = await queryRow<{ c: string }>(
|
||||||
|
`SELECT count(*)::text c FROM transaction_splits WHERE transaction_id = $1`,
|
||||||
|
[res.transactionId]
|
||||||
|
);
|
||||||
|
expect(Number(n!.c)).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("I11: a [Family] order records provenance and creates no transaction", async () => {
|
it("I11: a [Family] order records provenance and creates no transaction", async () => {
|
||||||
|
|||||||
@@ -171,9 +171,29 @@ export async function processOrderIngestion(
|
|||||||
creditsAmount = order.payment.credits_amount;
|
creditsAmount = order.payment.credits_amount;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- I1: cutover --------------------------------------------------------
|
// ---- I1: cutover (retired 2026-07-28) -----------------------------------
|
||||||
|
//
|
||||||
|
// This used to return early for any credits order dated before the cutover,
|
||||||
|
// storing NOTHING — no transaction and no metadata, so the receipt was
|
||||||
|
// discarded entirely.
|
||||||
|
//
|
||||||
|
// Its reason was splits, not spend: before 2026-01-09 shared expenses lived
|
||||||
|
// in SplitMyExpenses, and re-importing them would double-charge against
|
||||||
|
// carryover transaction 2348. That reason expired with 788219b, where
|
||||||
|
// ACTIVE_OBLIGATION became `settled = false AND transaction_date >=
|
||||||
|
// '2026-01-09'` — a pre-cutover split can no longer assert a debt, so a
|
||||||
|
// pre-cutover order cannot move a balance however it is recorded.
|
||||||
|
//
|
||||||
|
// What it was left doing was hiding ordinary history: 275 orders, $9,799.96
|
||||||
|
// of meals and rides across 2020-2025, invisible purely because they were
|
||||||
|
// paid from a gift-card balance instead of a card. Ingestion still writes no
|
||||||
|
// splits at all, so nothing here touches what anyone owes.
|
||||||
|
//
|
||||||
|
// The known imprecision is on the funding side and is accepted rather than
|
||||||
|
// guessed at — see migration 0026 for why the ShopBack loads are left as
|
||||||
|
// they are.
|
||||||
if (creditsAmount !== null && day < CUTOVER_DATE) {
|
if (creditsAmount !== null && day < CUTOVER_DATE) {
|
||||||
return { transactionId: null, metadataId: null, flags, skipped: "pre_cutover" };
|
flags.push("pre_cutover_credits_order");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- I6 / I5 ------------------------------------------------------------
|
// ---- I6 / I5 ------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user