Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d88ca2a24c | ||
|
|
91586ce4a4 | ||
|
|
4b09d4136f | ||
|
|
4f0f0dd285 | ||
|
|
b788b4e8fa | ||
|
|
b5569b8ccc | ||
|
|
d4ff68363a |
@@ -658,14 +658,33 @@ and the cross-repo contract are in smarthome `plans/intake-router-slice-plan.md`
|
|||||||
the email/order bridge books orders on its own schedule; `findDuplicateBatch`
|
the email/order bridge books orders on its own schedule; `findDuplicateBatch`
|
||||||
ignores *unresolved* batches by design, so two pending cards for one order
|
ignores *unresolved* batches by design, so two pending cards for one order
|
||||||
would otherwise each book it.
|
would otherwise each book it.
|
||||||
- **One `expense_metadata` row, agreed on by three queries.** A transaction can
|
- **One `expense_metadata` row, agreed on by every query that resolves one.** A
|
||||||
own a row *and* be the `matched_transaction_id` of another; `matchTransaction`
|
transaction can own a row *and* be the `matched_transaction_id` of another;
|
||||||
returns the row it resolved through and `replaceLineItems` writes to exactly
|
`matchTransaction` returns the row it resolved through and `replaceLineItems`
|
||||||
that one. All three (match, write, `GET /api/transactions/[id]/order`) order
|
writes to exactly that one. Every reader orders by
|
||||||
by `(transaction_id = $1) DESC **NULLS LAST**, id` — the `NULLS LAST` is
|
`(transaction_id = $1) DESC **NULLS LAST**, id` — the match, the write, the
|
||||||
load-bearing, because Postgres orders `DESC` as `NULLS FIRST` and the row that
|
order-detail GET, `getTransactions`' provenance LATERAL, `merchantForTransaction`
|
||||||
merely *points* at the transaction has a NULL `transaction_id`, so the clause
|
and the Slack details modal. The `NULLS LAST` is load-bearing: Postgres orders
|
||||||
meant to prefer the owned row preferred the other one.
|
`DESC` as `NULLS FIRST` and the row that merely *points* at the transaction has
|
||||||
|
a NULL `transaction_id`, so the clause meant to prefer the owned row preferred
|
||||||
|
the other one. Measured 2026-08-21: 667 rows, 147 pointing-only, **zero**
|
||||||
|
transactions resolving to more than one row — the ordering changes nothing
|
||||||
|
today and exists so the readers still agree when multiplicity appears.
|
||||||
|
- **The create is guarded by a transaction-scoped advisory lock on the
|
||||||
|
PURCHASE**, not the batch. `ON CONFLICT (source, source_ref)` handles a double
|
||||||
|
tap on one card; two *different* batches for one purchase have different
|
||||||
|
`source_ref`s and both clear their match and duplicate checks before either
|
||||||
|
inserts. The lock has to live inside `withTransaction` (`src/lib/db.ts`):
|
||||||
|
`queryRaw` is `$queryRawUnsafe` on a **pooled** client, so every call is its
|
||||||
|
own implicit transaction and `pg_advisory_xact_lock` taken in a statement of
|
||||||
|
its own is released before the next statement runs. The loser reports
|
||||||
|
`duplicate` pointing at the winner's transaction — the same answer the
|
||||||
|
sequential path gives.
|
||||||
|
- **`replaceLineItems` snapshots the undo baseline BEFORE it writes, on both
|
||||||
|
paths**, and treats an UPDATE that touched no row as a failure rather than a
|
||||||
|
success. The metadata INSERT is `ON CONFLICT (transaction_id) DO UPDATE` and
|
||||||
|
carries the batch's **currency** — a hard-coded `'AUD'` made `OrderDetails`
|
||||||
|
print `$` on a USD order, because it prefers `expense_metadata.currency`.
|
||||||
- **`intake_batches.previous_line_items` is the undo baseline, written once.**
|
- **`intake_batches.previous_line_items` is the undo baseline, written once.**
|
||||||
Returning whatever the write just read makes a pantry retry get its own write
|
Returning whatever the write just read makes a pantry retry get its own write
|
||||||
back as the "previous" state, so undo restores the thing it undid. Recorded
|
back as the "previous" state, so undo restores the thing it undid. Recorded
|
||||||
|
|||||||
@@ -283,6 +283,8 @@ model intake_batches {
|
|||||||
status String
|
status String
|
||||||
transaction_id Int?
|
transaction_id Int?
|
||||||
capture_id Int?
|
capture_id Int?
|
||||||
|
// The undo baseline, written once per batch (see 0034 and line-items.ts).
|
||||||
|
previous_line_items Json?
|
||||||
created_at DateTime @default(now()) @db.Timestamptz(6)
|
created_at DateTime @default(now()) @db.Timestamptz(6)
|
||||||
resolved_at DateTime? @db.Timestamptz(6)
|
resolved_at DateTime? @db.Timestamptz(6)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,25 @@ export function mockDbWithPool(p: Pool) {
|
|||||||
const result = await p.query(sql, params);
|
const result = await p.query(sql, params);
|
||||||
return result.rows[0] ?? null;
|
return result.rows[0] ?? null;
|
||||||
},
|
},
|
||||||
|
// Mirrors withTransaction: one connection, BEGIN/COMMIT around the callback,
|
||||||
|
// so pg_advisory_xact_lock actually holds across the statements inside it.
|
||||||
|
withTransaction: async (fn: (q: unknown) => Promise<unknown>) => {
|
||||||
|
const client = await p.connect();
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
const result = await fn({
|
||||||
|
queryRaw: async (sql: string, params: unknown[] = []) => (await client.query(sql, params)).rows,
|
||||||
|
queryRow: async (sql: string, params: unknown[] = []) => (await client.query(sql, params)).rows[0] ?? null,
|
||||||
|
});
|
||||||
|
await client.query("COMMIT");
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
},
|
||||||
prisma: p,
|
prisma: p,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -250,6 +250,35 @@ describe("PUT /api/transactions/[id]/line-items (C2)", () => {
|
|||||||
expect(second.previous_line_items).toEqual(items);
|
expect(second.previous_line_items).toEqual(items);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("a pinned row that no longer exists falls back to the live one, rather than reporting a phantom write", async () => {
|
||||||
|
const txn = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
|
||||||
|
const live = (await pool.query(`SELECT id FROM expense_metadata WHERE transaction_id = $1`, [txn])).rows[0].id as number;
|
||||||
|
const { replaceLineItems } = await import("../../lib/line-items");
|
||||||
|
const r = await replaceLineItems(txn, items, { source: "intake", metadataId: live + 999 });
|
||||||
|
expect(r.metadata_id).toBe(live);
|
||||||
|
expect(await lineItemsOf(txn)).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("two writes racing to create the metadata row both resolve to the same one", async () => {
|
||||||
|
const txn = await seedOrder({ merchant: "Coles", date: "2026-08-18", amount: 85.74, withMeta: false });
|
||||||
|
const both = await Promise.all([
|
||||||
|
put(txn, { line_items: items, source: "intake", replace: true }),
|
||||||
|
put(txn, { line_items: items, source: "intake", replace: true }),
|
||||||
|
]);
|
||||||
|
expect(both.map((r) => r.status)).toEqual([200, 200]);
|
||||||
|
expect((await pool.query(`SELECT count(*)::int AS n FROM expense_metadata WHERE transaction_id = $1`, [txn])).rows[0].n).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the create path records an undo baseline too, before it writes", async () => {
|
||||||
|
const txn = await seedOrder({ merchant: "Coles", date: "2026-08-18", amount: 85.74, withMeta: false });
|
||||||
|
const id = uuid();
|
||||||
|
await pool.query(`INSERT INTO intake_batches (id, source, file_sha256, status) VALUES ($1::uuid, 'other', '{}', 'received')`, [id]);
|
||||||
|
await put(txn, { line_items: items, source: "intake", batch_id: id, currency: "AUD", replace: true });
|
||||||
|
// [] rather than NULL: there was nothing there, and "nothing" is the state
|
||||||
|
// undo has to be able to restore.
|
||||||
|
expect((await pool.query(`SELECT previous_line_items FROM intake_batches WHERE id = $1`, [id])).rows[0].previous_line_items).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it("the panel reads the row the write landed on, whichever order the rows were inserted in", async () => {
|
it("the panel reads the row the write landed on, whichever order the rows were inserted in", async () => {
|
||||||
const t = await seedOrder({ merchant: "UBER *EATS", date: "2026-08-19", amount: 22.4, withMeta: false });
|
const t = await seedOrder({ merchant: "UBER *EATS", date: "2026-08-19", amount: 22.4, withMeta: false });
|
||||||
// Inserted first: the delivery receipt pointing at the statement line.
|
// Inserted first: the delivery receipt pointing at the statement line.
|
||||||
@@ -670,6 +699,42 @@ describe("Slack taps (C5)", () => {
|
|||||||
const t = (await pool.query(`SELECT amount::text, amount_aud, foreign_currency_amount::text, foreign_currency_code FROM transactions`)).rows[0];
|
const t = (await pool.query(`SELECT amount::text, amount_aud, foreign_currency_amount::text, foreign_currency_code FROM transactions`)).rows[0];
|
||||||
expect(t).toEqual({ amount: "22.40", amount_aud: null, foreign_currency_amount: "22.40", foreign_currency_code: "USD" });
|
expect(t).toEqual({ amount: "22.40", amount_aud: null, foreign_currency_amount: "22.40", foreign_currency_code: "USD" });
|
||||||
expect(lastSlackUpdate().text).toMatch(/USD 22\.40/);
|
expect(lastSlackUpdate().text).toMatch(/USD 22\.40/);
|
||||||
|
// OrderDetails prefers expense_metadata.currency, so a hard-coded 'AUD'
|
||||||
|
// here printed "$" on a USD order's receipt panel.
|
||||||
|
expect((await pool.query(`SELECT currency FROM expense_metadata`)).rows[0].currency).toBe("USD");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the hold applies to the Create tap's re-match, not only to the upload path", async () => {
|
||||||
|
modelReply = { ...burgertory, subtotal: null, items: burgertory.items.map((i) => ({ ...i, unit_price: null, line_total: null })) };
|
||||||
|
const id = uuid();
|
||||||
|
const body = await (await intakePOST(multipart({ source: "uber_eats", batch_id: id }, [pdf]))).json();
|
||||||
|
expect(body.status).toBe("candidates");
|
||||||
|
|
||||||
|
// Booked WITH its items between the card and the tap. The re-match walked
|
||||||
|
// past the guard routeFood applies, so the same upload destroyed the same
|
||||||
|
// receipt depending on which button reached it.
|
||||||
|
const txn = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
|
||||||
|
const already = [{ qty: 1, description: "the itemised receipt already there", amount: 22.4 }];
|
||||||
|
await pool.query(`UPDATE expense_metadata SET line_items = $2::jsonb WHERE transaction_id = $1`, [txn, JSON.stringify(already)]);
|
||||||
|
|
||||||
|
await slackPOST(slackPress("intake_create", { batch_id: id }));
|
||||||
|
expect(await lineItemsOf(txn)).toEqual(already);
|
||||||
|
expect(await batch(id)).toMatchObject({ status: "held" });
|
||||||
|
expect((await pool.query(`SELECT count(*)::int AS n FROM transactions`)).rows[0].n).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("two DIFFERENT batches for one purchase, tapped together, book it once", async () => {
|
||||||
|
const a = uuid();
|
||||||
|
const b = uuid();
|
||||||
|
await intakePOST(multipart({ source: "uber_eats", batch_id: a }, [pdf]));
|
||||||
|
await intakePOST(multipart({ source: "uber_eats", batch_id: b }, [{ name: "shot.png", type: "image/png", bytes: Buffer.from("shot") }]));
|
||||||
|
// Their source_refs differ, so ON CONFLICT cannot save this; both taps
|
||||||
|
// clear their match and duplicate checks before either inserts.
|
||||||
|
await Promise.all([slackPOST(slackPress("intake_create", { batch_id: a })), slackPOST(slackPress("intake_create", { batch_id: b }))]);
|
||||||
|
const rows = (await pool.query(`SELECT id FROM transactions`)).rows;
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect((await batch(a)).transaction_id).toBe(rows[0].id);
|
||||||
|
expect((await batch(b)).transaction_id).toBe(rows[0].id);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("unknown shared_by falls back to the default owner — never a NULL owner", async () => {
|
it("unknown shared_by falls back to the default owner — never a NULL owner", async () => {
|
||||||
|
|||||||
@@ -234,7 +234,7 @@ describe("cards carry the C5 action ids and JSON values", () => {
|
|||||||
expect(ids).toContain('"action_id":"intake_meal"');
|
expect(ids).toContain('"action_id":"intake_meal"');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("mrkdwn escapes the names it did not write; the plain_text button keeps them raw", () => {
|
it("every value the card did not write is escaped, plain_text included", () => {
|
||||||
const hostile = { ...c, merchant: "Ben & Jerry's <fake>" };
|
const hostile = { ...c, merchant: "Ben & Jerry's <fake>" };
|
||||||
const { text, blocks } = candidatesCard(hostile, [
|
const { text, blocks } = candidatesCard(hostile, [
|
||||||
{ transaction_id: 42, description: "SQ *BEN & JERRY <AU>", transaction_date: "2026-08-17", amount: "19.90" },
|
{ transaction_id: 42, description: "SQ *BEN & JERRY <AU>", transaction_date: "2026-08-17", amount: "19.90" },
|
||||||
@@ -242,24 +242,38 @@ describe("cards carry the C5 action ids and JSON values", () => {
|
|||||||
const sections = JSON.stringify(blocks.filter((b) => (b as Block).type === "section"));
|
const sections = JSON.stringify(blocks.filter((b) => (b as Block).type === "section"));
|
||||||
expect(sections).toContain("Ben & Jerry's <fake>");
|
expect(sections).toContain("Ben & Jerry's <fake>");
|
||||||
expect(sections).toContain("SQ *BEN & JERRY <AU>");
|
expect(sections).toContain("SQ *BEN & JERRY <AU>");
|
||||||
expect(sections).not.toContain("<fake>");
|
|
||||||
expect(text).toContain("Ben & Jerry's <fake>");
|
expect(text).toContain("Ben & Jerry's <fake>");
|
||||||
// The Create button's label is plain_text — Slack shows entities literally
|
// plain_text disables formatting, not entity decoding — &, < and > are
|
||||||
// there, so escaping it would print "Ben & Jerry's" on the button.
|
// control characters everywhere in a Slack payload.
|
||||||
const actions = JSON.stringify(blocks.filter((b) => (b as Block).type === "actions"));
|
const whole = JSON.stringify(blocks);
|
||||||
expect(actions).toContain("Ben & Jerry's <fake>");
|
expect(whole).not.toContain("<fake>");
|
||||||
|
expect(whole).not.toContain("<AU>");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("a foreign receipt is not printed as dollars", () => {
|
it("the currency is model output too, so it is escaped like any other", () => {
|
||||||
|
// parseExtraction accepts any string for `currency`; unescaped, `<@U123>`
|
||||||
|
// would be a live mention on every card the batch produces.
|
||||||
|
const { text } = enrichedCard({ ...c, currency: "<@U123>", transactionId: 7 });
|
||||||
|
expect(text).toContain("<@U123> 22.40");
|
||||||
|
expect(text).not.toContain("<@U123>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a foreign receipt is not printed as dollars — header and candidate rows alike", () => {
|
||||||
expect(enrichedCard({ ...c, currency: "USD", transactionId: 7 }).text).toBe(
|
expect(enrichedCard({ ...c, currency: "USD", transactionId: 7 }).text).toBe(
|
||||||
"Added 2 items to your USD 22.40 Uber Eats (Burgertory (Werribee)) order ✓"
|
"Added 2 items to your USD 22.40 Uber Eats (Burgertory (Werribee)) order ✓"
|
||||||
);
|
);
|
||||||
expect(enrichedCard({ ...c, currency: "AUD", transactionId: 7 }).text).toContain("$22.40");
|
expect(enrichedCard({ ...c, currency: "AUD", transactionId: 7 }).text).toContain("$22.40");
|
||||||
expect(enrichedCard({ ...c, transactionId: 7 }).text).toContain("$22.40");
|
expect(enrichedCard({ ...c, transactionId: 7 }).text).toContain("$22.40");
|
||||||
|
// A candidate row is a ledger transaction and carries its own currency; it
|
||||||
|
// used to print "$" beneath a header reading "USD 22.40".
|
||||||
|
const { blocks } = candidatesCard({ ...c, currency: "USD" }, [
|
||||||
|
{ transaction_id: 42, description: "Order - Burgertory", transaction_date: "2026-08-17", amount: "19.90", currency: "USD" },
|
||||||
|
]);
|
||||||
|
expect(JSON.stringify(blocks)).toContain("USD 19.90");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("liteLLMExtractor — only a finished reply is accepted", () => {
|
describe("liteLLMExtractor — a finish_reason that is present must be `stop`", () => {
|
||||||
const good = { merchant: "Burgertory (Werribee)", order_date: "2026-08-19", total: 22.4, subtotal: 28, currency: "AUD", items: [], adjustments: [] };
|
const good = { merchant: "Burgertory (Werribee)", order_date: "2026-08-19", total: 22.4, subtotal: 28, currency: "AUD", items: [], adjustments: [] };
|
||||||
const extractor = (finish_reason?: string) =>
|
const extractor = (finish_reason?: string) =>
|
||||||
liteLLMExtractor({
|
liteLLMExtractor({
|
||||||
@@ -271,12 +285,13 @@ describe("liteLLMExtractor — only a finished reply is accepted", () => {
|
|||||||
})) as unknown as typeof fetch,
|
})) as unknown as typeof fetch,
|
||||||
});
|
});
|
||||||
|
|
||||||
it("accepts `stop`, and a reply that reports no reason at all", async () => {
|
it("accepts `stop`, and — on purpose — a reply that reports no reason at all", async () => {
|
||||||
expect((await extractor("stop")([{ kind: "text", text: "x", name: "x" }])).total).toBe(22.4);
|
expect((await extractor("stop")([{ kind: "text", text: "x", name: "x" }])).total).toBe(22.4);
|
||||||
expect((await extractor()([{ kind: "text", text: "x", name: "x" }])).total).toBe(22.4);
|
expect((await extractor()([{ kind: "text", text: "x", name: "x" }])).total).toBe(22.4);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects any other reason, naming it — the strict schema only held for a reply that finished", async () => {
|
it("rejects any other reason, naming it — the strict schema only held for a reply that finished", async () => {
|
||||||
|
// An absent reason is NOT rejected: see the comment in intake-extract.ts.
|
||||||
await expect(extractor("length")([{ kind: "text", text: "x", name: "x" }])).rejects.toThrow(ExtractionError);
|
await expect(extractor("length")([{ kind: "text", text: "x", name: "x" }])).rejects.toThrow(ExtractionError);
|
||||||
await expect(extractor("length")([{ kind: "text", text: "x", name: "x" }])).rejects.toThrow(/too long/);
|
await expect(extractor("length")([{ kind: "text", text: "x", name: "x" }])).rejects.toThrow(/too long/);
|
||||||
await expect(extractor("content_filter")([{ kind: "text", text: "x", name: "x" }])).rejects.toThrow(/content_filter/);
|
await expect(extractor("content_filter")([{ kind: "text", text: "x", name: "x" }])).rejects.toThrow(/content_filter/);
|
||||||
|
|||||||
@@ -234,6 +234,16 @@ async function buildDetailsModal(transactionId: number, participantId: number) {
|
|||||||
`SELECT merchant_normalized AS merchant, line_items
|
`SELECT merchant_normalized AS merchant, line_items
|
||||||
FROM expense_metadata
|
FROM expense_metadata
|
||||||
WHERE transaction_id = $1 OR matched_transaction_id = $1
|
WHERE transaction_id = $1 OR matched_transaction_id = $1
|
||||||
|
-- Ordered the same way as replaceLineItems (line-items.ts) and
|
||||||
|
-- matchTransaction (intake-router.ts), so every reader of this row
|
||||||
|
-- resolves the same one a write lands on. NULLS LAST because DESC is
|
||||||
|
-- NULLS FIRST and the row that merely POINTS at the transaction has a
|
||||||
|
-- NULL transaction_id.
|
||||||
|
-- Measured 2026-08-21: 667 expense_metadata rows, 147 of them
|
||||||
|
-- pointing-only, and ZERO transactions resolving to more than one row by
|
||||||
|
-- either link — so this changes nothing today. It is here so the readers
|
||||||
|
-- still agree on the day multiplicity appears.
|
||||||
|
ORDER BY (transaction_id = $1) DESC NULLS LAST, id
|
||||||
LIMIT 1`,
|
LIMIT 1`,
|
||||||
[transactionId]
|
[transactionId]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -20,3 +20,33 @@ export async function queryRow<T>(sql: string, params: unknown[] = []): Promise<
|
|||||||
const rows = await prisma.$queryRawUnsafe<T[]>(sql, ...params);
|
const rows = await prisma.$queryRawUnsafe<T[]>(sql, ...params);
|
||||||
return rows.length > 0 ? rows[0] : null;
|
return rows.length > 0 ? rows[0] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The same two calls, scoped to one connection inside one transaction. */
|
||||||
|
export type Queryable = {
|
||||||
|
queryRaw<T>(sql: string, params?: unknown[]): Promise<T[]>;
|
||||||
|
queryRow<T>(sql: string, params?: unknown[]): Promise<T | null>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run several statements as one transaction, on one connection.
|
||||||
|
*
|
||||||
|
* The module-level `queryRaw`/`queryRow` are `$queryRawUnsafe` on a POOLED
|
||||||
|
* client, so each call is its own implicit transaction on whichever connection
|
||||||
|
* the pool hands out. That makes a check-then-write pair non-atomic, and it
|
||||||
|
* makes `pg_advisory_xact_lock` useless outside this helper — the lock is
|
||||||
|
* released the instant the statement that took it commits, which is before the
|
||||||
|
* next statement runs. Anything that needs a lock to still be held on its next
|
||||||
|
* query has to be in here.
|
||||||
|
*/
|
||||||
|
export async function withTransaction<T>(fn: (q: Queryable) => Promise<T>): Promise<T> {
|
||||||
|
return prisma.$transaction(async (tx) => {
|
||||||
|
const q: Queryable = {
|
||||||
|
queryRaw: <R>(sql: string, params: unknown[] = []) => tx.$queryRawUnsafe<R[]>(sql, ...params),
|
||||||
|
queryRow: async <R>(sql: string, params: unknown[] = []) => {
|
||||||
|
const rows = await tx.$queryRawUnsafe<R[]>(sql, ...params);
|
||||||
|
return rows.length > 0 ? rows[0] : null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return fn(q);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export async function handleIntakeAction(
|
|||||||
if (actionId === "intake_use_candidate") {
|
if (actionId === "intake_use_candidate") {
|
||||||
if (!Number.isInteger(value.transaction_id)) return note("That button carried no transaction id.");
|
if (!Number.isInteger(value.transaction_id)) return note("That button carried no transaction id.");
|
||||||
const txnId = value.transaction_id as number;
|
const txnId = value.transaction_id as number;
|
||||||
await replaceLineItems(txnId, ctx.items, { source: "intake", platform: ctx.platform, batchId: batch.id });
|
await replaceLineItems(txnId, ctx.items, { source: "intake", platform: ctx.platform, batchId: batch.id, currency: ctx.currency });
|
||||||
await queryRaw(
|
await queryRaw(
|
||||||
`UPDATE intake_batches SET status = 'enriched', kind = COALESCE(kind, 'food'), transaction_id = $2, resolved_at = now() WHERE id = $1::uuid`,
|
`UPDATE intake_batches SET status = 'enriched', kind = COALESCE(kind, 'food'), transaction_id = $2, resolved_at = now() WHERE id = $1::uuid`,
|
||||||
[batch.id, txnId]
|
[batch.id, txnId]
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { platformLabel } from "@/lib/intake-rules";
|
|||||||
|
|
||||||
export type Block = Record<string, unknown>;
|
export type Block = Record<string, unknown>;
|
||||||
|
|
||||||
export type Candidate = { transaction_id: number; description: string | null; transaction_date: string; amount: string | number };
|
export type Candidate = { transaction_id: number; description: string | null; transaction_date: string; amount: string | number; currency?: string | null };
|
||||||
|
|
||||||
export function financePublicUrl(): string {
|
export function financePublicUrl(): string {
|
||||||
return (process.env.FINANCE_PUBLIC_URL || "https://finance.bosecamp.com").replace(/\/$/, "");
|
return (process.env.FINANCE_PUBLIC_URL || "https://finance.bosecamp.com").replace(/\/$/, "");
|
||||||
@@ -42,7 +42,9 @@ export function esc(value: string | null | undefined): string {
|
|||||||
*/
|
*/
|
||||||
const money = (n: number | string, currency?: string | null) => {
|
const money = (n: number | string, currency?: string | null) => {
|
||||||
const amount = Number(n).toFixed(2);
|
const amount = Number(n).toFixed(2);
|
||||||
return !currency || currency === "AUD" ? `$${amount}` : `${currency} ${amount}`;
|
// The currency comes off the model (`parseExtraction` accepts any string), so
|
||||||
|
// an unescaped `<@U123>` here would be a mention on every card.
|
||||||
|
return !currency || currency === "AUD" ? `$${amount}` : `${esc(currency)} ${amount}`;
|
||||||
};
|
};
|
||||||
const section = (text: string): Block => ({ type: "section", text: { type: "mrkdwn", text } });
|
const section = (text: string): Block => ({ type: "section", text: { type: "mrkdwn", text } });
|
||||||
const button = (action_id: string, text: string, value: unknown, style?: "primary" | "danger"): Block => ({
|
const button = (action_id: string, text: string, value: unknown, style?: "primary" | "danger"): Block => ({
|
||||||
@@ -103,15 +105,17 @@ export function candidatesCard(c: CardInput, candidates: Candidate[]): { blocks:
|
|||||||
for (const cand of candidates) {
|
for (const cand of candidates) {
|
||||||
blocks.push({
|
blocks.push({
|
||||||
type: "section",
|
type: "section",
|
||||||
text: { type: "mrkdwn", text: `${esc(cand.transaction_date)} · ${esc(cand.description) || "—"} · ${money(cand.amount)}` },
|
text: { type: "mrkdwn", text: `${esc(cand.transaction_date)} · ${esc(cand.description) || "—"} · ${money(cand.amount, cand.currency)}` },
|
||||||
accessory: button("intake_use_candidate", "Use this", { batch_id: c.batchId, transaction_id: cand.transaction_id }),
|
accessory: button("intake_use_candidate", "Use this", { batch_id: c.batchId, transaction_id: cand.transaction_id }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
blocks.push({
|
blocks.push({
|
||||||
type: "actions",
|
type: "actions",
|
||||||
block_id: "intake_create",
|
block_id: "intake_create",
|
||||||
// A button's `text` is plain_text, never mrkdwn — the raw merchant belongs here.
|
// plain_text disables *formatting*, not entity decoding: `&`, `<` and `>`
|
||||||
elements: [button("intake_create", `Create new ${c.total !== null ? money(c.total, c.currency) + " " : ""}${c.merchant ?? ""} transaction`.replace(/\s+/g, " ").trim(), { batch_id: c.batchId }, "primary")],
|
// are control characters everywhere in a Slack payload, so the label is
|
||||||
|
// escaped like any other value we did not write.
|
||||||
|
elements: [button("intake_create", `Create new ${c.total !== null ? money(c.total, c.currency) + " " : ""}${esc(c.merchant)} transaction`.replace(/\s+/g, " ").trim(), { batch_id: c.batchId }, "primary")],
|
||||||
});
|
});
|
||||||
return { blocks, text };
|
return { blocks, text };
|
||||||
}
|
}
|
||||||
@@ -151,7 +155,12 @@ export function askCard(c: CardInput): { blocks: Block[]; text: string } {
|
|||||||
|
|
||||||
export function duplicateCard(c: { merchant: string | null; total: number | null; transactionId: number | null; captureId: number | null; batchId: string | null; currency?: string | null }): { blocks: Block[]; text: string } {
|
export function duplicateCard(c: { merchant: string | null; total: number | null; transactionId: number | null; captureId: number | null; batchId: string | null; currency?: string | null }): { blocks: Block[]; text: string } {
|
||||||
const where = c.transactionId ? `transaction ${c.transactionId}` : c.captureId ? `pantry capture ${c.captureId}` : c.batchId ? `batch ${esc(c.batchId)}` : "an earlier upload";
|
const where = c.transactionId ? `transaction ${c.transactionId}` : c.captureId ? `pantry capture ${c.captureId}` : c.batchId ? `batch ${esc(c.batchId)}` : "an earlier upload";
|
||||||
const text = `Already handled — ${esc(c.merchant) || "this order"} ${c.total !== null ? money(c.total, c.currency) : ""} is ${where}. Nothing was changed.`.replace(/\s+/g, " ");
|
// The header (merchant, date, total) IS the identity of a purchase here, so a
|
||||||
|
// genuine SECOND identical purchase — two of the same coffee, one day, one shop
|
||||||
|
// — reads as a re-share and is refused. That is the right default (a re-share is
|
||||||
|
// far commoner than a repeat) but it is a dead end without this sentence: the
|
||||||
|
// card must say what to do instead, or the ledger quietly loses the second one.
|
||||||
|
const text = `Already handled — ${esc(c.merchant) || "this order"} ${c.total !== null ? money(c.total, c.currency) : ""} is ${where}. Nothing was changed — if this really is a second, separate purchase, add it in the app.`.replace(/\s+/g, " ");
|
||||||
return { text, blocks: [section(`:repeat: ${text}`)] };
|
return { text, blocks: [section(`:repeat: ${text}`)] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -185,11 +185,18 @@ export function liteLLMExtractor(opts: {
|
|||||||
});
|
});
|
||||||
if (!response.ok) throw new ExtractionError(`LiteLLM returned ${response.status}`);
|
if (!response.ok) throw new ExtractionError(`LiteLLM returned ${response.status}`);
|
||||||
const raw = (await response.json()) as { choices?: { message?: { content?: string }; finish_reason?: string }[] };
|
const raw = (await response.json()) as { choices?: { message?: { content?: string }; finish_reason?: string }[] };
|
||||||
// Only `stop` means the strict schema was satisfied. A truncated reply can
|
// The rule is: a finish_reason that is PRESENT must be `stop`. An absent
|
||||||
// still parse once the JSON is closed, which quietly drops the tail of a
|
// one is accepted, deliberately — providers differ on whether they send the
|
||||||
// long receipt; a `content_filter` or `tool_calls` stop is a different reply
|
// field, and failing every call on a provider that omits it would be a
|
||||||
// shape entirely. Accepting anything but `stop` accepts a reply the schema
|
// certain outage in exchange for a case that also requires the model to
|
||||||
// never constrained — losing lines silently is worse than failing.
|
// emit syntactically complete JSON. The harm a truncated reply does — a
|
||||||
|
// partial item list replacing a good one — is closed on the write side
|
||||||
|
// instead, by the guard in `routeFood`/`createFromBatch`.
|
||||||
|
//
|
||||||
|
// `length` is the common one: a truncated reply still parses once the JSON
|
||||||
|
// happens to close, quietly dropping the tail of a long receipt. Any other
|
||||||
|
// reason (`content_filter`, `tool_calls`) is a different reply shape that
|
||||||
|
// the strict schema never constrained.
|
||||||
const finish = raw.choices?.[0]?.finish_reason;
|
const finish = raw.choices?.[0]?.finish_reason;
|
||||||
if (finish && finish !== "stop") {
|
if (finish && finish !== "stop") {
|
||||||
throw new ExtractionError(
|
throw new ExtractionError(
|
||||||
|
|||||||
+71
-33
@@ -1,6 +1,6 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { queryRaw, queryRow } from "@/lib/db";
|
import { queryRaw, queryRow, withTransaction } from "@/lib/db";
|
||||||
import { EXCLUDE_RECONCILED_SOURCE } from "@/lib/analytics-sql";
|
import { EXCLUDE_RECONCILED_SOURCE, NATIVE_CURRENCY } from "@/lib/analytics-sql";
|
||||||
import { DEFAULT_OWNER_ID } from "@/lib/order-ingestion";
|
import { DEFAULT_OWNER_ID } from "@/lib/order-ingestion";
|
||||||
import {
|
import {
|
||||||
classifyInput,
|
classifyInput,
|
||||||
@@ -215,6 +215,8 @@ type MatchRow = {
|
|||||||
amount: string;
|
amount: string;
|
||||||
merchant: string | null;
|
merchant: string | null;
|
||||||
platform: string | null;
|
platform: string | null;
|
||||||
|
/** What `amount` is denominated in — a candidate row is not always dollars. */
|
||||||
|
currency: string;
|
||||||
/** The `expense_metadata` row the match resolved through — null when there is none. */
|
/** The `expense_metadata` row the match resolved through — null when there is none. */
|
||||||
metadata_id: number | null;
|
metadata_id: number | null;
|
||||||
/** Does that row already carry a receipt? */
|
/** Does that row already carry a receipt? */
|
||||||
@@ -240,9 +242,10 @@ export async function matchTransaction(header: OrderHeader, platform: string | n
|
|||||||
`SELECT DISTINCT ON (t.id)
|
`SELECT DISTINCT ON (t.id)
|
||||||
t.id AS transaction_id, t.description, t.transaction_date::text, t.amount::text,
|
t.id AS transaction_id, t.description, t.transaction_date::text, t.amount::text,
|
||||||
COALESCE(em.merchant_normalized, t.merchant_normalized, t.merchant_name, t.description) AS merchant,
|
COALESCE(em.merchant_normalized, t.merchant_normalized, t.merchant_name, t.description) AS merchant,
|
||||||
em.platform, em.id AS metadata_id,
|
em.platform, em.id AS metadata_id, ${NATIVE_CURRENCY} AS currency,
|
||||||
COALESCE(jsonb_typeof(em.line_items) = 'array' AND jsonb_array_length(em.line_items) > 0, false) AS has_line_items
|
COALESCE(jsonb_typeof(em.line_items) = 'array' AND jsonb_array_length(em.line_items) > 0, false) AS has_line_items
|
||||||
FROM transactions t
|
FROM transactions t
|
||||||
|
LEFT JOIN statements s ON s.id = t.statement_id
|
||||||
LEFT JOIN expense_metadata em ON em.transaction_id = t.id OR em.matched_transaction_id = t.id
|
LEFT JOIN expense_metadata em ON em.transaction_id = t.id OR em.matched_transaction_id = t.id
|
||||||
WHERE ${EXCLUDE_RECONCILED_SOURCE}
|
WHERE ${EXCLUDE_RECONCILED_SOURCE}
|
||||||
AND t.transaction_date = $1::date
|
AND t.transaction_date = $1::date
|
||||||
@@ -271,8 +274,9 @@ export async function findCandidates(header: OrderHeader): Promise<Candidate[]>
|
|||||||
`SELECT DISTINCT ON (t.id)
|
`SELECT DISTINCT ON (t.id)
|
||||||
t.id AS transaction_id, t.description, t.transaction_date::text, t.amount::text,
|
t.id AS transaction_id, t.description, t.transaction_date::text, t.amount::text,
|
||||||
COALESCE(em.merchant_normalized, t.merchant_normalized, t.merchant_name, t.description) AS merchant,
|
COALESCE(em.merchant_normalized, t.merchant_normalized, t.merchant_name, t.description) AS merchant,
|
||||||
em.platform, em.id AS metadata_id, false AS has_line_items
|
em.platform, em.id AS metadata_id, ${NATIVE_CURRENCY} AS currency, false AS has_line_items
|
||||||
FROM transactions t
|
FROM transactions t
|
||||||
|
LEFT JOIN statements s ON s.id = t.statement_id
|
||||||
LEFT JOIN expense_metadata em ON em.transaction_id = t.id OR em.matched_transaction_id = t.id
|
LEFT JOIN expense_metadata em ON em.transaction_id = t.id OR em.matched_transaction_id = t.id
|
||||||
WHERE ${EXCLUDE_RECONCILED_SOURCE}
|
WHERE ${EXCLUDE_RECONCILED_SOURCE}
|
||||||
AND t.transaction_date BETWEEN $1::date - 3 AND $1::date + 3
|
AND t.transaction_date BETWEEN $1::date - 3 AND $1::date + 3
|
||||||
@@ -288,7 +292,7 @@ export async function findCandidates(header: OrderHeader): Promise<Candidate[]>
|
|||||||
return Math.abs(Number(a.amount) - header.total) - Math.abs(Number(b.amount) - header.total);
|
return Math.abs(Number(a.amount) - header.total) - Math.abs(Number(b.amount) - header.total);
|
||||||
})
|
})
|
||||||
.slice(0, 5)
|
.slice(0, 5)
|
||||||
.map((r) => ({ transaction_id: r.transaction_id, description: r.description, transaction_date: r.transaction_date, amount: r.amount }));
|
.map((r) => ({ transaction_id: r.transaction_id, description: r.description, transaction_date: r.transaction_date, amount: r.amount, currency: r.currency }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── the two destinations ───────────────────────────────────────────────────
|
// ─── the two destinations ───────────────────────────────────────────────────
|
||||||
@@ -335,19 +339,30 @@ const finish = (ctx: RouteContext, kind: IntakeKind, status: IntakeStatus, card:
|
|||||||
*/
|
*/
|
||||||
const unverifiable = (items: StoredLineItem[]) => items.every((i) => i.amount === null);
|
const unverifiable = (items: StoredLineItem[]) => items.every((i) => i.amount === null);
|
||||||
|
|
||||||
/** Food: enrich the matched order's line_items, or show candidates. NEVER auto-creates. */
|
/**
|
||||||
export async function routeFood(ctx: RouteContext): Promise<IntakeResult> {
|
* The hold, shared by EVERY path that enriches a matched transaction.
|
||||||
const { match } = await matchTransaction(ctx.header, ctx.platform);
|
*
|
||||||
if (match) {
|
* It lived only in `routeFood` and `createFromBatch`'s re-match walked straight
|
||||||
if (unverifiable(ctx.items) && match.has_line_items) {
|
* past it — the same upload destroyed the same receipt depending on which
|
||||||
|
* button reached it. Returns null when there is nothing to protect.
|
||||||
|
*/
|
||||||
|
async function holdRatherThanReplace(ctx: RouteContext, match: MatchRow): Promise<IntakeResult | null> {
|
||||||
|
if (!unverifiable(ctx.items) || !match.has_line_items) return null;
|
||||||
await updateBatch(ctx.batchId, { status: "held", items: ctx.items });
|
await updateBatch(ctx.batchId, { status: "held", items: ctx.items });
|
||||||
const card = heldCard(
|
const card = heldCard(
|
||||||
"this upload carries no prices, so nothing checked it against the total — and that order already has its items",
|
"this upload carries no prices, so nothing checked it against the total — and that order already has its items",
|
||||||
"I left the existing items alone"
|
"I left the existing items alone"
|
||||||
);
|
);
|
||||||
return finish(ctx, "held", "held", card, { transaction_id: match.transaction_id });
|
return finish(ctx, "held", "held", card, { transaction_id: match.transaction_id });
|
||||||
}
|
}
|
||||||
await replaceLineItems(match.transaction_id, ctx.items, { source: "intake", platform: ctx.platform, batchId: ctx.batchId, metadataId: match.metadata_id });
|
|
||||||
|
/** Food: enrich the matched order's line_items, or show candidates. NEVER auto-creates. */
|
||||||
|
export async function routeFood(ctx: RouteContext): Promise<IntakeResult> {
|
||||||
|
const { match } = await matchTransaction(ctx.header, ctx.platform);
|
||||||
|
if (match) {
|
||||||
|
const held = await holdRatherThanReplace(ctx, match);
|
||||||
|
if (held) return held;
|
||||||
|
await replaceLineItems(match.transaction_id, ctx.items, { source: "intake", platform: ctx.platform, batchId: ctx.batchId, metadataId: match.metadata_id, currency: ctx.currency });
|
||||||
await updateBatch(ctx.batchId, { kind: "food", status: "enriched", transaction_id: match.transaction_id, items: ctx.items });
|
await updateBatch(ctx.batchId, { kind: "food", status: "enriched", transaction_id: match.transaction_id, items: ctx.items });
|
||||||
return finish(ctx, "food", "enriched", enrichedCard({ ...cardInput(ctx), transactionId: match.transaction_id }), { transaction_id: match.transaction_id });
|
return finish(ctx, "food", "enriched", enrichedCard({ ...cardInput(ctx), transactionId: match.transaction_id }), { transaction_id: match.transaction_id });
|
||||||
}
|
}
|
||||||
@@ -406,7 +421,9 @@ export async function createFromBatch(batch: BatchRow, kind: "grocery" | "food")
|
|||||||
// whole slice exists to avoid.
|
// whole slice exists to avoid.
|
||||||
const { match } = await matchTransaction(header, header.platform);
|
const { match } = await matchTransaction(header, header.platform);
|
||||||
if (match) {
|
if (match) {
|
||||||
await replaceLineItems(match.transaction_id, items, { source: "intake", platform: header.platform, batchId: batch.id, metadataId: match.metadata_id });
|
const held = await holdRatherThanReplace(ctx, match);
|
||||||
|
if (held) return held;
|
||||||
|
await replaceLineItems(match.transaction_id, items, { source: "intake", platform: header.platform, batchId: batch.id, metadataId: match.metadata_id, currency: header.currency });
|
||||||
await updateBatch(batch.id, { kind, status: "enriched", transaction_id: match.transaction_id, items });
|
await updateBatch(batch.id, { kind, status: "enriched", transaction_id: match.transaction_id, items });
|
||||||
return finish(ctx, kind, "enriched", enrichedCard({ ...cardInput(ctx), transactionId: match.transaction_id }), { transaction_id: match.transaction_id });
|
return finish(ctx, kind, "enriched", enrichedCard({ ...cardInput(ctx), transactionId: match.transaction_id }), { transaction_id: match.transaction_id });
|
||||||
}
|
}
|
||||||
@@ -440,10 +457,33 @@ export async function createFromBatch(batch: BatchRow, kind: "grocery" | "food")
|
|||||||
// than asserting a conversion — the same shape order-ingestion.ts writes.
|
// than asserting a conversion — the same shape order-ingestion.ts writes.
|
||||||
const currency = header.currency ?? "AUD";
|
const currency = header.currency ?? "AUD";
|
||||||
const isAud = currency === "AUD";
|
const isAud = currency === "AUD";
|
||||||
// source/source_ref make a second press of the same card a no-op rather than a
|
// Check and insert under one lock, on one connection.
|
||||||
// second row — and ON CONFLICT, not SELECT-then-INSERT, so two presses that
|
//
|
||||||
// arrive together cannot both find nothing and both insert.
|
// `source_ref = batch.id` makes a second press of THIS card a no-op, and
|
||||||
const inserted = await queryRow<{ id: number }>(
|
// ON CONFLICT makes that safe against a double tap. It cannot help two
|
||||||
|
// DIFFERENT batches for one purchase — their keys differ — and both taps run
|
||||||
|
// their match and duplicate checks before either inserts, so both can find
|
||||||
|
// nothing. The lock is keyed on the purchase, not the batch, which is the
|
||||||
|
// only thing the two share. It has to be transaction-scoped and therefore
|
||||||
|
// inside withTransaction: taken by a statement of its own it would be
|
||||||
|
// released before the next one ran (see db.ts).
|
||||||
|
const purchaseKey = [header.platform ?? "", merchantKey(merchant), header.order_date, header.total.toFixed(2)].join("|");
|
||||||
|
const outcome = await withTransaction(async (q) => {
|
||||||
|
await q.queryRaw(`SELECT pg_advisory_xact_lock(hashtextextended($1, 0))`, [purchaseKey]);
|
||||||
|
// Mine (a double tap) or another batch's (the race). The ORDER BY is what
|
||||||
|
// tells them apart when both exist.
|
||||||
|
const existing = await q.queryRow<{ id: number; source_ref: string }>(
|
||||||
|
`SELECT id, source_ref FROM transactions
|
||||||
|
WHERE source = 'intake' AND superseded_by_id IS NULL
|
||||||
|
AND (source_ref = $1
|
||||||
|
OR (transaction_date = $2::date AND amount = $3::numeric
|
||||||
|
AND lower(merchant_normalized) = lower($4)))
|
||||||
|
ORDER BY (source_ref = $1) DESC NULLS LAST, id
|
||||||
|
LIMIT 1`,
|
||||||
|
[batch.id, header.order_date, header.total, merchant]
|
||||||
|
);
|
||||||
|
if (existing) return { id: existing.id, mine: existing.source_ref === batch.id };
|
||||||
|
const row = await q.queryRow<{ id: number }>(
|
||||||
`INSERT INTO transactions (
|
`INSERT INTO transactions (
|
||||||
transaction_date, description, amount, amount_aud, category, payment_method,
|
transaction_date, description, amount, amount_aud, category, payment_method,
|
||||||
merchant_name, merchant_normalized, transaction_type, owner_id, source, source_ref,
|
merchant_name, merchant_normalized, transaction_type, owner_id, source, source_ref,
|
||||||
@@ -452,23 +492,21 @@ export async function createFromBatch(batch: BatchRow, kind: "grocery" | "food")
|
|||||||
ON CONFLICT (source, source_ref) WHERE source IS NOT NULL AND source_ref IS NOT NULL
|
ON CONFLICT (source, source_ref) WHERE source IS NOT NULL AND source_ref IS NOT NULL
|
||||||
DO NOTHING
|
DO NOTHING
|
||||||
RETURNING id`,
|
RETURNING id`,
|
||||||
[
|
[header.order_date, description, header.total, isAud ? header.total : null, category, merchant, ownerId, batch.id, isAud ? null : header.total, isAud ? null : currency]
|
||||||
header.order_date,
|
|
||||||
description,
|
|
||||||
header.total,
|
|
||||||
isAud ? header.total : null,
|
|
||||||
category,
|
|
||||||
merchant,
|
|
||||||
ownerId,
|
|
||||||
batch.id,
|
|
||||||
isAud ? null : header.total,
|
|
||||||
isAud ? null : currency,
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
const txnId =
|
return { id: row!.id, mine: true };
|
||||||
inserted?.id ??
|
});
|
||||||
(await queryRow<{ id: number }>(`SELECT id FROM transactions WHERE source = 'intake' AND source_ref = $1`, [batch.id]))!.id;
|
|
||||||
await replaceLineItems(txnId, items, { source: "intake", platform: header.platform, batchId: batch.id });
|
// Another batch booked this purchase in the moment between the two taps. Say
|
||||||
|
// the same thing the sequential case says one second later, and write nothing.
|
||||||
|
if (!outcome.mine) {
|
||||||
|
await updateBatch(batch.id, { kind, status: "duplicate", transaction_id: outcome.id });
|
||||||
|
const card = duplicateCard({ merchant: header.merchant, total: header.total, currency: header.currency, transactionId: outcome.id, captureId: null, batchId: null });
|
||||||
|
return finish(ctx, kind, "duplicate", card, { transaction_id: outcome.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
const txnId = outcome.id;
|
||||||
|
await replaceLineItems(txnId, items, { source: "intake", platform: header.platform, batchId: batch.id, currency });
|
||||||
await updateBatch(batch.id, { kind, status: "created", transaction_id: txnId });
|
await updateBatch(batch.id, { kind, status: "created", transaction_id: txnId });
|
||||||
return finish(ctx, kind, "created", createdCard({ ...cardInput(ctx), transactionId: txnId }), { transaction_id: txnId });
|
return finish(ctx, kind, "created", createdCard({ ...cardInput(ctx), transactionId: txnId }), { transaction_id: txnId });
|
||||||
}
|
}
|
||||||
|
|||||||
+55
-27
@@ -1,4 +1,4 @@
|
|||||||
import { queryRow, queryRaw } from "@/lib/db";
|
import { queryRow } from "@/lib/db";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replace the itemised receipt behind a transaction (plan §11 C2).
|
* Replace the itemised receipt behind a transaction (plan §11 C2).
|
||||||
@@ -89,7 +89,7 @@ async function undoBaseline(batchId: string | null | undefined, snapshot: unknow
|
|||||||
export async function replaceLineItems(
|
export async function replaceLineItems(
|
||||||
transactionId: number,
|
transactionId: number,
|
||||||
lineItems: LineItemRecord[],
|
lineItems: LineItemRecord[],
|
||||||
opts: { source: LineItemsSource; platform?: string | null; batchId?: string | null; metadataId?: number | null }
|
opts: { source: LineItemsSource; platform?: string | null; batchId?: string | null; metadataId?: number | null; currency?: string | null }
|
||||||
): Promise<ReplaceResult> {
|
): Promise<ReplaceResult> {
|
||||||
const txn = await queryRow<{
|
const txn = await queryRow<{
|
||||||
id: number;
|
id: number;
|
||||||
@@ -115,42 +115,67 @@ export async function replaceLineItems(
|
|||||||
[opts.metadataId, transactionId]
|
[opts.metadataId, transactionId]
|
||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
const existing =
|
|
||||||
pinned ??
|
const json = JSON.stringify(lineItems);
|
||||||
(await queryRow<{ id: number; line_items: unknown }>(
|
|
||||||
|
/**
|
||||||
|
* Snapshot the baseline, then write. In that order on BOTH paths: the create
|
||||||
|
* path used to snapshot after its INSERT, so a crash in between left a retry
|
||||||
|
* recording the intake's own write as "previous" — undo restoring the thing
|
||||||
|
* it was meant to reverse, which is the failure `previous_line_items` exists
|
||||||
|
* to prevent.
|
||||||
|
*
|
||||||
|
* Returns null when the row is gone between the read and the write. That
|
||||||
|
* UPDATE reported success and the batch was marked enriched against a row
|
||||||
|
* nothing had written to.
|
||||||
|
*/
|
||||||
|
const write = async (row: { id: number; line_items: unknown }): Promise<ReplaceResult | null> => {
|
||||||
|
const previous = await undoBaseline(opts.batchId, Array.isArray(row.line_items) ? row.line_items : []);
|
||||||
|
const updated = await queryRow<{ id: number }>(
|
||||||
|
`UPDATE expense_metadata SET line_items = $2::jsonb WHERE id = $1 RETURNING id`,
|
||||||
|
[row.id, json]
|
||||||
|
);
|
||||||
|
if (!updated) return null;
|
||||||
|
return { transaction_id: transactionId, metadata_id: row.id, created_metadata: false, previous_line_items: previous, line_items: lineItems };
|
||||||
|
};
|
||||||
|
|
||||||
|
if (pinned) {
|
||||||
|
const done = await write(pinned);
|
||||||
|
if (done) return done;
|
||||||
|
}
|
||||||
|
const existing = await queryRow<{ id: number; line_items: unknown }>(
|
||||||
`SELECT id, line_items FROM expense_metadata
|
`SELECT id, line_items FROM expense_metadata
|
||||||
WHERE transaction_id = $1 OR matched_transaction_id = $1
|
WHERE transaction_id = $1 OR matched_transaction_id = $1
|
||||||
-- NULLS LAST is load-bearing, and its absence inverted this clause.
|
-- NULLS LAST is load-bearing, and its absence inverted this clause.
|
||||||
-- Postgres orders DESC as NULLS FIRST, and the row that merely POINTS
|
-- Postgres orders DESC as NULLS FIRST, and the row that merely POINTS at
|
||||||
-- at the transaction has a NULL transaction_id — so the comparison
|
-- the transaction has a NULL transaction_id — so the comparison meant to
|
||||||
-- meant to prefer the owned row preferred the other one, silently and
|
-- prefer the owned row preferred the other one, silently and in exactly
|
||||||
-- in exactly the case it was written for. matchTransaction spells it
|
-- the case it was written for. matchTransaction spells it out; this did
|
||||||
-- out; this did not, and the two therefore disagreed.
|
-- not, and the two therefore disagreed.
|
||||||
ORDER BY (transaction_id = $1) DESC NULLS LAST, id
|
ORDER BY (transaction_id = $1) DESC NULLS LAST, id
|
||||||
LIMIT 1`,
|
LIMIT 1`,
|
||||||
[transactionId]
|
[transactionId]
|
||||||
));
|
);
|
||||||
|
|
||||||
const json = JSON.stringify(lineItems);
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
const previous = await undoBaseline(opts.batchId, Array.isArray(existing.line_items) ? existing.line_items : []);
|
const done = await write(existing);
|
||||||
await queryRaw(`UPDATE expense_metadata SET line_items = $2::jsonb WHERE id = $1`, [existing.id, json]);
|
if (done) return done;
|
||||||
return {
|
|
||||||
transaction_id: transactionId,
|
|
||||||
metadata_id: existing.id,
|
|
||||||
created_metadata: false,
|
|
||||||
previous_line_items: previous,
|
|
||||||
line_items: lineItems,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Nothing to update. Snapshot the empty baseline BEFORE creating the row.
|
||||||
|
const previous = await undoBaseline(opts.batchId, []);
|
||||||
const reference = opts.batchId ? `intake:${opts.batchId}` : `intake:txn:${transactionId}`;
|
const reference = opts.batchId ? `intake:${opts.batchId}` : `intake:txn:${transactionId}`;
|
||||||
const created = await queryRow<{ id: number }>(
|
// ON CONFLICT because two calls for one transaction can both find no row and
|
||||||
|
// both INSERT; expense_metadata.transaction_id is UNIQUE, so one of them lost
|
||||||
|
// with a constraint error that reached the user as an error card. `xmax = 0`
|
||||||
|
// is how a plain insert is told from a conflict-update, so the reported
|
||||||
|
// `created_metadata` stays true.
|
||||||
|
const created = await queryRow<{ id: number; inserted: boolean }>(
|
||||||
`INSERT INTO expense_metadata (
|
`INSERT INTO expense_metadata (
|
||||||
transaction_id, source, platform, order_reference, line_items,
|
transaction_id, source, platform, order_reference, line_items,
|
||||||
merchant_normalized, amount, transaction_date, currency, flags, extraction_model
|
merchant_normalized, amount, transaction_date, currency, flags, extraction_model
|
||||||
) VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7::numeric, $8::date, 'AUD', '[]'::jsonb, 'cloud-budget')
|
) VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7::numeric, $8::date, $9, '[]'::jsonb, 'cloud-budget')
|
||||||
RETURNING id`,
|
ON CONFLICT (transaction_id) DO UPDATE SET line_items = EXCLUDED.line_items
|
||||||
|
RETURNING id, (xmax = 0) AS inserted`,
|
||||||
[
|
[
|
||||||
transactionId,
|
transactionId,
|
||||||
opts.source,
|
opts.source,
|
||||||
@@ -160,13 +185,16 @@ export async function replaceLineItems(
|
|||||||
txn.merchant_normalized ?? txn.merchant_name ?? null,
|
txn.merchant_normalized ?? txn.merchant_name ?? null,
|
||||||
txn.amount,
|
txn.amount,
|
||||||
txn.transaction_date,
|
txn.transaction_date,
|
||||||
|
// Not hard-coded 'AUD': the transaction may carry a foreign figure, and
|
||||||
|
// OrderDetails prefers this column, so a USD order printed "$".
|
||||||
|
opts.currency || "AUD",
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
transaction_id: transactionId,
|
transaction_id: transactionId,
|
||||||
metadata_id: created!.id,
|
metadata_id: created!.id,
|
||||||
created_metadata: true,
|
created_metadata: created!.inserted,
|
||||||
previous_line_items: await undoBaseline(opts.batchId, []),
|
previous_line_items: previous,
|
||||||
line_items: lineItems,
|
line_items: lineItems,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,6 +119,16 @@ export async function merchantForTransaction(
|
|||||||
const row = await queryRow<{ merchant_normalized: string | null }>(
|
const row = await queryRow<{ merchant_normalized: string | null }>(
|
||||||
`SELECT merchant_normalized FROM expense_metadata
|
`SELECT merchant_normalized FROM expense_metadata
|
||||||
WHERE transaction_id = $1 OR matched_transaction_id = $1
|
WHERE transaction_id = $1 OR matched_transaction_id = $1
|
||||||
|
-- Ordered the same way as replaceLineItems (line-items.ts) and
|
||||||
|
-- matchTransaction (intake-router.ts), so every reader of this row
|
||||||
|
-- resolves the same one a write lands on. NULLS LAST because DESC is
|
||||||
|
-- NULLS FIRST and the row that merely POINTS at the transaction has a
|
||||||
|
-- NULL transaction_id.
|
||||||
|
-- Measured 2026-08-21: 667 expense_metadata rows, 147 of them
|
||||||
|
-- pointing-only, and ZERO transactions resolving to more than one row by
|
||||||
|
-- either link — so this changes nothing today. It is here so the readers
|
||||||
|
-- still agree on the day multiplicity appears.
|
||||||
|
ORDER BY (transaction_id = $1) DESC NULLS LAST, id
|
||||||
LIMIT 1`,
|
LIMIT 1`,
|
||||||
[transactionId]
|
[transactionId]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -367,6 +367,16 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte
|
|||||||
SELECT em.route, em.platform
|
SELECT em.route, em.platform
|
||||||
FROM expense_metadata em
|
FROM expense_metadata em
|
||||||
WHERE em.transaction_id = t.id OR em.matched_transaction_id = t.id
|
WHERE em.transaction_id = t.id OR em.matched_transaction_id = t.id
|
||||||
|
-- Ordered the same way as replaceLineItems (line-items.ts) and
|
||||||
|
-- matchTransaction (intake-router.ts), so every reader of this row
|
||||||
|
-- resolves the same one a write lands on. NULLS LAST because DESC is
|
||||||
|
-- NULLS FIRST and the row that merely POINTS at the transaction has a
|
||||||
|
-- NULL transaction_id.
|
||||||
|
-- Measured 2026-08-21: 667 expense_metadata rows, 147 of them
|
||||||
|
-- pointing-only, and ZERO transactions resolving to more than one row by
|
||||||
|
-- either link — so this changes nothing today. It is here so the readers
|
||||||
|
-- still agree on the day multiplicity appears.
|
||||||
|
ORDER BY (em.transaction_id = t.id) DESC NULLS LAST, em.id
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
) order_ctx ON true
|
) order_ctx ON true
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
|
|||||||
Reference in New Issue
Block a user