Three defects on one surface, all invisible while a transaction has exactly one expense_metadata row — which is almost always. 1. ORDER BY (transaction_id = $1) DESC did the opposite of its comment. Postgres orders DESC as NULLS FIRST, and the row that merely POINTS at the transaction has a NULL transaction_id, so the clause written to prefer the owned row preferred the other one — in exactly the case it exists for. matchTransaction already spelled out NULLS LAST; now so do this and GET /api/transactions/[id]/order, which had no ORDER BY at all and could read a different row from the one an enrichment had just written. 2. replaceLineItems takes an optional metadataId. The match resolves a row through a platform-filtered join; re-picking independently here can land the write on a row the match ruled out. 3. previous_line_items on intake_batches is the undo baseline, recorded once per batch under COALESCE and read back. Returning whatever was just read hands a pantry retry its own write as the "previous" state, so undo restores the thing it undid. Migration 0034 is unapplied anywhere, so the column is added there rather than in a 0035.
75 lines
3.6 KiB
SQL
75 lines
3.6 KiB
SQL
-- Intake router (plans/intake-router-slice-plan.md §11 C1, smarthome repo).
|
|
--
|
|
-- One upload point for receipts — a screenshot, a PDF export, a pasted body —
|
|
-- that reads what it was given and does every step that applies: fill the
|
|
-- empty `line_items` on the order the email pipeline already booked, hand a
|
|
-- grocery receipt to pantry, or ask when it cannot tell.
|
|
--
|
|
-- Every batch is persisted BEFORE anything is extracted, for two reasons:
|
|
-- 1. The Slack card it produces carries buttons ("Use this", "Create",
|
|
-- "Groceries or a meal?") that are pressed minutes later in a fresh
|
|
-- request with nothing but the batch id to go on.
|
|
-- 2. A re-share of an already-handled order — a second crop, the PDF after
|
|
-- the screenshot — must be recognised, and the only durable evidence of
|
|
-- the first share is this row (`file_sha256` overlap, or the same
|
|
-- (platform, merchant, date, total) header already resolved).
|
|
--
|
|
-- `id` is the uuid n8n mints per webhook call, so a retried delivery is the
|
|
-- same batch rather than a second one. `header` holds the extracted
|
|
-- (merchant, order_date, total, platform, ...) and `items` the verbatim item
|
|
-- list, so a later tap can enrich or create without re-reading the files.
|
|
--
|
|
-- Idempotent: safe to re-run.
|
|
|
|
CREATE TABLE IF NOT EXISTS intake_batches (
|
|
id uuid PRIMARY KEY,
|
|
source text NOT NULL,
|
|
shared_by text,
|
|
file_sha256 text[] NOT NULL DEFAULT '{}',
|
|
header jsonb,
|
|
items jsonb,
|
|
kind text,
|
|
status text NOT NULL,
|
|
transaction_id integer,
|
|
capture_id integer,
|
|
-- The undo baseline: what `expense_metadata.line_items` held before this
|
|
-- batch first replaced it. Recorded here rather than returned from the write,
|
|
-- because pantry re-calls the enrich endpoint on every confirm and after a
|
|
-- timeout — a caller that hands back whatever it just read gives a retry its
|
|
-- OWN write as the "previous" state, and undo then restores the thing it was
|
|
-- meant to reverse. Written under COALESCE, so only the first snapshot sticks.
|
|
previous_line_items jsonb,
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
resolved_at timestamptz
|
|
);
|
|
|
|
-- For a database that already ran an earlier draft of this file.
|
|
ALTER TABLE intake_batches ADD COLUMN IF NOT EXISTS previous_line_items jsonb;
|
|
|
|
COMMENT ON TABLE intake_batches IS
|
|
'One row per upload batch through POST /api/intake. Persisted before '
|
|
'extraction so Slack taps can act on it later and re-shares are detected. '
|
|
'kind: grocery|food|ask|held|unsupported. status: received|enriched|created|'
|
|
'candidates|forwarded_to_pantry|needs_header|duplicate|unsupported|held|error.';
|
|
|
|
-- Overlap lookups for the re-share check (`file_sha256 && $1`).
|
|
CREATE INDEX IF NOT EXISTS intake_batches_file_sha256_gin
|
|
ON intake_batches USING gin (file_sha256);
|
|
|
|
CREATE INDEX IF NOT EXISTS intake_batches_transaction_id
|
|
ON intake_batches (transaction_id) WHERE transaction_id IS NOT NULL;
|
|
|
|
-- The "Groceries or a meal?" answer, remembered per merchant (plan §2, Codex
|
|
-- #6). A tiny answer table, not merchant master-data: the pattern list in code
|
|
-- decides the obvious cases and this catches the `Romeo's Food Hall`s. Keyed on
|
|
-- the normalised merchant name the router already computes for matching.
|
|
CREATE TABLE IF NOT EXISTS intake_merchant_kinds (
|
|
merchant_key text PRIMARY KEY,
|
|
kind text NOT NULL CHECK (kind IN ('grocery', 'food')),
|
|
created_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
|
|
COMMENT ON TABLE intake_merchant_kinds IS
|
|
'Per-merchant answer to "Groceries or a meal?" from the intake Slack card. '
|
|
'Consulted after the grocery pattern list; an unknown merchant asks once.';
|