ci / lint-test (push) Successful in 48s
The order-details panel reads qty/description/amount from expense_metadata.line_items. A grocery shop is a receipt like any other, so it stores the same keys rather than name/quantity/line_total — otherwise the rows arrive complete and display blank, which is exactly the failure ING-8 names. unit and category are the two fields a grocery line has and a delivery line does not; nothing renders them yet and the category composition will.
167 lines
7.7 KiB
TypeScript
167 lines
7.7 KiB
TypeScript
import { describe, it, expect, beforeAll, beforeEach, vi } from "vitest";
|
|
import { Pool } from "pg";
|
|
import { createPool, resetDB, seedParticipants } from "./helpers";
|
|
|
|
/**
|
|
* What happens to a scanned receipt when its statement finally arrives.
|
|
*
|
|
* This is the case the first design of this lane got wrong, so it is tested first-class.
|
|
* Reconciliation moves a manual row's overrides, tags and splits onto the statement row and
|
|
* then hides the manual row from every figure. `expense_metadata` was the one child it left
|
|
* behind — which did not matter while metadata only ever came from an email that had made
|
|
* its own transaction, and matters completely now that it carries a shop's line items. Left
|
|
* unmoved, the contents of the shop vanish at exactly the moment the statement line shows
|
|
* up, and `COLES 0556 MANOR LAKES` stays as unreadable as it was before anything was
|
|
* scanned.
|
|
*/
|
|
|
|
const TOKEN = "test-receipt-token";
|
|
let pool: Pool;
|
|
let ownerId: number;
|
|
let ingest: typeof import("../../app/api/receipts/ingest/route").POST;
|
|
let reconcile: typeof import("../../app/api/transactions/reconcile/route").POST;
|
|
|
|
const ingestReq = (body: unknown) =>
|
|
({ headers: { get: (h: string) => (h === "x-ingest-token" ? TOKEN : null) }, json: async () => body }) as never;
|
|
|
|
// The reconcile route authenticates a browser session rather than a shared secret.
|
|
const userReq = (body: unknown) => ({ json: async () => body }) as never;
|
|
|
|
const colesSplit = {
|
|
receipt_uid: "coles:7777:114:2153:2026-07-11",
|
|
capture_event_id: 412,
|
|
merchant_name: "Coles",
|
|
transaction_date: "2026-07-11",
|
|
total: 114.57,
|
|
tender_legs: [
|
|
{ leg_index: 1, amount: 40.75, card_last4: "0443", card_product: "STORE CARD", class: "gift_card" },
|
|
{ leg_index: 2, amount: 73.82, card_last4: "3302", card_product: "MASTERCARD", class: "card" },
|
|
],
|
|
line_items: [
|
|
{ description: "Pork Loin Roast", qty: 1, unit: "ea", amount: 15.86, category: "meat" },
|
|
{ description: "Jasmine Rice 1kg", qty: 1, unit: "ea", amount: 98.71, category: "pantry_dry" },
|
|
],
|
|
};
|
|
|
|
async function statementLine(amount: number, date: string, description: string) {
|
|
const statement = await pool.query(
|
|
`INSERT INTO statements (filename, account_number, bank_name, billing_start_date, billing_end_date, owner_id)
|
|
VALUES ('sept.pdf', '1234-5678-9012-3302', 'Test Bank', $1::date - 20, $1::date + 10, $2) RETURNING id`,
|
|
[date, ownerId]
|
|
);
|
|
const txn = await pool.query(
|
|
`INSERT INTO transactions (statement_id, transaction_date, description, amount, transaction_type, owner_id)
|
|
VALUES ($1, $2, $3, $4, 'debit', $5) RETURNING id`,
|
|
[statement.rows[0].id, date, description, amount, ownerId]
|
|
);
|
|
return txn.rows[0].id as number;
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
process.env.RECEIPT_INGEST_TOKEN = TOKEN;
|
|
pool = createPool();
|
|
// The reconcile route scopes every query to the signed-in user. Mocked before the import
|
|
// (doMock is not hoisted, so it can close over `ownerId`, which resetDB reassigns on each
|
|
// run) because an ES module binding cannot be reassigned afterwards.
|
|
vi.doMock("@/lib/auth", () => ({
|
|
getCurrentUser: async () => ({ id: ownerId, name: "Alice", email: "alice@example.com" }),
|
|
}));
|
|
({ POST: ingest } = await import("../../app/api/receipts/ingest/route"));
|
|
({ POST: reconcile } = await import("../../app/api/transactions/reconcile/route"));
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await resetDB(pool);
|
|
await pool.query("DELETE FROM expense_metadata");
|
|
({ ownerId } = await seedParticipants(pool));
|
|
});
|
|
|
|
const reconcileAs = (manualId: number, statementId: number) =>
|
|
reconcile(userReq({ matches: [{ manual_id: manualId, statement_tx_id: statementId }] }));
|
|
|
|
describe("a card leg meeting its statement line", () => {
|
|
it("carries the shop's line items onto the statement row", async () => {
|
|
await ingest(ingestReq(colesSplit));
|
|
const manual = await pool.query(
|
|
`SELECT t.id FROM transactions t JOIN expense_metadata em ON em.transaction_id = t.id
|
|
WHERE em.order_reference = 'pantry:coles:7777:114:2153:2026-07-11#2'`
|
|
);
|
|
const manualId = manual.rows[0].id as number;
|
|
const statementId = await statementLine(73.82, "2026-07-12", "COLES 7777 WERRIBEE");
|
|
|
|
const res = await reconcileAs(manualId, statementId);
|
|
expect(res.status ?? 200).toBe(200);
|
|
|
|
const moved = await pool.query(`SELECT transaction_id, line_items FROM expense_metadata WHERE order_reference = $1`, [
|
|
"pantry:coles:7777:114:2153:2026-07-11#2",
|
|
]);
|
|
// The whole point: the items are now on the row that survives, not the one that got hidden.
|
|
expect(moved.rows[0].transaction_id).toBe(statementId);
|
|
expect(moved.rows[0].line_items).toHaveLength(2);
|
|
});
|
|
|
|
it("counts the shop once, not twice", async () => {
|
|
await ingest(ingestReq(colesSplit));
|
|
const manual = await pool.query(
|
|
`SELECT t.id FROM transactions t JOIN expense_metadata em ON em.transaction_id = t.id
|
|
WHERE em.order_reference = 'pantry:coles:7777:114:2153:2026-07-11#2'`
|
|
);
|
|
const statementId = await statementLine(73.82, "2026-07-12", "COLES 7777 WERRIBEE");
|
|
await reconcileAs(manual.rows[0].id, statementId);
|
|
|
|
// Reconciled manual rows are excluded from figures by reconciled_with_id; what remains
|
|
// live is the gift-card leg plus the statement line = the $114.57 that was actually spent.
|
|
const { rows } = await pool.query(
|
|
`SELECT coalesce(sum(amount), 0)::text AS total FROM transactions
|
|
WHERE reconciled_with_id IS NULL AND superseded_by_id IS NULL`
|
|
);
|
|
expect(Number(rows[0].total)).toBeCloseTo(114.57, 2);
|
|
});
|
|
|
|
it("leaves the gift-card leg alone", async () => {
|
|
await ingest(ingestReq(colesSplit));
|
|
const manual = await pool.query(
|
|
`SELECT t.id FROM transactions t JOIN expense_metadata em ON em.transaction_id = t.id
|
|
WHERE em.order_reference = 'pantry:coles:7777:114:2153:2026-07-11#2'`
|
|
);
|
|
const statementId = await statementLine(73.82, "2026-07-12", "COLES 7777 WERRIBEE");
|
|
await reconcileAs(manual.rows[0].id, statementId);
|
|
|
|
const gift = await pool.query(
|
|
`SELECT t.reconciled_with_id, t.payment_method FROM transactions t
|
|
JOIN expense_metadata em ON em.transaction_id = t.id
|
|
WHERE em.order_reference = 'pantry:coles:7777:114:2153:2026-07-11#1'`
|
|
);
|
|
expect(gift.rows[0].reconciled_with_id).toBeNull();
|
|
expect(gift.rows[0].payment_method).toBe("credits");
|
|
});
|
|
|
|
it("keeps the other source's metadata when the statement row already has some", async () => {
|
|
// An emailed or Paperless copy of the same purchase may have got there first.
|
|
// transaction_id is UNIQUE, so one of them has to lose — and it must lose visibly
|
|
// rather than by constraint violation at 11pm.
|
|
await ingest(ingestReq(colesSplit));
|
|
const manual = await pool.query(
|
|
`SELECT t.id FROM transactions t JOIN expense_metadata em ON em.transaction_id = t.id
|
|
WHERE em.order_reference = 'pantry:coles:7777:114:2153:2026-07-11#2'`
|
|
);
|
|
const statementId = await statementLine(73.82, "2026-07-12", "COLES 7777 WERRIBEE");
|
|
await pool.query(
|
|
`INSERT INTO expense_metadata (transaction_id, source, order_reference, line_items)
|
|
VALUES ($1, 'email', 'email:already-here', '[]'::jsonb)`,
|
|
[statementId]
|
|
);
|
|
|
|
const res = await reconcileAs(manual.rows[0].id, statementId);
|
|
expect(res.status ?? 200).toBe(200);
|
|
|
|
const incumbent = await pool.query(`SELECT source FROM expense_metadata WHERE transaction_id = $1`, [statementId]);
|
|
expect(incumbent.rows.map((r) => r.source)).toEqual(["email"]);
|
|
|
|
const pantryRow = await pool.query(`SELECT flags FROM expense_metadata WHERE order_reference = $1`, [
|
|
"pantry:coles:7777:114:2153:2026-07-11#2",
|
|
]);
|
|
expect(JSON.stringify(pantryRow.rows[0].flags)).toContain("metadata_collision_on_reconcile");
|
|
});
|
|
});
|