Compare commits
84
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d88ca2a24c | ||
|
|
91586ce4a4 | ||
|
|
4b09d4136f | ||
|
|
4f0f0dd285 | ||
|
|
b788b4e8fa | ||
|
|
b5569b8ccc | ||
|
|
d4ff68363a | ||
|
|
631e75e58f | ||
|
|
c25db5211b | ||
|
|
9a442b7355 | ||
|
|
f9638cc5e5 | ||
|
|
7424d13858 | ||
|
|
be3d10d4f1 | ||
|
|
a4e5f540cc | ||
|
|
32a47bf8ee | ||
|
|
c7ea516c34 | ||
|
|
db5c9a02a7 | ||
|
|
a538376638 | ||
|
|
b958d05287 | ||
|
|
eaf5d5ac66 | ||
|
|
fc9ffaba25 | ||
|
|
9f1be74330 | ||
|
|
4f307e9517 | ||
|
|
2cfaf8e8c8 | ||
|
|
c3b623052d | ||
|
|
0bb4329249 | ||
|
|
95d8544752 | ||
|
|
6c14b493ef | ||
|
|
22c2349a47 | ||
|
|
3edcc27781 | ||
|
|
461c021e7a | ||
|
|
b82c4570bd | ||
|
|
afd75d3f09 | ||
|
|
2738213a23 | ||
|
|
bb8a009e02 | ||
|
|
3e826a317b | ||
|
|
21e9e765a3 | ||
|
|
493ff6f631 | ||
|
|
2c666236b2 | ||
|
|
215303161b | ||
|
|
3b5e495ca4 | ||
|
|
6b9b5fe518 | ||
|
|
231ef3411a | ||
|
|
699e4a2ddd | ||
|
|
79940202f1 | ||
|
|
9790b64e1d | ||
|
|
1b34cd65d8 | ||
|
|
c05c4b5a35 | ||
|
|
b26d526e83 | ||
|
|
872da3b12c | ||
|
|
b4f28010df | ||
|
|
0e5422919e | ||
|
|
7f8e363b5b | ||
|
|
502e3f563c | ||
|
|
7819a88af2 | ||
|
|
a1e776e9be | ||
|
|
9e4b518f57 | ||
|
|
2d341e24a0 | ||
|
|
6db2345c49 | ||
|
|
cb7665ded1 | ||
|
|
6e179d3a0a | ||
|
|
dbc6fd1352 | ||
|
|
c70d2b1fac | ||
|
|
f6c500b27a | ||
|
|
549abd8cca | ||
|
|
a465b147a3 | ||
|
|
17028c79ff | ||
|
|
69b3ed8ea9 | ||
|
|
a56e5e2de5 | ||
|
|
22e4a1ead0 | ||
|
|
dd0462a5f9 | ||
|
|
3144cf3176 | ||
|
|
fe104a9618 | ||
|
|
63aaf1eb21 | ||
|
|
7cf247951a | ||
|
|
d458228625 | ||
|
|
82f751cbf4 | ||
|
|
9f0f38449b | ||
|
|
0595d49d5c | ||
|
|
50c5b7c430 | ||
|
|
14d6b40578 | ||
|
|
8fbcbc5f83 | ||
|
|
aaa36dd75e | ||
|
|
66a6a51fb8 |
+12
-4
@@ -22,10 +22,18 @@ jobs:
|
||||
# the normal colour.
|
||||
- name: Generate Prisma client
|
||||
run: npx prisma generate
|
||||
# Advisory until the pre-existing lint debt is cleared (2026-07-19:
|
||||
# ~20 errors across budget/insights/shared pages) — then make blocking.
|
||||
- name: Lint (advisory)
|
||||
# Blocking since #79. It was advisory from 2026-07-19 while 23 errors sat
|
||||
# across the pages and the integration tests; those are now fixed, so a
|
||||
# new one must not be able to slip in behind a green tick. Warnings are
|
||||
# still allowed (eslint exits 0 on them) — there are 9 left, all
|
||||
# `no-unused-vars` or `exhaustive-deps` advisories, none of which should
|
||||
# stop a push.
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
continue-on-error: true
|
||||
# Typecheck is separate from `next build`: nothing else in CI compiles the
|
||||
# test files, so a type error in a fixture could only ever be found on a
|
||||
# developer's machine.
|
||||
- name: Typecheck
|
||||
run: npx tsc --noEmit
|
||||
- name: Unit tests
|
||||
run: npm test
|
||||
|
||||
@@ -6,15 +6,18 @@ Application code lives in `src/`. Next.js App Router pages and API route handler
|
||||
|
||||
Keep data flow consistent: API routes call query functions in `src/lib/queries.ts`, which use `queryRaw()` from `src/lib/db.ts`; client components access APIs through TanStack Query hooks in `src/lib/hooks.ts`.
|
||||
|
||||
**Task tracking is the Vikunja board at `https://tasks.bosecamp.com`** (project *Work*, saved filter **finance-app**), which replaced the smarthome repo's `ACTIONS.md` on 2026-07-30. Update the ticket in the same change as the code. See `CLAUDE.md` → "Work tracking".
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
|
||||
- `npm ci` installs the locked dependency set (Node 22 is used in CI).
|
||||
- `npm run dev` starts the local Next.js development server.
|
||||
- `npm run build` creates a production build; `npm start` serves it.
|
||||
- `npm run lint` runs the Next.js ESLint configuration. Existing lint debt makes CI lint advisory, but new code should pass.
|
||||
- `npm run lint` runs the Next.js ESLint configuration. **Blocking in CI since #79** — the pre-existing debt is cleared, so it must stay at zero errors (warnings are still allowed).
|
||||
- `npx tsc --noEmit` typechecks, including the test files. Also blocking in CI.
|
||||
- `npm test` runs fast unit tests.
|
||||
- `npm run test:setup` prepares the PostgreSQL test database using `.env.test`.
|
||||
- `npm run test:integration` runs database-backed tests.
|
||||
- `npm run test:setup` creates/refreshes the `personal_test` database (run it once, and after any migration).
|
||||
- `npm run test:integration` runs database-backed tests. It re-resolves the DSN from the live container first (`scripts/resolve-test-db.sh`), so `.env.test` must never carry a hand-written host — see `CLAUDE.md` → Testing.
|
||||
- `npm run test:all` runs both test suites.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
@@ -31,4 +34,4 @@ Add schema changes as the next numbered `prisma/migrations/NNNN_description/migr
|
||||
|
||||
## Commits & Pull Requests
|
||||
|
||||
History follows concise Conventional Commit-style subjects such as `feat(rules): preview rule changes`, `fix(trips): ...`, and `docs: ...`. Keep commits focused. Pull requests should explain behavior and data-model impact, link related issues, list validation commands, and include screenshots for UI changes. Ensure unit tests and the production build pass; call out any known lint warnings or migration steps.
|
||||
History follows concise Conventional Commit-style subjects such as `feat(rules): preview rule changes`, `fix(trips): ...`, and `docs: ...`. Keep commits focused. Pull requests should explain behavior and data-model impact, link related issues, list validation commands, and include screenshots for UI changes. Ensure lint, typecheck, unit tests and the production build pass; call out any migration steps.
|
||||
|
||||
@@ -11,6 +11,33 @@ Personal finance tracker. Bank statements are ingested via an N8N workflow (in t
|
||||
- **Auth**: `X-Forwarded-User` header (email) set by Traefik → `participants.email`. In dev/fallback: participant id=1 ("Me")
|
||||
- **Runs at**: port 3000 inside container, exposed on host port 4100, proxied at `https://finance.bosecamp.com`
|
||||
|
||||
## Work tracking — the board, not a markdown file
|
||||
|
||||
Outstanding work for this app lives on the Vikunja board at
|
||||
`https://tasks.bosecamp.com` (project **Work**), which replaced the smarthome
|
||||
repo's `ACTIONS.md` on 2026-07-30.
|
||||
|
||||
Find this app's work with the saved filter **finance-app**, or
|
||||
`done = false && labels in <finance-app-label-id>`. Note that a ticket can carry
|
||||
*several* system labels — the receipt→pantry work is labelled `finance-app`,
|
||||
`pantry-app` **and** `email-ingestion` — so don't assume the finance filter shows
|
||||
everything that will touch this codebase.
|
||||
|
||||
Epics that own most finance work: **Order & receipt ingestion (ING-7/8/10)**,
|
||||
**Cashback tracking (ING-6)**, **Utility bills slice**, **Lane C — Ingestion
|
||||
engine**, **Postgres estate & PG 14 EOL**.
|
||||
|
||||
**Update the ticket in the same change as the code.** The board is only worth
|
||||
having if its status is true, and the previous system drifted precisely because
|
||||
status lived somewhere nobody touched while shipping.
|
||||
|
||||
Token and API conventions: smarthome `CLAUDE.md` → "Work tracking". The token is
|
||||
in smarthome `docker/utilities/.env`; this repo does not carry it.
|
||||
|
||||
**One deadline here is real:** `postgres-personal` runs **PostgreSQL 14, EOL 12
|
||||
November 2026** — it holds `statements`, `transactions`, `orders` and
|
||||
`expense_metadata`. Tracked in the Postgres epic, not here.
|
||||
|
||||
## Common Commands
|
||||
|
||||
**Deployment is push-to-deploy via Komodo** (since 2026-07-19): pushing to `main` on
|
||||
@@ -32,6 +59,49 @@ docker exec postgres-personal psql -U personal -d personal
|
||||
docker logs finance -f
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
npm test # unit — pure logic, no database
|
||||
npm run test:integration # database-backed; resolves the DSN itself (see below)
|
||||
npm run test:all # both
|
||||
npm run test:setup # once, and after any migration: creates/refreshes personal_test
|
||||
npm run lint # blocking in CI since #79 — 0 errors, warnings allowed
|
||||
npx tsc --noEmit # blocking in CI; nothing else compiles the test files
|
||||
```
|
||||
|
||||
**The integration DSN is resolved at run time — never hand-write a host into
|
||||
`.env.test`.** `scripts/resolve-test-db.sh` runs automatically as
|
||||
`pretest:integration` and rewrites that file from the live container before
|
||||
every run. It prefers the container's **published host port** (`docker port
|
||||
postgres-personal` → `127.0.0.1:5432`, published since the 2026-08-17 PG17
|
||||
migration) and falls back to the bridge IP only where that is absent. Set
|
||||
`TEST_DATABASE_URL` to override it entirely.
|
||||
|
||||
This replaces the old "refresh the container IP" instruction, which described
|
||||
the defect rather than a workaround (#161). `setup-test-db.sh` used to bake the
|
||||
bridge IP into `.env.test` once, and Docker reassigns those on recreation: by
|
||||
2026-08-21 the stored `172.22.0.18` belonged to **gitea**, and an earlier drift
|
||||
had pointed the suite at **postgres-pantry** — whose database it would have
|
||||
TRUNCATEd had the credentials happened to match. A loopback address cannot be
|
||||
reassigned to another container, which is why the published port wins.
|
||||
|
||||
**Two guards, and both matter.** `helpers.ts::assertTestDatabase` refuses to
|
||||
truncate anything whose `current_database()` is not `personal_test`, and the
|
||||
suite runs `fileParallelism: false` because every file shares that one database
|
||||
and `resetDB()` TRUNCATEs it.
|
||||
|
||||
**A fresh checkout or worktree needs `npx prisma generate` first.** The client is
|
||||
generated into the gitignored `src/generated/prisma`, so without it every test
|
||||
that reaches `src/lib/db.ts` fails on a missing package — which reads as a code
|
||||
error rather than a setup one. CI does this explicitly for the same reason.
|
||||
|
||||
**Freeze the clock in any test whose subject is defined relative to "now".**
|
||||
`analytics-priority0.test.ts` uses `vi.useFakeTimers({ toFake: ["Date"] })` —
|
||||
only `Date`, because `pg` needs real timers for connection handling. Without it
|
||||
the fixtures drift out of the routes' rolling windows over time and the
|
||||
assertions quietly stop testing anything.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Key Files
|
||||
@@ -68,6 +138,29 @@ COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) -- merc
|
||||
COALESCE(o.category_override, t.category) -- category
|
||||
```
|
||||
|
||||
### Hiding categories in the transactions view
|
||||
|
||||
`getTransactions` takes `exclude_categories`. It is **opt-in per caller and
|
||||
never defaulted in `queries.ts`** — `GET /api/rules/[id]/matches` and
|
||||
`POST /api/rules/apply` both read their candidate rows through `getTransactions`,
|
||||
so a default exclusion there would silently shrink what a rule can preview and
|
||||
reach. Only the transactions page sets it.
|
||||
|
||||
The transactions view defaults it to `["transfers"]` (433 of 3,996 rows, ~11%),
|
||||
with a visible "Hide transfers" checkbox. Two rules the implementation depends
|
||||
on, both tested:
|
||||
|
||||
- **An explicit category pick beats the exclusion.** Selecting "Transfers" while
|
||||
the default is on subtracts it from the hidden list rather than returning zero
|
||||
rows — otherwise the view reads "you have no transfers".
|
||||
- **`COALESCE(..., '')` before `<> ALL`.** `NULL <> ALL(...)` is NULL, not true,
|
||||
so an uncategorised row would vanish from a filter that never named its
|
||||
category. Same trap `EXCLUDE_NON_SPEND` documents.
|
||||
|
||||
It defaults **off** when the view is scoped to a statement (`?statement_id=`).
|
||||
That is a reconciliation view — the row count has to match the statement, and a
|
||||
credit-card payment is exactly the row you went there to check.
|
||||
|
||||
## Database
|
||||
|
||||
```bash
|
||||
@@ -81,20 +174,59 @@ docker exec postgres-personal psql -U personal -d personal < prisma/migrations/<
|
||||
### Key Tables
|
||||
|
||||
- `statements` — one row per billing period per bank account
|
||||
- `transactions` — line items; `statement_id` is nullable (NULL = manual entry); `reconciled_with_id` links a manual tx to its matched statement tx; `payment_method` (migration 0016) is `card | cash | bank_transfer | other`, NULL = unknown
|
||||
- `transactions` — line items; `statement_id` is nullable (NULL = manual entry); `reconciled_with_id` links a manual tx to its matched statement tx; `payment_method` (migration 0016, `credits` added by 0018) is `card | cash | bank_transfer | credits | other`, NULL = unknown, and the TypeScript mirror is `src/lib/payment-methods.ts`; `source` / `source_ref` / `source_account` (migration 0028) identify a row that came from an account feed rather than a statement or a hand entry — `source_ref` is the provider's own id and carries a partial unique index, which is the only thing making a re-import idempotent
|
||||
|
||||
### Cash and reconciliation
|
||||
|
||||
`payment_method = 'cash'` excludes a transaction from reconciliation via the
|
||||
`notCash()` fragment in `queries.ts`. Cash never appears on a statement, so
|
||||
`payment_method` in (`cash`, `credits`) excludes a transaction from
|
||||
reconciliation via the `needsCardMatch()` fragment in `queries.ts` (named
|
||||
`notCash()` when it only knew about cash). Cash never appears on a statement, so
|
||||
without it a cash entry sits in the pending queue forever being offered matches
|
||||
within 3 days and 1% on amount — and accepting one is silently destructive:
|
||||
reconciled manual rows are filtered out of every query, so the cash spend
|
||||
disappears while the card transaction it matched claims to be that same spend.
|
||||
|
||||
Only cash is excluded. Bank transfers *do* appear on a statement now that
|
||||
transaction accounts are imported, and NULL means unknown — both stay
|
||||
candidates, preserving the behaviour of every pre-existing row.
|
||||
`credits` is the same shape for a different reason: a gift card or store balance
|
||||
already paid the bill, so no card leg is ever coming. `bankLabel()` therefore
|
||||
renders those rows **"Gift Card"** rather than "Manual" — "Manual" reads as
|
||||
"still awaiting a card line", which is wrong here.
|
||||
|
||||
Only those two are excluded on payment method. Bank transfers *do* appear on a
|
||||
statement now that transaction accounts are imported, and NULL means unknown —
|
||||
both stay candidates, preserving the behaviour of every pre-existing row.
|
||||
|
||||
**The vocabulary lives in three places and drifted between them (fixed
|
||||
2026-08-17).** Migration 0018 admitted `credits` to the CHECK constraint for the
|
||||
order pipeline; the `VALID_METHODS` literal in `PATCH /api/transactions/[id]`
|
||||
and the add-transaction dropdown were never updated. So the database held 372
|
||||
gift-card rows carrying a value the UI could not offer and the API answered 400
|
||||
for — a hand-entered gift-card purchase silently took the NULL default, and NULL
|
||||
*is* reconcilable, so it joined the queue to be offered matches that cannot
|
||||
exist. `src/lib/payment-methods.ts` is now the single list (mirroring
|
||||
`statement-types.ts`), and both routes validate against it.
|
||||
`src/__tests__/integration/payment-methods.test.ts` asserts it against
|
||||
`pg_get_constraintdef` and evaluates `needsCardMatch()` in real SQL, so the next
|
||||
migration that widens the constraint fails the suite instead of drifting
|
||||
quietly.
|
||||
|
||||
**Account-feed rows are NOT excluded, and the removed exclusion is worth
|
||||
knowing about.** There was an `awaitsStatementLine()` predicate here, added with
|
||||
the Frollo importer on the premise that a feed row is the account's own ledger
|
||||
entry with no statement line coming. That premise was asserted and never tested.
|
||||
It was false for almost every account: 422 of the first 550 imported rows already
|
||||
had a statement twin.
|
||||
|
||||
What matters is how it got in. The queue jumped from 8 to 558 the moment the feed
|
||||
landed, and that jump was read as noise and filtered out. **The queue was right** —
|
||||
those rows genuinely were provisional entries awaiting statement lines — and
|
||||
filtering it removed the only mechanism that would ever have collapsed them, so
|
||||
the duplicates became permanent instead of transient and stayed invisible until a
|
||||
human saw one salary payment listed twice in two currencies.
|
||||
|
||||
A feed row belongs in the queue. Queue volume is solved **upstream**, by not
|
||||
importing rows the ledger already holds (`LEDGER_MATCH_DAYS`), never downstream
|
||||
by hiding the ones that are there. If a genuinely statement-less feed is ever
|
||||
added, give it its own predicate and prove the premise with a query first.
|
||||
|
||||
ATM withdrawals stay categorised as spend rather than `transfers`. Treating them
|
||||
as transfers only works if every cash purchase is logged; with partial logging
|
||||
@@ -133,6 +265,37 @@ filter on `settled` — half a 2025 grocery shop was your expense whether or not
|
||||
the other half was repaid. Filtering it out re-inflates exactly the figures the
|
||||
historical import exists to correct.
|
||||
|
||||
**Every split totals 100%, and the payer's row is written down.** `myShare`
|
||||
resolves the payer's share as `100 - SUM(everyone else)`, so a 50/50 stored as
|
||||
a lone "Sonu 50%" row still computed correctly — and still read on screen as a
|
||||
50% share against a blank. `completeSplit` (`src/lib/splits.ts`) is the single
|
||||
place that materialises the remainder, and every write path ends in it:
|
||||
`applyRuleActions`, `POST /api/transactions`, the Slack nudge's share button,
|
||||
and the rule-revert restore. `POST /api/transactions/[id]/splits` needs no call
|
||||
— it already rejects anything not summing to 100.
|
||||
|
||||
The remainder always goes to the transaction's **owner**, never to "me". The
|
||||
owner's row on their own transaction is excluded from both halves of
|
||||
`getParticipantBalances` (`ts.participant_id != $1` on transactions I own; the
|
||||
converse on ones I do not), so writing it cannot create, enlarge or discharge a
|
||||
debt. A row for *me* on someone else's transaction is a real obligation — never
|
||||
synthesise one. This is what made the 7-row backfill in `22e4a1e` safe;
|
||||
balances were byte-identical across it.
|
||||
|
||||
There is no database-level constraint on the sum. Enforcing it needs a deferred
|
||||
constraint trigger, and the rule path commits its DELETE and INSERT as separate
|
||||
autocommitted statements, so the trigger would reject the intermediate state.
|
||||
|
||||
`share_percent` has a CHECK of `> 0 AND <= 100`, so a 0% row cannot be stored —
|
||||
when the others grow to cover the whole amount, the owner's row is deleted
|
||||
rather than zeroed.
|
||||
|
||||
**Un-sharing needs DELETE, not an empty POST.** The splits route rejects an
|
||||
empty array ("splits array required"), so `DELETE /api/transactions/[id]/splits`
|
||||
is the only way to clear. The order panel's "Shared 50/50" toggle was inert in
|
||||
both directions until `22e4a1e` because it posted a lone 50% row to share and
|
||||
`[]` to un-share, and the endpoint rejected both.
|
||||
|
||||
**Any split write path that deletes-and-recreates must carry `settled` across.**
|
||||
`POST /api/transactions/[id]/splits` did not, and silently converted discharged
|
||||
obligations into live debt — $37,233.28 was exposed. Fixed in `6add958`.
|
||||
@@ -168,7 +331,213 @@ a *cancelled* booking has both legs untagged from the trip by hand, because a
|
||||
trip never incurred a cost it cancelled.
|
||||
|
||||
**Trips:** Europe 2026 (id 1, 19 Mar–12 Apr), Auckland 2026 (id 2),
|
||||
Europe — Sonu + Sunny (id 3, 12–28 Apr, created 2026-07-28 from tag 5).
|
||||
Europe — Sonu + Sunny (id 3, 12–28 Apr, created 2026-07-28 from tag 5),
|
||||
Singapore + Bangkok 2026 (id 4).
|
||||
|
||||
### Why `travel` looked useless on a trip page, and the fix (2026-08-02)
|
||||
|
||||
`travel` was ~60% of every trip and told you nothing. The tempting fix — a finer
|
||||
travel taxonomy (flights / stays / getting around) — needs a hand-maintained
|
||||
merchant list, which is the trap ticket #19 already describes. It is also the
|
||||
wrong diagnosis.
|
||||
|
||||
**Measured on Europe 2026, `travel` is the only category that spans both phases
|
||||
of a trip. Every other category is 100% on-the-ground — dining, transport,
|
||||
entertainment, groceries and shopping are all exactly $0.00 before departure.**
|
||||
So the category chart was not a bad chart; it was two different economies stacked
|
||||
into one, and travel was the only thing visible in the union.
|
||||
|
||||
The fix is to split on `trips.start_date` and use the axis that carries
|
||||
information in each phase:
|
||||
|
||||
- **Booked ahead** (before `start_date`, **$21,229.56 / 56%** on Europe, 30
|
||||
bookings) — everything is a flight, a stay or a rail ticket, so category is a
|
||||
constant and **merchant** is the axis: Agoda $4,491, Air India $3,454, Azwebin
|
||||
$2,696, Luxury Escapes $2,463.
|
||||
- **On the ground** (on/after `start_date`, **$16,946.77**, 180 charges) — travel
|
||||
falls to $8,241 among dining $4,452, transport $2,938, entertainment $698,
|
||||
groceries $589, shopping $29. **Category is finally worth charting.**
|
||||
|
||||
These are the **net** figures the page shows, and they are lower than a raw
|
||||
`SUM(amount)` by design: the phase queries apply `NET_SPEND_ROWS` / `SPEND_SIGNED`
|
||||
and `EXCLUDE_RECONCILED_SOURCE`, the same fragments as every other analytic. Raw
|
||||
sums give $22,050.51 committed and Luxury Escapes at $3,283.80 — the $820.95 gap
|
||||
is a partial refund on that booking. Do not "fix" the difference; a partly
|
||||
refunded booking must not read at full price.
|
||||
|
||||
`getTripAnalytics` returns `phases`, `committed_merchants`, `on_ground_categories`
|
||||
and `on_ground_daily`. A trip with a NULL `start_date` has no knowable departure,
|
||||
so the SQL folds everything into on-ground rather than reporting it as committed.
|
||||
|
||||
**The daily rate is the only figure comparable between trips**, because totals are
|
||||
not — trips differ in length. Europe $677.88/day, Sonu + Sunny $573.57, Singapore
|
||||
+ Bangkok $309.76, Auckland $83.39. The page ranks the current trip against the
|
||||
others from the already-loaded `useTrips()` list.
|
||||
|
||||
**A trip with near-zero committed spend is a filing artefact, not a cheap trip.**
|
||||
Europe — Sonu + Sunny shows $184.84 committed against Europe 2026's $22,050.51
|
||||
because both legs' flights and stays were filed on the first trip. The page says
|
||||
so rather than letting the ratio read as missing data.
|
||||
|
||||
Two chart rules this page now follows, both from the `dataviz` skill and both
|
||||
previously broken here:
|
||||
|
||||
- **One series → one colour.** The category bars use a single copper hue with the
|
||||
category as a direct label. `CATEGORY_COLORS` was a per-bar rainbow, which
|
||||
double-encodes identity the label already carries — and the trip subset **fails**
|
||||
CVD validation on this surface (`other` ↔ `shopping` ΔE 5.0 protan, below the
|
||||
floor of 6). Do not reintroduce per-category colour on a labelled bar chart.
|
||||
- **No serif and no `tabular-nums` on the hero figure.** Fraunces is for section
|
||||
headings; a display face on a large number reads as decoration, and equal-width
|
||||
digits make it look loose.
|
||||
|
||||
The phase split bar is two ordinal steps of one hue (`#7c4820` → `#d28a47`),
|
||||
validated with `--ordinal` against the `#171410` card surface, with a 2px gap so
|
||||
the boundary is an edge rather than a colour change. Both segments are
|
||||
direct-labelled, so it needs no legend.
|
||||
|
||||
### Trip participation is derived, and a trip is shared
|
||||
|
||||
Rebuilt 2026-08-02. Trips were scoped to `trips.owner_id`, so Sonu saw **no
|
||||
trips at all** despite paying for 104 of the tagged rows herself — her own
|
||||
spending was invisible on the only page organised around it.
|
||||
|
||||
**A participant is anyone with a split on, who paid for, or whose payment is
|
||||
scoped to, a transaction tagged to the trip.** Derived (`TRIP_PARTICIPANT` in
|
||||
`queries.ts`), never stored. A membership table was designed and rejected: it
|
||||
would be a second record of a fact the expenses already carry, and two records
|
||||
of one fact drift — the same reason sharing is a real split rather than a flag.
|
||||
The derivation also gets the exclusions right for free, which a table has to be
|
||||
kept in sync to do: Singapore + Bangkok 2026 has no Sonu split and no Sonu
|
||||
payment, so she is not a participant and never sees it. Live result is
|
||||
Siddharth 4 trips, Sonu 3, Molina 1.
|
||||
|
||||
**Everything is shared except delete.** Read, edit and assign are open to any
|
||||
participant. `deleteTrip` stays `owner_id`-only because both trip foreign keys
|
||||
are `ON DELETE SET NULL`, so deleting Europe 2026 untags 210 transactions *and*
|
||||
NULLs the trip scope on 6 payments — which is where the hand-derived
|
||||
Europe-first allocation lives, and nothing recomputes it. The route returns 403
|
||||
with the reason rather than a 404 that pretends the trip is missing.
|
||||
|
||||
**`getTransactions` gained `trip_all_rows`, and it is opt-in for a reason.** A
|
||||
participant sees every row on a trip, not only their own — the trip total
|
||||
already counts every payer. It must NOT be implied by `trip_id` being present:
|
||||
`GET /api/transactions` is also the main transactions list, and its trip filter
|
||||
has to keep owner scoping or filtering your own ledger by "Europe 2026" would
|
||||
quietly fill it with someone else's rows. Participation is re-checked in SQL, so
|
||||
passing the flag for a trip you are not on returns nothing rather than
|
||||
everything. Only `trips/[id]/page.tsx` sets it.
|
||||
|
||||
**Trip owed is pairwise and returns BOTH directions, never netted.** `owed` is
|
||||
unchanged — their share of rows *the viewer* paid. `i_owe` is the mirror: the
|
||||
viewer's share of rows *that participant* paid. Rendering the pair from the
|
||||
viewer's side is the whole fix; an obligation lives on a row someone else paid
|
||||
for, so a viewer-as-payer figure can never contain it, and Sonu's Europe 2026
|
||||
read "you are owed $2,408.24" while omitting the $8,004.04 she owed.
|
||||
|
||||
**The API returns both halves whole; the trip page nets them for display.** One
|
||||
figure per person, with the breakdown beside it, because a net nobody can
|
||||
decompose is how a wrong figure survives.
|
||||
|
||||
**A NEGATIVE trip net is not a bill — this is the trap, and it was got wrong
|
||||
twice.** A payment is allocated to a scope as a lump sum, and the grouped-payment
|
||||
allocation gave each trip enough to clear the payer's **gross** share. So netting
|
||||
the other side off leaves a fully-paid trip negative by exactly what the payment
|
||||
over-covered: Europe reads **−$802.75** because Sonu paid $8,004.04 against a net
|
||||
share of $7,201.30. That surplus is already carried in the overall balance —
|
||||
**she still owes $5,313.38 overall** — so labelling it "you owe them" was flatly
|
||||
wrong. Scope nets sum to the overall figure; a negative simply means this scope
|
||||
was over-covered and the excess sits in another.
|
||||
|
||||
The discriminator is `paid_to_me`:
|
||||
|
||||
- negative **with** a payment into the scope → over-covered, nothing to pay
|
||||
- negative **with no** payment → genuinely owed, because the viewer's share of
|
||||
the other person's spending exceeds theirs
|
||||
|
||||
All three of today's negatives are the first kind (Europe Sonu −$802.75,
|
||||
Sonu + Sunny −$936.34, Europe Molina −$816.16). Both cases are tested. The trip
|
||||
table therefore carries an **Overall balance** column from the unscoped
|
||||
`getParticipantBalances` — the trip figure alone cannot tell you whether to pay
|
||||
anyone, and **settlement is always against the overall figure, never one trip.**
|
||||
|
||||
**On whether to net — the reasoning reversed once, and the second answer is the
|
||||
right one.** The first objection was that the grouped-payment allocation (memory
|
||||
case `allocate_grouped_payments`) cleared each trip against the *one-directional*
|
||||
gross, Europe first with the remainder to household, so netting would redefine
|
||||
that debt after the fact. Checking the underlying rows overturned it: Europe's
|
||||
$802.75 is **56 real transactions Sonu paid** across Rome, Venice, the Dolomites,
|
||||
Bellagio, Lucerne and Paris on which Siddharth holds 25% — and `paid_by_me` is
|
||||
**$0.00 on every row of every trip**, because nothing has ever been recorded
|
||||
going from him to her. The one-directional view was concealing a live obligation,
|
||||
not protecting an allocation. Her side was paid in full and looked settled only
|
||||
because the allocation derived her payment split *from* her gross, so it lands on
|
||||
zero by construction.
|
||||
|
||||
Current nets: Auckland Sonu **+$1,077.25** (1,505.64 − 428.39), Europe Sonu
|
||||
**−$802.75**, Sonu + Sunny **−$936.34**, Europe Molina **−$816.16** — the three
|
||||
negatives all being over-coverage, per the rule above. The `owed` column itself
|
||||
was verified byte-identical when the mirror was added — $1,505.64, −$816.16,
|
||||
$0.00, $0.00.
|
||||
|
||||
**A payment left on the household tab makes a settled trip debt read as
|
||||
outstanding.** Payment 5 (Molina → Sonu, $1,605.49) discharged the Europe debt
|
||||
between those two but carries `trip_id IS NULL`, so a trip-scoped net cannot see
|
||||
it and Europe still shows it owing. That is the cost of scope being optional, and
|
||||
the reason the Record Payment modal now asks. Fixable per row with
|
||||
`UPDATE split_payments SET trip_id = 1 WHERE id = 5` — not done, it is a data
|
||||
decision.
|
||||
|
||||
### The Shared view shows payer and category, and is searchable (2026-08-02)
|
||||
|
||||
`getSharedTransactions` already returned `owner_name` and `effective_category`;
|
||||
the table simply never rendered them. **Paid by** sits next to **Splits**
|
||||
deliberately — together they are the two halves of the question the page exists
|
||||
to answer, whose money went out and whose share it was. It shows the *effective*
|
||||
owner (`COALESCE(t.owner_id, s.owner_id)`), which is the account the spend left,
|
||||
and the same figure every balance on the page is computed from. Category uses the
|
||||
override-first COALESCE, so a correction made anywhere shows here.
|
||||
|
||||
Search is **client-side**, unlike the transactions page. This endpoint returns
|
||||
every split row in one request (1,267 today) with no pagination, so there is
|
||||
nothing for a server round-trip to narrow, and the sort was already client-side.
|
||||
It matches description, merchant, notes, category and payer — deliberately **not**
|
||||
participant names, because the participant dropdown already does that and typing
|
||||
"sonu" matching every row she is split on would read as broken.
|
||||
|
||||
### Payment scope reaches the API (2026-08-02)
|
||||
|
||||
`split_payments.trip_id` has existed since migration 0022, but `POST
|
||||
/api/split-payments` never read it and `GET` never returned it — so **every
|
||||
payment recorded through the app landed on the household tab**, and the 9
|
||||
trip-scoped rows had to be written by hand in SQL. A $11k Europe settlement was
|
||||
silently reducing the ongoing household balance.
|
||||
|
||||
Both fixed. The modal has a "Settles" selector (Household or a trip) and history
|
||||
shows each payment's scope as a chip. **"Both" needs no new shape:** one
|
||||
transfer becomes one row per scope sharing a `linked_transaction_id`, which is
|
||||
why there is deliberately no unique constraint on it — tx 4121's $4,794.06 sits
|
||||
as $1,145.52 against Europe — Sonu + Sunny and $3,648.54 against household, and
|
||||
tx 4111's $3,779.33 spans two trips. All six linked transfers reconcile to the
|
||||
cent.
|
||||
|
||||
### Three write paths that had no authorisation
|
||||
|
||||
All closed 2026-08-02. Each was reachable by any authenticated participant:
|
||||
|
||||
- **`assignTransactionsToTrip`** took no caller and checked nothing, so
|
||||
`PATCH /api/trips/[id]/transactions` and `POST /api/transactions/bulk`
|
||||
(`assign_trip`) let anyone move any transaction id into any trip id. Not being
|
||||
able to *see* a trip was no obstacle, because the write path never read one.
|
||||
Now: only rows the caller can already see move, and a non-null destination must
|
||||
be a trip they participate in — enforced in the query, not the route, so
|
||||
neither caller can bypass it. Returns the count actually moved.
|
||||
- **`DELETE /api/split-payments?id=`** deleted by id with no check at all. Erasing
|
||||
a settlement silently resurrects a discharged debt — the same class of damage
|
||||
as the split rewrite that reset `settled`. Now limited to the two people the
|
||||
payment is between.
|
||||
- **`POST /api/split-payments`** accepted any `from`/`to` pair. Now the payment
|
||||
must involve the caller, and a trip scope must be a trip they are on.
|
||||
|
||||
**Partial split coverage inside a category is usually correct, not a gap.** Only
|
||||
*shared* items are split. `utilities` sits at 69% yours because Globird, OVO, GWW
|
||||
@@ -183,8 +552,158 @@ category-level ratio that "looks wrong" usually is not.
|
||||
offset account, 39 rows, $37,980.24) are categorised `transfers`, indistinguishable
|
||||
from ordinary internal transfers. The loan model below is unbuilt.
|
||||
|
||||
**`order_reviews` is a table wired to nothing** — 0 rows, no API, no UI, no
|
||||
writes. The "never order from here again" capability does not exist.
|
||||
### Order verdicts — "never order from here again"
|
||||
|
||||
Built 2026-07-28 (migrations 0024, 0025). `order_reviews` was previously a table
|
||||
wired to nothing; it now backs `GET`/`PUT /api/transactions/[id]/review`, the UI
|
||||
in `components/order-details.tsx`, and the Slack card in
|
||||
`lib/slack-blocks.ts` + `app/api/slack/interactive/route.ts`. Logic in
|
||||
`lib/order-reviews.ts`.
|
||||
|
||||
Five things about the shape, each load-bearing:
|
||||
|
||||
- **Per person, not per order.** `UNIQUE (transaction_id, participant_id)`. A
|
||||
shared meal produces two opinions that routinely disagree, and the
|
||||
disagreement is the useful part. `PUT` defaults to the **signed-in user**,
|
||||
not the owner — Sonu authenticates through the same Traefik OAuth as
|
||||
participant 4, so an owner default would file her verdict under his name.
|
||||
- **Five levels** — `loved`, `liked`, `ok`, `bad`, `never`. Three collapsed the
|
||||
distinction that decides a re-order; `bad` was added because the jump from
|
||||
`ok` to `never again` is too big and most disappointments live in the gap.
|
||||
- **Only `never` sets `warn`.** A blacklist that fires for every mediocre meal
|
||||
is one nobody reads. `bad` and `never` both set `order_again = false` — you
|
||||
would not choose either — but only `never` raises the alarm on a future
|
||||
order. "Would I order it" and "warn me about it" are different questions.
|
||||
- **Item verdicts key on the item DESCRIPTION**, not its index — an index is
|
||||
meaningless across orders, and "the Pad Thai here is good" has to survive
|
||||
into the next order from the same merchant. Pooled case-folded across the
|
||||
merchant's orders. Only `loved`/`never`: a per-item "ok" answers neither
|
||||
question you ask at order time.
|
||||
- **An ABSENT `item_verdicts` means "leave them alone"; `[]` clears them.**
|
||||
Without that distinction a note-only save wipes every per-item opinion — the
|
||||
same shape as the bug that reset `settled` on split rewrites, and just as
|
||||
invisible on screen. Mutation-tested.
|
||||
|
||||
**The merchant is the restaurant, not the courier.** `orderDescription` does not
|
||||
append the platform — that was added on request and reversed on 2026-07-28,
|
||||
because it fragmented the merchant and the platform already renders in the Order
|
||||
details panel. 81 descriptions were backfilled. `merchantVerdict` joins
|
||||
**case-folded**: the platforms capitalise differently (`TEG Kebabs & Biryani` vs
|
||||
`TEG KEBABS & BIRYANI`) and an exact match kept two separate histories, so a
|
||||
"never again" through one app never warned in the other.
|
||||
|
||||
The merchant signal is *derived* by aggregating on
|
||||
`expense_metadata.merchant_normalized` — never `transactions.merchant_name`,
|
||||
which is a bank descriptor.
|
||||
|
||||
**Sharing is a real 50/50 split, not a flag** — the split already IS the record,
|
||||
and two records of one fact drift apart. The toggle **refuses** when a
|
||||
participant outside {1, 4} is present or the second consumer's share is not 50:
|
||||
splits are made by hand here, so a third party or an uneven share is deliberate
|
||||
and one tap must not flatten it. It says which case it refused on.
|
||||
|
||||
`/api/orders/ingest` returns `prior_verdict` (so the nudge can warn inline) and
|
||||
`slack_blocks` (so Block Kit stays in tested code rather than n8n expressions).
|
||||
|
||||
### Slack cards — two rules that cost real data
|
||||
|
||||
1. **Update via `response_url`, never the HTTP response body.** Block Kit
|
||||
interactivity ignores the response body; replacing a message that way is
|
||||
legacy attachment-style behaviour. Getting this wrong meant every press wrote
|
||||
correctly and left the card stale, so a working button looked dead, got
|
||||
pressed again, and toggled itself back — three splits were lost before
|
||||
`conversations.history` showing `edited: false` settled it. `response_url`
|
||||
needs no bot token, so the app posts it directly.
|
||||
2. **Never hand-write a card.** A card built with a guessed `shared: false`
|
||||
mislabels an already-shared order and the button then deletes the split.
|
||||
Render by calling the interactive endpoint with a no-op verb (`<id>:noop`):
|
||||
it writes nothing and returns blocks built from live state.
|
||||
|
||||
Slack reaches this route through an n8n webhook, not directly — see the
|
||||
smarthome repo's CLAUDE.md and the `slack-interactive-via-n8n` memory.
|
||||
|
||||
### Intake router — one upload, every applicable step (2026-08-21)
|
||||
|
||||
`POST /api/intake` (token `INTAKE_TOKEN`, multipart: `source`, `shared_by`,
|
||||
`batch_id`, 1–6 `file`s) is the finance half of the share-sheet intake; design
|
||||
and the cross-repo contract are in smarthome `plans/intake-router-slice-plan.md`
|
||||
§11 (C1, C2, C5 live here; C3/C4 are pantry-app; C6 is n8n + compose).
|
||||
|
||||
- **Order of operations is the plan's and is load-bearing:** persist
|
||||
`intake_batches` row → classify each file → ONE LiteLLM call for the whole
|
||||
batch (`cloud-budget`, `temperature 0`, strict `json_schema`; PDFs go in as
|
||||
`unpdf` text, never pixels) → header `(merchant, date, total)` → merchant
|
||||
kind → duplicate check → reconciliation → match → act. `src/lib/intake-rules.ts`
|
||||
holds the pure decisions, `intake-router.ts` the orchestration.
|
||||
- **Match is `(platform, merchant, date, total)`, exact, never widened.** A
|
||||
miss or an ambiguity returns candidates (same merchant ±3 days) with
|
||||
`intake_use_candidate` / `intake_create` buttons; a transaction is created
|
||||
only on the explicit tap, ING-10-style (`statement_id NULL`, `owner_id` from
|
||||
`shared_by` → `participants.email`, else `DEFAULT_OWNER_ID`;
|
||||
`source='intake'`, `source_ref=batch_id` so a second press is a no-op —
|
||||
written as `ON CONFLICT … DO NOTHING`, because a SELECT-then-INSERT loses a
|
||||
double tap). A non-AUD receipt is booked the way `order-ingestion.ts` books
|
||||
one: `amount` native, `amount_aud` **NULL**, `foreign_currency_*` set — no FX
|
||||
rate exists at intake and inventing one is worse than admitting there is none.
|
||||
- **A NULL platform means "no platform", not "any platform".** An in-store tap
|
||||
(`coles`, `woolworths`, `other`) matches only rows whose `expense_metadata`
|
||||
carries no platform of its own. The wildcard reading let a till receipt match
|
||||
the Uber Eats order for the same shop on the same day and overwrite its items.
|
||||
The **candidates** query is the opposite and deliberately so: it has no
|
||||
platform filter at all, because the card is a human choice shown precisely
|
||||
because the exact key missed — the platform is one of the things that may be
|
||||
wrong, so the list must widen there, not narrow.
|
||||
- **The Create tap re-runs the match before it inserts**, and checks for a
|
||||
resolved sibling batch. The card is minutes old by the time it is pressed and
|
||||
the email/order bridge books orders on its own schedule; `findDuplicateBatch`
|
||||
ignores *unresolved* batches by design, so two pending cards for one order
|
||||
would otherwise each book it.
|
||||
- **One `expense_metadata` row, agreed on by every query that resolves one.** A
|
||||
transaction can own a row *and* be the `matched_transaction_id` of another;
|
||||
`matchTransaction` returns the row it resolved through and `replaceLineItems`
|
||||
writes to exactly that one. Every reader orders by
|
||||
`(transaction_id = $1) DESC **NULLS LAST**, id` — the match, the write, the
|
||||
order-detail GET, `getTransactions`' provenance LATERAL, `merchantForTransaction`
|
||||
and the Slack details modal. The `NULLS LAST` is load-bearing: Postgres orders
|
||||
`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.**
|
||||
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
|
||||
under `COALESCE(previous_line_items, …)` and read back.
|
||||
- **Grocery never writes finance here.** The ORIGINAL files go to pantry
|
||||
`POST /api/capture` with `intake.finance_target_transaction_id` +
|
||||
`finance_action: enrich|create`; pantry's confirm calls
|
||||
`PUT /api/transactions/[id]/line-items` back (C4), which is why that route
|
||||
accepts `RECEIPT_INGEST_TOKEN` too. Files are kept under `INTAKE_FILE_DIR`
|
||||
keyed by sha256 so a later "Groceries" tap can still forward them.
|
||||
- **Reconciliation is against the printed item subtotal, not the total.** A
|
||||
delivery order's total is items + fees − promotions ($118.94 of Coles lines
|
||||
became $85.74), so checking lines against the total would hold every
|
||||
delivery order. Tolerance $1 or 2%; a failure is `held`, nothing written.
|
||||
- **Names stay verbatim.** Burgertory calls its burgers "Michael" and "Joel";
|
||||
the prompt forbids canonicalising and the stored shape is the order lane's
|
||||
(`qty`, `description`, `amount`, `options`) so `order-details.tsx` renders it.
|
||||
- Statuses beyond the contract list: `ask` (the "Groceries or a meal?" holding
|
||||
state), `held`, `error` (502 — model or pantry down; the batch row says so).
|
||||
|
||||
### The shared loan
|
||||
|
||||
@@ -263,6 +782,93 @@ deleted, and deleting a rule must not cascade away the audit trail.
|
||||
changed by something else since the run are flagged, because reverting restores
|
||||
the pre-run value and discards the later edit.
|
||||
|
||||
### Importing an aggregator/bank CSV (`/api/import/csv`)
|
||||
|
||||
There is **one** import path — the CSV import modal — and Frollo goes through it
|
||||
like any other file. A bespoke Frollo importer, API route, CLI and two scheduled
|
||||
n8n workflows existed for a day and were deleted on 2026-08-13: net −1,152 lines.
|
||||
|
||||
**The rule that made the rest unnecessary is statement coverage.** Each account's
|
||||
newest `billing_end_date` is a watermark; a row on or before it is already in the
|
||||
ledger. On the real 2,607-row export that leaves **171 rows** — with no account
|
||||
allowlist and no credit-card exclusion, because cards have current statements and
|
||||
drop out on their own. Every bit of the deleted apparatus was doing by hand what
|
||||
`getStatementCoverage()` does generically.
|
||||
|
||||
Note what this replaced. The first attempt asked whether a row's date fell inside
|
||||
a statement's min–max *window*, which for accounts whose statements span 182 to
|
||||
460 days swallows a year and answers nothing — it let 422 duplicates into 550
|
||||
rows. Amount-matching cannot substitute either: the two sources decompose the
|
||||
same event differently, bundling a Wise transfer fee into the transfer (10001.13)
|
||||
where the statement itemises it (10000.00 + 1.13).
|
||||
|
||||
**Map these optional columns or lose something silently:**
|
||||
|
||||
| Column | Without it |
|
||||
|---|---|
|
||||
| **Account** | nothing is excluded — you review the whole file |
|
||||
| **Currency** | a foreign row is stored as if AUD (USD 10,782 → A$10,782) |
|
||||
| **Row ID** | a re-import duplicates instead of no-opping |
|
||||
|
||||
Leave **Category** unmapped for an aggregator: its spend categories are not
|
||||
trusted, and you set them per row in the review step.
|
||||
|
||||
**Two bugs fixed the day this shipped, both of which the flow depends on:**
|
||||
`batchInsertCSVTransactions` accepted a `category` and then omitted it from the
|
||||
INSERT column list, so every category chosen in review was discarded and the
|
||||
trigger wrote `'other'` — not cosmetic, because an uncategorised credit is
|
||||
admitted by `NET_SPEND_ROWS` and negated by `SPEND_SIGNED`, so 62 imported
|
||||
transfers cancelled **$74,338** of spend while counting as no income. And the
|
||||
path had no idempotency at all: `row_index` is assigned MAX+1 every run, making
|
||||
`uq_transaction_identity` structurally unable to fire. It now writes
|
||||
`source`/`source_ref` with `ON CONFLICT DO NOTHING`.
|
||||
|
||||
**The in-file duplicate warning is not redundant with `source_ref`.** A CDR
|
||||
re-consent re-exports an account's whole history under fresh ids while the
|
||||
originals survive — 385 twins in one 2,563-row export, doubling every salary
|
||||
payment. Fresh ids mean `source_ref` sees new rows, and 385 is not visible by eye
|
||||
in a review table. Consents expire annually, so expect it.
|
||||
|
||||
Undo an import: `DELETE FROM transactions WHERE source = '<source>'`.
|
||||
|
||||
### Zip, and the three things a BNPL account needs (2026-08-15)
|
||||
|
||||
24 statements, Dec 2020 → Jun 2026, all balance-asserting at $0.00. Zip is a
|
||||
credit account, so importing it changes how three existing row types must read.
|
||||
|
||||
**1. A repayment leaving the bank is a duplicate ONLY where a Zip statement
|
||||
covers the purchases.** Zip's own `Payment` rows are already `transfers`. On the
|
||||
bank side, `ZIPPAY*` debits are the same money — but only 2 of 8 fall inside a
|
||||
Zip statement period. Those two are now `transfers`; **the other six must stay as
|
||||
spend**, because for those periods the bank debit is the only record the purchase
|
||||
ever happened. Moving all eight would have deleted $703.35 of real spend. Same
|
||||
statement-coverage rule as the CSV importer, and the same trap.
|
||||
|
||||
**2. `Account Credit` is cashback, and Zip labels its three money-in types
|
||||
differently.** `Payment` = repayment from the bank. `Refund <merchant>` = a
|
||||
genuine merchant refund. `Account Credit` = Zip cashback — 34 rows, $740, round
|
||||
amounts ($20 ×20, $30 ×10, $10 ×4), with no matching debit from any bank account.
|
||||
|
||||
Gemini filed the identical description two ways — 23 as `other` (which *subtracts*
|
||||
from spend) and 11 as `transfers` (excluded entirely) — so one thing was doing two
|
||||
opposite things. All 34 are now `shopping` + `credit`, **netting against spend**:
|
||||
cashback lowers what a purchase actually cost, which is how `NET_SPEND_ROWS` /
|
||||
`SPEND_SIGNED` already treat a refund. Filing it as `income` was rejected for the
|
||||
reason the Up item sales note gives — it flatters `net = income − spent` and mixes
|
||||
non-earnings into the salary line. Ties to the **Cashback tracking (ING-6)** epic;
|
||||
a dedicated `cashback` category would need the CHECK constraint, the TS mirror,
|
||||
and a decision in every analytics fragment, so it was not done here.
|
||||
|
||||
**3. A gift card stays spend.** Prezzee is $1,580 of the history. Stored value is
|
||||
tempting to call `transfers`, but that only works if every downstream purchase is
|
||||
logged — it is not, for 2020. Identical reasoning to the ATM-withdrawal rule.
|
||||
|
||||
All 137 Zip rows are categorised; net spend is $11,135.11 against $10,085.11 of
|
||||
`transfers`. **Statement 200 (Sept 2021) came through incomplete** — no
|
||||
`billing_end_date`, no balances, 2 rows — so it is the one statement not covered
|
||||
by `uq_statement_identity` (the index is partial on `billing_end_date IS NOT
|
||||
NULL`) and a re-import would duplicate it. Worth re-processing paperless doc 296.
|
||||
|
||||
### Trusting extracted statement data
|
||||
|
||||
**Balance assertions are the check that works.** `getStatements` computes
|
||||
@@ -338,6 +944,99 @@ rather than hand-rolling them. Two failure modes they exist to prevent:
|
||||
Use the `EXCLUDE_NON_SPEND` fragment: a bare `category NOT IN (...)` evaluates
|
||||
to NULL for uncategorised rows and drops them from totals.
|
||||
|
||||
### Spend is gated on MY_SPEND_SCOPE, not OWNER_SCOPE (2026-08-15)
|
||||
|
||||
**Ownership is not a precondition for an expense being yours.** Every spend
|
||||
analytic used to gate on `OWNER_SCOPE = $1`, with `mySplitOf` scaling *within*
|
||||
that gate — so your half of a shop Sonu paid for counted as **zero**, in monthly,
|
||||
daily, merchants, subscriptions, fees and the budget page. 167 rows / **$3,210.91**
|
||||
across Jan–Jul 2026, worst in April (+$1,354.83, the Europe trips). Meanwhile
|
||||
`getParticipantBalances` booked the matching debt correctly, so the app could say
|
||||
you owed her for a shop while insisting you had not spent anything on it.
|
||||
|
||||
Use `MY_SPEND_SCOPE()` — `owner = me OR I hold a split` — for anything measuring
|
||||
what *I* spent. `OWNER_SCOPE` is still right for anything measuring an **account**:
|
||||
the income and investment lines in `/analytics/monthly`, and the statement-level
|
||||
fee rollup in `/analytics/fees` (which reads `statements`, where splits are
|
||||
meaningless).
|
||||
|
||||
**`myShare` had to change with it, and widening the gate alone would have been
|
||||
worse than the bug.** Its old third fallback, `100 - everyone else`, is the
|
||||
*payer's* remainder — on someone else's unsplit row it returns 100 and would have
|
||||
moved their entire bill onto you. It now branches on ownership:
|
||||
|
||||
- **My row** — unchanged: explicit split, then `my_share_percent`, then the
|
||||
remainder.
|
||||
- **Their row** — an explicit split row only; absent means **0**.
|
||||
|
||||
`my_share_percent` is deliberately not consulted on someone else's row. It is one
|
||||
unscoped column on `transaction_overrides` writable by anyone who can see the row,
|
||||
so "my" can only mean the owner's. All 402 rows carrying one today are owner-side.
|
||||
|
||||
That 0 is what makes the wider gate safe: admitting a row can never add more than
|
||||
the share actually held. Tested both ways in
|
||||
`src/__tests__/integration/analytics-sql.test.ts` — the discriminating cases are
|
||||
all ones where you hold **no** split on someone else's row, because that is the
|
||||
only place old and new disagree.
|
||||
|
||||
`MY_SHARE_PCT` in `queries.ts` mirrors `myShare` in subselect form for the
|
||||
transactions list (which has no viewer-scoped `ts` join). A test asserts the two
|
||||
agree row for row across seven fixture shapes — keep it that way, since the list
|
||||
is the drill-down for these totals.
|
||||
|
||||
**No historical restatement:** every non-owner split is 2026-dated. The 1,266
|
||||
pre-2026 SplitMyExpenses splits are all on rows you own, so nothing before the
|
||||
cutover moves.
|
||||
|
||||
### Changing who paid (2026-08-15)
|
||||
|
||||
Owner was **write-once for every ingestion path** until now — a pantry receipt
|
||||
hardcodes `DEFAULT_OWNER_ID` (`receipt-ingestion.ts`), and there was not one
|
||||
`UPDATE ... SET owner_id` in `src/`. A shop the other person paid for was
|
||||
permanently filed as yours.
|
||||
|
||||
Two routes, because the owner lives in two places:
|
||||
|
||||
- **`PATCH /api/transactions/[id]` with `owner_id`** — manual rows only
|
||||
(`statement_id IS NULL`). A statement row returns 400 `statement_owned` and
|
||||
points at the statements page; its effective owner is
|
||||
`COALESCE(t.owner_id, s.owner_id)`, so writing it here would either no-op or
|
||||
detach one row from the account it was extracted from.
|
||||
- **`PATCH /api/statements/[id]` with `owner_id`** — the statements page has had
|
||||
this dropdown since it was built, wired to a route with **no PATCH handler**.
|
||||
Every change 405'd, and `useUpdateStatement` never checked `res.ok`, so it
|
||||
failed silently and the select just snapped back.
|
||||
|
||||
**The statement route writes both tables.** 2,194 statement rows carry their own
|
||||
`owner_id` against 1,803 that inherit, so updating `statements` alone moves less
|
||||
than half and splits one account's history between two people. It updates rows
|
||||
matching the *old* owner and returns `rows_moved`; all 2,194 agree today, and a
|
||||
row that disagrees was set deliberately and is left alone.
|
||||
|
||||
**Reassignment is a one-way door, and the guard is the point.** Access is
|
||||
`owner OR holds a split` (`canAccessTransactions`), so handing a row over while
|
||||
holding no split removes it from your list and 404s every route that could put
|
||||
it back — only the new owner can undo it. The route returns **409
|
||||
`would_lose_access`** and the modal offers the two real ways forward: add your
|
||||
split first, or "Give it away anyway" (`release: true`). Taking a row *onto*
|
||||
your ledger is never blocked — there is no door to close behind you.
|
||||
|
||||
Reassignment is a correction to a row you already hold, never a way to reach one
|
||||
you do not: `canAccessTransactions` runs first, so claiming a stranger's
|
||||
transaction is a 404 before any owner logic runs.
|
||||
|
||||
**Splits are deliberately not rewritten.** They record shares, not direction —
|
||||
`getParticipantBalances` derives who owes whom from ownership, so a 50/50 flips
|
||||
from "they owe me" to "I owe them" untouched, `settled` included. Tested both
|
||||
directions.
|
||||
|
||||
**Order matters when you do this by hand:** split first, then reassign. The
|
||||
reverse locks you out, which is exactly what the 409 exists to stop.
|
||||
|
||||
`useUpdateTransaction` also gained the missing `res.ok` check. Without it every
|
||||
rejection resolved as success — the modal closed, the list refetched, and the
|
||||
edit silently vanished.
|
||||
|
||||
### Statement types
|
||||
|
||||
`statements.statement_type` is constrained to `credit_card | transaction |
|
||||
@@ -350,6 +1049,109 @@ Card'`) and the DB normalises it on write. The raw extracted value is preserved
|
||||
The TypeScript mirror is `src/lib/statement-types.ts` — keep the list, the SQL
|
||||
function, and the CHECK constraint in sync when adding a type.
|
||||
|
||||
### Bank names are normalised on write (migration 0031)
|
||||
|
||||
**`bank_name` is a component of the duplicate check.** `uq_statement_identity`
|
||||
is `(bank_name, account_number, billing_end_date)`, so two spellings of one
|
||||
institution mean the same statement can be ingested twice without the index ever
|
||||
firing. Gemini reads the name off whatever the PDF prints, so the spelling varies
|
||||
per *document*, not per account.
|
||||
|
||||
Zip exposed it: account `2705256` arrived under four names in sixteen minutes —
|
||||
`ZipMoney Payments Pty Ltd`, `ZipMoney Payments Pty Limited`, `Zip Pay`,
|
||||
`ZipPay` — with ten more in the queue. It was not unique. 17 `bank_name` values
|
||||
represented 13 institutions, and every split pair shared an account number:
|
||||
Amex (14/1), Citibank/Citi (8/4), NAB (3/1). Fragmentation also splits the
|
||||
statements and transactions bank filters and `by_bank` in `/api/analytics/fees`.
|
||||
|
||||
`normalize_bank_name()` + `trg_statements_normalize_bank_name` (BEFORE INSERT OR
|
||||
UPDATE OF bank_name) mirror the 0013 `statement_type` pattern, with **one
|
||||
deliberate difference: the vocabulary is open.** An unrecognised name passes
|
||||
through tidied, never collapsed to a fallback, and there is **no CHECK
|
||||
constraint** — a bank this household has never used must be able to arrive
|
||||
without a migration, and destroying its name on first contact is worse than
|
||||
leaving it unmapped.
|
||||
|
||||
Matching is by **prefix, not by enumerated spelling** (`zip%`, `citi%`,
|
||||
`%american express%`, `%national australia bank%`), so unseen variants like
|
||||
`Zip Co Australia Pty Ltd` normalise with no code change. Branches for Westpac,
|
||||
ANZ, HSBC, ING, AMP, Up and CommBank are no-ops today that map *to* the spelling
|
||||
already in use — they exist to catch the legal-entity variant a future PDF might
|
||||
print.
|
||||
|
||||
Wise is deliberately unmapped: `Wise Australia Pty Ltd.` is a single spelling, so
|
||||
shortening it would be a rename nobody asked for rather than a merge.
|
||||
|
||||
**One institution's national entities stay apart (migration 0032).** A prefix
|
||||
generalises past the evidence it was built on: every 0031 merge was provably one
|
||||
account, but `citi%` would have flattened Citibank India into Citibank Australia
|
||||
on arrival. `hsbc%` and `%american express%` had the same defect. The country now
|
||||
comes from the name when the name states it and from the currency otherwise, with
|
||||
AUD as home taking no suffix — so nothing renamed (`UPDATE 0`).
|
||||
|
||||
Currency alone cannot be the discriminator: **Wise holds AUD, EUR and USD
|
||||
accounts under one provider**, so "non-AUD means a different bank" would shatter
|
||||
it into three. That is why Wise must stay unmapped, and why mapping any
|
||||
multi-currency provider through this function would be wrong.
|
||||
|
||||
`N.A.` is deliberately not a country marker — it means "National Association", a
|
||||
US legal form printed on Citibank letterhead worldwide including India.
|
||||
|
||||
**The residual gap and its tell.** The function cannot know that two names for a
|
||||
*new* bank are one institution. That always shows up the same way, so check the
|
||||
`statement_identity_drift` view (migration 0033) rather than trusting the map.
|
||||
Non-empty means an account is fragmented, by name or by number.
|
||||
|
||||
### Account identity is derived, not the raw number (migration 0033)
|
||||
|
||||
`account_number` fragments exactly like `bank_name` did, from the same cause —
|
||||
Gemini copies what the PDF prints. ANZ's Access Advantage arrived as both
|
||||
`4085-56264` (4 statements) and `408556264` (1). Since `uq_statement_identity`
|
||||
keyed on it, re-importing one period under the other spelling evaded the
|
||||
duplicate check.
|
||||
|
||||
`account_number_key` is a **GENERATED ALWAYS** column — `account_number` with
|
||||
separators stripped — and the unique index is now
|
||||
`(bank_name, account_number_key, billing_end_date)`. **Never write to it;
|
||||
display `account_number`.**
|
||||
|
||||
Derived rather than rewritten because the raw number is the readable one and
|
||||
some of it is structure: Up stores `633-123 / 176540052`, a BSB *and* an account
|
||||
number, and flattening it would lose a distinction a human reads at a glance to
|
||||
fix a machine problem. Case and the Amex mask (`XXXX-XXXXXX-01000`) are
|
||||
preserved — `X` records which digits were redacted.
|
||||
|
||||
This is **not** what caused the documented 31-row / $42,040.68 ANZ duplication.
|
||||
Statements 107/142/143 overlap on *different* end dates, which that index cannot
|
||||
catch at any spelling. Different problem, same table.
|
||||
|
||||
`account_owner_mappings` also keys on `(bank_name, account_number)` with its own
|
||||
UNIQUE constraint, so any future rename must update it too or strand its rows.
|
||||
It is empty today; the migration handles it anyway.
|
||||
|
||||
**Normalising `bank_name` broke the N8N new-bank alert, and the fix lives in the
|
||||
workflow.** `Check Known Bank` ran
|
||||
`SELECT COUNT(id) FROM statements WHERE bank_name = '<raw Gemini name>'`,
|
||||
comparing the raw extraction against the now-canonical column *before* the insert
|
||||
— so the trigger had not fired yet and nothing ever matched. That branch tags the
|
||||
document **pending and holds it for Slack approval**, so every Zip statement
|
||||
stalled awaiting a manual click, not merely a noisy alert. It was a regression
|
||||
made worse by normalisation: previously a repeat of the same spelling at least
|
||||
matched.
|
||||
|
||||
Fixed 2026-08-15 by comparing like with like:
|
||||
|
||||
```sql
|
||||
SELECT COUNT(id) as count FROM statements
|
||||
WHERE bank_name = normalize_bank_name('{{ ...summary.bank_name }}', '{{ ...summary.currency }}')
|
||||
```
|
||||
|
||||
Currency is passed so a genuinely new national entity (Citibank India) still
|
||||
alerts. Verified live: the run after the fix took 12s against 30–53s for the
|
||||
approval-branch runs before it. Workflow `FysADdFwEtwONQl4` in the smarthome
|
||||
repo — **any future change to `normalize_bank_name()` must keep that node in
|
||||
step**, since it is the one caller outside this codebase.
|
||||
|
||||
### Loans
|
||||
|
||||
A loan repayment is **not** an expense. It is part principal (equity, a
|
||||
@@ -381,6 +1183,33 @@ Loan interest uses the `loan_interest` category; principal repayments use
|
||||
`investment` (excluded from spend, surfaced on the investments line in monthly
|
||||
analytics).
|
||||
|
||||
### The investments line is signed
|
||||
|
||||
A withdrawal from a fund is a **disinvestment**, not income. Units convert back
|
||||
to cash; net worth is unchanged. `INVESTMENT_SIGNED` (`analytics-sql.ts`) makes
|
||||
credits and refunds negative so they net against contributions, and
|
||||
`/api/analytics/monthly` is the only consumer.
|
||||
|
||||
Summed unsigned, a withdrawal read as *more* money invested: March 2026 showed
|
||||
$38,615.34 of investing in a month that was net **−$11,384.66**, because a
|
||||
$25,000 Raiz withdrawal was added to an $8,563.80 IBKR deposit instead of
|
||||
cancelling it. **Each credit costs twice** — once for being added, once for not
|
||||
being subtracted — so the error is double the credit, $50,000 in that month.
|
||||
|
||||
Filing withdrawals as `income` is the other tempting answer and is worse: it
|
||||
books an asset disposal as earnings and feeds `net = income − spent −
|
||||
investments` with a flattering sign. Same reason the Up item sales in Known Gaps
|
||||
do not belong on the income line.
|
||||
|
||||
**What this cannot resolve:** part of a withdrawal genuinely *is* income — the
|
||||
capital gain. The bank descriptor is one gross figure with no cost base
|
||||
(`TRANSFER FROM RAIZ WITHDRAWAL 7D5262D8A839248A12`), so it cannot be decomposed
|
||||
from statement data. Netting tracks cash committed against cash returned and
|
||||
leaves the gain for holdings data to surface; it does not assert the gain is zero.
|
||||
|
||||
Consequence for the UI: a net-disinvesting month is real data, so the budget page
|
||||
gates on `!== 0`, not `> 0`, and renders negatives in amber.
|
||||
|
||||
### Prisma
|
||||
|
||||
The schema at `prisma/schema.prisma` covers all tables. The generated client (gitignored) must be regenerated after schema changes:
|
||||
@@ -408,11 +1237,12 @@ See `README.md` → **Known Gaps / TODOs** for full details.
|
||||
|
||||
**Payment provider tracking**: `merchant_normalized` currently conflates payment provider (PayPal, Afterpay, Zip) with the actual merchant. Plan: add `payment_provider` column, update Gemini prompt to extract it separately, backfill from `merchant_name` patterns, surface in UI filters.
|
||||
|
||||
### Open as of 2026-07-26
|
||||
### Open as of 2026-07-29
|
||||
|
||||
- **Shared expenses redesign** — `docs/shared-expenses-design.md`. Phase 0 done;
|
||||
Phases 1–4 unbuilt. Deliberately paused to live with the current behaviour
|
||||
before committing to a model designed in one session.
|
||||
- **Shared expenses: loan section only** — the redesign in
|
||||
`docs/shared-expenses-design.md` is built and live as of 2026-07-28; the
|
||||
loan model at the end of that doc remains a proposal (Sonu's `…emi`
|
||||
contributions still read as ordinary transfers).
|
||||
- **Expense baseline / emergency reserve** — `docs/expense-baseline.md`. One-off
|
||||
analysis, nothing built. Records four data corrections the raw numbers need
|
||||
(misfiled Raiz/super/brokerage debits, `other` credits read as negative spend,
|
||||
@@ -428,7 +1258,19 @@ See `README.md` → **Known Gaps / TODOs** for full details.
|
||||
drone, camera). Correct in that they are excluded from spend, but it mixes
|
||||
asset disposals into the income line alongside salary.
|
||||
- **`payment_method` is not shown in the transactions list** — settable on create
|
||||
and edit only. Worth a column or filter if cash becomes routine.
|
||||
and edit only. Worth a column or filter if cash becomes routine. A `credits`
|
||||
row is at least visible indirectly: `bankLabel()` renders it "Gift Card"
|
||||
rather than "Manual", and the bank filter has a chip for it.
|
||||
- **219 manual rows carry a NULL `payment_method`**, so they sit in the pending
|
||||
queue being offered card matches. NULL means *unknown*, not *unreconcilable* —
|
||||
that is deliberate — but any of them paid by gift card or cash is being
|
||||
offered a match that cannot exist. No bulk fix, because guessing the method
|
||||
from a description would be a guess written into a column that suppresses
|
||||
reconciliation.
|
||||
- **Pantry receipts still land as yours.** `processReceiptIngestion` hardcodes
|
||||
`DEFAULT_OWNER_ID`; the ingest route takes no owner. Correctable per row now
|
||||
(see "Changing who paid"), but a "Paid by" step at capture time would stop the
|
||||
correction being needed.
|
||||
- **Raw statement exports live in `dump/`**, gitignored since `31a8177`. They were
|
||||
committed by accident in `030490e` and remain in that commit's history; the repo
|
||||
has no GitHub remote, so exposure is limited to the local Gitea. Purging history
|
||||
|
||||
@@ -282,6 +282,18 @@ All routes require authentication via `X-Forwarded-User` header (set by Traefik)
|
||||
| GET / POST | `/api/tags` | List or create tags |
|
||||
| PATCH / DELETE | `/api/tags/[id]` | Update or delete a tag |
|
||||
|
||||
**Machine routes** — shared-secret `x-ingest-token` header instead of `X-Forwarded-User`; each fails closed when its token is unset:
|
||||
|
||||
| Method | Route | Token | Description |
|
||||
|--------|-------|-------|-------------|
|
||||
| POST / PATCH | `/api/orders/ingest` | `ORDER_INGEST_TOKEN` | n8n order receipts → transactions (see smarthome CLAUDE.md "Order Processor") |
|
||||
| POST | `/api/receipts/ingest` | `RECEIPT_INGEST_TOKEN` | pantry-app confirmed grocery receipts → one transaction per tender leg (ING-10) |
|
||||
| POST | `/api/intake` | `INTAKE_TOKEN` | **Intake router** (2026-08-21): multipart `source` (`uber_eats\|doordash\|coles\|woolworths\|other`), `shared_by`, `batch_id` (uuid), 1–6 `file`s (image / PDF / text). Persists `intake_batches`, extracts once via LiteLLM `cloud-budget` (PDF → text via `unpdf`), matches `(platform, merchant, date, total)` exactly, then enriches the matched order's `line_items` (food), forwards the original files to pantry `POST /api/capture` (grocery), or replies with candidates / "Groceries or a meal?" / needs-header / held / duplicate. Returns `{kind, status, transaction_id?, capture_id?, candidates?, slack_blocks, text}` |
|
||||
| PUT | `/api/transactions/[id]/line-items` | `INTAKE_TOKEN` or `RECEIPT_INGEST_TOKEN` | Idempotent replace of `expense_metadata.line_items` for `transaction_id = :id OR matched_transaction_id = :id`; body `{line_items, source: 'intake'\|'pantry', batch_id?, platform?, replace: true}`; creates the metadata row if absent, never a transaction (404). Returns `{previous_line_items, line_items}` — pantry calls it on every confirm and with `previous_line_items` on undo |
|
||||
| POST | `/api/slack/interactive` | `ORDER_INGEST_TOKEN` + Slack signature | Order-nudge buttons and the intake taps (`intake_use_candidate`, `intake_create`, `intake_grocery`, `intake_meal`) |
|
||||
|
||||
Env for the intake router: `INTAKE_TOKEN`, `LITELLM_BASE_URL` + `LITELLM_KEY` (OpenAI-compatible, alias `cloud-budget`), `PANTRY_BASE_URL` (+ the existing `RECEIPT_INGEST_TOKEN` as pantry's machine auth), optional `INTAKE_FILE_DIR` (original files by sha256, default `/app/data/intake` — a later "Groceries" tap re-sends them) and `FINANCE_PUBLIC_URL` (the card's View link, default `https://finance.bosecamp.com`). Design: smarthome `plans/intake-router-slice-plan.md` §11.
|
||||
|
||||
---
|
||||
|
||||
## Ingestion Pipeline
|
||||
@@ -347,6 +359,8 @@ docker exec postgres-personal psql -U personal -d personal \
|
||||
| `0009_split_payments` | `split_payments` |
|
||||
| `0010_csv_import_reconcile` | `reconciled_with_id`, CSV import support |
|
||||
| `0011_trips` | `trips`, `trip_id` on `transaction_overrides` (catch-up — was applied directly) |
|
||||
| … | |
|
||||
| `0034_intake_batches` | `intake_batches` (one row per `/api/intake` upload, written before extraction) and `intake_merchant_kinds` (the remembered "Groceries or a meal?" answer per merchant) |
|
||||
|
||||
> `paperless_doc_id` on statements and the `uq_statements_paperless_doc_id` index were added directly (not tracked in a migration file).
|
||||
> `owner_id` on transactions and `statement_id` made nullable were applied directly (March 2026) to support manual transaction entry without a fake statement.
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
**Date:** 2026-07-26
|
||||
**Status:** Priority 0 implemented 2026-07-27 (see below). Priorities 1–4 remain
|
||||
proposals.
|
||||
proposals, tracked on the board under epic **Analytics & Insights information
|
||||
architecture** (#154) — verified 2026-08-02, nothing in 1, 2 or 4 has been
|
||||
started, and 3 is partially covered by the shared-expenses rebuild rather than by
|
||||
this review.
|
||||
|
||||
## Implementation status — Priority 0 (2026-07-27)
|
||||
|
||||
@@ -44,7 +47,40 @@ Also fixed while in here, both found by checking rather than by proposal:
|
||||
category-day, so the pace chart ended the month a few cents off its own
|
||||
headline. Both now carry 4dp and round once, at display.
|
||||
|
||||
Guarded by `src/__tests__/integration/analytics-sql.test.ts`.
|
||||
Guarded in two files. `src/__tests__/integration/analytics-sql.test.ts` covers
|
||||
the SQL fragments — `EXCLUDE_RECONCILED_SOURCE`, `NATIVE_CURRENCY` and
|
||||
`INVESTMENT_SIGNED`. Since 2026-08-21 `analytics-priority0.test.ts` covers items
|
||||
2, 3 and 6 (#155): the three where a regression is silent rather than loud — the
|
||||
pace chart simply going back to disagreeing with the headline printed directly
|
||||
above it, the fees figure back to growing forever, and a partial month back to
|
||||
being measured against complete ones, none of which fails anything.
|
||||
|
||||
Two things were needed to make item 6 testable at all, and both are the point
|
||||
rather than incidental. The rules lived as closures inside `budget/page.tsx`
|
||||
reading `new Date()` inline, so they now live in `src/lib/partial-month.ts` with
|
||||
an injectable clock; and the tests freeze that clock, because a fixture defined
|
||||
against the real "now" drifts out of the routes' rolling windows and the
|
||||
assertion goes vacuous — the same class of silent failure, one level up. Item
|
||||
2's suite carries a placebo assertion for the same reason: parity between two
|
||||
queries that are identical by construction proves nothing unless some fixture
|
||||
can actually tell the old and new spend definitions apart.
|
||||
|
||||
### Landed after this doc, in the same family (2026-08-02)
|
||||
|
||||
Two changes postdate the Priority 0 pass and belong to the same
|
||||
metric-integrity thread, so read them alongside it:
|
||||
|
||||
- **The investments line is signed** (`c70d2b1`, 2026-07-31). `INVESTMENT_SIGNED`
|
||||
makes credits and refunds negative so a withdrawal nets against contributions
|
||||
instead of reading as more money invested. March 2026 had shown $38,615.34 of
|
||||
investing in a month that was net **−$11,384.66**. Consequence for the
|
||||
Analytics section below: the "income, expenses, invested, and net-cash strip"
|
||||
it describes now carries a *signed* invested figure, and a net-disinvesting
|
||||
month is real data — the budget page gates on `!== 0`, not `> 0`.
|
||||
- **Transfers hidden by default in the transactions view** (`f6c500b`,
|
||||
2026-07-30), with an explicit category pick overriding the exclusion and the
|
||||
default off when scoped to a statement. An IA change in this review's
|
||||
territory that this review did not propose.
|
||||
|
||||
The doc's characterisation of `REGULAR_CATEGORIES` (Insights section) is also
|
||||
slightly off: the set has 13 members including rent, utilities, insurance and
|
||||
@@ -530,9 +566,10 @@ The unfiltered balance cards correctly implement a running ledger:
|
||||
|
||||
> splits minus payments
|
||||
|
||||
This is coherent and should not be changed to exclude splits marked `settled`.
|
||||
The `transaction_splits.settled` field is dead data and must not be used for new
|
||||
UI claims.
|
||||
*(Superseded 2026-07-28: the shared-expenses rebuild made `settled`
|
||||
load-bearing — it gates `ACTIVE_OBLIGATION` and survives split rewrites. See
|
||||
`docs/shared-expenses-design.md` for the live model; the paragraph above
|
||||
described the pre-rebuild state.)*
|
||||
|
||||
### What the current model cannot answer
|
||||
|
||||
@@ -654,7 +691,7 @@ inferred from actual contributions.
|
||||
|
||||
## Recommended implementation order
|
||||
|
||||
### Priority 0 — metric integrity
|
||||
### Priority 0 — metric integrity *(done 2026-07-27; test gap #155)*
|
||||
|
||||
1. Exclude reconciled source rows from monthly analytics.
|
||||
2. Make spend pace use the same spend semantics as the headline.
|
||||
@@ -663,14 +700,14 @@ inferred from actual contributions.
|
||||
5. Fix Shared foreign-currency presentation.
|
||||
6. Avoid partial-month versus full-month comparisons.
|
||||
|
||||
### Priority 1 — product hierarchy
|
||||
### Priority 1 — product hierarchy *(#156)*
|
||||
|
||||
1. Add Overview and make it the default route.
|
||||
2. Group sidebar navigation by user intent.
|
||||
3. Rename `/budget` to `/analytics`.
|
||||
4. Add consistent methodology and coverage indicators.
|
||||
|
||||
### Priority 2 — Analytics and Insights
|
||||
### Priority 2 — Analytics and Insights *(#157)*
|
||||
|
||||
1. Simplify Analytics around period, comparison, change explanation, trend, and
|
||||
drill-down.
|
||||
@@ -679,7 +716,7 @@ inferred from actual contributions.
|
||||
4. Add flexible-spending levers, anomalies, and a data-quality work queue.
|
||||
5. Rework recurring patterns and fees into decision-oriented summaries.
|
||||
|
||||
### Priority 3 — Shared
|
||||
### Priority 3 — Shared *(#158; items 2 and 5 landed with the shared-expenses rebuild)*
|
||||
|
||||
1. Add settlement contexts.
|
||||
2. Link payments to real transactions.
|
||||
@@ -688,7 +725,7 @@ inferred from actual contributions.
|
||||
5. Backfill historical closed-context splits so long-range personal analytics
|
||||
become comparable.
|
||||
|
||||
### Priority 4 — polish
|
||||
### Priority 4 — polish *(#159)*
|
||||
|
||||
1. Improve mobile layouts.
|
||||
2. Add accessibility semantics.
|
||||
|
||||
Generated
+19
-1
@@ -16,7 +16,8 @@
|
||||
"prisma": "^7.4.2",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"recharts": "^3.8.0"
|
||||
"recharts": "^3.8.0",
|
||||
"unpdf": "^1.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
@@ -8494,6 +8495,23 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unpdf": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/unpdf/-/unpdf-1.8.1.tgz",
|
||||
"integrity": "sha512-xkURhy2SoGpOIH0a1gLHNkASPIQYonadDJs2AQwPEfUakafeD9EA1WTWWsaR++gfTCXJpV27W7tU1nXuk82UKQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@napi-rs/canvas": "^0.1.69 || ^1.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@napi-rs/canvas": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/unrs-resolver": {
|
||||
"version": "1.11.1",
|
||||
"resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz",
|
||||
|
||||
+3
-1
@@ -10,6 +10,7 @@
|
||||
"test": "vitest run --config vitest.config.ts",
|
||||
"test:watch": "vitest --config vitest.config.ts",
|
||||
"test:setup": "bash scripts/setup-test-db.sh",
|
||||
"pretest:integration": "bash scripts/resolve-test-db.sh",
|
||||
"test:integration": "vitest run --config vitest.integration.config.ts",
|
||||
"test:all": "npm test && npm run test:integration"
|
||||
},
|
||||
@@ -22,7 +23,8 @@
|
||||
"prisma": "^7.4.2",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"recharts": "^3.8.0"
|
||||
"recharts": "^3.8.0",
|
||||
"unpdf": "^1.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
-- A fifth verdict: "bad", between "ok" and "never".
|
||||
--
|
||||
-- "OK" to "Never again" is a big jump and most disappointments live in the gap
|
||||
-- (user, 2026-07-28). Without it, a merely poor meal either flatters itself as
|
||||
-- OK or gets blacklisted, and the blacklist is the signal that has to stay
|
||||
-- sharp — `warn` remains exclusive to 'never' so it is not diluted by every
|
||||
-- mediocre delivery.
|
||||
--
|
||||
-- Safe as a straight widening: no existing row uses a value being removed,
|
||||
-- because nothing is being removed.
|
||||
ALTER TABLE order_reviews DROP CONSTRAINT IF EXISTS chk_order_review_rating;
|
||||
|
||||
ALTER TABLE order_reviews ADD CONSTRAINT chk_order_review_rating
|
||||
CHECK (rating IS NULL OR rating IN ('loved', 'liked', 'ok', 'bad', 'never'));
|
||||
@@ -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;
|
||||
@@ -0,0 +1,37 @@
|
||||
-- Receipt scans as a second producer on the order lane.
|
||||
--
|
||||
-- A grocery shop paid with a supermarket gift card settles against no statement and
|
||||
-- arrives in no mail, so the pantry scan is the only touchpoint that ever sees it. Rather
|
||||
-- than a second ingestion mechanism, a scan lands as a manual transaction
|
||||
-- (statement_id IS NULL) and the existing pending-reconciliation queue resolves it —
|
||||
-- with needsCardMatch() already excluding cash/credits rows for which no card leg is
|
||||
-- ever coming.
|
||||
|
||||
-- The receipt as text, kept as evidence rather than parsed into a decision. The token
|
||||
-- that distinguishes a gift card from a bank card was only findable by reading two
|
||||
-- payments side by side on one receipt; whether it holds across merchants is answerable
|
||||
-- from stored blocks and not at all from none.
|
||||
ALTER TABLE expense_metadata
|
||||
ADD COLUMN IF NOT EXISTS tender_raw TEXT;
|
||||
|
||||
-- The image the extraction came from. Not the idempotency key -- a photo and the store's
|
||||
-- e-receipt PDF of one purchase hash differently -- but exact where it applies, and the
|
||||
-- only thing a later cross-source dedupe against an emailed or Paperless copy could match
|
||||
-- on.
|
||||
ALTER TABLE expense_metadata
|
||||
ADD COLUMN IF NOT EXISTS receipt_sha256 TEXT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_expense_receipt_sha256
|
||||
ON expense_metadata (receipt_sha256)
|
||||
WHERE receipt_sha256 IS NOT NULL;
|
||||
|
||||
-- Legs of one split-tender shop, so a $40.75 gift-card row can be shown as part of a
|
||||
-- $114.57 purchase instead of an orphan. The shared receipt identity is carried in
|
||||
-- order_reference ('pantry:<merchant:store:register:number:date>#<leg>'); this column is
|
||||
-- what makes the group queryable without parsing that string.
|
||||
ALTER TABLE expense_metadata
|
||||
ADD COLUMN IF NOT EXISTS receipt_group TEXT;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_expense_receipt_group
|
||||
ON expense_metadata (receipt_group)
|
||||
WHERE receipt_group IS NOT NULL;
|
||||
@@ -0,0 +1,54 @@
|
||||
-- A feed that re-sends yesterday's rows needs an identity the importer can recognise.
|
||||
--
|
||||
-- The CSV import that exists today is built for a one-off: `batchInsertCSVTransactions`
|
||||
-- assigns `row_index` as MAX(row_index) + 1 over the owner's manual rows, so the same
|
||||
-- file imported twice produces two sets of rows with different indexes. That defeats
|
||||
-- `uq_transaction_identity` (statement_id, transaction_date, description, amount,
|
||||
-- row_index) by construction — the constraint cannot fire, because the fifth column is
|
||||
-- guaranteed fresh on every run. Nothing else stops it either.
|
||||
--
|
||||
-- For a hand-driven bank CSV that is tolerable; the operator sees the file once. For a
|
||||
-- recurring aggregator export it is not, because the windows overlap *by design*: a
|
||||
-- 12-month export pulled weekly re-states ~51 weeks of rows it has already sent. The
|
||||
-- failure would be silent and cumulative, and it is not one we can clean up afterwards —
|
||||
-- every child of `transactions` is ON DELETE CASCADE, so a duplicate must be superseded
|
||||
-- rather than deleted (migration 0023), and ~$42k of re-imported statement rows already
|
||||
-- exist as evidence of how this goes.
|
||||
--
|
||||
-- So the provider's own key travels with the row. Frollo issues a stable per-transaction
|
||||
-- `id`, which is exactly what the wallet-capture lane lacked and could never synthesise
|
||||
-- from a notification. The same two columns serve any future feed that has one.
|
||||
|
||||
ALTER TABLE transactions
|
||||
ADD COLUMN IF NOT EXISTS source TEXT;
|
||||
|
||||
COMMENT ON COLUMN transactions.source IS
|
||||
'Feed this row was ingested from (e.g. ''frollo''). NULL = entered by hand, imported from a statement, or predates the column.';
|
||||
|
||||
ALTER TABLE transactions
|
||||
ADD COLUMN IF NOT EXISTS source_ref TEXT;
|
||||
|
||||
COMMENT ON COLUMN transactions.source_ref IS
|
||||
'The provider''s own identifier for this transaction, verbatim. Idempotency key for re-imports; never generated locally.';
|
||||
|
||||
-- Partial, so the millions of rows with no source do not have to be unique on (NULL,
|
||||
-- NULL). Enforced in the database rather than in the importer: an ON CONFLICT DO NOTHING
|
||||
-- that silently depends on application-side dedupe is one refactor away from not.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_transaction_source_ref
|
||||
ON transactions (source, source_ref)
|
||||
WHERE source IS NOT NULL AND source_ref IS NOT NULL;
|
||||
|
||||
-- A source row is only half-identified without knowing which account it came from --
|
||||
-- two accounts at the same institution can legitimately carry the same provider id
|
||||
-- namespace. Kept as free text rather than a foreign key: finance-app has no account
|
||||
-- entity, and inventing one to hold a label from an external system would be the tail
|
||||
-- wagging the dog.
|
||||
ALTER TABLE transactions
|
||||
ADD COLUMN IF NOT EXISTS source_account TEXT;
|
||||
|
||||
COMMENT ON COLUMN transactions.source_account IS
|
||||
'Account label as the source system names it, for provenance and for scoping an import to particular accounts. Not an entity reference.';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_source
|
||||
ON transactions (source, transaction_date)
|
||||
WHERE source IS NOT NULL;
|
||||
@@ -0,0 +1,25 @@
|
||||
-- 0029: let the spine bridge write one row PER SHIPMENT of a split order.
|
||||
--
|
||||
-- An Amazon order that ships in two boxes is charged per shipment, so its
|
||||
-- order_total matches no statement line — the bridge (ingestion-engine
|
||||
-- jobs/order_transaction_bridge.py) matches each shipment's own amount and
|
||||
-- items instead. Those rows share (source='order-bridge', order_reference),
|
||||
-- which uq_expense_source_order forbade.
|
||||
--
|
||||
-- The index exists as the MEAL lane's idempotency key (0018 I7), and that
|
||||
-- lane's semantics are untouched: the scope simply excludes bridge rows,
|
||||
-- whose idempotency key is source_message_id (<entity_key> for whole-order
|
||||
-- rows, <entity_key>#f<fact_id> per shipment) — now enforced with its own
|
||||
-- unique index instead of by convention. No app code does ON CONFLICT
|
||||
-- against either index; ingest idempotency is SELECT-based
|
||||
-- (lib/order-ingestion.ts).
|
||||
|
||||
DROP INDEX IF EXISTS uq_expense_source_order;
|
||||
|
||||
CREATE UNIQUE INDEX uq_expense_source_order
|
||||
ON expense_metadata (source, order_reference)
|
||||
WHERE order_reference IS NOT NULL AND source <> 'order-bridge';
|
||||
|
||||
CREATE UNIQUE INDEX uq_expense_bridge_message
|
||||
ON expense_metadata (source, source_message_id)
|
||||
WHERE source = 'order-bridge';
|
||||
@@ -0,0 +1,127 @@
|
||||
-- 0030 — order ↔ transaction, many-to-many (board 205, orders phase 2)
|
||||
--
|
||||
-- Phase 1 read linkage out of `expense_metadata`, which is shaped as ONE ROW
|
||||
-- PER TRANSACTION: `transaction_id` is UNIQUE and `matched_transaction_id`
|
||||
-- partial-unique. Every multiplicity it expresses today is smuggled through a
|
||||
-- string key — migration 0029 keys split shipments `<entity_key>#f<fact_id>` —
|
||||
-- and a BNPL plan needs four rows for one order with no such trick available.
|
||||
--
|
||||
-- The flagship case: order_ebay_14-11714-95953, a A$1,599 DJI drone paid in
|
||||
-- four A$399.75 Afterpay legs (txns 2318, 2333, 1652, 1664). Phase 1 shows it
|
||||
-- with zero linked transactions, which is correct and useless.
|
||||
--
|
||||
-- KEYED ON entity_key, NOT entities.id. finance-app does not model the spine
|
||||
-- and must not carry an FK across an ownership boundary that a spine
|
||||
-- re-extraction can decompile. The real cascade risk is NOT transaction
|
||||
-- deletion (that is handled below) but spine RE-KEYING: supersede_stale()
|
||||
-- decompiles an entity when a re-extraction yields a different entity_key, and
|
||||
-- a TEXT key with no FK silently orphans. Bridge-sourced links are rebuildable;
|
||||
-- `manual` ones are curation and are not. Hence the orphan view at the end.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS order_transaction_links (
|
||||
id SERIAL PRIMARY KEY,
|
||||
entity_key TEXT NOT NULL,
|
||||
transaction_id INTEGER NOT NULL REFERENCES transactions(id) ON DELETE CASCADE,
|
||||
leg_kind TEXT NOT NULL CHECK (leg_kind IN ('charge','shipment','instalment','refund','fee')),
|
||||
-- HUMAN position only: "2 of 4". Never an id.
|
||||
--
|
||||
-- The first draft stored a fact id here. Live shipment keys already carry
|
||||
-- fact ids 22664 and 22711, max(extracted_facts.id) is 23,781 today, and
|
||||
-- SMALLINT tops out at 32,767 — that ceiling arrives on ordinary corpus
|
||||
-- growth and the insert dies with `smallint out of range`. Fact ids go in
|
||||
-- source_fact_id, which is BIGINT.
|
||||
leg_index SMALLINT,
|
||||
leg_count SMALLINT,
|
||||
source_fact_id BIGINT,
|
||||
-- The LEG's own amount, signed: positive is money out, negative is a credit
|
||||
-- coming back. Storing the order total on every leg would make four rows sum
|
||||
-- to four times the purchase.
|
||||
amount NUMERIC(12,2) NOT NULL,
|
||||
currency TEXT NOT NULL DEFAULT 'AUD',
|
||||
source TEXT NOT NULL CHECK (source IN ('order-bridge','instalment-matcher','manual')),
|
||||
confidence TEXT NOT NULL DEFAULT 'exact' CHECK (confidence IN ('exact','derived','manual')),
|
||||
evidence JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- Every index needs IF NOT EXISTS, not just the table: a run that creates the
|
||||
-- table and then fails during the backfill would otherwise abort on re-run
|
||||
-- with "relation already exists" and need manual surgery.
|
||||
--
|
||||
-- uq_otl_transaction is load-bearing. Without it two matchers can each claim
|
||||
-- txn 2318 and the order reads as paid twice. It is deliberately stricter than
|
||||
-- reality for one case — a single card charge covering two orders — which then
|
||||
-- surfaces as a NAMED REJECTED INSERT rather than as silent duplication.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_otl_transaction ON order_transaction_links (transaction_id);
|
||||
|
||||
-- WHAT IDENTIFIES A LEG DEPENDS ON ITS KIND, and the first version of this
|
||||
-- index got it wrong in the most on-brand way available. It read
|
||||
-- (entity_key, leg_kind, COALESCE(leg_index, 0)), so two shipment legs of the
|
||||
-- same order — both with a NULL leg_index — collapsed to the same key and
|
||||
-- ON CONFLICT DO NOTHING dropped one SILENTLY. Caught only because the
|
||||
-- backfill reported 62 rows against 63 candidates.
|
||||
--
|
||||
-- The row it ate was order_amazon_249-4859367-0690246's $130.00 second
|
||||
-- shipment (txn 3171) — the same order named in migration 0029's comment as
|
||||
-- the reason split shipments need distinct keys at all, because Amazon mails
|
||||
-- and CHARGES per shipment. A uniqueness rule that cannot hold two shipments
|
||||
-- is the exact defect 0029 was written to fix.
|
||||
--
|
||||
-- So: an instalment leg is identified by its INDEX (1 of 4), a shipment leg by
|
||||
-- its FACT (each shipment is its own extracted fact), and a whole-order charge
|
||||
-- by neither — one per order, which the zeros express.
|
||||
DROP INDEX IF EXISTS uq_otl_leg;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_otl_leg ON order_transaction_links
|
||||
(entity_key, leg_kind, COALESCE(leg_index, 0), COALESCE(source_fact_id, 0));
|
||||
CREATE INDEX IF NOT EXISTS idx_otl_entity ON order_transaction_links (entity_key);
|
||||
|
||||
-- Backfill the existing bridge rows so the table is not empty on day one and
|
||||
-- the readers can switch over in the same change. `#f<fact_id>` splits into a
|
||||
-- shipment leg carrying its fact id; everything else is a whole-order charge.
|
||||
INSERT INTO order_transaction_links
|
||||
(entity_key, transaction_id, leg_kind, leg_index, source_fact_id, amount, currency, source, confidence, evidence)
|
||||
SELECT split_part(em.source_message_id, '#', 1) AS entity_key,
|
||||
COALESCE(em.matched_transaction_id, em.transaction_id) AS transaction_id,
|
||||
CASE WHEN em.source_message_id LIKE '%#f%' THEN 'shipment' ELSE 'charge' END,
|
||||
NULL::smallint,
|
||||
NULLIF(substring(em.source_message_id FROM '#f([0-9]+)$'), '')::bigint,
|
||||
COALESCE(em.amount, 0),
|
||||
COALESCE(em.currency, 'AUD'),
|
||||
'order-bridge',
|
||||
'exact',
|
||||
jsonb_build_object('backfilled_from', 'expense_metadata',
|
||||
'source_message_id', em.source_message_id)
|
||||
FROM expense_metadata em
|
||||
WHERE em.source = 'order-bridge'
|
||||
AND COALESCE(em.matched_transaction_id, em.transaction_id) IS NOT NULL
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Spine re-keying orphans links silently. Bridge rows can be rebuilt by
|
||||
-- re-running the bridge; a `manual` orphan is lost curation and is the row
|
||||
-- that actually needs a human. Same idea as order_settlement_violations.
|
||||
CREATE OR REPLACE VIEW order_link_orphans AS
|
||||
SELECT l.id, l.entity_key, l.transaction_id, l.leg_kind, l.leg_index,
|
||||
l.amount, l.currency, l.source, l.confidence, l.created_at
|
||||
FROM order_transaction_links l
|
||||
WHERE NOT EXISTS (SELECT 1 FROM entities e WHERE e.entity_key = l.entity_key);
|
||||
|
||||
-- Two stores that can disagree will disagree. The bridge now writes both in
|
||||
-- ONE transaction, so a half-written pair should be impossible — this view
|
||||
-- exists to prove that rather than to assume it, and it must stay empty.
|
||||
--
|
||||
-- The plan's suggested guard (widen the bridge's `NOT EXISTS expense_metadata`
|
||||
-- to "neither store has it") was NOT taken: the expense_metadata INSERT has no
|
||||
-- ON CONFLICT, so re-attempting an order that already has a receipt row would
|
||||
-- duplicate it. Detecting drift is cheap; re-attempting a non-idempotent write
|
||||
-- to fix a state that cannot occur is not.
|
||||
CREATE OR REPLACE VIEW order_link_drift AS
|
||||
SELECT em.source_message_id,
|
||||
COALESCE(em.matched_transaction_id, em.transaction_id) AS transaction_id,
|
||||
em.amount, em.currency, em.reconciled_at
|
||||
FROM expense_metadata em
|
||||
WHERE em.source = 'order-bridge'
|
||||
AND COALESCE(em.matched_transaction_id, em.transaction_id) IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM order_transaction_links l
|
||||
WHERE l.transaction_id = COALESCE(em.matched_transaction_id, em.transaction_id)
|
||||
);
|
||||
@@ -0,0 +1,148 @@
|
||||
-- Normalise statements.bank_name so one institution is one name.
|
||||
--
|
||||
-- WHY THIS IS NOT COSMETIC: `uq_statement_identity` is
|
||||
-- (bank_name, account_number, billing_end_date). The bank name is a *component
|
||||
-- of the duplicate check*, so two spellings of one institution mean the same
|
||||
-- statement can be ingested twice without the index ever firing. Gemini reads
|
||||
-- the name off whatever the PDF happens to print, so the spelling varies per
|
||||
-- document, not per account.
|
||||
--
|
||||
-- Zip made this visible. One account (2705256) arrived under four names in
|
||||
-- sixteen minutes — 'ZipMoney Payments Pty Ltd', 'ZipMoney Payments Pty
|
||||
-- Limited', 'Zip Pay', 'ZipPay' — with ten or more still in the ingestion
|
||||
-- queue. It was not unique: 17 bank_name values represented 13 institutions,
|
||||
-- and every split pair shared an identical account number.
|
||||
--
|
||||
-- American Express (14) / AMERICAN EXPRESS (1) -> XXXX-XXXXXX-01000
|
||||
-- Citibank (8) / Citi (4) -> 5258073330340253
|
||||
-- National Australia Bank Limited (3) / ... (NAB) (1) -> 1015001000116227
|
||||
--
|
||||
-- Fragmentation also splits the statements-page bank filter, the transactions
|
||||
-- bank filter, and by_bank in /api/analytics/fees.
|
||||
--
|
||||
-- THE VOCABULARY IS OPEN, unlike statement_type in 0013. An unrecognised name
|
||||
-- passes through tidied, never collapsed to a fallback: a bank this household
|
||||
-- has never used must be able to arrive without a migration, and destroying its
|
||||
-- name on first contact is far worse than leaving it unmapped. That is also why
|
||||
-- there is deliberately NO CHECK constraint here.
|
||||
--
|
||||
-- Aliases for banks that only ever arrive one way (Westpac, ANZ, HSBC, ING,
|
||||
-- AMP, Up, CommBank) are no-ops today and map TO the spelling already in use,
|
||||
-- so they cannot rename anything. They exist to catch the legal-entity variant
|
||||
-- the next PDF might print — which is exactly how Zip got here.
|
||||
--
|
||||
-- Wise is deliberately left alone: 'Wise Australia Pty Ltd.' is a single
|
||||
-- spelling, so shortening it would be a rename nobody asked for rather than a
|
||||
-- merge. Add a branch if a second spelling ever shows up.
|
||||
--
|
||||
-- Idempotent: safe to re-run.
|
||||
|
||||
CREATE OR REPLACE FUNCTION normalize_bank_name(raw TEXT)
|
||||
RETURNS TEXT AS $$
|
||||
DECLARE
|
||||
v TEXT;
|
||||
BEGIN
|
||||
IF raw IS NULL THEN
|
||||
RETURN NULL;
|
||||
END IF;
|
||||
|
||||
-- Collapse whitespace before matching so 'Zip Pay' and 'Zip Pay' agree.
|
||||
v := lower(regexp_replace(trim(raw), '\s+', ' ', 'g'));
|
||||
|
||||
IF v = '' THEN
|
||||
-- An empty string is not a name. NULL is how the schema already spells
|
||||
-- "unknown": bankLabel() renders it 'Manual' and uq_statement_identity
|
||||
-- excludes it.
|
||||
RETURN NULL;
|
||||
END IF;
|
||||
|
||||
-- ---- Merges: institutions seen under more than one spelling ----
|
||||
|
||||
-- 'Zip Pay', 'ZipPay', 'ZipMoney Payments Pty Ltd', 'ZipMoney Payments Pty
|
||||
-- Limited'. Anchored so a merchant that merely contains "zip" cannot match.
|
||||
IF v LIKE 'zip%' THEN
|
||||
RETURN 'Zip';
|
||||
END IF;
|
||||
|
||||
IF v LIKE '%american express%' OR v LIKE 'amex%' THEN
|
||||
RETURN 'American Express';
|
||||
END IF;
|
||||
|
||||
-- Citi, Citibank, Citigroup — one card, 5258073330340253.
|
||||
IF v LIKE 'citi%' THEN
|
||||
RETURN 'Citibank';
|
||||
END IF;
|
||||
|
||||
IF v LIKE '%national australia bank%' OR v = 'nab' THEN
|
||||
RETURN 'NAB';
|
||||
END IF;
|
||||
|
||||
-- ---- Defensive: map future legal-entity variants onto the name in use ----
|
||||
|
||||
IF v LIKE '%westpac%' THEN
|
||||
RETURN 'Westpac';
|
||||
END IF;
|
||||
|
||||
IF v = 'anz' OR v LIKE 'anz %' OR v LIKE '%australia and new zealand banking%' THEN
|
||||
RETURN 'ANZ';
|
||||
END IF;
|
||||
|
||||
IF v LIKE '%commonwealth bank%' OR v LIKE 'commbank%' OR v = 'cba' THEN
|
||||
RETURN 'Commonwealth Bank';
|
||||
END IF;
|
||||
|
||||
IF v LIKE 'hsbc%' THEN
|
||||
RETURN 'HSBC';
|
||||
END IF;
|
||||
|
||||
IF v = 'ing' OR v LIKE 'ing %' THEN
|
||||
RETURN 'ING';
|
||||
END IF;
|
||||
|
||||
IF v = 'amp' OR v LIKE 'amp %' THEN
|
||||
RETURN 'AMP Bank';
|
||||
END IF;
|
||||
|
||||
IF v = 'up' OR v LIKE 'up bank%' THEN
|
||||
RETURN 'Up';
|
||||
END IF;
|
||||
|
||||
-- Unknown institution: keep exactly what was extracted, tidied only.
|
||||
RETURN regexp_replace(trim(raw), '\s+', ' ', 'g');
|
||||
END;
|
||||
$$ LANGUAGE plpgsql IMMUTABLE;
|
||||
|
||||
-- Normalise on write so the N8N workflow keeps sending whatever the PDF prints.
|
||||
CREATE OR REPLACE FUNCTION statements_normalize_bank_name_trigger()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.bank_name := normalize_bank_name(NEW.bank_name);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_statements_normalize_bank_name ON statements;
|
||||
CREATE TRIGGER trg_statements_normalize_bank_name
|
||||
BEFORE INSERT OR UPDATE OF bank_name ON statements
|
||||
FOR EACH ROW EXECUTE FUNCTION statements_normalize_bank_name_trigger();
|
||||
|
||||
-- Backfill. Verified collision-free against uq_statement_identity before
|
||||
-- writing this: no two statements share (normalised name, account_number,
|
||||
-- billing_end_date).
|
||||
UPDATE statements
|
||||
SET bank_name = normalize_bank_name(bank_name)
|
||||
WHERE bank_name IS DISTINCT FROM normalize_bank_name(bank_name);
|
||||
|
||||
-- account_owner_mappings keys on (bank_name, account_number) with its own
|
||||
-- UNIQUE constraint, so a rename on `statements` would strand its rows. The
|
||||
-- table is empty today; this keeps it aligned if it is ever populated before
|
||||
-- this migration runs somewhere else. ON CONFLICT because two spellings of one
|
||||
-- bank could otherwise merge into one key.
|
||||
UPDATE account_owner_mappings
|
||||
SET bank_name = normalize_bank_name(bank_name)
|
||||
WHERE bank_name IS DISTINCT FROM normalize_bank_name(bank_name)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM account_owner_mappings other
|
||||
WHERE other.bank_name = normalize_bank_name(account_owner_mappings.bank_name)
|
||||
AND other.account_number = account_owner_mappings.account_number
|
||||
);
|
||||
@@ -0,0 +1,164 @@
|
||||
-- Keep one institution's national entities apart. Fixes 0031.
|
||||
--
|
||||
-- 0031 merged spellings by prefix — `citi%` -> 'Citibank'. Every merge it made
|
||||
-- was provably one account (each split pair shared an account_number), but the
|
||||
-- PREFIX generalises past that evidence: Citibank Australia and Citibank India
|
||||
-- are different banks, different accounts, different currency, and `citi%`
|
||||
-- flattens them into one. Older Citi India statements are queued to load, so
|
||||
-- this would have merged them on arrival.
|
||||
--
|
||||
-- It is not a Citi problem. `hsbc%` and `%american express%` carry the identical
|
||||
-- defect — all three are global banks operating as separate national entities.
|
||||
--
|
||||
-- WHY NOT DISCRIMINATE ON CURRENCY ALONE: Wise holds AUD, EUR and USD accounts
|
||||
-- under one provider, so a blanket "non-AUD means a different bank" rule would
|
||||
-- shatter it into three. Wise stays unmapped for exactly this reason — mapping
|
||||
-- a genuinely multi-currency provider through this function would be wrong, and
|
||||
-- that is now a documented property rather than an accident.
|
||||
--
|
||||
-- THE RULE: the country comes from the name when the name states it, and from
|
||||
-- the currency otherwise. AUD is home and takes no suffix, so nothing renames
|
||||
-- today — all 21 existing statements keep the name 0031 gave them.
|
||||
--
|
||||
-- 'Citibank' + AUD -> 'Citibank' (unchanged)
|
||||
-- 'Citi' + AUD -> 'Citibank' (unchanged)
|
||||
-- 'Citibank Australia'+ AUD -> 'Citibank'
|
||||
-- 'Citibank India' + INR -> 'Citibank India'
|
||||
-- 'Citibank N.A.' + INR -> 'Citibank India' (currency carries it)
|
||||
-- 'HSBC' + AUD -> 'HSBC' (unchanged)
|
||||
--
|
||||
-- Degradation is safe but not silent: an India statement whose currency was
|
||||
-- extracted as AUD and whose name omits the country WILL merge. The tell is a
|
||||
-- second account_number appearing under one bank_name with a different
|
||||
-- currency — see the check in CLAUDE.md.
|
||||
--
|
||||
-- Idempotent: safe to re-run.
|
||||
|
||||
-- 'N.A.' is deliberately absent: it means "National Association", a US legal
|
||||
-- form that appears on Citibank letterhead worldwide, including India. Reading
|
||||
-- it as a country marker is precisely the mistake this migration exists to fix.
|
||||
CREATE OR REPLACE FUNCTION bank_country_suffix(v TEXT, cur TEXT)
|
||||
RETURNS TEXT AS $$
|
||||
DECLARE
|
||||
c TEXT := upper(trim(coalesce(cur, '')));
|
||||
BEGIN
|
||||
-- The name wins when it states a country: it is the source's own assertion,
|
||||
-- and it survives a currency that failed to extract.
|
||||
IF v LIKE '%australia%' OR v LIKE '% au%' THEN RETURN ''; END IF; -- home
|
||||
IF v LIKE '%india%' THEN RETURN ' India'; END IF;
|
||||
IF v LIKE '%singapore%' THEN RETURN ' Singapore'; END IF;
|
||||
IF v LIKE '%hong kong%' THEN RETURN ' Hong Kong'; END IF;
|
||||
IF v LIKE '%new zealand%' THEN RETURN ' NZ'; END IF;
|
||||
IF v LIKE '%united kingdom%' OR v LIKE '% uk%' THEN RETURN ' UK'; END IF;
|
||||
IF v LIKE '%emirates%' OR v LIKE '% uae%' THEN RETURN ' UAE'; END IF;
|
||||
|
||||
-- Otherwise infer from the statement's own currency. AUD is home.
|
||||
IF c = '' OR c = 'AUD' THEN RETURN ''; END IF;
|
||||
|
||||
IF c = 'INR' THEN RETURN ' India'; END IF;
|
||||
IF c = 'SGD' THEN RETURN ' Singapore'; END IF;
|
||||
IF c = 'HKD' THEN RETURN ' Hong Kong'; END IF;
|
||||
IF c = 'NZD' THEN RETURN ' NZ'; END IF;
|
||||
IF c = 'GBP' THEN RETURN ' UK'; END IF;
|
||||
IF c = 'AED' THEN RETURN ' UAE'; END IF;
|
||||
IF c = 'USD' THEN RETURN ' US'; END IF;
|
||||
|
||||
-- EUR spans countries and cannot name one, so fall back to the code itself.
|
||||
-- A distinguishable name beats a wrong one.
|
||||
RETURN ' ' || c;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql IMMUTABLE;
|
||||
|
||||
-- `currency` is a second parameter with a default so any existing single-arg
|
||||
-- call keeps working; the trigger passes NEW.currency.
|
||||
CREATE OR REPLACE FUNCTION normalize_bank_name(raw TEXT, cur TEXT DEFAULT NULL)
|
||||
RETURNS TEXT AS $$
|
||||
DECLARE
|
||||
v TEXT;
|
||||
BEGIN
|
||||
IF raw IS NULL THEN
|
||||
RETURN NULL;
|
||||
END IF;
|
||||
|
||||
v := lower(regexp_replace(trim(raw), '\s+', ' ', 'g'));
|
||||
|
||||
IF v = '' THEN
|
||||
RETURN NULL;
|
||||
END IF;
|
||||
|
||||
-- ---- Merges: institutions seen under more than one spelling ----
|
||||
-- Each carries a country suffix, empty for AUD, so one bank's national
|
||||
-- entities stay apart while its spellings collapse.
|
||||
|
||||
IF v LIKE 'zip%' THEN
|
||||
RETURN 'Zip' || bank_country_suffix(v, cur);
|
||||
END IF;
|
||||
|
||||
IF v LIKE '%american express%' OR v LIKE 'amex%' THEN
|
||||
RETURN 'American Express' || bank_country_suffix(v, cur);
|
||||
END IF;
|
||||
|
||||
IF v LIKE 'citi%' THEN
|
||||
RETURN 'Citibank' || bank_country_suffix(v, cur);
|
||||
END IF;
|
||||
|
||||
IF v LIKE '%national australia bank%' OR v = 'nab' THEN
|
||||
RETURN 'NAB';
|
||||
END IF;
|
||||
|
||||
-- ---- Defensive: map future legal-entity variants onto the name in use ----
|
||||
|
||||
IF v LIKE '%westpac%' THEN
|
||||
RETURN 'Westpac' || bank_country_suffix(v, cur);
|
||||
END IF;
|
||||
|
||||
IF v = 'anz' OR v LIKE 'anz %' OR v LIKE '%australia and new zealand banking%' THEN
|
||||
RETURN 'ANZ' || bank_country_suffix(v, cur);
|
||||
END IF;
|
||||
|
||||
IF v LIKE '%commonwealth bank%' OR v LIKE 'commbank%' OR v = 'cba' THEN
|
||||
RETURN 'Commonwealth Bank';
|
||||
END IF;
|
||||
|
||||
IF v LIKE 'hsbc%' THEN
|
||||
RETURN 'HSBC' || bank_country_suffix(v, cur);
|
||||
END IF;
|
||||
|
||||
IF v = 'ing' OR v LIKE 'ing %' THEN
|
||||
RETURN 'ING' || bank_country_suffix(v, cur);
|
||||
END IF;
|
||||
|
||||
IF v = 'amp' OR v LIKE 'amp %' THEN
|
||||
RETURN 'AMP Bank';
|
||||
END IF;
|
||||
|
||||
IF v = 'up' OR v LIKE 'up bank%' THEN
|
||||
RETURN 'Up';
|
||||
END IF;
|
||||
|
||||
-- Unknown institution: keep exactly what was extracted, tidied only.
|
||||
-- Wise reaches here, which is what keeps its AUD/EUR/USD accounts together.
|
||||
RETURN regexp_replace(trim(raw), '\s+', ' ', 'g');
|
||||
END;
|
||||
$$ LANGUAGE plpgsql IMMUTABLE;
|
||||
|
||||
CREATE OR REPLACE FUNCTION statements_normalize_bank_name_trigger()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.bank_name := normalize_bank_name(NEW.bank_name, NEW.currency);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Re-point the trigger: it must fire when `currency` changes too, since the
|
||||
-- country is now partly derived from it.
|
||||
DROP TRIGGER IF EXISTS trg_statements_normalize_bank_name ON statements;
|
||||
CREATE TRIGGER trg_statements_normalize_bank_name
|
||||
BEFORE INSERT OR UPDATE OF bank_name, currency ON statements
|
||||
FOR EACH ROW EXECUTE FUNCTION statements_normalize_bank_name_trigger();
|
||||
|
||||
-- Backfill. Expected to touch nothing: every existing statement is AUD except
|
||||
-- Wise, which is unmapped.
|
||||
UPDATE statements
|
||||
SET bank_name = normalize_bank_name(bank_name, currency)
|
||||
WHERE bank_name IS DISTINCT FROM normalize_bank_name(bank_name, currency);
|
||||
@@ -0,0 +1,71 @@
|
||||
-- One account, one identity — without destroying how the number reads.
|
||||
--
|
||||
-- `account_number` carries the same fragmentation `bank_name` had, from the
|
||||
-- same cause: Gemini copies whatever the PDF prints, and the formatting varies
|
||||
-- per document.
|
||||
--
|
||||
-- ANZ '4085-56264' 4 statements ANZ ACCESS ADVANTAGE
|
||||
-- ANZ '408556264' 1 statement ACCESS ADVANTAGE <- same account
|
||||
--
|
||||
-- That matters because `uq_statement_identity` is
|
||||
-- (bank_name, account_number, billing_end_date). Re-importing one period under
|
||||
-- the other spelling evades the duplicate check entirely.
|
||||
--
|
||||
-- (It is NOT what caused the documented 31-row / $42,040.68 ANZ duplication.
|
||||
-- Statements 107/142/143 overlap on *different* end dates, which that index
|
||||
-- cannot catch at any spelling. Different problem, same table.)
|
||||
--
|
||||
-- WHY A GENERATED COLUMN RATHER THAN REWRITING THE VALUE: the raw number is the
|
||||
-- readable one and some of it is structure, not noise. Up stores
|
||||
-- '633-123 / 176540052' — a BSB and an account number — and flattening that to
|
||||
-- '633123176540052' would lose a distinction a human reads at a glance, to fix
|
||||
-- a machine problem. So the raw text stays exactly as extracted and the
|
||||
-- comparison key is derived beside it. Same reason the app derives trip
|
||||
-- participation instead of storing it: two records of one fact drift.
|
||||
--
|
||||
-- Separators only. Case and the Amex mask ('XXXX-XXXXXX-01000') are preserved,
|
||||
-- because 'X' is real information about which digits were redacted, and
|
||||
-- case-folding an alphanumeric account id could merge two genuinely different
|
||||
-- ones.
|
||||
--
|
||||
-- Verified collision-free before rebuilding the index: no two statements share
|
||||
-- (bank_name, stripped account_number, billing_end_date).
|
||||
--
|
||||
-- Idempotent: safe to re-run.
|
||||
|
||||
ALTER TABLE statements
|
||||
ADD COLUMN IF NOT EXISTS account_number_key TEXT
|
||||
GENERATED ALWAYS AS (
|
||||
NULLIF(regexp_replace(coalesce(account_number, ''), '[^0-9A-Za-z]', '', 'g'), '')
|
||||
) STORED;
|
||||
|
||||
COMMENT ON COLUMN statements.account_number_key IS
|
||||
'Derived from account_number, separators stripped. The identity used by '
|
||||
'uq_statement_identity so one account under two formats is one account. '
|
||||
'Never write to it — it is GENERATED. Display account_number instead.';
|
||||
|
||||
-- Rebuild the identity index on the derived key. Dropped and recreated rather
|
||||
-- than added alongside: leaving the old one in place would keep admitting the
|
||||
-- duplicate it exists to stop.
|
||||
DROP INDEX IF EXISTS uq_statement_identity;
|
||||
CREATE UNIQUE INDEX uq_statement_identity
|
||||
ON statements (bank_name, account_number_key, billing_end_date)
|
||||
WHERE bank_name IS NOT NULL AND billing_end_date IS NOT NULL;
|
||||
|
||||
-- The fragmentation detector, now able to see both halves: one account under
|
||||
-- two bank names, or one bank under two spellings of one number.
|
||||
CREATE OR REPLACE VIEW statement_identity_drift AS
|
||||
SELECT account_number_key,
|
||||
array_agg(DISTINCT bank_name) AS bank_names,
|
||||
array_agg(DISTINCT account_number) AS account_numbers,
|
||||
count(*) AS statements
|
||||
FROM statements
|
||||
WHERE account_number_key IS NOT NULL
|
||||
GROUP BY account_number_key
|
||||
HAVING count(DISTINCT bank_name) > 1
|
||||
OR count(DISTINCT account_number) > 1;
|
||||
|
||||
COMMENT ON VIEW statement_identity_drift IS
|
||||
'Non-empty means an account is fragmented: normalize_bank_name() cannot know '
|
||||
'that two names for a bank it has never seen are one institution, and this is '
|
||||
'how that always shows up. Check it after loading a new bank.';
|
||||
@@ -0,0 +1,74 @@
|
||||
-- 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.';
|
||||
@@ -135,6 +135,11 @@ model statements {
|
||||
card_name String?
|
||||
account_type String?
|
||||
account_number String
|
||||
/// GENERATED ALWAYS (migration 0033) — account_number with separators
|
||||
/// stripped, so '4085-56264' and '408556264' are one account. Backs
|
||||
/// uq_statement_identity. Read-only: never write to it, display
|
||||
/// account_number instead.
|
||||
account_number_key String?
|
||||
billing_start_date DateTime? @db.Date
|
||||
billing_end_date DateTime? @db.Date
|
||||
total_amount_due Decimal? @db.Decimal(12, 2)
|
||||
@@ -187,6 +192,9 @@ model transactions {
|
||||
superseded_by_id Int?
|
||||
principal_amount Decimal? @db.Decimal(12, 2)
|
||||
interest_amount Decimal? @db.Decimal(12, 2)
|
||||
source String? // feed this row came from, e.g. "frollo"; NULL = statement/manual (migration 0028)
|
||||
source_ref String? // the provider's own transaction id — idempotency key for re-imports
|
||||
source_account String? // account label as the source names it
|
||||
statement statements? @relation(fields: [statement_id], references: [id], onDelete: Cascade)
|
||||
reconciled_with transactions? @relation("reconciled", fields: [reconciled_with_id], references: [id], onDelete: SetNull)
|
||||
reconciled_by transactions[] @relation("reconciled")
|
||||
@@ -215,6 +223,20 @@ model expense_metadata {
|
||||
transaction_date DateTime? @db.Date
|
||||
extraction_model String? @default("gemini-2.5-flash")
|
||||
created_at DateTime? @default(now())
|
||||
// In the database since migrations 0019/0020 but absent from this model until 0027.
|
||||
// Regenerating the client from the stale definition would have dropped columns the order
|
||||
// lane writes on every ingest.
|
||||
card_last4 String?
|
||||
currency String?
|
||||
flags Json @default("[]")
|
||||
reconciled_at DateTime? @db.Timestamptz(6)
|
||||
matched_transaction_id Int?
|
||||
platform String?
|
||||
route Json?
|
||||
// 0027 — receipt scans as a second producer on this lane.
|
||||
tender_raw String?
|
||||
receipt_sha256 String?
|
||||
receipt_group String?
|
||||
transaction transactions? @relation(fields: [transaction_id], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([source, order_reference], name: "uq_expense_source_order")
|
||||
@@ -246,3 +268,30 @@ model rule_apply_runs {
|
||||
reverted_at DateTime?
|
||||
snapshot Json @default("[]")
|
||||
}
|
||||
|
||||
// 0034 — intake router. One row per upload batch, written before extraction so
|
||||
// Slack taps can act on it later and a re-share is detected. Columns are the
|
||||
// §11 C1 contract in plans/intake-router-slice-plan.md (smarthome repo).
|
||||
model intake_batches {
|
||||
id String @id @db.Uuid
|
||||
source String
|
||||
shared_by String?
|
||||
file_sha256 String[] @default([])
|
||||
header Json?
|
||||
items Json?
|
||||
kind String?
|
||||
status String
|
||||
transaction_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)
|
||||
resolved_at DateTime? @db.Timestamptz(6)
|
||||
}
|
||||
|
||||
// 0034 — the remembered answer to "Groceries or a meal?" per merchant.
|
||||
model intake_merchant_kinds {
|
||||
merchant_key String @id
|
||||
kind String
|
||||
created_at DateTime @default(now()) @db.Timestamptz(6)
|
||||
}
|
||||
|
||||
Executable
+66
@@ -0,0 +1,66 @@
|
||||
#!/bin/bash
|
||||
# Resolve the integration-test DSN at run time and (re)write .env.test.
|
||||
#
|
||||
# Why this exists (#161): .env.test used to be written ONCE by
|
||||
# setup-test-db.sh with the Postgres container's bridge IP baked in. Container
|
||||
# IPs are reassigned on recreation, so the file rotted silently — by
|
||||
# 2026-08-21 its 172.22.0.18 belonged to *gitea*, and an earlier drift had the
|
||||
# suite pointing its TRUNCATE at postgres-pantry. It only failed safe because
|
||||
# the credentials happened not to match.
|
||||
#
|
||||
# The fix is to stop persisting a host at all: this runs as `pretest:integration`,
|
||||
# so the DSN is re-derived from the live container immediately before every run.
|
||||
#
|
||||
# Resolution order:
|
||||
# 1. An explicit TEST_DATABASE_URL in the environment wins (CI, remote DB).
|
||||
# 2. The container's published host port (`docker port`) — postgres-personal
|
||||
# publishes 127.0.0.1:5432 since the 2026-08-17 PG17 migration. Preferred
|
||||
# because a loopback address cannot be reassigned to another container.
|
||||
# 3. The container's bridge IP, for a host where the port is not published.
|
||||
#
|
||||
# helpers.ts::assertTestDatabase is the backstop: it refuses to TRUNCATE
|
||||
# anything whose current_database() is not `personal_test`.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PG_CONTAINER=${PG_CONTAINER:-postgres-personal}
|
||||
PG_USER=${PG_USER:-personal}
|
||||
PG_PASS=${PG_PASS:-personalpassword123}
|
||||
TEST_DB=${TEST_DB:-personal_test}
|
||||
|
||||
ENV_FILE="$(cd "$(dirname "$0")/.." && pwd)/.env.test"
|
||||
|
||||
if [ -n "${TEST_DATABASE_URL:-}" ]; then
|
||||
DSN="$TEST_DATABASE_URL"
|
||||
SOURCE="TEST_DATABASE_URL"
|
||||
else
|
||||
if ! docker inspect "$PG_CONTAINER" >/dev/null 2>&1; then
|
||||
echo "ERROR: container '$PG_CONTAINER' not found. Start it, or set TEST_DATABASE_URL." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# `docker port` prints e.g. "127.0.0.1:5432" (possibly several lines).
|
||||
PUBLISHED=$(docker port "$PG_CONTAINER" 5432/tcp 2>/dev/null | head -1 || true)
|
||||
|
||||
if [ -n "$PUBLISHED" ]; then
|
||||
HOSTPORT="$PUBLISHED"
|
||||
SOURCE="published port"
|
||||
else
|
||||
IP=$(docker inspect "$PG_CONTAINER" \
|
||||
--format '{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}' 2>/dev/null \
|
||||
| tr ' ' '\n' | grep -v '^$' | head -1)
|
||||
if [ -z "$IP" ]; then
|
||||
echo "ERROR: '$PG_CONTAINER' has neither a published 5432 nor a bridge IP." >&2
|
||||
exit 1
|
||||
fi
|
||||
HOSTPORT="$IP:5432"
|
||||
SOURCE="bridge IP"
|
||||
fi
|
||||
|
||||
DSN="postgresql://$PG_USER:$PG_PASS@$HOSTPORT/$TEST_DB"
|
||||
fi
|
||||
|
||||
printf 'DATABASE_URL=%s\n' "$DSN" > "$ENV_FILE"
|
||||
|
||||
# Never print the password.
|
||||
echo "resolve-test-db: ${DSN%%://*}://***@${DSN#*@} (via $SOURCE)"
|
||||
+14
-18
@@ -1,25 +1,24 @@
|
||||
#!/bin/bash
|
||||
# Creates the personal_test database and writes .env.test
|
||||
# Run once before integration tests: npm run test:setup
|
||||
# Creates the personal_test database and copies the production schema into it.
|
||||
# Run once (and after any migration) before integration tests: npm run test:setup
|
||||
#
|
||||
# It no longer writes a host into .env.test. That was the #161 defect: the file
|
||||
# was written once with the container's bridge IP and then rotted as the IP was
|
||||
# reassigned. scripts/resolve-test-db.sh now re-derives the DSN before every
|
||||
# integration run (wired as `pretest:integration`), and this script just calls it
|
||||
# at the end so a fresh setup leaves a usable .env.test behind.
|
||||
|
||||
set -e
|
||||
|
||||
PG_CONTAINER=postgres-personal
|
||||
PG_USER=personal
|
||||
PG_PASS=personalpassword123
|
||||
TEST_DB=personal_test
|
||||
PG_CONTAINER=${PG_CONTAINER:-postgres-personal}
|
||||
PG_USER=${PG_USER:-personal}
|
||||
TEST_DB=${TEST_DB:-personal_test}
|
||||
|
||||
# Discover the container's bridge IP (accessible from the host on Linux)
|
||||
PG_HOST=$(docker inspect "$PG_CONTAINER" \
|
||||
--format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' 2>/dev/null | head -1)
|
||||
|
||||
if [ -z "$PG_HOST" ]; then
|
||||
if ! docker inspect "$PG_CONTAINER" >/dev/null 2>&1; then
|
||||
echo "ERROR: Could not find container '$PG_CONTAINER'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Postgres container at $PG_HOST:5432"
|
||||
|
||||
# Create test database (ignore error if it already exists)
|
||||
docker exec "$PG_CONTAINER" psql -U "$PG_USER" -d postgres \
|
||||
-c "CREATE DATABASE $TEST_DB;" 2>/dev/null || true
|
||||
@@ -31,9 +30,6 @@ docker exec "$PG_CONTAINER" pg_dump -U "$PG_USER" --schema-only "$PG_USER" \
|
||||
|
||||
echo "Schema ready."
|
||||
|
||||
# Write .env.test
|
||||
cat > "$(dirname "$0")/../.env.test" << EOF
|
||||
DATABASE_URL=postgresql://$PG_USER:$PG_PASS@$PG_HOST:5432/$TEST_DB
|
||||
EOF
|
||||
bash "$(dirname "$0")/resolve-test-db.sh"
|
||||
|
||||
echo ".env.test written — integration tests can now run with: npm run test:integration"
|
||||
echo "Integration tests can now run with: npm run test:integration"
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,56 @@
|
||||
[
|
||||
{
|
||||
"file": "dd-credits-zero.html",
|
||||
"id": "19b692bdf1ea7a08",
|
||||
"subject": "Order Confirmation for Siddharth from Chilli India",
|
||||
"receivedAt": "2025-12-29T08:14:00.000Z",
|
||||
"sender": "DoorDash <no-reply@doordash.com>",
|
||||
"why": "DoorDash credits-funded, Total Charged $0.00",
|
||||
"stated": null
|
||||
},
|
||||
{
|
||||
"file": "ue-aud-prefix.html",
|
||||
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAhmyVi0AAAA",
|
||||
"subject": "Your Friday evening order with Uber Eats",
|
||||
"receivedAt": "2025-10-17T09:50:39Z",
|
||||
"sender": "noreply@uber.com",
|
||||
"why": "Uber total written as Total A\\$",
|
||||
"stated": "Total A$54.87"
|
||||
},
|
||||
{
|
||||
"file": "ue-nzd-prefix.html",
|
||||
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAf4nGLUAAAA",
|
||||
"subject": "Your Saturday evening order with Uber Eats",
|
||||
"receivedAt": "2025-05-03T19:26:05Z",
|
||||
"sender": "noreply@uber.com",
|
||||
"why": "Uber total written as Total NZ\\$",
|
||||
"stated": "Total NZ$22.83"
|
||||
},
|
||||
{
|
||||
"file": "ut-aud-prefix.html",
|
||||
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAhLnaIfAAAA",
|
||||
"subject": "Your Saturday morning trip with Uber",
|
||||
"receivedAt": "2025-09-05T23:17:58Z",
|
||||
"sender": "noreply@uber.com",
|
||||
"why": "Uber total written as Total A\\$",
|
||||
"stated": "Total A$14.19"
|
||||
},
|
||||
{
|
||||
"file": "ut-nzd-prefix.html",
|
||||
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAf1poEUAAAA",
|
||||
"subject": "Your Sunday afternoon trip with Uber",
|
||||
"receivedAt": "2025-04-27T13:27:16Z",
|
||||
"sender": "noreply@uber.com",
|
||||
"why": "Uber total written as Total NZ\\$",
|
||||
"stated": "Total NZ$10.83"
|
||||
},
|
||||
{
|
||||
"file": "ut-inr-symbol.html",
|
||||
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAgPBHtAAAAA",
|
||||
"subject": "Your Friday evening trip with Uber",
|
||||
"receivedAt": "2025-06-06T18:00:51Z",
|
||||
"sender": "noreply@uber.com",
|
||||
"why": "Uber total written as Total \u20b9",
|
||||
"stated": "Total \u20b9622.74"
|
||||
}
|
||||
]
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,868 @@
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
<head><!--[if gte mso 9]><xml>
|
||||
<o:OfficeDocumentSettings>
|
||||
<o:AllowPNG/>
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml><![endif]-->
|
||||
<title>DoorDash</title>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0 ">
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<style type="text/css">body {
|
||||
margin: 0 auto;
|
||||
padding: 0;
|
||||
-webkit-text-size-adjust: 100%!important;
|
||||
-ms-text-size-adjust: 100%!important;
|
||||
-webkit-font-smoothing: antialiased!important;
|
||||
}
|
||||
img {
|
||||
border: 0!important;
|
||||
outline: none!important;
|
||||
}
|
||||
p {
|
||||
Margin: 0px!important;
|
||||
Padding: 0px!important;
|
||||
}
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
mso-table-lspace: 0px;
|
||||
mso-table-rspace: 0px;
|
||||
}
|
||||
td, a, span {
|
||||
border-collapse: collapse;
|
||||
mso-line-height-rule: exactly;
|
||||
}
|
||||
.ExternalClass * {
|
||||
line-height: 100%;
|
||||
}
|
||||
.em_defaultlink a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
a[x-apple-data-detectors], u+.em_body a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
font-size: inherit;
|
||||
font-family: inherit;
|
||||
font-weight: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
@media only screen and (max-width:667px) {
|
||||
.em_main_table {
|
||||
width: 100%!important;
|
||||
}
|
||||
.em_wrapper {
|
||||
width: 100%!important;
|
||||
}
|
||||
.em_hide {
|
||||
display: none!important;
|
||||
}
|
||||
.em_hauto {
|
||||
height: auto !important;
|
||||
}
|
||||
.em_full_img img {
|
||||
width: 100%!important;
|
||||
height: auto!important;
|
||||
}
|
||||
.em_pad1 {
|
||||
padding-right: 10px!important;
|
||||
}
|
||||
.em_hauto {
|
||||
height: auto!important;
|
||||
}
|
||||
.em_side15 {
|
||||
width: 40px!important;
|
||||
}
|
||||
.em_h20 {
|
||||
height: 40px!important;
|
||||
font-size: 1px!important;
|
||||
line-height: 1px!important;
|
||||
}
|
||||
.em_h10 {
|
||||
height: 10px!important;
|
||||
font-size: 1px!important;
|
||||
line-height: 1px!important;
|
||||
}
|
||||
.em_h30 {
|
||||
height: 30px!important;
|
||||
}
|
||||
u+.em_body .em_full_wrap {
|
||||
width: 100%!important;
|
||||
width: 100vw!important;
|
||||
}
|
||||
.em_side30 {
|
||||
width: 26px!important;
|
||||
}
|
||||
.em_cta {
|
||||
width: 190px !important;
|
||||
height: 40px!important;
|
||||
}
|
||||
.em_cta a {
|
||||
font-size: 17px !important;
|
||||
line-height: 40px!important;
|
||||
}
|
||||
.em_h90 {
|
||||
height: 140px !important;
|
||||
}
|
||||
.em_font_58 {
|
||||
font-size: 40px!important;
|
||||
line-height: 44px!important;
|
||||
}
|
||||
.em_pad1 {
|
||||
padding: 0px 15px !important;
|
||||
}
|
||||
.en_icon {
|
||||
width: 30px !important;
|
||||
padding-bottom: 10px !important;
|
||||
}
|
||||
.em_rounded {
|
||||
border-top-left-radius: 25px !important;
|
||||
border-top-right-radius: 25px !important;
|
||||
}
|
||||
.em_bold {
|
||||
letter-spacing: -1px !important;
|
||||
}
|
||||
.em_side_15 {
|
||||
width: 15px!important;
|
||||
}
|
||||
.em_h20 {
|
||||
height: 20px!important;
|
||||
font-size: 1px!important;
|
||||
line-height: 1px!important;
|
||||
}
|
||||
.em_ptop {
|
||||
padding-top: 20px !important;
|
||||
}
|
||||
}
|
||||
@media screen and (max-width:480px) {
|
||||
.em_side30 {
|
||||
width: 26px!important;
|
||||
}
|
||||
.ft_16 {
|
||||
font-size: 14px!important;
|
||||
line-height: 18px!important;
|
||||
}
|
||||
.em_side15 {
|
||||
width: 40px!important;
|
||||
}
|
||||
.em_font_58 {
|
||||
font-size: 35px!important;
|
||||
line-height: 42px!important;
|
||||
}
|
||||
.em_cta {
|
||||
width: 165px !important;
|
||||
height: 38px!important;
|
||||
}
|
||||
.em_cta a {
|
||||
font-size: 15px !important;
|
||||
line-height: 38px!important;
|
||||
}
|
||||
.em_h90 {
|
||||
height: 105px !important;
|
||||
}
|
||||
.em_font_20 {
|
||||
font-size: 22px!important;
|
||||
line-height: 26px!important;
|
||||
}
|
||||
.em_f_16 {
|
||||
font-size: 16px!important;
|
||||
line-height: 20px!important;
|
||||
}
|
||||
.em_img img {
|
||||
width: 22px !important;
|
||||
height: auto !important;
|
||||
padding-top: 3px !important;
|
||||
}
|
||||
.em_cta1 {
|
||||
width: 200px!important;
|
||||
height: 45px!important;
|
||||
}
|
||||
.em_cta1 a {
|
||||
font-size: 16px!important;
|
||||
line-height: 45px!important;
|
||||
}
|
||||
}
|
||||
@media screen and (max-width:374px) {
|
||||
.ft_16 {
|
||||
font-size: 12px!important;
|
||||
line-height: 16px!important;
|
||||
}
|
||||
.em_side15 {
|
||||
width: 40px!important;
|
||||
}
|
||||
.em_side30 {
|
||||
width: 20px!important;
|
||||
}
|
||||
.em_img img {
|
||||
width: 20px !important;
|
||||
height: auto !important;
|
||||
padding-top: 3px !important;
|
||||
}
|
||||
.em_f_16 {
|
||||
font-size: 15px!important;
|
||||
line-height: 19px!important;
|
||||
}
|
||||
.em_font_20 {
|
||||
font-size: 20px!important;
|
||||
line-height: 24px!important;
|
||||
}
|
||||
.em_font_58 {
|
||||
font-size: 30px!important;
|
||||
line-height: 38px!important;
|
||||
}
|
||||
.em_cta {
|
||||
width: 160px !important;
|
||||
height: 38px!important;
|
||||
}
|
||||
.em_cta a {
|
||||
font-size: 15px !important;
|
||||
line-height: 38px!important;
|
||||
}
|
||||
.em_cta1 {
|
||||
width: 180px!important;
|
||||
height: 42px!important;
|
||||
}
|
||||
.em_cta1 a {
|
||||
font-size: 15px!important;
|
||||
line-height: 42px!important;
|
||||
}
|
||||
.em_h90 {
|
||||
height: 95px !important;
|
||||
}
|
||||
}
|
||||
@media screen {
|
||||
@font-face {
|
||||
font-family: 'TTNorms-Regular';
|
||||
src: url('https://typography.doordash.com/TTNorms-Regular.woff') format('woff'), url('https://typography.doordash.com/TTNorms-Regular.ttf') format('truetype');
|
||||
font-weight: normal !important;
|
||||
font-style: normal !important;
|
||||
mso-font-alt: 'Arial'
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'TTNorms-Medium';
|
||||
src: url('https://typography.doordash.com/TTNorms-Medium.woff') format('woff'), url('https://typography.doordash.com/TTNorms-Medium.ttf') format('truetype');
|
||||
font-weight: normal !important;
|
||||
font-style: normal !important;
|
||||
mso-font-alt: 'Arial'
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'TTNorms-Bold';
|
||||
src: url('https://typography.doordash.com/TTNorms-Bold.woff') format('woff'), url('https://typography.doordash.com/TTNorms-Bold.ttf') format('truetype');
|
||||
font-weight: normal !important;
|
||||
font-style: normal !important;
|
||||
mso-font-alt: 'Arial'
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'TTNorms-ExtraBold';
|
||||
src: url('https://typography.doordash.com/TTNorms-ExtraBold.woff') format('woff'), url('https://typography.doordash.com/TTNorms-ExtraBold.ttf') format('truetype');
|
||||
font-weight: normal !important;
|
||||
font-style: normal !important;
|
||||
mso-font-alt: 'Arial'
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="em_body" style="margin:0px auto; padding:0px;" bgcolor="#ffffff">
|
||||
<span style="color:transparent;visibility:hidden;display:none;opacity:0;height:0;width:0;font-size:0;"></span> <!-- == Body Section == -->
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" class="em_full_wrap" bgcolor="#ffffff">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" valign="top">
|
||||
<table align="center" width="700" border="0" cellspacing="0" cellpadding="0" class="em_main_table" style="width:700px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" valign="top"><!----><!-- --><!-- --><!-- --><!-- -->
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" align="center" bgcolor="#D1EDEE">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="em_h30" height="62" style="height:62px; line-height:0px; font-size:0px;"></td>
|
||||
</tr> <!-- banner Section -->
|
||||
<tr>
|
||||
<td valign="top" align="center">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" align="center">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="em_hauto" valign="top" align="center" bgcolor="#D1EDEE" background="https://assets.doordash.team/m/1a24eac71707251f/original/-05_May-Order_Confirmation_Revamp-bg_img_02fr.jpg" style="background-position: right bottom; background-size: cover; background-repeat: no-repeat; height: 526px;" height="526"><!--[if gte mso 9]>
|
||||
<v:image xmlns:v="urn:schemas-microsoft-com:vml" fill="true" stroke="false" style=" border: 0;display: inline-block; width:700px;height:526px;" src="https://assets.doordash.team/m/1a24eac71707251f/original/-05_May-Order_Confirmation_Revamp-bg_img_02fr.jpg" />
|
||||
<v:rect xmlns:v="urn:schemas-microsoft-com:vml" fill="true" stroke="false" style=" border: 0;display: inline-block;position: absolute; width:700px;height:526px;">
|
||||
<v:fill opacity="0%" color="#ededed" />
|
||||
<v:textbox inset="0,0,0,0">
|
||||
<!----><!----><!----><!----><!---->
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" align="center">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="em_side30" width="60" style="width:60px;"></td>
|
||||
<td class="em_hauto" align="center" valign="top">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" align="center">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" valign="top"><a target="_blank" style="text-decoration:none;" href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiX9NQvXQ9aE-2FeLMhxL9C-2FAErzBY_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRx2m-2Bx6Npoq7irZzHPfD9P-2FYYJptv8l-2BigMjxI45jkjAkgNtbVB16VoUxsk7SMPBw8a-2FelGMT82ceEz-2BvpqbQp0r1xbE2OA2TnPx3xvM1srvI631o-2F8btZwumk47Ma4HzQmpBExphRnMlSBa03L-2BMfiW-2FCEtNMxDTu80LwRKAX2WPgKnh9s65o0kO1VZbqgD-2BYFuy89wo4udZAodoWOJMa96gK612W1szjrdsLrKjxfDUc0Za1usKJAnXY5kDXHTw-2Fpxx9OvPCGHs2fMZcwUWGci4Hc8OSpZo0jFqKGUCE-2Fc-2BJFtx0BdkoZinPoMkq9Ta7dllJhS5ZGwpNXmwbdtFXIovnb2p2aN3iw5lQ3AtNyeEXxPIRpxDcc9oLJtssFxcwSrBufr8VSnX0iS-2FHSyQwvZtgbeR2JES2snlC0cmemuvsIRd0GVwUJMvxxYF6C6TW1lDGwv-2F1ZNyYp-2FNfzKa-2BxdXdFcj0XZ58smKSK29HEN9FrTupJ9IVUs9-2BodhHpswAMZwgdH1Zng8FsOutsKQh9U6tAttMWs-2F17L53RCWUgjdFZL-2BBM-2FvMAbWQw8VEkpCEspZjWjjzE0oiyQq85glyUiycwLDkDUnlXnoi-2Ff89LkDx-2Bc-2BDpwSECL30G4Nt-2FixG9XD5XY21LkSdp3wDhHwzT5424p65s8zLSQqDqMSr-2FJg-3D-3D" universal="true"><img width="45" alt="DOORDASH" style="display:block; max-width:45px;font-family:Arial, sans-serif;font-size:20px; line-height:30px; color:#ee3623; font-weight:bold;" border="0" class="en_icon" src="https://assets.doordash.team/m/835d1d775f776ef/original/-04_April-MX_Winback_Campaign-logo_img.png"> </a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="em_h20" height="50" style="height:50px; line-height:0px; font-size:0px;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="em_defaultlink em_font_58 em_bold" align="left" valign="top" style="font-family:'TTNorms-Bold', Arial, sans-serif; color:#000000;font-size:51px; line-height:60px; font-weight: bold;"><!---->Thanks for your<br> order, Siddharth<!----></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="em_h20" height="66" style="height:66px; line-height:0px; font-size:0px;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" valign="top">
|
||||
<table width="220" border="0" cellspacing="0" cellpadding="0" align="left" style="width:220px; max-width:220px;" class="em_cta">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="em_defaultlink em_cta em_bold" align="center" valign="middle" height="45" style="font-family:'TTNorms-Bold', Arial, sans-serif;color:#ffffff;font-size:18px; background-color:#eb1700; border-radius:25px; font-weight: bold; "><a target="_blank" style="text-decoration:none; display:block; color:#ffffff; line-height:45px;" href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiV6F8V0WgHH5qpw3jxS-2Fa6EGy0eTNS93bJBD8CrAgawzeLHduIsGGvaKZS6iNaYp-2Fk-3DXis9_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRx2m-2Bx6Npoq7irZzHPfD9P-2FYYJptv8l-2BigMjxI45jkjAkgNtbVB16VoUxsk7SMPBw8a-2FelGMT82ceEz-2BvpqbQp0r1xbE2OA2TnPx3xvM1srvI631o-2F8btZwumk47Ma4HzQmpBExphRnMlSBa03L-2BMfiW-2FCEtNMxDTu80LwRKAX2WPgKnh9s65o0kO1VZbqgD-2BYFuy89wo4udZAodoWOJMa96gK612W1szjrdsLrKjxfDUc0Za1usKJAnXY5kDXHTw-2Fpxx9OvPCGHs2fMZcwUWGci4Hc8OSpZo0jFqKGUCE-2Fc-2BJFtx0BdkoZinPoMkq9Ta7dllJhS5ZGwpNXmwbdtFXIovnb2p2aN3iw5lQ3AtNyeEXxPIRpxDcc9oLJtssFxcwSrBufr8VSnX0iS-2FHSyQwvZtgbeR2JES2snlC0cmemuvsIRd0GVwUJMvxxYF6C6TW1lDGwv-2F1ZNyYp-2FNfzKa-2BxdXdFcj0XZ58smKSK29HEN9FrTupJ9IVUs9-2BodhHpswAMZwgdH1Zng8FsOutsKQh9DhoSoVJQPXHb0daBH8Su71-2FWDBw7ES1ME-2FUoDwtJ1oW0yC1YYQRsoWgxjV-2BgzJbsIISc2svF3BIflrR-2F4JaCzYXaSuX0Iv-2BBnV5dxK3NGiCpg70gOKqGPmQgE4kTqX7GZZVXQUInMiK7lB-2BeczLXaA-3D-3D" universal="true">Track Your Order</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="em_h90" height="28" style="height:28px; line-height:0px; font-size:0px;"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
<td class="em_side15" width="20" style="width:20px;"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table> <!--[if gte mso 9]>
|
||||
</v:textbox>
|
||||
</v:rect>
|
||||
</v:image>
|
||||
<![endif]--></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr> <!-- //banner Section -->
|
||||
<tr>
|
||||
<td valign="top" align="center">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" align="center">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td width="6%" style="width:6%;"></td>
|
||||
<td valign="top" align="center">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" align="center">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td valign="top" align="center" class="em_rounded" style="border-top-left-radius: 40px; border-top-right-radius: 40px; background-color: #ffffff;">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" align="center">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="em_side15" width="40" style="width: 40px;"></td>
|
||||
<td valign="top" align="center">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" align="center">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="em_h20" height="50" style="height:50px; line-height:0px; font-size:0px;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="em_defaultlink" align="left" valign="top" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:17px; line-height:24px;">Paid with credits<br> Chilli India</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="em_defaultlink em_bold" align="left" valign="top" style="font-family:'TTNorms-Bold', Arial, sans-serif; color:#767676;font-size:17px; line-height:24px; font-weight:bold; color:#000000;">Total: $0.00</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="em_h20" height="35" style="height:35px; line-height:0px; font-size:0px;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="em_defaultlink em_bold" align="left" valign="top" style="font-family:'TTNorms-Bold', Arial, sans-serif; color:#000000;font-size:26px; line-height:36px; font-weight: bold;">Your receipt</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="em_defaultlink" align="left" valign="top" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:21px;">2/210 Ballan Rd, Wyndham Vale VIC 3024, Australia</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="em_h20" height="45" style="height:45px; line-height:0px; font-size:0px;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td valign="top" align="center">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" align="center">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="em_defaultlink" align="left" valign="top" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:17px; line-height:24px;"><font size="2" color="#666666"><b>- For: Siddharth Bose -</b></font><br><br>
|
||||
<table width="100%" style="margin: auto; margin-bottom: 20px">
|
||||
<tbody>
|
||||
<tr style="text-align: left;">
|
||||
<td valign="top" width="10%" style="color: #666666; font-size: 18px; line-height: 24px">1x</td>
|
||||
<td valign="top" width="75%" style="color: #666666; font-size: 18px; line-height: 24px"><b>Chicken Lollipops</b> (NON-VEGETARIAN STARTERS)<br></td>
|
||||
<td valign="top" width="15%" style="color: #666666; font-size: 18px; line-height: 24px text-align: right">$23.88</td>
|
||||
</tr>
|
||||
<tr style="text-align: left;">
|
||||
<td valign="top" width="10%" style="color: #666666; font-size: 18px; line-height: 24px">2x</td>
|
||||
<td valign="top" width="75%" style="color: #666666; font-size: 18px; line-height: 24px"><b>Hyderabadi Goat Dum Biryani</b> (BIRYANI'S)<br></td>
|
||||
<td valign="top" width="15%" style="color: #666666; font-size: 18px; line-height: 24px text-align: right">$47.98</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="em_h20" height="22" style="height:22px; line-height:0px; font-size:0px;"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
<td class="em_side15" width="40" style="width: 40px;"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td valign="top" align="center" bgcolor="#ffffff" class="em_pad1">
|
||||
<table width="530" style="width:530px;" border="0" cellspacing="0" cellpadding="0" align="center" class="em_wrapper">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td bgcolor="#e5e5e5" height="2" style="line-height:0px; font-size:0px; height: 2px;"><img alt="" border="0" height="1" style="display:block;" width="1" src="https://assets.doordash.team/m/1b5c04bd5b887a06/original/-05_May-90D_Resurrection_Campaign_Refresh_T2-spacer.gif"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td valign="top" align="center" style="background-color: #ffffff;">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" align="center">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="em_side15" width="40" style="width: 40px;"></td>
|
||||
<td valign="top" align="center">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" align="center">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="em_h20" height="10" style="height:10px; line-height:0px; font-size:0px;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td valign="top" align="center">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" align="center">
|
||||
<tbody>
|
||||
<tr><!---->
|
||||
<td class="em_defaultlink" align="left" valign="top" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;">Subtotal</td> <!---->
|
||||
<td class="em_defaultlink" align="right" valign="top" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;">$71.86</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td valign="top" align="center">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" align="center">
|
||||
<tbody>
|
||||
<tr><!---->
|
||||
<td class="em_defaultlink" align="left" valign="top" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;">Taxes</td> <!---->
|
||||
<td class="em_defaultlink" align="right" valign="top" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;">$0.00</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr> <!----><!---->
|
||||
<tr>
|
||||
<td valign="top" align="center">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" align="center">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="em_defaultlink" align="left" valign="top" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;">Delivery Fee</td>
|
||||
<td class="em_defaultlink" align="right" valign="top" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;">$0.00</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td valign="top" align="center">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" align="center">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="em_defaultlink" align="left" valign="top" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;">Service Fee</td>
|
||||
<td class="em_defaultlink" align="right" valign="top" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;">$0.00</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr> <!-- --><!---->
|
||||
<tr>
|
||||
<td valign="top" align="center">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" align="center">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="em_defaultlink" align="left" valign="top" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;">Discounts</td>
|
||||
<td class="em_defaultlink" align="right" valign="top" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;">-$71.86</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr> <!---->
|
||||
<tr>
|
||||
<td class="em_h20" height="18" style="height:18px; line-height:0px; font-size:0px;"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
<td class="em_side15" width="40" style="width: 40px;"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td valign="top" align="center" bgcolor="#ffffff" class="em_pad1">
|
||||
<table width="530" style="width:530px;" border="0" cellspacing="0" cellpadding="0" align="center" class="em_wrapper">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td bgcolor="#e5e5e5" height="2" style="line-height:0px; font-size:0px; height: 2px;"><img alt="" border="0" height="1" style="display:block;" width="1" src="https://assets.doordash.team/m/1b5c04bd5b887a06/original/-05_May-90D_Resurrection_Campaign_Refresh_T2-spacer.gif"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td valign="top" align="center" style="background-color: #ffffff;">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" align="center">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="em_side15" width="40" style="width: 40px;"></td>
|
||||
<td valign="top" align="center">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" align="center">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="em_h20" height="12" style="height:12px; line-height:0px; font-size:0px;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td valign="top" align="center">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" align="center">
|
||||
<tbody>
|
||||
<tr><!---->
|
||||
<td class="em_defaultlink em_bold" align="left" valign="top" style="font-family:'TTNorms-Bold', Arial, sans-serif; color:#000000;font-size:17px; line-height:24px; font-weight: bold;">Total Charged</td> <!---->
|
||||
<td class="em_defaultlink em_bold" align="right" valign="top" style="font-family:'TTNorms-Bold', Arial, sans-serif; color:#000000;font-size:17px; line-height:24px; font-weight: bold;">$0.00</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr> <!----><!----><!----><!----><!----> <!---->
|
||||
<tr>
|
||||
<td class="em_h20" height="15" style="height:15px; line-height:0px; font-size:0px;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="em_defaultlink" align="left" valign="top" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#ff2f07;font-size:14px; line-height:21px; font-weight: bold;"><a style="color:#ff2f07; text-decoration:none;" target="_blank" href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiV6F8V0WgHH5qpw3jxS-2Fa6EGy0eTNS93bJBD8CrAgawzf-2F8itKxlwlpgC5eDvfobsPqcWIfajKSF9K5IVNYF4z5cDdr_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRx2m-2Bx6Npoq7irZzHPfD9P-2FYYJptv8l-2BigMjxI45jkjAkgNtbVB16VoUxsk7SMPBw8a-2FelGMT82ceEz-2BvpqbQp0r1xbE2OA2TnPx3xvM1srvI631o-2F8btZwumk47Ma4HzQmpBExphRnMlSBa03L-2BMfiW-2FCEtNMxDTu80LwRKAX2WPgKnh9s65o0kO1VZbqgD-2BYFuy89wo4udZAodoWOJMa96gK612W1szjrdsLrKjxfDUc0Za1usKJAnXY5kDXHTw-2Fpxx9OvPCGHs2fMZcwUWGci4Hc8OSpZo0jFqKGUCE-2Fc-2BJFtx0BdkoZinPoMkq9Ta7dllJhS5ZGwpNXmwbdtFXIovnb2p2aN3iw5lQ3AtNyeEXxPIRpxDcc9oLJtssFxcwSrBufr8VSnX0iS-2FHSyQwvZtgbeR2JES2snlC0cmemuvsIRd0GVwUJMvxxYF6C6TW1lDGwv-2F1ZNyYp-2FNfzKa-2BxdXdFcj0XZ58smKSK29HEN9FrTupJ9IVUs9-2BodhHpswAMZwgdH1Zng8FsOutsKQh9yGet7aCJhkLcWM9jtE5P15Gw2aS9-2Bd8S0lxqE0ofFQUprfLXvCW-2Fyw6lcqXZj16oIJGN1Z3kyxulmmMYsrht81Fbmp7xhgSLYmCFSjIeEUapNr9xy5EHJGf5QgpKFBoWt24n8NDLxIVQHt7hhCz72g-3D-3D" universal="true">Get Order Help</a></td>
|
||||
</tr> <!-- -->
|
||||
<tr>
|
||||
<td class="em_h20" height="58" style="height:58px; line-height:0px; font-size:0px;"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table> <!-- == //Body Section == --><!-- == Footer Section == --><!-- == //Footer Section == --></td>
|
||||
<td class="em_side15" width="40" style="width: 40px;"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
<td width="6%" style="width:6%;"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top" bgcolor="#00838a">
|
||||
<table width="700" border="0" cellspacing="0" cellpadding="0" align="center" style="width:700px;" bgcolor="#00838a" class="em_wrapper">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="em_h20" height="73" style="height:73px;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top">
|
||||
<table width="700" border="0" cellspacing="0" cellpadding="0" align="center" style="width:700px;" class="em_wrapper">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td valign="top">
|
||||
<table width="275" border="0" cellspacing="0" cellpadding="0" align="left" style="width:275px;" dir="ltr" class="em_wrapper">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" valign="top"><a target="_blank" style="text-decoration: none;" href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiV6F8V0WgHH5qpw3jxS-2Fa6EGy0eTNS93bJBD8CrAgawzeLHduIsGGvaKZS6iNaYp-2Fk-3DINmM_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRx2m-2Bx6Npoq7irZzHPfD9P-2FYYJptv8l-2BigMjxI45jkjAkgNtbVB16VoUxsk7SMPBw8a-2FelGMT82ceEz-2BvpqbQp0r1xbE2OA2TnPx3xvM1srvI631o-2F8btZwumk47Ma4HzQmpBExphRnMlSBa03L-2BMfiW-2FCEtNMxDTu80LwRKAX2WPgKnh9s65o0kO1VZbqgD-2BYFuy89wo4udZAodoWOJMa96gK612W1szjrdsLrKjxfDUc0Za1usKJAnXY5kDXHTw-2Fpxx9OvPCGHs2fMZcwUWGci4Hc8OSpZo0jFqKGUCE-2Fc-2BJFtx0BdkoZinPoMkq9Ta7dllJhS5ZGwpNXmwbdtFXIovnb2p2aN3iw5lQ3AtNyeEXxPIRpxDcc9oLJtssFxcwSrBufr8VSnX0iS-2FHSyQwvZtgbeR2JES2snlC0cmemuvsIRd0GVwUJMvxxYF6C6TW1lDGwv-2F1ZNyYp-2FNfzKa-2BxdXdFcj0XZ58smKSK29HEN9FrTupJ9IVUs9-2BodhHpswAMZwgdH1Zng8FsOutsKQh9ab-2BWHgIPHw-2FHA-2F8LmdECgPJuMjpcjZqmvbOeE-2FjweAF697cyRBN1bGlXezke3iQVhJlWoqTPIP6wOH-2B4pWw-2FSZzecFDoRexCypWWX3P9hHvZQo7rZEU7tsxj5JgFvOgtMytra1OA69PgnlD7sYqwLg-3D-3D" universal="true"><img width="275" border="0" alt="DOORDASH | Pick up your order | Ready now | I Picked Up My Order" style="font-family:Arial,sans-serif;font-size:16px;line-height:20px;color:#ffffff;display:block;max-width:275px;" src="https://assets.doordash.team/m/21479b41d7738a14/original/-08_August-Pickup_Order_Confirmation_Banner_Add-imgpsh_fullsize_anim.jpg"></a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table> <!--[if gte mso 9]></td><td valign="top"><![endif]-->
|
||||
<table width="425" border="0" cellspacing="0" cellpadding="0" align="right" style="width:425px;" class="em_wrapper">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td valign="top" align="center" class="em_ptop">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="em_side_15" width="20" style="width:20px;"></td>
|
||||
<td valign="top" align="center">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="em_defaultlink em_font_20" align="left" valign="top" style="font-family:'TTNorms-Bold',Arial,sans-serif;color:#ffffff;font-size:24px;line-height:27px;font-weight:bold;"><a target="_blank" style="text-decoration: none; color:#ffffff;" href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiV6F8V0WgHH5qpw3jxS-2Fa6EGy0eTNS93bJBD8CrAgawzeLHduIsGGvaKZS6iNaYp-2Fk-3DP1G7_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRx2m-2Bx6Npoq7irZzHPfD9P-2FYYJptv8l-2BigMjxI45jkjAkgNtbVB16VoUxsk7SMPBw8a-2FelGMT82ceEz-2BvpqbQp0r1xbE2OA2TnPx3xvM1srvI631o-2F8btZwumk47Ma4HzQmpBExphRnMlSBa03L-2BMfiW-2FCEtNMxDTu80LwRKAX2WPgKnh9s65o0kO1VZbqgD-2BYFuy89wo4udZAodoWOJMa96gK612W1szjrdsLrKjxfDUc0Za1usKJAnXY5kDXHTw-2Fpxx9OvPCGHs2fMZcwUWGci4Hc8OSpZo0jFqKGUCE-2Fc-2BJFtx0BdkoZinPoMkq9Ta7dllJhS5ZGwpNXmwbdtFXIovnb2p2aN3iw5lQ3AtNyeEXxPIRpxDcc9oLJtssFxcwSrBufr8VSnX0iS-2FHSyQwvZtgbeR2JES2snlC0cmemuvsIRd0GVwUJMvxxYF6C6TW1lDGwv-2F1ZNyYp-2FNfzKa-2BxdXdFcj0XZ58smKSK29HEN9FrTupJ9IVUs9-2BodhHpswAMZwgdH1Zng8FsOutsKQh9TY-2FNuh-2BDY9H-2BggfcF9DNm8tS-2F0Or82eJo3NB1P4i45n8bhW7X6ws7ulGWM6bg3oQzJvOsdVijjWDx4dcCUeJGbF-2B0Ym3Jb-2BHgIZXtdvXT3n2YQ06merlibFALmewVfDnhuod0QtRNmiXw-2FFVUDN9Ng-3D-3D" universal="true">What to do when you arrive</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="em_h20" height="30" style="height:30px;line-height:0px;font-size:0px;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td valign="top" align="left">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" align="left">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td valign="top" align="left" style="font-size:0px; line-height:0px;">
|
||||
<table width="42" border="0" cellspacing="0" cellpadding="0" align="left" style="width:42px;" class="em_img"><!--[if mso]>
|
||||
<tr>
|
||||
<td height="4" style="height:4px; line-height:0px; font-size:0px;"><img alt="" src="https://assets.doordash.team/m/28838ff35afbe7b6/original/-04_April-Merchant_Activation_DelayedBanking_T2-spacer.gif" width="1" height="1" border="0" style="display:block;"/></td>
|
||||
</tr>
|
||||
<![endif]-->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td valign="top" align="left" class="em_img"><a target="_blank" style="text-decoration: none; color:#ffffff;" href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiV6F8V0WgHH5qpw3jxS-2Fa6EGy0eTNS93bJBD8CrAgawzeLHduIsGGvaKZS6iNaYp-2Fk-3D7lI6_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRx2m-2Bx6Npoq7irZzHPfD9P-2FYYJptv8l-2BigMjxI45jkjAkgNtbVB16VoUxsk7SMPBw8a-2FelGMT82ceEz-2BvpqbQp0r1xbE2OA2TnPx3xvM1srvI631o-2F8btZwumk47Ma4HzQmpBExphRnMlSBa03L-2BMfiW-2FCEtNMxDTu80LwRKAX2WPgKnh9s65o0kO1VZbqgD-2BYFuy89wo4udZAodoWOJMa96gK612W1szjrdsLrKjxfDUc0Za1usKJAnXY5kDXHTw-2Fpxx9OvPCGHs2fMZcwUWGci4Hc8OSpZo0jFqKGUCE-2Fc-2BJFtx0BdkoZinPoMkq9Ta7dllJhS5ZGwpNXmwbdtFXIovnb2p2aN3iw5lQ3AtNyeEXxPIRpxDcc9oLJtssFxcwSrBufr8VSnX0iS-2FHSyQwvZtgbeR2JES2snlC0cmemuvsIRd0GVwUJMvxxYF6C6TW1lDGwv-2F1ZNyYp-2FNfzKa-2BxdXdFcj0XZ58smKSK29HEN9FrTupJ9IVUs9-2BodhHpswAMZwgdH1Zng8FsOutsKQh9ZzL5VDXQ75SCksv9T-2BBr2RypmGvKuusULq0tKEaH4uE4vzXMns5EJCzWV6TYk-2FjXjSFiR6SpLmekxAokzPq1w4yx6ZFdXk4Dobwp054H1RQbz6TdnF7tabjp8IrSXgVr9WY7iNtPL92D9OBckgsOpA-3D-3D" universal="true"><img width="25" alt="1" border="0" style="width:25px; max-width:25px; display:block;" src="https://assets.doordash.team/m/7e33ff26ef721acd/original/-06_June-Pickup_Ready_Trigger_Transactional-img3.png"></a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
<td class="em_defaultlink em_f_16" align="left" valign="top" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#ffffff; font-size:18px; line-height:25px;"><a target="_blank" style="text-decoration: none; color:#ffffff;" href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiV6F8V0WgHH5qpw3jxS-2Fa6EGy0eTNS93bJBD8CrAgawzeLHduIsGGvaKZS6iNaYp-2Fk-3D7Yxi_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRx2m-2Bx6Npoq7irZzHPfD9P-2FYYJptv8l-2BigMjxI45jkjAkgNtbVB16VoUxsk7SMPBw8a-2FelGMT82ceEz-2BvpqbQp0r1xbE2OA2TnPx3xvM1srvI631o-2F8btZwumk47Ma4HzQmpBExphRnMlSBa03L-2BMfiW-2FCEtNMxDTu80LwRKAX2WPgKnh9s65o0kO1VZbqgD-2BYFuy89wo4udZAodoWOJMa96gK612W1szjrdsLrKjxfDUc0Za1usKJAnXY5kDXHTw-2Fpxx9OvPCGHs2fMZcwUWGci4Hc8OSpZo0jFqKGUCE-2Fc-2BJFtx0BdkoZinPoMkq9Ta7dllJhS5ZGwpNXmwbdtFXIovnb2p2aN3iw5lQ3AtNyeEXxPIRpxDcc9oLJtssFxcwSrBufr8VSnX0iS-2FHSyQwvZtgbeR2JES2snlC0cmemuvsIRd0GVwUJMvxxYF6C6TW1lDGwv-2F1ZNyYp-2FNfzKa-2BxdXdFcj0XZ58smKSK29HEN9FrTupJ9IVUs9-2BodhHpswAMZwgdH1Zng8FsOutsKQh9mJLiBoDz0shrpBZ9UyY35gO2Tvb1mkMlDjb1L4hP-2Fe8w1mhlj7Vq-2BgkxFJshnRkjNvwHS6KydeE1U6kxaxP-2FgBg0SHCyHHPyCHpGC1WUI1-2BvIBH45EESdEbgRRstSmiMJnlSZBODcuvw6-2B-2FMmUFcNg-3D-3D" universal="true">Skip the line! Go straight to the restaurant’s pick-up counter.</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="em_h20" height="18" style="height:18px;line-height:0px;font-size:0px;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td valign="top" align="left">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" align="left">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td valign="top" align="left" style="font-size:0px; line-height:0px;">
|
||||
<table width="42" border="0" cellspacing="0" cellpadding="0" align="left" style="width:42px;" class="em_img"><!--[if mso]>
|
||||
<tr>
|
||||
<td height="4" style="height:4px; line-height:0px; font-size:0px;"><img alt="" src="https://assets.doordash.team/m/28838ff35afbe7b6/original/-04_April-Merchant_Activation_DelayedBanking_T2-spacer.gif" width="1" height="1" border="0" style="display:block;"/></td>
|
||||
</tr>
|
||||
<![endif]-->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td valign="top" align="left" class="em_img"><a target="_blank" style="text-decoration: none; color:#ffffff;" href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiV6F8V0WgHH5qpw3jxS-2Fa6EGy0eTNS93bJBD8CrAgawzeLHduIsGGvaKZS6iNaYp-2Fk-3DQVMA_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRx2m-2Bx6Npoq7irZzHPfD9P-2FYYJptv8l-2BigMjxI45jkjAkgNtbVB16VoUxsk7SMPBw8a-2FelGMT82ceEz-2BvpqbQp0r1xbE2OA2TnPx3xvM1srvI631o-2F8btZwumk47Ma4HzQmpBExphRnMlSBa03L-2BMfiW-2FCEtNMxDTu80LwRKAX2WPgKnh9s65o0kO1VZbqgD-2BYFuy89wo4udZAodoWOJMa96gK612W1szjrdsLrKjxfDUc0Za1usKJAnXY5kDXHTw-2Fpxx9OvPCGHs2fMZcwUWGci4Hc8OSpZo0jFqKGUCE-2Fc-2BJFtx0BdkoZinPoMkq9Ta7dllJhS5ZGwpNXmwbdtFXIovnb2p2aN3iw5lQ3AtNyeEXxPIRpxDcc9oLJtssFxcwSrBufr8VSnX0iS-2FHSyQwvZtgbeR2JES2snlC0cmemuvsIRd0GVwUJMvxxYF6C6TW1lDGwv-2F1ZNyYp-2FNfzKa-2BxdXdFcj0XZ58smKSK29HEN9FrTupJ9IVUs9-2BodhHpswAMZwgdH1Zng8FsOutsKQh9TT29R06OuljbEo39-2FOTjWKbFYuihjiQbzN93K0WenMsdSv3CMjQ3LgXlB8Z23eVnUTgVUn-2FexjpNLPsk-2F4F8eQUAmmd-2FroRbrb-2B-2FHRZ4PM81L9BwR4dUhMMytvUHgXMeMaVQ-2BvlEXL-2B4-2BA-2F6NKiDHw-3D-3D" universal="true"><img width="25" alt="2" border="0" style="width:25px; max-width:25px; display:block;" src="https://assets.doordash.team/m/6e7053e55643679c/original/-06_June-Pickup_Ready_Trigger_Transactional-img4.png"></a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
<td class="em_defaultlink em_f_16" align="left" valign="top" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#ffffff; font-size:18px; line-height:25px;"><a target="_blank" style="text-decoration: none; color:#ffffff;" href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiV6F8V0WgHH5qpw3jxS-2Fa6EGy0eTNS93bJBD8CrAgawzeLHduIsGGvaKZS6iNaYp-2Fk-3DhA1g_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRx2m-2Bx6Npoq7irZzHPfD9P-2FYYJptv8l-2BigMjxI45jkjAkgNtbVB16VoUxsk7SMPBw8a-2FelGMT82ceEz-2BvpqbQp0r1xbE2OA2TnPx3xvM1srvI631o-2F8btZwumk47Ma4HzQmpBExphRnMlSBa03L-2BMfiW-2FCEtNMxDTu80LwRKAX2WPgKnh9s65o0kO1VZbqgD-2BYFuy89wo4udZAodoWOJMa96gK612W1szjrdsLrKjxfDUc0Za1usKJAnXY5kDXHTw-2Fpxx9OvPCGHs2fMZcwUWGci4Hc8OSpZo0jFqKGUCE-2Fc-2BJFtx0BdkoZinPoMkq9Ta7dllJhS5ZGwpNXmwbdtFXIovnb2p2aN3iw5lQ3AtNyeEXxPIRpxDcc9oLJtssFxcwSrBufr8VSnX0iS-2FHSyQwvZtgbeR2JES2snlC0cmemuvsIRd0GVwUJMvxxYF6C6TW1lDGwv-2F1ZNyYp-2FNfzKa-2BxdXdFcj0XZ58smKSK29HEN9FrTupJ9IVUs9-2BodhHpswAMZwgdH1Zng8FsOutsKQh9mzX65ESzRyRG6-2FahXssBqsMHZ1Pb-2B4JH0zke3ALCl5ba1asn0pPJcsHIF21G1gxaXIDB1BTSmsTeeAP2vv2l9m-2B-2Be0B820AC82EmsiVAu-2BAXT5BUOLtE5KSO6nb0Tu8lymdaakTiTguPn9SK02pMfw-3D-3D" universal="true">Show the staff your DoorDash app or receipt to claim your order.</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="em_h20" height="12" style="height:12px;line-height:0px;font-size:0px;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td valign="top" align="left">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" align="left">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td valign="top" align="left" style="font-size:0px; line-height:0px;">
|
||||
<table width="42" border="0" cellspacing="0" cellpadding="0" align="left" style="width:42px;" class="em_img"><!--[if mso]>
|
||||
<tr>
|
||||
<td height="4" style="height:4px; line-height:0px; font-size:0px;"><img alt="" src="https://assets.doordash.team/m/28838ff35afbe7b6/original/-04_April-Merchant_Activation_DelayedBanking_T2-spacer.gif" width="1" height="1" border="0" style="display:block;"/></td>
|
||||
</tr>
|
||||
<![endif]-->
|
||||
<tbody>
|
||||
<tr>
|
||||
<td valign="top" align="left" class="em_img"><a target="_blank" style="text-decoration: none; color:#ffffff;" href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiV6F8V0WgHH5qpw3jxS-2Fa6EGy0eTNS93bJBD8CrAgawzeLHduIsGGvaKZS6iNaYp-2Fk-3D0mF__gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRx2m-2Bx6Npoq7irZzHPfD9P-2FYYJptv8l-2BigMjxI45jkjAkgNtbVB16VoUxsk7SMPBw8a-2FelGMT82ceEz-2BvpqbQp0r1xbE2OA2TnPx3xvM1srvI631o-2F8btZwumk47Ma4HzQmpBExphRnMlSBa03L-2BMfiW-2FCEtNMxDTu80LwRKAX2WPgKnh9s65o0kO1VZbqgD-2BYFuy89wo4udZAodoWOJMa96gK612W1szjrdsLrKjxfDUc0Za1usKJAnXY5kDXHTw-2Fpxx9OvPCGHs2fMZcwUWGci4Hc8OSpZo0jFqKGUCE-2Fc-2BJFtx0BdkoZinPoMkq9Ta7dllJhS5ZGwpNXmwbdtFXIovnb2p2aN3iw5lQ3AtNyeEXxPIRpxDcc9oLJtssFxcwSrBufr8VSnX0iS-2FHSyQwvZtgbeR2JES2snlC0cmemuvsIRd0GVwUJMvxxYF6C6TW1lDGwv-2F1ZNyYp-2FNfzKa-2BxdXdFcj0XZ58smKSK29HEN9FrTupJ9IVUs9-2BodhHpswAMZwgdH1Zng8FsOutsKQh94MnLK9HomwO-2FOszt9E7QJHHky1AOyVqjlUKLIboeAL6IP3yZz1z7H74tRU5A-2FfyfbWbF1523ckL0gqpecu-2Bicoc7KBKQr7IOGs9J72-2BrnJ8ScOZ17p3zx6y-2BSKj0LdG0Y3zo3RT9jUWy9uC41IvVug-3D-3D" universal="true"><img width="25" alt="3" border="0" style="width:25px; max-width:25px; display:block;" src="https://assets.doordash.team/m/4ac84ede5f4634dd/original/-06_June-Pickup_Ready_Trigger_Transactional-img5.png"></a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
<td class="em_defaultlink em_f_16" align="left" valign="top" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#ffffff; font-size:18px; line-height:25px;"><a target="_blank" style="text-decoration: none; color:#ffffff;" href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiV6F8V0WgHH5qpw3jxS-2Fa6EGy0eTNS93bJBD8CrAgawzeLHduIsGGvaKZS6iNaYp-2Fk-3Dd9zs_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRx2m-2Bx6Npoq7irZzHPfD9P-2FYYJptv8l-2BigMjxI45jkjAkgNtbVB16VoUxsk7SMPBw8a-2FelGMT82ceEz-2BvpqbQp0r1xbE2OA2TnPx3xvM1srvI631o-2F8btZwumk47Ma4HzQmpBExphRnMlSBa03L-2BMfiW-2FCEtNMxDTu80LwRKAX2WPgKnh9s65o0kO1VZbqgD-2BYFuy89wo4udZAodoWOJMa96gK612W1szjrdsLrKjxfDUc0Za1usKJAnXY5kDXHTw-2Fpxx9OvPCGHs2fMZcwUWGci4Hc8OSpZo0jFqKGUCE-2Fc-2BJFtx0BdkoZinPoMkq9Ta7dllJhS5ZGwpNXmwbdtFXIovnb2p2aN3iw5lQ3AtNyeEXxPIRpxDcc9oLJtssFxcwSrBufr8VSnX0iS-2FHSyQwvZtgbeR2JES2snlC0cmemuvsIRd0GVwUJMvxxYF6C6TW1lDGwv-2F1ZNyYp-2FNfzKa-2BxdXdFcj0XZ58smKSK29HEN9FrTupJ9IVUs9-2BodhHpswAMZwgdH1Zng8FsOutsKQh9T2MYp7K6Sf6fSWsAKtcO-2FN-2BLKQVPx0NItCo6TS9Lbz9DVXwvRY01J3L-2BB0rcAe8LX34GOvEUDMoiaIp-2Fzc1jNSYPOGyfNBhgoTZHJZOge23hA-2FAM5zvUmMgQIklw4qg2o0sFVjjpxrorZZrSxn3vvA-3D-3D" universal="true">Before heading out, double check to confirm you’ve received all items.</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="em_h20" height="32" style="height:32px;line-height:0px;font-size:0px;"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" valign="top">
|
||||
<table width="244" border="0" cellspacing="0" cellpadding="0" align="left" style="max-width:244px;width:244px;background-color:#f7f7f7;border-radius:25px;" class="em_cta1">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="em_defaultlink em_cta1" align="center" valign="middle" height="52" style="font-family:'TTNorms-Bold',Arial,sans-serif;color:#00838a;font-size:21px;font-weight:bold;"><a target="_blank" style="text-decoration:none;display:block;color:#00838a;line-height:52px;" href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiV6F8V0WgHH5qpw3jxS-2Fa6EGy0eTNS93bJBD8CrAgawzeLHduIsGGvaKZS6iNaYp-2Fk-3D2r2h_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRx2m-2Bx6Npoq7irZzHPfD9P-2FYYJptv8l-2BigMjxI45jkjAkgNtbVB16VoUxsk7SMPBw8a-2FelGMT82ceEz-2BvpqbQp0r1xbE2OA2TnPx3xvM1srvI631o-2F8btZwumk47Ma4HzQmpBExphRnMlSBa03L-2BMfiW-2FCEtNMxDTu80LwRKAX2WPgKnh9s65o0kO1VZbqgD-2BYFuy89wo4udZAodoWOJMa96gK612W1szjrdsLrKjxfDUc0Za1usKJAnXY5kDXHTw-2Fpxx9OvPCGHs2fMZcwUWGci4Hc8OSpZo0jFqKGUCE-2Fc-2BJFtx0BdkoZinPoMkq9Ta7dllJhS5ZGwpNXmwbdtFXIovnb2p2aN3iw5lQ3AtNyeEXxPIRpxDcc9oLJtssFxcwSrBufr8VSnX0iS-2FHSyQwvZtgbeR2JES2snlC0cmemuvsIRd0GVwUJMvxxYF6C6TW1lDGwv-2F1ZNyYp-2FNfzKa-2BxdXdFcj0XZ58smKSK29HEN9FrTupJ9IVUs9-2BodhHpswAMZwgdH1Zng8FsOutsKQh9BlqHmjcqtruwkpuxTPfGAFBqNGbOff2wsddx2rLTQfUNcIpX6rp-2Fi3hN0y91lSiN77BoHqgHjhwWBGPj2dqZReijcs9pT2mVBhbLSE-2B6gD7cK3oVswuoKjmdtE9pHt-2FGYeampPt5ChmBk8wrTekfpg-3D-3D" universal="true">Track Your Order</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="em_h20" height="82" style="height:82px;line-height:0px;font-size:0px;"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
<td class="em_side_15" width="65" style="width:65px;"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" valign="top"><!--
|
||||
|
||||
|
||||
--> <!--[if (gte mso 9)|(IE)]>
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:620px;">
|
||||
<tr>
|
||||
<td align="center">
|
||||
<![endif]-->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" id="Footer" role="presentation" style="mso-table-lspace: 0; mso-table-rspace: 0; width: 100%; max-width: 700px; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" style="padding: 0 24px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0; mso-table-rspace: 0; width:100%;max-width:572px; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding: 48px 0 32px 0; border-bottom: 1px solid #E7E7E7;"><a href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiX9NQvXQ9aE-2FeLMhxL9C-2FAExxvL_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRx2m-2Bx6Npoq7irZzHPfD9P-2FYYJptv8l-2BigMjxI45jkjAkgNtbVB16VoUxsk7SMPBw8a-2FelGMT82ceEz-2BvpqbQp0r1xbE2OA2TnPx3xvM1srvI631o-2F8btZwumk47Ma4HzQmpBExphRnMlSBa03L-2BMfiW-2FCEtNMxDTu80LwRKAX2WPgKnh9s65o0kO1VZbqgD-2BYFuy89wo4udZAodoWOJMa96gK612W1szjrdsLrKjxfDUc0Za1usKJAnXY5kDXHTw-2Fpxx9OvPCGHs2fMZcwUWGci4Hc8OSpZo0jFqKGUCE-2Fc-2BJFtx0BdkoZinPoMkq9Ta7dllJhS5ZGwpNXmwbdtFXIovnb2p2aN3iw5lQ3AtNyeEXxPIRpxDcc9oLJtssFxcwSrBufr8VSnX0iS-2FHSyQwvZtgbeR2JES2snlC0cmemuvsIRd0GVwUJMvxxYF6C6TW1lDGwv-2F1ZNyYp-2FNfzKa-2BxdXdFcj0XZ58smKSK29HEN9FrTupJ9IVUs9-2BodhHpswAMZwgdH1Zng8FsOutsKQh9qQUzDXwAPVf3usjWBBGhHRLmujJqcr5Rpr0PP44oD28hQuWJPYJrorj7eh2LRnirXsIBnEOnfFYtqzJkMGMEGNmEOgHh3wyCdzJ2n2Puv04jUFFdcFfzwf2DhbocZswmyBpEfcpIH-2FlFcdQy-2FhT-2BOw-3D-3D" target="_blank" style="color: #191919;" universal="true"><img alt="DoorDash" border="0" src="https://assets.doordash.team/m/60228540eee4df0c/original/DoorDash-Logo-Full-Red100.png" style="-ms-interpolation-mode: bicubic; border: 0; display: block; height: auto; line-height: 100%; outline: none; color: #FF3008; font-family:'TTNorms',system-ui,sans-serif; font-weight: bold; font-size: 18px; text-decoration: none; width: 183px;" width="183"></a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="padding:16px 0 0 0;">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0; mso-table-rspace: 0; width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="padding:0 0 16px 0;">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%" style="mso-table-lspace: 0; mso-table-rspace: 0; width: 100%; max-width: 280px; border: 0; padding: 0; border-collapse: collapse; float: left;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" valign="top" style="padding: 16px 0; width: 50%;" width="50%">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%" style="mso-table-lspace: 0; mso-table-rspace: 0; width: 100%; max-width: 140px; border: 0; padding: 0; border-collapse: collapse;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" valign="top" style="height: 40px;" height="40"><a href="https://tracksg.doordash.com/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2BzcjCYVO02E6L8GZdu019pLCdzrwjBbgDTooc-2Bf2hmV45f2EGDuiNzmiTMuRZPKlHjhcIixsxt6mci9emxQ30-2BVxGv0RnlPka-2FFd4fNutCfWP59qLn5b2LnYMFz9QVRs-2FSPJVeax0MxwhWejYUsJXRb6tR22CAvldJ8oQ3uaWNGfc2kv_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRx2m-2Bx6Npoq7irZzHPfD9P-2FYYJptv8l-2BigMjxI45jkjAkgNtbVB16VoUxsk7SMPBw8a-2FelGMT82ceEz-2BvpqbQp0r1xbE2OA2TnPx3xvM1srvI631o-2F8btZwumk47Ma4HzQmpBExphRnMlSBa03L-2BMfiW-2FCEtNMxDTu80LwRKAX2WPgKnh9s65o0kO1VZbqgD-2BYFuy89wo4udZAodoWOJMa96gK612W1szjrdsLrKjxfDUc0Za1usKJAnXY5kDXHTw-2Fpxx9OvPCGHs2fMZcwUWGci4Hc8OSpZo0jFqKGUCE-2Fc-2BJFtx0BdkoZinPoMkq9Ta7dllJhS5ZGwpNXmwbdtFXIovnb2p2aN3iw5lQ3AtNyeEXxPIRpxDcc9oLJtssFxcwSrBufr8VSnX0iS-2FHSyQwvZtgbeR2JES2snlC0cmemuvsIRd0GVwUJMvxxYF6C6TW1lDGwv-2F1ZNyYp-2FNfzKa-2BxdXdFcj0XZ58smKSK29HEN9FrTupJ9IVUs9-2BodhHpswAMZwgdH1Zng8FsOutsKQh9R4wgtEbczXbCqxsOmwOuD8TRRqpB8mgi65GNM0ra-2BDIasAQnax2eVxI3R-2B-2FuRqaLHBmPzYkNY7qp1O6je5HGjjUVmC9twIfopxuQDZ6-2FKexPxWMVSM-2F8loKR-2Bqg4VGGsWxZnktEHCP49h2XEsQVyrw-3D-3D" target="_blank" style="color: #191919;"><img alt="Download the app" src="https://assets.doordash.team/m/500fea64c23bc120/original/icon-footer-mobileapp.png" width="55" style="-ms-interpolation-mode: bicubic; border: 0; display: block; height: auto; line-height: 100%; outline: none; text-decoration: none; width: 55px;"></a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" valign="top" style="color: #191919;"><!--
|
||||
|
||||
|
||||
|
||||
--> <p style="font-family:'TTNorms',system-ui,sans-serif;font-size:16px;line-height:22px;font-weight:500;margin:0;"><a href="https://tracksg.doordash.com/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2BzcjCYVO02E6L8GZdu019pLCdzrwjBbgDTooc-2Bf2hmV45f2EGDuiNzmiTMuRZPKlHjhcIixsxt6mci9emxQ30-2BVxGv0RnlPka-2FFd4fNutCfWP59qLn5b2LnYMFz9QVRs-2FSPJVeax0MxwhWejYUsJXRb6tR22CAvldJ8oQ3uaWNGfq8tx_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRx2m-2Bx6Npoq7irZzHPfD9P-2FYYJptv8l-2BigMjxI45jkjAkgNtbVB16VoUxsk7SMPBw8a-2FelGMT82ceEz-2BvpqbQp0r1xbE2OA2TnPx3xvM1srvI631o-2F8btZwumk47Ma4HzQmpBExphRnMlSBa03L-2BMfiW-2FCEtNMxDTu80LwRKAX2WPgKnh9s65o0kO1VZbqgD-2BYFuy89wo4udZAodoWOJMa96gK612W1szjrdsLrKjxfDUc0Za1usKJAnXY5kDXHTw-2Fpxx9OvPCGHs2fMZcwUWGci4Hc8OSpZo0jFqKGUCE-2Fc-2BJFtx0BdkoZinPoMkq9Ta7dllJhS5ZGwpNXmwbdtFXIovnb2p2aN3iw5lQ3AtNyeEXxPIRpxDcc9oLJtssFxcwSrBufr8VSnX0iS-2FHSyQwvZtgbeR2JES2snlC0cmemuvsIRd0GVwUJMvxxYF6C6TW1lDGwv-2F1ZNyYp-2FNfzKa-2BxdXdFcj0XZ58smKSK29HEN9FrTupJ9IVUs9-2BodhHpswAMZwgdH1Zng8FsOutsKQh9nCInppLEx0QjS9UIqmR9A6tK1rrX29VgRrPMFhXKvuK7oQPkDzgCCSJrLjL7o4NQWvDB1sSZqQ-2FSe1FEfAAfV3nF79K2drmxPzHnH-2BfZnDBqxXCk-2FJySmYh7qTnjrSmQQOjWq0ELD3XYA-2Ffl8KwTSw-3D-3D" target="_blank" style="color: #191919; text-decoration: none;">Download <br>the app</a></p></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
<td data-skip-shadow-validation="true" align="left" valign="top" style="padding: 16px 0; width: 50%;" width="50%"><!---->
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%" style="mso-table-lspace: 0; mso-table-rspace: 0; width: 100%; max-width: 140px; border: 0; padding: 0; border-collapse: collapse;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" valign="top" style="height: 40px;" height="40"><a href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiXcTh9RnomDHl8hjluP2Kf-2BD5RGeDVqI9bmTUfMaMWMZA-3D-3D9OQ4_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRx2m-2Bx6Npoq7irZzHPfD9P-2FYYJptv8l-2BigMjxI45jkjAkgNtbVB16VoUxsk7SMPBw8a-2FelGMT82ceEz-2BvpqbQp0r1xbE2OA2TnPx3xvM1srvI631o-2F8btZwumk47Ma4HzQmpBExphRnMlSBa03L-2BMfiW-2FCEtNMxDTu80LwRKAX2WPgKnh9s65o0kO1VZbqgD-2BYFuy89wo4udZAodoWOJMa96gK612W1szjrdsLrKjxfDUc0Za1usKJAnXY5kDXHTw-2Fpxx9OvPCGHs2fMZcwUWGci4Hc8OSpZo0jFqKGUCE-2Fc-2BJFtx0BdkoZinPoMkq9Ta7dllJhS5ZGwpNXmwbdtFXIovnb2p2aN3iw5lQ3AtNyeEXxPIRpxDcc9oLJtssFxcwSrBufr8VSnX0iS-2FHSyQwvZtgbeR2JES2snlC0cmemuvsIRd0GVwUJMvxxYF6C6TW1lDGwv-2F1ZNyYp-2FNfzKa-2BxdXdFcj0XZ58smKSK29HEN9FrTupJ9IVUs9-2BodhHpswAMZwgdH1Zng8FsOutsKQh9KiOVKcHwAKXzSDerRpI06sHCdXelptvmh5TX6eheG3hL-2BDJApljrdxxxsnF2yuJCcY59R18ThK4PytDo6b3-2FxMMs2AmhqUuk6lOquSQHZZ9xaFPJvK-2B2VyuvOy2K6Cq8pjVy05LWrU-2BDmDv0dWLLkQ-3D-3D" target="_blank" style="color: #191919;" universal="true"><img alt="Shop Gift Cards" src="https://assets.doordash.team/m/2f055075aa667d75/original/icon-dd-giftcard.png" width="43" style="-ms-interpolation-mode: bicubic; border: 0; display: block; height: auto; line-height: 100%; outline: none; text-decoration: none; width: 43px;"></a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" valign="top" style="color: #191919;"><!--
|
||||
|
||||
|
||||
|
||||
--> <p style="font-family:'TTNorms',system-ui,sans-serif;font-size:16px;line-height:22px;font-weight:500;margin:0;"><a href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiXcTh9RnomDHl8hjluP2Kf-2BD5RGeDVqI9bmTUfMaMWMZA-3D-3DY5So_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRx2m-2Bx6Npoq7irZzHPfD9P-2FYYJptv8l-2BigMjxI45jkjAkgNtbVB16VoUxsk7SMPBw8a-2FelGMT82ceEz-2BvpqbQp0r1xbE2OA2TnPx3xvM1srvI631o-2F8btZwumk47Ma4HzQmpBExphRnMlSBa03L-2BMfiW-2FCEtNMxDTu80LwRKAX2WPgKnh9s65o0kO1VZbqgD-2BYFuy89wo4udZAodoWOJMa96gK612W1szjrdsLrKjxfDUc0Za1usKJAnXY5kDXHTw-2Fpxx9OvPCGHs2fMZcwUWGci4Hc8OSpZo0jFqKGUCE-2Fc-2BJFtx0BdkoZinPoMkq9Ta7dllJhS5ZGwpNXmwbdtFXIovnb2p2aN3iw5lQ3AtNyeEXxPIRpxDcc9oLJtssFxcwSrBufr8VSnX0iS-2FHSyQwvZtgbeR2JES2snlC0cmemuvsIRd0GVwUJMvxxYF6C6TW1lDGwv-2F1ZNyYp-2FNfzKa-2BxdXdFcj0XZ58smKSK29HEN9FrTupJ9IVUs9-2BodhHpswAMZwgdH1Zng8FsOutsKQh9GV3TaqwrUSNs0VNNJqAxdBlgKOmAVgbASYjmJxklvK8fK57W2se0CiBUCWF5vIsxzo-2B0-2FWPkW4yMrhD1MZWbP2Ins6z-2FKAMOGJThI3gulXRLVoaHYzIz5CZWYLXMQuTGoICEKHKLxMF797snr-2FlEDg-3D-3D" target="_blank" style="color: #191919; text-decoration: none;" universal="true">Shop <br>Gift Cards</a></p></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table> <!----></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table> <!--[if gte mso 9]></td><td align="left"><![endif]-->
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%" style="mso-table-lspace: 0; mso-table-rspace: 0; width: 100%; max-width: 280px; border: 0; padding: 0; border-collapse: collapse; float: left;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" valign="top" style="padding: 16px 0; width: 50%;" width="50%">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%" style="mso-table-lspace: 0; mso-table-rspace: 0; width: 100%; max-width: 140px; border: 0; padding: 0; border-collapse: collapse;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" valign="top" style="height: 40px;" height="40"><a href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiVT5CX2SpN45IR7hQUH3fjvHqA4t-2F4SC8-2BnSVnPlHEF7Q-3D-3DBrSO_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRx2m-2Bx6Npoq7irZzHPfD9P-2FYYJptv8l-2BigMjxI45jkjAkgNtbVB16VoUxsk7SMPBw8a-2FelGMT82ceEz-2BvpqbQp0r1xbE2OA2TnPx3xvM1srvI631o-2F8btZwumk47Ma4HzQmpBExphRnMlSBa03L-2BMfiW-2FCEtNMxDTu80LwRKAX2WPgKnh9s65o0kO1VZbqgD-2BYFuy89wo4udZAodoWOJMa96gK612W1szjrdsLrKjxfDUc0Za1usKJAnXY5kDXHTw-2Fpxx9OvPCGHs2fMZcwUWGci4Hc8OSpZo0jFqKGUCE-2Fc-2BJFtx0BdkoZinPoMkq9Ta7dllJhS5ZGwpNXmwbdtFXIovnb2p2aN3iw5lQ3AtNyeEXxPIRpxDcc9oLJtssFxcwSrBufr8VSnX0iS-2FHSyQwvZtgbeR2JES2snlC0cmemuvsIRd0GVwUJMvxxYF6C6TW1lDGwv-2F1ZNyYp-2FNfzKa-2BxdXdFcj0XZ58smKSK29HEN9FrTupJ9IVUs9-2BodhHpswAMZwgdH1Zng8FsOutsKQh9z0KK6nQtSnhfb-2FUomS803meemWRHA4sQjByYkmPXACE0mylumcET2NUswGrd9benLF5r-2FPeUmoj3Lse1JLnNkeRzU1y-2B50hwAOrNPeBBwiD8LueTQRuRIJU9n9bIHMUL2vW-2F41pw0gRonBp1iZLPEQ-3D-3D" target="_blank" style="color: #191919;" universal="true"><img alt="Refer and Earn Credit" src="https://assets.doordash.team/m/73675450875ebb57/original/icon-footer-refer.png" width="30" style="-ms-interpolation-mode: bicubic; border: 0; display: block; height: auto; line-height: 100%; outline: none; text-decoration: none; width: 30px;"></a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" valign="top" style="color: #191919;"><!--
|
||||
|
||||
|
||||
|
||||
--> <p style="font-family:'TTNorms',system-ui,sans-serif;font-size:16px;line-height:22px;font-weight:500;margin:0;"><a href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiVT5CX2SpN45IR7hQUH3fjvHqA4t-2F4SC8-2BnSVnPlHEF7Q-3D-3DQYAA_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRx2m-2Bx6Npoq7irZzHPfD9P-2FYYJptv8l-2BigMjxI45jkjAkgNtbVB16VoUxsk7SMPBw8a-2FelGMT82ceEz-2BvpqbQp0r1xbE2OA2TnPx3xvM1srvI631o-2F8btZwumk47Ma4HzQmpBExphRnMlSBa03L-2BMfiW-2FCEtNMxDTu80LwRKAX2WPgKnh9s65o0kO1VZbqgD-2BYFuy89wo4udZAodoWOJMa96gK612W1szjrdsLrKjxfDUc0Za1usKJAnXY5kDXHTw-2Fpxx9OvPCGHs2fMZcwUWGci4Hc8OSpZo0jFqKGUCE-2Fc-2BJFtx0BdkoZinPoMkq9Ta7dllJhS5ZGwpNXmwbdtFXIovnb2p2aN3iw5lQ3AtNyeEXxPIRpxDcc9oLJtssFxcwSrBufr8VSnX0iS-2FHSyQwvZtgbeR2JES2snlC0cmemuvsIRd0GVwUJMvxxYF6C6TW1lDGwv-2F1ZNyYp-2FNfzKa-2BxdXdFcj0XZ58smKSK29HEN9FrTupJ9IVUs9-2BodhHpswAMZwgdH1Zng8FsOutsKQh9b8DqiT5Q5ujCYXW1jmCIIQr2jrhQiXxpUpJkZVq6-2FXE3xAAaIU9uNCa3Tst0CMi30Xmf6B6blHudQ41g-2Bdo-2F-2FEJaQOyAihNzLn1QjSXjCXm9wsvNG4WFRvHcpjaUB9ysdVzj58W2YmDqsrwUX-2BAamA-3D-3D" target="_blank" style="color: #191919; text-decoration: none;" universal="true">Refer and <br>Earn Credit</a></p></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
<td align="left" valign="top" style="padding: 16px 0; width: 50%;" width="50%">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%" style="mso-table-lspace: 0; mso-table-rspace: 0; width: 100%; max-width: 140px; border: 0; padding: 0; border-collapse: collapse;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" valign="top" style="height: 40px;" height="40"><a href="https://tracksg.doordash.com/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiUmEso6tSMMqb4wX3Q58ulh0RYYyPmHEEAnk-2BGx0Kt-2BeA-3D-3Dnczm_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRx2m-2Bx6Npoq7irZzHPfD9P-2FYYJptv8l-2BigMjxI45jkjAkgNtbVB16VoUxsk7SMPBw8a-2FelGMT82ceEz-2BvpqbQp0r1xbE2OA2TnPx3xvM1srvI631o-2F8btZwumk47Ma4HzQmpBExphRnMlSBa03L-2BMfiW-2FCEtNMxDTu80LwRKAX2WPgKnh9s65o0kO1VZbqgD-2BYFuy89wo4udZAodoWOJMa96gK612W1szjrdsLrKjxfDUc0Za1usKJAnXY5kDXHTw-2Fpxx9OvPCGHs2fMZcwUWGci4Hc8OSpZo0jFqKGUCE-2Fc-2BJFtx0BdkoZinPoMkq9Ta7dllJhS5ZGwpNXmwbdtFXIovnb2p2aN3iw5lQ3AtNyeEXxPIRpxDcc9oLJtssFxcwSrBufr8VSnX0iS-2FHSyQwvZtgbeR2JES2snlC0cmemuvsIRd0GVwUJMvxxYF6C6TW1lDGwv-2F1ZNyYp-2FNfzKa-2BxdXdFcj0XZ58smKSK29HEN9FrTupJ9IVUs9-2BodhHpswAMZwgdH1Zng8FsOutsKQh9uKQbfTVabK-2BCeR1hvsRChiANq1hO-2B0sgHCiQ2TT5j-2FThmhWRJWIFB-2FqVuSp-2FMqtezLuyqCEjJCQtd2a380n0ZMCRlgPpuxIxADrzkVC1pyCVnzEURgTGA0zWXTzRFtJkheSwwpf-2BdoaO5DIriyKX2g-3D-3D" target="_blank" style="color: #191919;"><img alt="Deliver with DoorDash" src="https://assets.doordash.team/m/d3fd5564cd1d0f3/original/icon-footer-deliverdd.png" width="27" style="-ms-interpolation-mode: bicubic; border: 0; display: block; height: auto; line-height: 100%; outline: none; text-decoration: none; width: 27px;"></a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" valign="top" style="color: #191919;"><!--
|
||||
|
||||
|
||||
|
||||
--> <p style="font-family:'TTNorms',system-ui,sans-serif;font-size:16px;line-height:22px;font-weight:500;margin:0;"><a href="https://tracksg.doordash.com/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiUmEso6tSMMqb4wX3Q58ulh0RYYyPmHEEAnk-2BGx0Kt-2BeA-3D-3D2kBd_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRx2m-2Bx6Npoq7irZzHPfD9P-2FYYJptv8l-2BigMjxI45jkjAkgNtbVB16VoUxsk7SMPBw8a-2FelGMT82ceEz-2BvpqbQp0r1xbE2OA2TnPx3xvM1srvI631o-2F8btZwumk47Ma4HzQmpBExphRnMlSBa03L-2BMfiW-2FCEtNMxDTu80LwRKAX2WPgKnh9s65o0kO1VZbqgD-2BYFuy89wo4udZAodoWOJMa96gK612W1szjrdsLrKjxfDUc0Za1usKJAnXY5kDXHTw-2Fpxx9OvPCGHs2fMZcwUWGci4Hc8OSpZo0jFqKGUCE-2Fc-2BJFtx0BdkoZinPoMkq9Ta7dllJhS5ZGwpNXmwbdtFXIovnb2p2aN3iw5lQ3AtNyeEXxPIRpxDcc9oLJtssFxcwSrBufr8VSnX0iS-2FHSyQwvZtgbeR2JES2snlC0cmemuvsIRd0GVwUJMvxxYF6C6TW1lDGwv-2F1ZNyYp-2FNfzKa-2BxdXdFcj0XZ58smKSK29HEN9FrTupJ9IVUs9-2BodhHpswAMZwgdH1Zng8FsOutsKQh9le2StZYD3ubMp1KkMbKkNUQoWO4K5WcuSFl-2FAzi28IA-2FJy-2BsG6J9DEMetOBvHewEWRbr4bovhkci5-2FtozPIGcm3J-2BqMc1HK7CIpSAJIDCObAMR4Q5g5WWg7fk-2BiHH7rjW4X3Te-2Ft2Y1UFFZYA6Eu9g-3D-3D" target="_blank" style="color: #191919; text-decoration: none;">Deliver with <br>DoorDash</a></p></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="color: #191919; padding: 16px 0;"><!--
|
||||
|
||||
|
||||
|
||||
--> <p style="font-family:'TTNorms',system-ui,sans-serif;font-size:14px;line-height:18px;font-weight:400;margin:0;color:#9A9A9A;">©2025 <a style="color: #9A9A9A; text-decoration: none;">DoorDash Inc. <br>303 2nd Street, South Tower, Suite 800 <br>San Francisco, CA 94107</a></p> <p style="font-family:'TTNorms',system-ui,sans-serif;font-size:14px;line-height:18px;font-weight:400;margin:0;color:#9A9A9A;"><a href="https://tracksg.doordash.com/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FDD8FcMNUAvAdfcDQmgdYAfdX67BmAmt0ufvV0DN-2BQcKLBxKTnrSNIWzubRh3E-2BfgEsS2yrPfbmkJcf80iMMHs-3DwEg6_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRx2m-2Bx6Npoq7irZzHPfD9P-2FYYJptv8l-2BigMjxI45jkjAkgNtbVB16VoUxsk7SMPBw8a-2FelGMT82ceEz-2BvpqbQp0r1xbE2OA2TnPx3xvM1srvI631o-2F8btZwumk47Ma4HzQmpBExphRnMlSBa03L-2BMfiW-2FCEtNMxDTu80LwRKAX2WPgKnh9s65o0kO1VZbqgD-2BYFuy89wo4udZAodoWOJMa96gK612W1szjrdsLrKjxfDUc0Za1usKJAnXY5kDXHTw-2Fpxx9OvPCGHs2fMZcwUWGci4Hc8OSpZo0jFqKGUCE-2Fc-2BJFtx0BdkoZinPoMkq9Ta7dllJhS5ZGwpNXmwbdtFXIovnb2p2aN3iw5lQ3AtNyeEXxPIRpxDcc9oLJtssFxcwSrBufr8VSnX0iS-2FHSyQwvZtgbeR2JES2snlC0cmemuvsIRd0GVwUJMvxxYF6C6TW1lDGwv-2F1ZNyYp-2FNfzKa-2BxdXdFcj0XZ58smKSK29HEN9FrTupJ9IVUs9-2BodhHpswAMZwgdH1Zng8FsOutsKQh9h3Rn-2F7yUSjc-2FwVCqVcNmU8CXMadRKnLkc4c7YA9pFdgbp7NrgKsaXN-2BHGEi4WqxDUgyheueRw5y1AuaHsjK8WHj9Bt5Bom8lX-2FhZeE9Fj3XHsEbY4o3UeLMcHYf0NXcPVaFrGwukrORpM40XL8d6BQ-3D-3D" target="_blank" style="color: #9A9A9A; text-decoration: none;">Privacy Policy</a></p></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="color: #191919; padding: 0 0 16px 0;">
|
||||
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="mso-table-lspace: 0; mso-table-rspace: 0; border: 0; padding: 0; border-collapse: collapse;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" valign="middle" style="color: #191919; padding: 0 8px 0 0;"><!----> <a href="https://tracksg.doordash.com/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FB3YMlmntmTqKXVA7huNFqC-2FY2hoh-2Fz5YPfVOcCa6quNkOp_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRx2m-2Bx6Npoq7irZzHPfD9P-2FYYJptv8l-2BigMjxI45jkjAkgNtbVB16VoUxsk7SMPBw8a-2FelGMT82ceEz-2BvpqbQp0r1xbE2OA2TnPx3xvM1srvI631o-2F8btZwumk47Ma4HzQmpBExphRnMlSBa03L-2BMfiW-2FCEtNMxDTu80LwRKAX2WPgKnh9s65o0kO1VZbqgD-2BYFuy89wo4udZAodoWOJMa96gK612W1szjrdsLrKjxfDUc0Za1usKJAnXY5kDXHTw-2Fpxx9OvPCGHs2fMZcwUWGci4Hc8OSpZo0jFqKGUCE-2Fc-2BJFtx0BdkoZinPoMkq9Ta7dllJhS5ZGwpNXmwbdtFXIovnb2p2aN3iw5lQ3AtNyeEXxPIRpxDcc9oLJtssFxcwSrBufr8VSnX0iS-2FHSyQwvZtgbeR2JES2snlC0cmemuvsIRd0GVwUJMvxxYF6C6TW1lDGwv-2F1ZNyYp-2FNfzKa-2BxdXdFcj0XZ58smKSK29HEN9FrTupJ9IVUs9-2BodhHpswAMZwgdH1Zng8FsOutsKQh9OdczW9buEiXcf20eZjTw6j3J4DfvzhvSMCiSvWZwiDKXclLhN-2FIwB-2B7PGLYtvQ60V3Jr5QHNH8gJFbU-2Bz1k7P2vaHXNv3aCa97vS-2FNB7-2FPdCUjGx9V-2F2kW5cbmeoapqiZzw-2BuS7DT16VHd3sto3Eaw-3D-3D" target="_blank" style="color: #191919;"><img alt="fb" src="https://assets.doordash.team/m/5188bb1f5f04acaa/original/logo-facebook.png" width="24" style="-ms-interpolation-mode: bicubic; border: 0; display: block; height: auto; line-height: 100%; outline: none; text-decoration: none; width: 24px;"></a></td>
|
||||
<td align="left" valign="middle" style="color: #191919; padding: 0 8px 0 0;"><a href="https://tracksg.doordash.com/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B4AUo6me50US-2FvcfePJUZTRhfLFumwrfkjz2KbYGBSYXzRff_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRx2m-2Bx6Npoq7irZzHPfD9P-2FYYJptv8l-2BigMjxI45jkjAkgNtbVB16VoUxsk7SMPBw8a-2FelGMT82ceEz-2BvpqbQp0r1xbE2OA2TnPx3xvM1srvI631o-2F8btZwumk47Ma4HzQmpBExphRnMlSBa03L-2BMfiW-2FCEtNMxDTu80LwRKAX2WPgKnh9s65o0kO1VZbqgD-2BYFuy89wo4udZAodoWOJMa96gK612W1szjrdsLrKjxfDUc0Za1usKJAnXY5kDXHTw-2Fpxx9OvPCGHs2fMZcwUWGci4Hc8OSpZo0jFqKGUCE-2Fc-2BJFtx0BdkoZinPoMkq9Ta7dllJhS5ZGwpNXmwbdtFXIovnb2p2aN3iw5lQ3AtNyeEXxPIRpxDcc9oLJtssFxcwSrBufr8VSnX0iS-2FHSyQwvZtgbeR2JES2snlC0cmemuvsIRd0GVwUJMvxxYF6C6TW1lDGwv-2F1ZNyYp-2FNfzKa-2BxdXdFcj0XZ58smKSK29HEN9FrTupJ9IVUs9-2BodhHpswAMZwgdH1Zng8FsOutsKQh9sIbSZ5WBiRa4hCI-2B34oVAaYz2w9t6Dpx6XDZHG1iczvd6GtkYR4x4eU86rC0FX1Ud2VYsHAqw4Q5JXWIiZf3H8oX1DPHS-2BCiLKN2gwMqd1gGkcHdN-2FX94xN0E7YU7pI7vb82d-2BHY0LZGRmP3wSrPQQ-3D-3D" target="_blank" style="color: #191919;"><img alt="tw" src="https://assets.doordash.team/m/250ca598c483f1b8/original/logo-x.png" width="24" style="-ms-interpolation-mode: bicubic; border: 0; display: block; height: auto; line-height: 100%; outline: none; text-decoration: none; width: 24px;"></a></td>
|
||||
<td align="left" valign="middle" style="color: #191919; padding: 0 12px 0 0;"><!----> <a href="https://tracksg.doordash.com/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B2DmXtXKS05Hn1cgbRhFddpiLHO0EWksR6pd5wFKsdGvQCGd_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRx2m-2Bx6Npoq7irZzHPfD9P-2FYYJptv8l-2BigMjxI45jkjAkgNtbVB16VoUxsk7SMPBw8a-2FelGMT82ceEz-2BvpqbQp0r1xbE2OA2TnPx3xvM1srvI631o-2F8btZwumk47Ma4HzQmpBExphRnMlSBa03L-2BMfiW-2FCEtNMxDTu80LwRKAX2WPgKnh9s65o0kO1VZbqgD-2BYFuy89wo4udZAodoWOJMa96gK612W1szjrdsLrKjxfDUc0Za1usKJAnXY5kDXHTw-2Fpxx9OvPCGHs2fMZcwUWGci4Hc8OSpZo0jFqKGUCE-2Fc-2BJFtx0BdkoZinPoMkq9Ta7dllJhS5ZGwpNXmwbdtFXIovnb2p2aN3iw5lQ3AtNyeEXxPIRpxDcc9oLJtssFxcwSrBufr8VSnX0iS-2FHSyQwvZtgbeR2JES2snlC0cmemuvsIRd0GVwUJMvxxYF6C6TW1lDGwv-2F1ZNyYp-2FNfzKa-2BxdXdFcj0XZ58smKSK29HEN9FrTupJ9IVUs9-2BodhHpswAMZwgdH1Zng8FsOutsKQh9ZzV7C3gLn62BVgulaM3ruZ6flQuhgjIPc1NKsAsFHFyrWmfULs5CePKbilEE0Qe73jOUvfNQUBPBk9vGBwIkf7xoJsNuuTs3yaXkRyJUDRxEVe6XFZ17E-2F0vekhq57UAEdx1IoMULbfAwi5OIUcQJQ-3D-3D" target="_blank" style="color: #191919;"><img alt="ig" src="https://assets.doordash.team/m/30ad9ef70caf3a55/original/logo-instagram.png" width="24" style="-ms-interpolation-mode: bicubic; border: 0; display: block; height: auto; line-height: 100%; outline: none; text-decoration: none; width: 24px;"></a></td>
|
||||
<td align="left" valign="middle" style="color: #191919; padding: 0 8px 0 0;"><a href="https://tracksg.doordash.com/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B9Cc31vkufkxkjeQDdWBHi-2BEykKWTniWhBO2q7EZvDyOuAqc_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRx2m-2Bx6Npoq7irZzHPfD9P-2FYYJptv8l-2BigMjxI45jkjAkgNtbVB16VoUxsk7SMPBw8a-2FelGMT82ceEz-2BvpqbQp0r1xbE2OA2TnPx3xvM1srvI631o-2F8btZwumk47Ma4HzQmpBExphRnMlSBa03L-2BMfiW-2FCEtNMxDTu80LwRKAX2WPgKnh9s65o0kO1VZbqgD-2BYFuy89wo4udZAodoWOJMa96gK612W1szjrdsLrKjxfDUc0Za1usKJAnXY5kDXHTw-2Fpxx9OvPCGHs2fMZcwUWGci4Hc8OSpZo0jFqKGUCE-2Fc-2BJFtx0BdkoZinPoMkq9Ta7dllJhS5ZGwpNXmwbdtFXIovnb2p2aN3iw5lQ3AtNyeEXxPIRpxDcc9oLJtssFxcwSrBufr8VSnX0iS-2FHSyQwvZtgbeR2JES2snlC0cmemuvsIRd0GVwUJMvxxYF6C6TW1lDGwv-2F1ZNyYp-2FNfzKa-2BxdXdFcj0XZ58smKSK29HEN9FrTupJ9IVUs9-2BodhHpswAMZwgdH1Zng8FsOutsKQh9wuaGJcQf0orrDSRaKoipbvQDaYa-2F47nRDf-2BwZRi-2Bn5jM1kHB5Q6cFntzO0XSQZhBjEzoXpkvqIfaaBv1-2FpxzBWFnB-2F-2BDuOm3iKIn45MZr57P5q0PDBMjssxnMRoNDScjLmJIuivQwjsX9jOuk1SMcg-3D-3D" target="_blank" style="color: #191919;"><img alt="blog" src="https://assets.doordash.team/m/4d258e2251ad126b/original/icon_medium.png" width="18" style="-ms-interpolation-mode: bicubic; border: 0; display: block; height: auto; line-height: 100%; outline: none; text-decoration: none; width: 18px;"></a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="color: #191919; padding: 0 0 24px 0;"><!--
|
||||
|
||||
|
||||
|
||||
--> <p style="font-family:'TTNorms',system-ui,sans-serif;font-size:14px;line-height:18px;font-weight:500;margin:0;color:#9A9A9A;"><a href="https://tracksg.doordash.com/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FDD8FcMNUAvAdfcDQmgdYABnncKv1afIihr5Gt6bkMjwdgg_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRx2m-2Bx6Npoq7irZzHPfD9P-2FYYJptv8l-2BigMjxI45jkjAkgNtbVB16VoUxsk7SMPBw8a-2FelGMT82ceEz-2BvpqbQp0r1xbE2OA2TnPx3xvM1srvI631o-2F8btZwumk47Ma4HzQmpBExphRnMlSBa03L-2BMfiW-2FCEtNMxDTu80LwRKAX2WPgKnh9s65o0kO1VZbqgD-2BYFuy89wo4udZAodoWOJMa96gK612W1szjrdsLrKjxfDUc0Za1usKJAnXY5kDXHTw-2Fpxx9OvPCGHs2fMZcwUWGci4Hc8OSpZo0jFqKGUCE-2Fc-2BJFtx0BdkoZinPoMkq9Ta7dllJhS5ZGwpNXmwbdtFXIovnb2p2aN3iw5lQ3AtNyeEXxPIRpxDcc9oLJtssFxcwSrBufr8VSnX0iS-2FHSyQwvZtgbeR2JES2snlC0cmemuvsIRd0GVwUJMvxxYF6C6TW1lDGwv-2F1ZNyYp-2FNfzKa-2BxdXdFcj0XZ58smKSK29HEN9FrTupJ9IVUs9-2BodhHpswAMZwgdH1Zng8FsOutsKQh959Xd7Y6Gud2X1ehoSf-2B6JFftx-2BaY5Mq4YxuhkM55n-2BkcU47okbJWk6U0blQD0LfYHq227JOJbCKmDYH7YjpQEWkr2EOi8Ib9oLB5Md7Giae-2B-2FhI2ZoWq9YJipl7-2FZ37LRnS8I5tmKbedGAiHdI2hdg-3D-3D" target="_blank" style="color: #9A9A9A; text-decoration: none;">Help Center</a></p></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table> <!--[if (gte mso 9)|(IE)]>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<![endif]--></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td valign="top" align="center">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" align="center" bgcolor="#f4f4f4">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="em_side30" width="55" style="width: 55px;"></td>
|
||||
<td valign="top" align="center" style="padding-top:30px;">
|
||||
<table width="100%" border="0" cellspacing="0" cellpadding="0" align="left">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="font-family: 'TTNorms-Regular', Helvetica, Arial, sans-serif; font-size: 14px; line-height: 18px; color: #ababab; font-weight:400;font-style:italic;" align="left" valign="top">Prop 65 Restaurant <a class="em_color_u" style="text-decoration:underline; color:#939393;" target="_blank" href="https://tracksg.doordash.com/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B15-2BhWQxoYkZ56rcWBjgowVNhC-2BKbbpJ-2FyS4l8ZzrqkvpAbpmh6xMGqJ3PxZACrgiw-3D-3DW9aM_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRx2m-2Bx6Npoq7irZzHPfD9P-2FYYJptv8l-2BigMjxI45jkjAkgNtbVB16VoUxsk7SMPBw8a-2FelGMT82ceEz-2BvpqbQp0r1xbE2OA2TnPx3xvM1srvI631o-2F8btZwumk47Ma4HzQmpBExphRnMlSBa03L-2BMfiW-2FCEtNMxDTu80LwRKAX2WPgKnh9s65o0kO1VZbqgD-2BYFuy89wo4udZAodoWOJMa96gK612W1szjrdsLrKjxfDUc0Za1usKJAnXY5kDXHTw-2Fpxx9OvPCGHs2fMZcwUWGci4Hc8OSpZo0jFqKGUCE-2Fc-2BJFtx0BdkoZinPoMkq9Ta7dllJhS5ZGwpNXmwbdtFXIovnb2p2aN3iw5lQ3AtNyeEXxPIRpxDcc9oLJtssFxcwSrBufr8VSnX0iS-2FHSyQwvZtgbeR2JES2snlC0cmemuvsIRd0GVwUJMvxxYF6C6TW1lDGwv-2F1ZNyYp-2FNfzKa-2BxdXdFcj0XZ58smKSK29HEN9FrTupJ9IVUs9-2BodhHpswAMZwgdH1Zng8FsOutsKQh9-2BX92qlOdUUG5zpRoqMtp61TUZGhnlfUjf4Og4-2FnA7XVQxt39u1XghxMdeU3hIXIwRwwyZu19taIo0dOY04BW2IM7y6Mx8nbbC7OYNoIDu4xkgO-2BFZwj-2BXHg9RkRpYVV4MlfX8V6IsDT0GVEpEfgfCw-3D-3D">WARNING</a><br> <br> Prop 65 Alcohol <a class="em_color_u" style="text-decoration:underline; color:#939393;" target="_blank" href="https://tracksg.doordash.com/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B15-2BhWQxoYkZ56rcWBjgowVkh-2FKcx9IdsYOYOX3bSSQXX2VU9kylc3JZnginfYpP8Fv58V2Gad0szmjBZwRM34k-3Dgpsi_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRx2m-2Bx6Npoq7irZzHPfD9P-2FYYJptv8l-2BigMjxI45jkjAkgNtbVB16VoUxsk7SMPBw8a-2FelGMT82ceEz-2BvpqbQp0r1xbE2OA2TnPx3xvM1srvI631o-2F8btZwumk47Ma4HzQmpBExphRnMlSBa03L-2BMfiW-2FCEtNMxDTu80LwRKAX2WPgKnh9s65o0kO1VZbqgD-2BYFuy89wo4udZAodoWOJMa96gK612W1szjrdsLrKjxfDUc0Za1usKJAnXY5kDXHTw-2Fpxx9OvPCGHs2fMZcwUWGci4Hc8OSpZo0jFqKGUCE-2Fc-2BJFtx0BdkoZinPoMkq9Ta7dllJhS5ZGwpNXmwbdtFXIovnb2p2aN3iw5lQ3AtNyeEXxPIRpxDcc9oLJtssFxcwSrBufr8VSnX0iS-2FHSyQwvZtgbeR2JES2snlC0cmemuvsIRd0GVwUJMvxxYF6C6TW1lDGwv-2F1ZNyYp-2FNfzKa-2BxdXdFcj0XZ58smKSK29HEN9FrTupJ9IVUs9-2BodhHpswAMZwgdH1Zng8FsOutsKQh9mvITrwEQj9x3VDARPEbXxE-2F-2Bku-2FgBEukw4-2FlFJug1YLEHPvpxieYuJgxS0Faiq-2B6DpzyS3s9P-2BhpgswwZogoBC6HPkvgNTQV-2FJnfghwCIrprRnT0sMQXmUS1SpDaQPeZc0Ukc3RwTTcq7Kq-2BVVcrng-3D-3D">WARNING</a><br> Drinking distilled spirits, beer, coolers, wine, and other alcoholic beverages may increase cancer risk, and, during pregnancy, can cause birth defects. For more information go to <a href="https://tracksg.doordash.com/ls/click?upn=u001.w8bmSeHXyA0fd7rAPHCC-2BveYEEDFpPbKqpH0908D-2FbFTn662MrnwWC94hremUVyWnadZ_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRx2m-2Bx6Npoq7irZzHPfD9P-2FYYJptv8l-2BigMjxI45jkjAkgNtbVB16VoUxsk7SMPBw8a-2FelGMT82ceEz-2BvpqbQp0r1xbE2OA2TnPx3xvM1srvI631o-2F8btZwumk47Ma4HzQmpBExphRnMlSBa03L-2BMfiW-2FCEtNMxDTu80LwRKAX2WPgKnh9s65o0kO1VZbqgD-2BYFuy89wo4udZAodoWOJMa96gK612W1szjrdsLrKjxfDUc0Za1usKJAnXY5kDXHTw-2Fpxx9OvPCGHs2fMZcwUWGci4Hc8OSpZo0jFqKGUCE-2Fc-2BJFtx0BdkoZinPoMkq9Ta7dllJhS5ZGwpNXmwbdtFXIovnb2p2aN3iw5lQ3AtNyeEXxPIRpxDcc9oLJtssFxcwSrBufr8VSnX0iS-2FHSyQwvZtgbeR2JES2snlC0cmemuvsIRd0GVwUJMvxxYF6C6TW1lDGwv-2F1ZNyYp-2FNfzKa-2BxdXdFcj0XZ58smKSK29HEN9FrTupJ9IVUs9-2BodhHpswAMZwgdH1Zng8FsOutsKQh9R4aHBRRb0w2n2FCCFOYlx5z5AkE1SBYQ4UrFK5MnIcZwchKoiq4OgAxK2JI9YXzMnQgtsALyjfQmjeNbjSPupiwUSEcdZiaXYMI5yiGsQTPa0RJfavowHuHTjgibU3ACzfx4hlVCHQwD8lzLzrLM0A-3D-3D">www.P65Warnings.ca.gov/alcohol</a>.<br> <br> Prop 65 BPA <a class="em_color_u" style="text-decoration:underline; color:#939393;" target="_blank" href="https://tracksg.doordash.com/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B15-2BhWQxoYkZ56rcWBjgowW6WbwCN57LjWD5UaEYOVHrJPpLgJl33D1dHGtpAMDr2w-3D-3DHmwR_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRx2m-2Bx6Npoq7irZzHPfD9P-2FYYJptv8l-2BigMjxI45jkjAkgNtbVB16VoUxsk7SMPBw8a-2FelGMT82ceEz-2BvpqbQp0r1xbE2OA2TnPx3xvM1srvI631o-2F8btZwumk47Ma4HzQmpBExphRnMlSBa03L-2BMfiW-2FCEtNMxDTu80LwRKAX2WPgKnh9s65o0kO1VZbqgD-2BYFuy89wo4udZAodoWOJMa96gK612W1szjrdsLrKjxfDUc0Za1usKJAnXY5kDXHTw-2Fpxx9OvPCGHs2fMZcwUWGci4Hc8OSpZo0jFqKGUCE-2Fc-2BJFtx0BdkoZinPoMkq9Ta7dllJhS5ZGwpNXmwbdtFXIovnb2p2aN3iw5lQ3AtNyeEXxPIRpxDcc9oLJtssFxcwSrBufr8VSnX0iS-2FHSyQwvZtgbeR2JES2snlC0cmemuvsIRd0GVwUJMvxxYF6C6TW1lDGwv-2F1ZNyYp-2FNfzKa-2BxdXdFcj0XZ58smKSK29HEN9FrTupJ9IVUs9-2BodhHpswAMZwgdH1Zng8FsOutsKQh9Iemc3b-2FjbWd4-2F8WUYcyMZ0svv7YTBgClQkvSBCqOUtSXfuZ5-2Bg6lHR4W-2BXc7uxb7qMuyzGRpiJLm-2BACbcHm-2Bku-2FTC7lNYoLHy-2FYQRbNcm-2FOk3qSlHHVnzxm6RihOUBKkOHuSpC42MXuR2bWQI4odUw-3D-3D">WARNING</a></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="em_h20" height="30" style="height:30px; line-height: 0px; font-size: 0px;"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
<td class="em_side15" width="180" style="width: 180px;"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="em_hide" style="line-height:1px;min-width:700px;background-color:#f4f4f4;"><img alt="" height="1" width="700" style="max-height:1px; min-height:1px; display:block; width:700px; min-width:700px;" border="0" src="https://assets.doordash.team/m/1b5c04bd5b887a06/original/-05_May-90D_Resurrection_Campaign_Refresh_T2-spacer.gif"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<img src="https://tracksg.doordash.com/wf/open?upn=u001.EBL2ug8kstebd25Xirrl3olMckTI261ldPjJ39bNHcC7U6EKGAOA4dSVB97lcv3b8qsQRq6LwkHED6F3X4gqaODB2b1wZFk4or4JbrThItoY-2FjAhYjqDwj5repwbzWNEue2JKteT4DEg7QY2vIxKrLZJCbuXHYZIzbee8h9tIN-2FDXVgfLQD4cGXRG4pvU9txqqdu78N63JaPaY-2BDo4qOWLe6-2BqnVfGz4g-2Ba-2BajGOeDJRt49161pCqdpSqkANOD7uPpa-2FIsuThD8ZfI9T6VtFxjuGxrsPYp-2F2dfZ3k2vX3ldEfD-2BAXCWlv9p884-2F2SRyJZCjbRCptm2UzImQFAxyJ3JjD349MBw90of5ckPJkHLa2s8Pad24yQCbMOuM35kYIvL2F-2FBQ0ptFHzENChwdaRJrb6cBf-2Fn7zxgLCTbbfJjc5PYH6f03JNOD7t3NknVYEdYwx3buSysW6f2CnLZESh4zqNMaIRkPKYyOSjrjWYLnf73C4QA8-2FPwb3GS8GN0yT9WHX-2B-2Fte61MfQMplNw8cWBdOS46KJWZvpse8d0Z4dx5DCBHkyZIknmmkb-2BsCT0b3oWk-2B8GCKHQ7XZKQurRgp5DLtNovpC0Zt-2FDfIJYaJ00-2FgnfaSpwfDnDOJqwv49a5Dcb5IOuGknhwTag1y2B-2BMO6SRdaOa8Ws8-2Fvq9FRdtwzwDiTXIPmoFZFkHF2ExDW7FJI4YvTjC1RaOciyUTbaapJuI9uLzrvXEHAUix3hAWSTl-2B6AVVANasaQrx-2FLjSL0AwM8ccKHDEgdM5eZ8FhiVwA-3D-3D" alt="" width="1" height="1" border="0" style="height:1px !important;width:1px !important;border-width:0 !important;margin-top:0 !important;margin-bottom:0 !important;margin-right:0 !important;margin-left:0 !important;padding-top:0 !important;padding-bottom:0 !important;padding-right:0 !important;padding-left:0 !important;"/></body>
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,531 @@
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from "vitest";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { createPool, mockDbWithPool, resetDB } from "./helpers";
|
||||
import {
|
||||
comparisonBasis,
|
||||
baselineMonths,
|
||||
baselineAverage,
|
||||
spendThrough,
|
||||
prevMonth,
|
||||
} from "../../lib/partial-month";
|
||||
|
||||
/**
|
||||
* The three Priority 0 behaviours that shipped without a test (#155).
|
||||
*
|
||||
* `docs/ui-information-architecture-review.md` claimed Priority 0 was guarded;
|
||||
* `analytics-sql.test.ts` only ever covered the three SQL *fragments*
|
||||
* (EXCLUDE_RECONCILED_SOURCE, NATIVE_CURRENCY, INVESTMENT_SIGNED). Items 2, 3
|
||||
* and 6 of that list had nothing:
|
||||
*
|
||||
* 2. the spend-pace series must use the same spend semantics as the headline
|
||||
* printed directly above it;
|
||||
* 3. fees and interest must be bounded by an explicit, reported period;
|
||||
* 6. a month in progress must never be compared against complete ones.
|
||||
*
|
||||
* All three fail *silently* when they regress — no error, no empty screen, just
|
||||
* a number that quietly disagrees with the one beside it. That is precisely the
|
||||
* class the review flagged and the reason these are worth the fixture cost.
|
||||
*
|
||||
* The clock is frozen for every test in this file. Two of the three behaviours
|
||||
* are defined relative to "now" (which month is in progress, how far back the
|
||||
* default fees window reaches), so against the real clock the fixtures would
|
||||
* drift out of the routes' windows and the assertions would go vacuous — which
|
||||
* is the failure mode this file exists to prevent, one level up. Only `Date` is
|
||||
* faked: `pg` uses real timers for its connection handling.
|
||||
*/
|
||||
|
||||
const pool = createPool();
|
||||
mockDbWithPool(pool);
|
||||
|
||||
/** Frozen wall clock: the 15th, so the current month is genuinely half-done. */
|
||||
const NOW = new Date(2026, 7, 15, 10, 0, 0); // 2026-08-15, local time
|
||||
const CURRENT_MONTH = "2026-08";
|
||||
const ELAPSED = 15;
|
||||
|
||||
let monthlyGET: (req: NextRequest) => Promise<Response>;
|
||||
let dailyGET: (req: NextRequest) => Promise<Response>;
|
||||
let feesGET: (req: NextRequest) => Promise<Response>;
|
||||
let ownerId: number;
|
||||
|
||||
// One cast at the boundary: these are the two properties the analytics routes
|
||||
// read (`url` for the query string, `headers` for the auth header, which is
|
||||
// mocked out anyway), not a whole NextRequest.
|
||||
const req = (path: string) =>
|
||||
({ url: `http://localhost${path}`, headers: { get: () => null } }) as unknown as NextRequest;
|
||||
|
||||
const json = async (res: Response) => (await res.json()) as Record<string, unknown>;
|
||||
|
||||
beforeAll(async () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
vi.setSystemTime(NOW);
|
||||
|
||||
vi.doMock("@/lib/auth", () => ({
|
||||
getCurrentUser: async () => ({ id: ownerId, name: "Owner", email: "o@x" }),
|
||||
}));
|
||||
({ GET: monthlyGET } = await import("../../app/api/analytics/monthly/route"));
|
||||
({ GET: dailyGET } = await import("../../app/api/analytics/daily/route"));
|
||||
({ GET: feesGET } = await import("../../app/api/analytics/fees/route"));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDB(pool);
|
||||
const a = await pool.query(`INSERT INTO participants (name) VALUES ('Owner') RETURNING id`);
|
||||
const b = await pool.query(`INSERT INTO participants (name) VALUES ('Other') RETURNING id`);
|
||||
ownerId = a.rows[0].id;
|
||||
otherId = b.rows[0].id;
|
||||
});
|
||||
|
||||
let otherId: number;
|
||||
|
||||
/** A manual transaction owned by `owner`. Returns its id. */
|
||||
async function txn(over: {
|
||||
date: string;
|
||||
amount: number;
|
||||
type?: string;
|
||||
category?: string;
|
||||
owner?: number;
|
||||
description?: string;
|
||||
interest_amount?: number;
|
||||
reconciled_with_id?: number;
|
||||
}): Promise<number> {
|
||||
const r = await pool.query(
|
||||
`INSERT INTO transactions
|
||||
(owner_id, statement_id, transaction_date, description, amount,
|
||||
transaction_type, category, interest_amount, reconciled_with_id, row_index)
|
||||
VALUES ($1, NULL, $2, $3, $4, $5, $6, $7, $8, 0) RETURNING id`,
|
||||
[
|
||||
over.owner ?? ownerId,
|
||||
over.date,
|
||||
over.description ?? `fixture ${over.date} ${over.amount}`,
|
||||
over.amount,
|
||||
over.type ?? "debit",
|
||||
over.category ?? "groceries",
|
||||
over.interest_amount ?? null,
|
||||
over.reconciled_with_id ?? null,
|
||||
]
|
||||
);
|
||||
return r.rows[0].id as number;
|
||||
}
|
||||
|
||||
async function split(txnId: number, participantId: number, pct: number) {
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
|
||||
VALUES ($1, $2, $3)`,
|
||||
[txnId, participantId, pct]
|
||||
);
|
||||
}
|
||||
|
||||
async function tag(txnId: number, name: string) {
|
||||
const t = await pool.query(
|
||||
`INSERT INTO tags (name) VALUES ($1) ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name RETURNING id`,
|
||||
[name]
|
||||
);
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_tags (transaction_id, tag_id) VALUES ($1, $2)`,
|
||||
[txnId, t.rows[0].id]
|
||||
);
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Item 2 — the pace chart and the headline must agree
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The pace chart used to sum gross `amount_aud ?? amount` over
|
||||
* `transaction_type = 'debit'` in the browser. Measured on live data it ended
|
||||
* July at $4,747.31 against a headline of $3,597.10 — a 32% overstatement of
|
||||
* the number printed directly above it, with both labelled "spend".
|
||||
*
|
||||
* `/api/analytics/daily` fixed that by building the series from the same
|
||||
* fragments as `/monthly`. Nothing has been asserting it since, and the two
|
||||
* routes are separate files: the drift they were merged to prevent can
|
||||
* reappear the moment one is edited alone.
|
||||
*
|
||||
* Every fixture below is a shape where the naive gross-debit sum and the real
|
||||
* spend definition disagree. The last test proves that — without it, parity
|
||||
* could hold simply because the fixtures were too plain to discriminate, and
|
||||
* the test would pass against a route that had regressed.
|
||||
*/
|
||||
describe("Priority 0 item 2 — spend pace matches the headline", () => {
|
||||
/** Everything the two routes are supposed to treat identically. */
|
||||
async function seedDivergentMonth(month: string) {
|
||||
const d = (day: number) => `${month}-${String(day).padStart(2, "0")}`;
|
||||
|
||||
// Plain debit — both definitions agree on this one.
|
||||
await txn({ date: d(2), amount: 100, category: "groceries" });
|
||||
|
||||
// Half mine. Gross says 200; my share is 100.
|
||||
const shared = await txn({ date: d(3), amount: 200, category: "groceries" });
|
||||
await split(shared, ownerId, 50);
|
||||
await split(shared, otherId, 50);
|
||||
|
||||
// A refund. Ignored entirely by a debit-only sum; must cancel spend here.
|
||||
await txn({ date: d(5), amount: 40, type: "refund", category: "groceries" });
|
||||
|
||||
// Fee and interest rows: spend, but not type 'debit'.
|
||||
await txn({ date: d(6), amount: 15, type: "fee", category: "fees" });
|
||||
await txn({ date: d(7), amount: 25, type: "interest", category: "fees" });
|
||||
|
||||
// An itemised loan repayment: type 'payment', so a debit-only sum skips it,
|
||||
// but its interest portion is real spend and its principal is not.
|
||||
await txn({
|
||||
date: d(8),
|
||||
amount: 1200,
|
||||
type: "payment",
|
||||
category: "loan",
|
||||
interest_amount: 300,
|
||||
});
|
||||
|
||||
// The manual row a statement line superseded — double count if counted.
|
||||
const survivor = await txn({ date: d(9), amount: 60, category: "dining" });
|
||||
await txn({ date: d(9), amount: 60, category: "dining", reconciled_with_id: survivor });
|
||||
|
||||
// Categories that are not spend at all.
|
||||
await txn({ date: d(10), amount: 5000, category: "transfers" });
|
||||
await txn({ date: d(11), amount: 2000, category: "investment" });
|
||||
await txn({ date: d(12), amount: 3000, type: "credit", category: "income" });
|
||||
|
||||
// A `family`-tagged row: excluded from budget spend by EXCLUDE_NON_BUDGET_TAGS.
|
||||
const family = await txn({ date: d(13), amount: 500, category: "groceries" });
|
||||
await tag(family, "family");
|
||||
|
||||
// Someone else's row I hold a split on — mine only to the extent of the split.
|
||||
const theirs = await txn({ date: d(14), amount: 80, category: "dining", owner: otherId });
|
||||
await split(theirs, ownerId, 25);
|
||||
await split(theirs, otherId, 75);
|
||||
}
|
||||
|
||||
/** Sum the sparse daily map for one month. */
|
||||
const sumDaily = (daily: Record<string, Record<number, number>>, month: string) =>
|
||||
Object.values(daily[month] ?? {}).reduce((s, v) => s + v, 0);
|
||||
|
||||
it("the daily series sums to the monthly headline, to the cent", async () => {
|
||||
await seedDivergentMonth("2026-06");
|
||||
|
||||
const monthly = await json(await monthlyGET(req("/api/analytics/monthly?months=6")));
|
||||
const daily = await json(await dailyGET(req("/api/analytics/daily?months=6")));
|
||||
|
||||
const headline = (monthly.totals as Record<string, { spent: number }>)["2026-06"].spent;
|
||||
const pace = sumDaily(daily.daily as Record<string, Record<number, number>>, "2026-06");
|
||||
|
||||
// Cents, not exact float equality: the two routes group at different grains
|
||||
// (month+category vs month+day+category) and both carry 4dp, so they can
|
||||
// differ below a cent by construction. The displayed grain is what has to
|
||||
// agree — that IS the bug, the chart ending the month a few cents off the
|
||||
// headline it sits under.
|
||||
expect(Math.round(pace * 100) / 100).toBe(headline);
|
||||
});
|
||||
|
||||
it("agrees per category, not just in total", async () => {
|
||||
// A total can match while two categories are wrong in opposite directions,
|
||||
// and the category drill-down is a real surface on this page.
|
||||
await seedDivergentMonth("2026-06");
|
||||
|
||||
const monthly = await json(await monthlyGET(req("/api/analytics/monthly?months=6")));
|
||||
const daily = await json(await dailyGET(req("/api/analytics/daily?months=6")));
|
||||
|
||||
const byCategory = (daily.byCategory as Record<string, Record<string, Record<number, number>>>)["2026-06"];
|
||||
const rows = monthly.rows as { category: string; spent: Record<string, number> }[];
|
||||
|
||||
expect(Object.keys(byCategory).sort()).toEqual(
|
||||
rows.filter((r) => r.spent["2026-06"] !== undefined).map((r) => r.category).sort()
|
||||
);
|
||||
|
||||
for (const r of rows) {
|
||||
const expected = r.spent["2026-06"];
|
||||
if (expected === undefined) continue;
|
||||
const got = Object.values(byCategory[r.category] ?? {}).reduce((s, v) => s + v, 0);
|
||||
expect([r.category, Math.round(got * 100) / 100]).toEqual([
|
||||
r.category,
|
||||
Math.round(expected * 100) / 100,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it("holds across every month in the window, not only the seeded one", async () => {
|
||||
await seedDivergentMonth("2026-05");
|
||||
await seedDivergentMonth("2026-06");
|
||||
await seedDivergentMonth("2026-07");
|
||||
|
||||
const monthly = await json(await monthlyGET(req("/api/analytics/monthly?months=6")));
|
||||
const daily = await json(await dailyGET(req("/api/analytics/daily?months=6")));
|
||||
const totals = monthly.totals as Record<string, { spent: number }>;
|
||||
const dailyMap = daily.daily as Record<string, Record<number, number>>;
|
||||
|
||||
for (const m of monthly.months as string[]) {
|
||||
expect([m, Math.round(sumDaily(dailyMap, m) * 100) / 100]).toEqual([m, totals[m].spent]);
|
||||
}
|
||||
});
|
||||
|
||||
it("the fixtures actually discriminate — a gross debit sum does NOT agree", async () => {
|
||||
// The placebo. Parity between two identical-by-construction queries is easy
|
||||
// to assert and worthless if no fixture can tell the definitions apart.
|
||||
// This pins the gap the old client-side series had.
|
||||
await seedDivergentMonth("2026-06");
|
||||
|
||||
const monthly = await json(await monthlyGET(req("/api/analytics/monthly?months=6")));
|
||||
const headline = (monthly.totals as Record<string, { spent: number }>)["2026-06"].spent;
|
||||
|
||||
const naive = await pool.query<{ total: string }>(
|
||||
`SELECT COALESCE(SUM(COALESCE(amount_aud, amount)), 0)::numeric(14,2) AS total
|
||||
FROM transactions
|
||||
WHERE transaction_type = 'debit'
|
||||
AND transaction_date >= '2026-06-01' AND transaction_date < '2026-07-01'`
|
||||
);
|
||||
|
||||
expect(Number(naive.rows[0].total)).toBeGreaterThan(headline);
|
||||
});
|
||||
});
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Item 3 — fees and interest are bounded by an explicit, reported period
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* `/api/analytics/fees` used to aggregate every statement ever imported with no
|
||||
* date filter, and the UI printed the result with no period label — so a
|
||||
* lifetime-to-date total read as a current-period one and grew forever. It was
|
||||
* overstating the last 12 months by roughly $2,700 of fees.
|
||||
*
|
||||
* The regression is silent in the worst way: the number is always plausible and
|
||||
* always too big, and it gets worse the longer the app is used.
|
||||
*/
|
||||
describe("Priority 0 item 3 — fees and interest carry a date range", () => {
|
||||
/** A statement dated by the period it covers, carrying fees and interest. */
|
||||
async function statement(billingEnd: string, fees: number, interest: number, bank = "Fixture Bank") {
|
||||
const r = await pool.query(
|
||||
`INSERT INTO statements
|
||||
(filename, bank_name, account_number, billing_end_date, currency,
|
||||
fees_charged, interest_charged, owner_id)
|
||||
VALUES ($1, $2, '0000', $3, 'AUD', $4, $5, $6) RETURNING id`,
|
||||
[`fees-${billingEnd}-${bank}.pdf`, bank, billingEnd, fees, interest, ownerId]
|
||||
);
|
||||
return r.rows[0].id as number;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
// Inside the default 12-month window (opens 2025-09-01 from a 2026-08-15 now).
|
||||
await statement("2026-07-31", 10, 5);
|
||||
await statement("2025-10-31", 20, 7);
|
||||
// Outside it — the tail that used to be silently included.
|
||||
await statement("2024-06-30", 1000, 500);
|
||||
await statement("2019-01-31", 2500, 900);
|
||||
});
|
||||
|
||||
it("defaults to the last 12 months and says so", async () => {
|
||||
const body = await json(await feesGET(req("/api/analytics/fees")));
|
||||
const period = body.period as { months: number; from: string; to: string; all_time: boolean };
|
||||
|
||||
expect(period.months).toBe(12);
|
||||
expect(period.all_time).toBe(false);
|
||||
// The window opens on the 1st of the month 11 months back, and toDateStr()
|
||||
// must not shift it a day earlier — `toISOString()` on a local-midnight
|
||||
// Date does exactly that in any timezone east of Greenwich, which is how
|
||||
// every analytics window used to be a day early.
|
||||
expect(period.from).toBe("2025-09-01");
|
||||
expect(period.to).toBe("2026-09-01");
|
||||
|
||||
expect(body.total_fees).toBe(30);
|
||||
expect(body.total_interest).toBe(12);
|
||||
});
|
||||
|
||||
it("excludes the older tail — the $2,700-of-fees overstatement", async () => {
|
||||
const body = await json(await feesGET(req("/api/analytics/fees")));
|
||||
// 3530 of fees exist; only 30 are in the last 12 months.
|
||||
expect(body.total_fees).toBe(30);
|
||||
expect(body.total_fees).not.toBe(3530);
|
||||
});
|
||||
|
||||
it("months=0 asks for all time deliberately, and is labelled as such", async () => {
|
||||
const body = await json(await feesGET(req("/api/analytics/fees?months=0")));
|
||||
const period = body.period as { months: number; from: null; to: null; all_time: boolean };
|
||||
|
||||
expect(period.all_time).toBe(true);
|
||||
// A null range is the honest answer for an unbounded total; a from/to here
|
||||
// would be a period claim the number does not actually make.
|
||||
expect(period.from).toBeNull();
|
||||
expect(period.to).toBeNull();
|
||||
|
||||
expect(body.total_fees).toBe(3530);
|
||||
expect(body.total_interest).toBe(1412);
|
||||
});
|
||||
|
||||
it("honours a narrower explicit window", async () => {
|
||||
const body = await json(await feesGET(req("/api/analytics/fees?months=3")));
|
||||
const period = body.period as { months: number; from: string };
|
||||
expect(period.months).toBe(3);
|
||||
expect(period.from).toBe("2026-06-01");
|
||||
// Only the 2026-07-31 statement falls inside three months.
|
||||
expect(body.total_fees).toBe(10);
|
||||
expect(body.total_interest).toBe(5);
|
||||
});
|
||||
|
||||
it("bounds the transaction-level fee lines by the same window", async () => {
|
||||
// Two halves of the same answer, and only the statement half was ever
|
||||
// date-filtered in the original. A bounded total beside an unbounded list
|
||||
// is the same defect wearing the fix.
|
||||
await txn({ date: "2026-07-10", amount: 12, type: "fee", category: "fees" });
|
||||
await txn({ date: "2020-02-10", amount: 99, type: "fee", category: "fees" });
|
||||
|
||||
const bounded = await json(await feesGET(req("/api/analytics/fees")));
|
||||
const all = await json(await feesGET(req("/api/analytics/fees?months=0")));
|
||||
|
||||
expect((bounded.transactions as unknown[]).length).toBe(1);
|
||||
expect((all.transactions as unknown[]).length).toBe(2);
|
||||
});
|
||||
|
||||
it("clamps a nonsense months parameter instead of unbounding the total", async () => {
|
||||
// A NaN reaching `months` would make `new Date(y, NaN, 1)` an Invalid Date
|
||||
// and the window meaningless. Falling back to 12 keeps the default claim.
|
||||
const body = await json(await feesGET(req("/api/analytics/fees?months=banana")));
|
||||
expect((body.period as { months: number }).months).toBe(12);
|
||||
expect(body.total_fees).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// Item 6 — a month in progress is never compared against complete ones
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Two rules, both silent when broken (see src/lib/partial-month.ts):
|
||||
*
|
||||
* 1. the in-progress month never enters a baseline;
|
||||
* 2. when it is the month selected, both sides of every comparison are
|
||||
* truncated to the same day.
|
||||
*
|
||||
* These are driven off real `/api/analytics/daily` output rather than a
|
||||
* hand-built map, because rule 2 depends on that route's day-of-month
|
||||
* granularity — a route returning month totals only would make the rule
|
||||
* unimplementable while every pure-function test still passed.
|
||||
*/
|
||||
describe("Priority 0 item 6 — partial-month comparisons", () => {
|
||||
/** `perDay` dollars on each of days 1..n of `month`. */
|
||||
async function evenMonth(month: string, perDay: number, days: number) {
|
||||
for (let d = 1; d <= days; d++) {
|
||||
await txn({ date: `${month}-${String(d).padStart(2, "0")}`, amount: perDay, category: "groceries" });
|
||||
}
|
||||
}
|
||||
|
||||
async function dailyMap() {
|
||||
const body = await json(await dailyGET(req("/api/analytics/daily?months=12")));
|
||||
return body.daily as Record<string, Record<number, number>>;
|
||||
}
|
||||
|
||||
it("recognises the in-progress month and the day to compare through", () => {
|
||||
expect(comparisonBasis(CURRENT_MONTH, NOW)).toEqual({ isPartial: true, compareDay: ELAPSED });
|
||||
// A complete month compares through its last day, so the same code path
|
||||
// serves both cases and `spendThrough` becomes a no-op.
|
||||
expect(comparisonBasis("2026-06", NOW)).toEqual({ isPartial: false, compareDay: 30 });
|
||||
expect(comparisonBasis("2026-02", NOW)).toEqual({ isPartial: false, compareDay: 28 });
|
||||
});
|
||||
|
||||
it("keeps the in-progress month out of the baseline", () => {
|
||||
const totals: Record<string, { spent: number }> = {
|
||||
"2026-05": { spent: 100 },
|
||||
"2026-06": { spent: 100 },
|
||||
"2026-07": { spent: 100 },
|
||||
[CURRENT_MONTH]: { spent: 40 }, // half a month of spend
|
||||
};
|
||||
const months = ["2026-05", "2026-06", "2026-07", CURRENT_MONTH];
|
||||
|
||||
// Baseline for a complete month: every other complete month with data.
|
||||
expect(baselineMonths(months, "2026-07", totals, NOW)).toEqual(["2026-05", "2026-06"]);
|
||||
// Baseline for the month in progress: itself excluded twice over.
|
||||
expect(baselineMonths(months, CURRENT_MONTH, totals, NOW)).toEqual([
|
||||
"2026-05",
|
||||
"2026-06",
|
||||
"2026-07",
|
||||
]);
|
||||
// The partial month is never in it. Averaging its 40 in would drag a
|
||||
// baseline of 100 down to 85 and make an ordinary month look extravagant.
|
||||
expect(baselineMonths(months, "2026-07", totals, NOW)).not.toContain(CURRENT_MONTH);
|
||||
});
|
||||
|
||||
it("drops a month with no data rather than averaging a zero into the baseline", () => {
|
||||
const totals: Record<string, { spent: number }> = { "2026-05": { spent: 100 }, "2026-06": { spent: 0 } };
|
||||
expect(baselineMonths(["2026-05", "2026-06"], "2026-07", totals, NOW)).toEqual(["2026-05"]);
|
||||
});
|
||||
|
||||
it("compares the month in progress through the same day of each prior month", async () => {
|
||||
// $10/day everywhere. Prior months ran the full month; the current one has
|
||||
// only reached the 15th, so it holds $150 against their $300-odd.
|
||||
await evenMonth("2026-06", 10, 30);
|
||||
await evenMonth("2026-07", 10, 31);
|
||||
await evenMonth(CURRENT_MONTH, 10, ELAPSED);
|
||||
|
||||
const daily = await dailyMap();
|
||||
const totals: Record<string, { spent: number }> = {
|
||||
"2026-06": { spent: 300 },
|
||||
"2026-07": { spent: 310 },
|
||||
[CURRENT_MONTH]: { spent: 150 },
|
||||
};
|
||||
const months = ["2026-06", "2026-07", CURRENT_MONTH];
|
||||
const { isPartial, compareDay } = comparisonBasis(CURRENT_MONTH, NOW);
|
||||
const baseline = baselineMonths(months, CURRENT_MONTH, totals, NOW);
|
||||
|
||||
const mine = spendThrough(daily[CURRENT_MONTH], compareDay);
|
||||
const avg = baselineAverage(baseline, { isPartial, compareDay, daily, totals });
|
||||
|
||||
// Truncated on both sides, this month is exactly typical.
|
||||
expect(mine).toBe(150);
|
||||
expect(avg).toBe(150);
|
||||
expect(Math.round(((mine - avg) / avg) * 100)).toBe(0);
|
||||
|
||||
// Untruncated — the bug — it reads as 51% below average on identical
|
||||
// spending, purely because half the month has not happened yet.
|
||||
const naiveAvg = baseline.reduce((s, m) => s + totals[m].spent, 0) / baseline.length;
|
||||
expect(Math.round(((mine - naiveAvg) / naiveAvg) * 100)).toBe(-51);
|
||||
});
|
||||
|
||||
it("leaves a complete month compared on full totals", async () => {
|
||||
await evenMonth("2026-06", 10, 30);
|
||||
await evenMonth("2026-07", 20, 31);
|
||||
|
||||
const daily = await dailyMap();
|
||||
const totals: Record<string, { spent: number }> = { "2026-06": { spent: 300 }, "2026-07": { spent: 620 } };
|
||||
const months = ["2026-06", "2026-07"];
|
||||
const { isPartial, compareDay } = comparisonBasis("2026-07", NOW);
|
||||
const baseline = baselineMonths(months, "2026-07", totals, NOW);
|
||||
|
||||
expect(isPartial).toBe(false);
|
||||
// Truncation must not quietly apply to a finished month: June's full 30
|
||||
// days are the right comparison for July's full 31.
|
||||
expect(baselineAverage(baseline, { isPartial, compareDay, daily, totals })).toBe(300);
|
||||
});
|
||||
|
||||
it("truncates the top-movers comparison on both sides", async () => {
|
||||
// The movers row asks "biggest category moves vs last month". With one side
|
||||
// full and the other half-done, every category reads as a large decrease.
|
||||
await evenMonth("2026-07", 10, 31); // $310 in July
|
||||
await evenMonth(CURRENT_MONTH, 10, ELAPSED); // $150 so far in August
|
||||
|
||||
const body = await json(await dailyGET(req("/api/analytics/daily?months=12")));
|
||||
const byCategory = body.byCategory as Record<string, Record<string, Record<number, number>>>;
|
||||
const { compareDay } = comparisonBasis(CURRENT_MONTH, NOW);
|
||||
const pm = prevMonth(CURRENT_MONTH);
|
||||
expect(pm).toBe("2026-07");
|
||||
|
||||
const now = spendThrough(byCategory[CURRENT_MONTH]?.groceries, compareDay);
|
||||
const before = spendThrough(byCategory[pm]?.groceries, compareDay);
|
||||
expect(now - before).toBe(0);
|
||||
|
||||
// Against July's full month it would report a $160 fall that never happened.
|
||||
const fullBefore = Object.values(byCategory[pm].groceries).reduce((s, v) => s + v, 0);
|
||||
expect(now - fullBefore).toBe(-160);
|
||||
});
|
||||
|
||||
it("crosses a year boundary correctly", () => {
|
||||
expect(prevMonth("2026-01")).toBe("2025-12");
|
||||
expect(comparisonBasis("2025-12", NOW)).toEqual({ isPartial: false, compareDay: 31 });
|
||||
});
|
||||
|
||||
it("handles February in a leap year", () => {
|
||||
// 2028 is a leap year; a hardcoded 28 would silently drop the 29th.
|
||||
expect(comparisonBasis("2028-02", new Date(2028, 5, 1)).compareDay).toBe(29);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,17 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, beforeAll } from "vitest";
|
||||
import { queryRaw, queryRow } from "../../lib/db";
|
||||
import {
|
||||
EXCLUDE_RECONCILED_SOURCE,
|
||||
NATIVE_CURRENCY,
|
||||
AMOUNT_UNCONVERTED,
|
||||
INVESTMENT_SIGNED,
|
||||
MY_SPEND_SCOPE,
|
||||
NET_SPEND_ROWS,
|
||||
SPEND_SIGNED,
|
||||
myShare,
|
||||
mySplitOf,
|
||||
} from "../../lib/analytics-sql";
|
||||
import { MY_SHARE_PCT } from "../../lib/queries";
|
||||
|
||||
/**
|
||||
* These fragments are the ones that drifted.
|
||||
@@ -102,9 +109,17 @@ describe("NATIVE_CURRENCY", () => {
|
||||
// Opposite convention: on an AUD statement, `amount` is AUD and
|
||||
// foreign_currency_code merely notes what was originally charged. Reading
|
||||
// the foreign code here would mislabel an AUD row as USD.
|
||||
// owner_id is NOT NULL DEFAULT 1 with an FK to participants, so this insert
|
||||
// relied on a participant with id 1 already existing — true only when a
|
||||
// sibling test file had seeded one and left it behind. On a freshly reset
|
||||
// database it failed the FK. Own the fixture instead of inheriting it.
|
||||
const owner = await queryRow<{ id: number }>(
|
||||
`INSERT INTO participants (name) VALUES ('Analytics fixture — owner') RETURNING id`
|
||||
);
|
||||
const stmt = await queryRow<{ id: number }>(
|
||||
`INSERT INTO statements (bank_name, account_number, billing_end_date, currency, filename)
|
||||
VALUES ('Analytics Fixture Bank','0000','2026-03-31','AUD','analytics-fixture.pdf') RETURNING id`
|
||||
`INSERT INTO statements (bank_name, account_number, billing_end_date, currency, filename, owner_id)
|
||||
VALUES ('Analytics Fixture Bank','0000','2026-03-31','AUD','analytics-fixture.pdf', $1) RETURNING id`,
|
||||
[owner!.id]
|
||||
);
|
||||
const id = await scratchTxn(
|
||||
"transaction_date, description, amount, amount_aud, transaction_type, statement_id, foreign_currency_amount, foreign_currency_code",
|
||||
@@ -124,3 +139,265 @@ describe("NATIVE_CURRENCY", () => {
|
||||
expect((await currencyOf(id)).ccy).toBe("AUD");
|
||||
});
|
||||
});
|
||||
|
||||
describe("INVESTMENT_SIGNED", () => {
|
||||
/** The signed value the investments line would attribute to this row. */
|
||||
async function signedValue(id: number): Promise<number> {
|
||||
const row = await queryRow<{ v: string }>(
|
||||
`SELECT (${INVESTMENT_SIGNED})::text AS v FROM transactions t WHERE t.id = $1`,
|
||||
[id]
|
||||
);
|
||||
return Number(row!.v);
|
||||
}
|
||||
|
||||
it("counts a contribution positive", async () => {
|
||||
const id = await scratchTxn(
|
||||
"transaction_date, description, amount, transaction_type, category",
|
||||
"'2026-03-01','Investment fixture — deposit', 5000.00, 'debit', 'investment'"
|
||||
);
|
||||
expect(await signedValue(id)).toBe(5000);
|
||||
});
|
||||
|
||||
it("counts a withdrawal negative so it nets against contributions", async () => {
|
||||
// The $25,000 Raiz withdrawal that made March 2026 read as a $38,615.34
|
||||
// investing month when it was net -$11,384.66.
|
||||
const id = await scratchTxn(
|
||||
"transaction_date, description, amount, transaction_type, category",
|
||||
"'2026-03-19','Investment fixture — withdrawal', 25000.00, 'credit', 'investment'"
|
||||
);
|
||||
expect(await signedValue(id)).toBe(-25000);
|
||||
});
|
||||
|
||||
it("a deposit and an equal withdrawal net to zero", async () => {
|
||||
const inId = await scratchTxn(
|
||||
"transaction_date, description, amount, transaction_type, category",
|
||||
"'2026-03-01','Investment fixture — net in', 1000.00, 'debit', 'investment'"
|
||||
);
|
||||
const outId = await scratchTxn(
|
||||
"transaction_date, description, amount, transaction_type, category",
|
||||
"'2026-03-02','Investment fixture — net out', 1000.00, 'credit', 'investment'"
|
||||
);
|
||||
expect(await signedValue(inId) + await signedValue(outId)).toBe(0);
|
||||
});
|
||||
|
||||
it("prefers amount_aud over the native amount", async () => {
|
||||
// The IBKR rows are USD; the line is denominated in AUD.
|
||||
const id = await scratchTxn(
|
||||
"transaction_date, description, amount, amount_aud, transaction_type, category",
|
||||
"'2026-07-25','Investment fixture — foreign', 10000.00, 14310.00, 'debit', 'investment'"
|
||||
);
|
||||
expect(await signedValue(id)).toBe(14310);
|
||||
});
|
||||
|
||||
it("treats a refund like a withdrawal", async () => {
|
||||
const id = await scratchTxn(
|
||||
"transaction_date, description, amount, transaction_type, category",
|
||||
"'2026-03-01','Investment fixture — reversal', 1500.00, 'refund', 'investment'"
|
||||
);
|
||||
expect(await signedValue(id)).toBe(-1500);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Whose expense is it — the gate that decides, and the share that scales it.
|
||||
*
|
||||
* Both fragments changed together on 2026-08-15 and neither is safe alone.
|
||||
* `OWNER_SCOPE = $1` was the gate on every spend analytic, with `mySplitOf`
|
||||
* scaling within it, so a row someone else paid for contributed nothing to my
|
||||
* spend no matter what share I held — 167 rows / $3,210.91 of my own share
|
||||
* missing from Jan–Jul 2026, while the balance queries booked the debt
|
||||
* correctly. Widening the gate without also fixing `myShare` is worse than
|
||||
* leaving it: the old remainder fallback hands the viewer the PAYER's share on
|
||||
* a row with no split of theirs, which on an unsplit row is the full amount.
|
||||
*
|
||||
* So the discriminating cases here are all ones where I hold NO split row on
|
||||
* someone else's transaction. That is where old and new disagree, and it is the
|
||||
* only place the widened gate could do damage.
|
||||
*/
|
||||
describe("MY_SPEND_SCOPE / myShare", () => {
|
||||
let me = 0;
|
||||
let them = 0;
|
||||
let third = 0;
|
||||
|
||||
beforeAll(async () => {
|
||||
const mk = async (name: string) =>
|
||||
(await queryRow<{ id: number }>(
|
||||
`INSERT INTO participants (name) VALUES ($1) RETURNING id`,
|
||||
[name]
|
||||
))!.id;
|
||||
me = await mk("Share fixture — viewer");
|
||||
them = await mk("Share fixture — payer");
|
||||
third = await mk("Share fixture — third party");
|
||||
});
|
||||
|
||||
/** A transaction owned by `owner`, optionally split, optionally overridden. */
|
||||
async function fixture(
|
||||
label: string,
|
||||
owner: number,
|
||||
splits: [number, number][] = [],
|
||||
myShareOverride?: number
|
||||
) {
|
||||
const id = await scratchTxn(
|
||||
"transaction_date, description, amount, transaction_type, category, owner_id",
|
||||
`'2026-06-01', $1, 100.00, 'debit', 'groceries', $2`,
|
||||
[`Share fixture — ${label}`, owner]
|
||||
);
|
||||
for (const [pid, pct] of splits) {
|
||||
await queryRaw(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
|
||||
VALUES ($1, $2, $3)`,
|
||||
[id, pid, pct]
|
||||
);
|
||||
}
|
||||
if (myShareOverride !== undefined) {
|
||||
await queryRaw(
|
||||
`INSERT INTO transaction_overrides (transaction_id, my_share_percent) VALUES ($1, $2)`,
|
||||
[id, myShareOverride]
|
||||
);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
const JOINS = `
|
||||
FROM transactions t
|
||||
LEFT JOIN statements s ON s.id = t.statement_id
|
||||
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||
LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1`;
|
||||
|
||||
async function inScope(id: number, viewer: number): Promise<boolean> {
|
||||
const rows = await queryRaw(
|
||||
`SELECT t.id ${JOINS} WHERE t.id = $2 AND ${MY_SPEND_SCOPE()}`,
|
||||
[viewer, id]
|
||||
);
|
||||
return rows.length === 1;
|
||||
}
|
||||
|
||||
async function share(id: number, viewer: number): Promise<number> {
|
||||
const row = await queryRow<{ v: string }>(
|
||||
`SELECT (${myShare()})::numeric AS v ${JOINS} WHERE t.id = $2`,
|
||||
[viewer, id]
|
||||
);
|
||||
return Number(row!.v);
|
||||
}
|
||||
|
||||
/** What this row contributes to the viewer's net spend. */
|
||||
async function spend(id: number, viewer: number): Promise<number> {
|
||||
const row = await queryRow<{ v: string | null }>(
|
||||
`SELECT SUM(${mySplitOf(SPEND_SIGNED)})::numeric(14,4) AS v ${JOINS}
|
||||
WHERE t.id = $2 AND ${MY_SPEND_SCOPE()} AND ${NET_SPEND_ROWS}`,
|
||||
[viewer, id]
|
||||
);
|
||||
return Number(row?.v ?? 0);
|
||||
}
|
||||
|
||||
describe("the gate", () => {
|
||||
it("admits a row someone else paid for that I hold a split on", async () => {
|
||||
const id = await fixture("their shop, my half", them, [[me, 50], [them, 50]]);
|
||||
expect(await inScope(id, me)).toBe(true);
|
||||
});
|
||||
|
||||
it("still admits a row I paid for with no splits at all", async () => {
|
||||
const id = await fixture("mine alone", me);
|
||||
expect(await inScope(id, me)).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a row someone else paid for that I have no split on", async () => {
|
||||
const id = await fixture("not mine", them, [[them, 60], [third, 40]]);
|
||||
expect(await inScope(id, me)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("my share of someone else's row", () => {
|
||||
it("is my split percent", async () => {
|
||||
const id = await fixture("their shop, my quarter", them, [[me, 25], [them, 75]]);
|
||||
expect(await share(id, me)).toBe(25);
|
||||
});
|
||||
|
||||
it("is 0 with no split of mine — NOT the payer's remainder", async () => {
|
||||
// The load-bearing case. An unsplit row someone else paid for has no
|
||||
// `ts.share_percent`, so the old `100 - everyone else` fallback returned
|
||||
// 100 and would have moved their entire grocery bill onto my spend the
|
||||
// moment the gate widened.
|
||||
const id = await fixture("entirely theirs", them);
|
||||
expect(await share(id, me)).toBe(0);
|
||||
expect(await spend(id, me)).toBe(0);
|
||||
});
|
||||
|
||||
it("ignores my_share_percent, which can only mean the owner's share", async () => {
|
||||
// One unscoped column per transaction, writable by anyone who can see the
|
||||
// row. Reading it as the viewer's share on someone else's transaction
|
||||
// would be a guess.
|
||||
const id = await fixture("their row, their override", them, [], 30);
|
||||
expect(await share(id, me)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("my share of my own row is unchanged", () => {
|
||||
it("prefers an explicit split row for me", async () => {
|
||||
const id = await fixture("mine, split explicitly", me, [[me, 35], [them, 65]]);
|
||||
expect(await share(id, me)).toBe(35);
|
||||
});
|
||||
|
||||
it("falls back to the my_share_percent override", async () => {
|
||||
const id = await fixture("mine, overridden", me, [], 80);
|
||||
expect(await share(id, me)).toBe(80);
|
||||
});
|
||||
|
||||
it("falls back to whatever is left after everyone else", async () => {
|
||||
const id = await fixture("mine, remainder", me, [[them, 40]]);
|
||||
expect(await share(id, me)).toBe(60);
|
||||
});
|
||||
|
||||
it("is 0 when a row I paid for is allocated entirely to someone else", async () => {
|
||||
const id = await fixture("mine, all theirs", me, [[them, 100]]);
|
||||
expect(await share(id, me)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the regression it was written for", () => {
|
||||
it("counts my half of a shop the other person paid for", async () => {
|
||||
const id = await fixture("the grocery shop", them, [[me, 50], [them, 50]]);
|
||||
expect(await spend(id, me)).toBe(50);
|
||||
});
|
||||
|
||||
it("still counts my half of the same shop when I paid", async () => {
|
||||
const id = await fixture("the same shop, my card", me, [[me, 50], [them, 50]]);
|
||||
expect(await spend(id, me)).toBe(50);
|
||||
});
|
||||
|
||||
it("counts my share whether or not the split is settled", async () => {
|
||||
// `myShare` must not filter on `settled` — half a shop was my expense
|
||||
// whether or not the other half was repaid. Same rule, payer-side.
|
||||
const id = await fixture("settled shop", them, [[me, 50], [them, 50]]);
|
||||
await queryRaw(`UPDATE transaction_splits SET settled = true WHERE transaction_id = $1`, [id]);
|
||||
expect(await spend(id, me)).toBe(50);
|
||||
});
|
||||
|
||||
it("never counts one row for both people at full value", async () => {
|
||||
const id = await fixture("one shop, two people", them, [[me, 50], [them, 50]]);
|
||||
expect(await spend(id, me) + await spend(id, them)).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the transactions-list mirror agrees", () => {
|
||||
it("MY_SHARE_PCT matches myShare on every fixture shape", async () => {
|
||||
const shapes: [string, number, [number, number][], number | undefined][] = [
|
||||
["mirror A", them, [[me, 25], [them, 75]], undefined],
|
||||
["mirror B", them, [], undefined],
|
||||
["mirror C", them, [], 30],
|
||||
["mirror D", me, [[me, 35], [them, 65]], undefined],
|
||||
["mirror E", me, [], 80],
|
||||
["mirror F", me, [[them, 40]], undefined],
|
||||
["mirror G", me, [], undefined],
|
||||
];
|
||||
for (const [label, owner, splits, override] of shapes) {
|
||||
const id = await fixture(label, owner, splits, override);
|
||||
const mirrored = await queryRow<{ v: string }>(
|
||||
`SELECT (${MY_SHARE_PCT})::numeric AS v ${JOINS} WHERE t.id = $2`,
|
||||
[me, id]
|
||||
);
|
||||
expect([label, Number(mirrored!.v)]).toEqual([label, await share(id, me)]);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,12 +23,65 @@ export function mockDbWithPool(p: Pool) {
|
||||
const result = await p.query(sql, params);
|
||||
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,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse to truncate anything that is not the test database.
|
||||
*
|
||||
* `.env.test` used to name the Postgres container by bridge IP, and Docker
|
||||
* reassigns those on recreation: 172.22.0.47 stopped being `postgres-personal`
|
||||
* and became `postgres-pantry`, so the suite spent a while pointing its
|
||||
* TRUNCATE at another app's database. It only failed safe because the
|
||||
* credentials happened not to match — had they matched, this would have wiped
|
||||
* pantry-app.
|
||||
*
|
||||
* `scripts/resolve-test-db.sh` (wired as `pretest:integration`) now re-derives
|
||||
* the DSN from the live container before every run, preferring the published
|
||||
* loopback port. This check stays anyway: running vitest directly bypasses the
|
||||
* pretest hook, and a stale `.env.test` would still be read.
|
||||
*
|
||||
* Checked once per process, before the first truncate.
|
||||
*/
|
||||
let targetVerified = false;
|
||||
async function assertTestDatabase(pool: Pool) {
|
||||
if (targetVerified) return;
|
||||
const { rows } = await pool.query<{ db: string }>(`SELECT current_database() AS db`);
|
||||
const db = rows[0]?.db;
|
||||
if (db !== "personal_test") {
|
||||
throw new Error(
|
||||
`Refusing to truncate: connected to "${db}", expected "personal_test". ` +
|
||||
`Re-resolve the DSN with \`bash scripts/resolve-test-db.sh\` (or just run ` +
|
||||
`\`npm run test:integration\`, which does it for you), and run ` +
|
||||
`\`npm run test:setup\` if personal_test does not exist yet.`
|
||||
);
|
||||
}
|
||||
targetVerified = true;
|
||||
}
|
||||
|
||||
/** Wipe all data tables and restart sequences between tests. */
|
||||
export async function resetDB(pool: Pool) {
|
||||
await assertTestDatabase(pool);
|
||||
await pool.query(`
|
||||
TRUNCATE
|
||||
split_payments,
|
||||
|
||||
@@ -0,0 +1,793 @@
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from "vitest";
|
||||
import { Pool } from "pg";
|
||||
import { readFileSync, mkdtempSync, rmSync } from "fs";
|
||||
import { tmpdir } from "os";
|
||||
import { join, resolve } from "path";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { createPool, mockDbWithPool, resetDB, seedParticipants } from "./helpers";
|
||||
import type { ExtractedOrder } from "../../lib/intake-rules";
|
||||
|
||||
/**
|
||||
* The intake router against the real schema (plan §11 C1, C2, C5).
|
||||
*
|
||||
* The model is stubbed at the HTTP boundary — `fetch` to LiteLLM answers with a
|
||||
* canned extraction, `fetch` to pantry with a canned capture — so the PDF text
|
||||
* path, the persistence, the match and the writes all run for real. The two
|
||||
* PDFs are the user's own Uber Eats exports; the transactions seeded here are
|
||||
* shaped like production rows 6846 and 6857 (platform `ubereats`, `line_items`
|
||||
* empty, the email pipeline's booking of the same two orders).
|
||||
*/
|
||||
|
||||
const INTAKE = "test-intake-token";
|
||||
const RECEIPT = "test-receipt-token";
|
||||
const fixtures = resolve(__dirname, "../fixtures/intake");
|
||||
const BURGERTORY = readFileSync(resolve(fixtures, "ubereats-burgertory-2026-08-19.pdf"));
|
||||
const COLES = readFileSync(resolve(fixtures, "ubereats-coles-2026-08-18.pdf"));
|
||||
|
||||
const burgertory: ExtractedOrder = {
|
||||
merchant: "Burgertory (Werribee)", order_date: "2026-08-19", total: 22.4, subtotal: 28, currency: "AUD",
|
||||
items: [
|
||||
{ quantity: 1, description: "Michael", unit_price: 14, line_total: 14, options: ["Milk Bun"] },
|
||||
{ quantity: 1, description: "Joel", unit_price: 14, line_total: 14, options: [] },
|
||||
],
|
||||
adjustments: [{ label: "Delivery Fee", amount: 5.49 }, { label: "Promotion", amount: -8 }],
|
||||
};
|
||||
const coles: ExtractedOrder = {
|
||||
merchant: "Coles (Wyndham Vale)", order_date: "2026-08-18", total: 85.74, subtotal: 118.94, currency: "AUD",
|
||||
items: [
|
||||
{ quantity: 3, description: "Coca-Cola Zero Sugar Soft Drink (2L)", unit_price: 4.4, line_total: 13.2, options: [] },
|
||||
{ quantity: 1, description: "Coles RSPCA Approved Chicken Thigh Cutlets approx. 1.1kg", unit_price: 11, line_total: 12.76, options: ["Final 1.16 kg $11.00/kg"] },
|
||||
{ quantity: 1, description: "everything else", unit_price: 92.98, line_total: 92.98, options: [] },
|
||||
],
|
||||
adjustments: [],
|
||||
};
|
||||
|
||||
let pool: Pool;
|
||||
let ownerId: number;
|
||||
let otherId: number;
|
||||
let fileDir: string;
|
||||
let intakePOST: (req: NextRequest) => Promise<Response>;
|
||||
let lineItemsPUT: (req: NextRequest, ctx: { params: Promise<{ id: string }> }) => Promise<Response>;
|
||||
let slackPOST: (req: NextRequest) => Promise<Response>;
|
||||
let orderGET: (req: NextRequest, ctx: { params: Promise<{ id: string }> }) => Promise<Response>;
|
||||
|
||||
/** What the next LiteLLM call answers, and what the next pantry call answers. */
|
||||
let modelReply: ExtractedOrder | Error = burgertory;
|
||||
let pantryReply: { status: number; body: unknown } = { status: 200, body: { captureId: 901, items: [1, 2, 3] } };
|
||||
const calls: { url: string; body: unknown }[] = [];
|
||||
|
||||
function stubFetch() {
|
||||
vi.stubGlobal("fetch", async (url: string | URL, init?: RequestInit) => {
|
||||
const u = String(url);
|
||||
if (u.includes("/v1/chat/completions")) {
|
||||
calls.push({ url: u, body: JSON.parse(String(init?.body)) });
|
||||
if (modelReply instanceof Error) return new Response("boom", { status: 500 });
|
||||
return Response.json({ choices: [{ message: { content: JSON.stringify(modelReply) }, finish_reason: "stop" }] });
|
||||
}
|
||||
if (u.includes("/api/capture")) {
|
||||
const form = init?.body as FormData;
|
||||
calls.push({ url: u, body: { intake: JSON.parse(String(form.get("intake"))), files: form.getAll("image").map((f) => (f as File).name), headers: init?.headers } });
|
||||
return Response.json(pantryReply.body, { status: pantryReply.status });
|
||||
}
|
||||
if (u.includes("hooks.slack.com")) {
|
||||
calls.push({ url: u, body: JSON.parse(String(init?.body)) });
|
||||
return new Response("ok");
|
||||
}
|
||||
throw new Error(`unexpected fetch ${u}`);
|
||||
});
|
||||
}
|
||||
|
||||
const multipart = (fields: Record<string, string>, files: { name: string; type: string; bytes: Buffer }[], token: string | null = INTAKE) => {
|
||||
const fd = new FormData();
|
||||
for (const [k, v] of Object.entries(fields)) fd.append(k, v);
|
||||
for (const f of files) fd.append("file", new File([new Uint8Array(f.bytes)], f.name, { type: f.type }));
|
||||
return new Request("http://finance.test/api/intake", { method: "POST", headers: token ? { "x-ingest-token": token } : {}, body: fd }) as unknown as NextRequest;
|
||||
};
|
||||
const jsonReq = (body: unknown, token: string | null) =>
|
||||
({ headers: { get: (h: string) => (h === "x-ingest-token" ? token : null) }, json: async () => body }) as unknown as NextRequest;
|
||||
const put = (id: number | string, body: unknown, token: string | null = RECEIPT) => lineItemsPUT(jsonReq(body, token), { params: Promise.resolve({ id: String(id) }) });
|
||||
const uuid = () => crypto.randomUUID();
|
||||
|
||||
/** A transaction shaped like the email pipeline's booking of an order, line_items empty. */
|
||||
async function seedOrder(over: { merchant: string; date: string; amount: number; platform?: string | null; owner?: number; withMeta?: boolean }) {
|
||||
const t = await pool.query(
|
||||
`INSERT INTO transactions (owner_id, statement_id, transaction_date, description, amount, amount_aud, transaction_type, category, merchant_name, merchant_normalized, payment_method, row_index)
|
||||
VALUES ($1, NULL, $2, $3, $4, $4, 'debit', 'dining', $5, $5, 'credits', 0) RETURNING id`,
|
||||
[over.owner ?? ownerId, over.date, `Order - ${over.merchant}`, over.amount, over.merchant]
|
||||
);
|
||||
const id = t.rows[0].id as number;
|
||||
if (over.withMeta !== false) {
|
||||
await pool.query(
|
||||
`INSERT INTO expense_metadata (transaction_id, source, platform, order_reference, line_items, merchant_normalized, amount, transaction_date, currency)
|
||||
VALUES ($1, 'email', $2, $3, '[]'::jsonb, $4, $5, $6, 'AUD')`,
|
||||
[id, over.platform === undefined ? "ubereats" : over.platform, uuid(), over.merchant, over.amount, over.date]
|
||||
);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
/** What `components/order-details.tsx` would actually render for a transaction. */
|
||||
const orderPanel = async (txnId: number) =>
|
||||
(await (await orderGET({ headers: { get: () => null } } as unknown as NextRequest, { params: Promise.resolve({ id: String(txnId) }) })).json()) as { line_items?: unknown[] } | null;
|
||||
const metaRows = async () => (await pool.query(`SELECT id, platform, line_items FROM expense_metadata ORDER BY id`)).rows as { id: number; platform: string | null; line_items: { description: string }[] }[];
|
||||
const lineItemsOf = async (txnId: number) =>
|
||||
(await pool.query(`SELECT line_items FROM expense_metadata WHERE transaction_id = $1 OR matched_transaction_id = $1`, [txnId])).rows[0]?.line_items;
|
||||
const batch = async (id: string) => (await pool.query(`SELECT * FROM intake_batches WHERE id = $1`, [id])).rows[0];
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.INTAKE_TOKEN = INTAKE;
|
||||
process.env.RECEIPT_INGEST_TOKEN = RECEIPT;
|
||||
process.env.ORDER_INGEST_TOKEN = "test-order-token";
|
||||
process.env.LITELLM_BASE_URL = "http://litellm.test";
|
||||
process.env.LITELLM_KEY = "k";
|
||||
process.env.PANTRY_BASE_URL = "http://pantry.test";
|
||||
process.env.FINANCE_PUBLIC_URL = "https://finance.test";
|
||||
fileDir = mkdtempSync(join(tmpdir(), "intake-test-"));
|
||||
process.env.INTAKE_FILE_DIR = fileDir;
|
||||
pool = createPool();
|
||||
mockDbWithPool(pool);
|
||||
// The Slack signature gate is exercised by slack-verify.test.ts; here it is
|
||||
// bypassed so the intake branch of the route is what is under test.
|
||||
vi.doMock("@/lib/slack-verify", () => ({
|
||||
verifySlackSignature: () => true,
|
||||
participantForSlackUser: () => 1,
|
||||
slackUserForParticipant: () => null,
|
||||
}));
|
||||
// The order-detail panel is the surface an enrichment has to appear on, so
|
||||
// the read is exercised beside the write rather than assumed.
|
||||
vi.doMock("@/lib/auth", () => ({ getCurrentUser: async () => ({ id: 1, name: "Owner", email: "o@x" }) }));
|
||||
vi.doMock("@/lib/queries", async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
canAccessTransactions: async () => true,
|
||||
}));
|
||||
({ POST: intakePOST } = await import("../../app/api/intake/route"));
|
||||
({ PUT: lineItemsPUT } = await import("../../app/api/transactions/[id]/line-items/route"));
|
||||
({ POST: slackPOST } = await import("../../app/api/slack/interactive/route"));
|
||||
({ GET: orderGET } = await import("../../app/api/transactions/[id]/order/route"));
|
||||
stubFetch();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.unstubAllGlobals();
|
||||
rmSync(fileDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDB(pool);
|
||||
await pool.query(`TRUNCATE intake_batches, intake_merchant_kinds`);
|
||||
({ ownerId, otherId } = await seedParticipants(pool, ["Alice", "Bob"]));
|
||||
await pool.query(`UPDATE participants SET email = 'bob@example.test' WHERE id = $1`, [otherId]);
|
||||
modelReply = burgertory;
|
||||
pantryReply = { status: 200, body: { captureId: 901, items: [1, 2, 3] } };
|
||||
calls.length = 0;
|
||||
});
|
||||
|
||||
// ─── C2 ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("PUT /api/transactions/[id]/line-items (C2)", () => {
|
||||
const items = [{ qty: 1, description: "Michael", amount: 14, options: ["Milk Bun"] }, { qty: 1, description: "Joel", amount: 14 }];
|
||||
|
||||
it("fails closed: no token, wrong token", async () => {
|
||||
expect((await put(1, { line_items: items, source: "intake", replace: true }, null)).status).toBe(401);
|
||||
expect((await put(1, { line_items: items, source: "intake", replace: true }, "nope")).status).toBe(401);
|
||||
});
|
||||
|
||||
it("accepts either token", async () => {
|
||||
const id = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
|
||||
expect((await put(id, { line_items: items, source: "pantry", replace: true }, RECEIPT)).status).toBe(200);
|
||||
expect((await put(id, { line_items: items, source: "intake", replace: true }, INTAKE)).status).toBe(200);
|
||||
});
|
||||
|
||||
it("404 when the transaction does not exist — it never creates one", async () => {
|
||||
const res = await put(999999, { line_items: items, source: "intake", replace: true });
|
||||
expect(res.status).toBe(404);
|
||||
expect((await pool.query(`SELECT count(*)::int AS n FROM transactions`)).rows[0].n).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects a body that is not a replace, a bad source, or nameless items", async () => {
|
||||
const id = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
|
||||
expect((await put(id, { line_items: items, source: "intake" })).status).toBe(400);
|
||||
expect((await put(id, { line_items: items, source: "n8n", replace: true })).status).toBe(400);
|
||||
expect((await put(id, { line_items: [{ qty: 1 }], source: "intake", replace: true })).status).toBe(400);
|
||||
});
|
||||
|
||||
it("idempotent replace: returns previous + new, and a second identical call is a no-op", async () => {
|
||||
const id = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
|
||||
const first = await (await put(id, { line_items: items, source: "intake", replace: true })).json();
|
||||
expect(first.previous_line_items).toEqual([]);
|
||||
expect(first.line_items).toHaveLength(2);
|
||||
expect(first.created_metadata).toBe(false);
|
||||
expect(await lineItemsOf(id)).toHaveLength(2);
|
||||
|
||||
const corrected = [{ qty: 1, description: "Michael (corrected)", amount: 14 }];
|
||||
const second = await (await put(id, { line_items: corrected, source: "pantry", replace: true })).json();
|
||||
expect(second.previous_line_items.map((i: { description: string }) => i.description)).toEqual(["Michael", "Joel"]);
|
||||
expect(await lineItemsOf(id)).toEqual(corrected);
|
||||
|
||||
const third = await (await put(id, { line_items: corrected, source: "pantry", replace: true })).json();
|
||||
expect(third.previous_line_items).toEqual(corrected);
|
||||
expect((await pool.query(`SELECT count(*)::int AS n FROM expense_metadata`)).rows[0].n).toBe(1);
|
||||
});
|
||||
|
||||
it("undo shape: PUT previous_line_items back restores the row", async () => {
|
||||
const id = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
|
||||
const r = await (await put(id, { line_items: items, source: "pantry", replace: true })).json();
|
||||
await put(id, { line_items: r.previous_line_items, source: "pantry", replace: true });
|
||||
expect(await lineItemsOf(id)).toEqual([]);
|
||||
});
|
||||
|
||||
it("creates the expense_metadata row when the transaction has none, with source and platform", async () => {
|
||||
const id = await seedOrder({ merchant: "Coles", date: "2026-08-18", amount: 85.74, withMeta: false });
|
||||
const res = await (await put(id, { line_items: items, source: "intake", platform: "ubereats", batch_id: "b1", replace: true })).json();
|
||||
expect(res.created_metadata).toBe(true);
|
||||
const em = (await pool.query(`SELECT source, platform, order_reference, merchant_normalized, amount::text FROM expense_metadata WHERE transaction_id = $1`, [id])).rows[0];
|
||||
expect(em).toEqual({ source: "intake", platform: "ubereats", order_reference: "intake:b1", merchant_normalized: "Coles", amount: "85.74" });
|
||||
});
|
||||
|
||||
it("the undo baseline is recorded ONCE per batch: a retry gets the original, not its own write", async () => {
|
||||
const txn = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
|
||||
const original = [{ qty: 1, description: "what the order already had", amount: 22.4 }];
|
||||
await pool.query(`UPDATE expense_metadata SET line_items = $2::jsonb WHERE transaction_id = $1`, [txn, JSON.stringify(original)]);
|
||||
const id = uuid();
|
||||
await pool.query(`INSERT INTO intake_batches (id, source, file_sha256, status) VALUES ($1::uuid, 'other', '{}', 'received')`, [id]);
|
||||
|
||||
const first = await (await put(txn, { line_items: items, source: "pantry", batch_id: id, replace: true })).json();
|
||||
expect(first.previous_line_items).toEqual(original);
|
||||
// Pantry re-calls on every confirm and after a timeout. Before, the retry
|
||||
// was handed its OWN write back, so undo restored the thing it undid.
|
||||
const retry = await (await put(txn, { line_items: items, source: "pantry", batch_id: id, replace: true })).json();
|
||||
expect(retry.previous_line_items).toEqual(original);
|
||||
expect((await pool.query(`SELECT previous_line_items FROM intake_batches WHERE id = $1`, [id])).rows[0].previous_line_items).toEqual(original);
|
||||
|
||||
await put(txn, { line_items: retry.previous_line_items, source: "pantry", batch_id: id, replace: true });
|
||||
expect(await lineItemsOf(txn)).toEqual(original);
|
||||
});
|
||||
|
||||
it("without a batch id the baseline is still the row as read — nothing else changes", async () => {
|
||||
const txn = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
|
||||
const first = await (await put(txn, { line_items: items, source: "pantry", replace: true })).json();
|
||||
expect(first.previous_line_items).toEqual([]);
|
||||
const second = await (await put(txn, { line_items: items, source: "pantry", replace: true })).json();
|
||||
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 () => {
|
||||
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.
|
||||
await pool.query(
|
||||
`INSERT INTO expense_metadata (transaction_id, matched_transaction_id, source, platform, order_reference, line_items) VALUES (NULL, $1, 'email', 'ubereats', $2, '[]'::jsonb)`,
|
||||
[t, uuid()]
|
||||
);
|
||||
// Inserted second: a row the transaction owns. An unordered LIMIT 1 reads
|
||||
// the first one, the write takes the second, and the enrichment reports
|
||||
// success against a row nothing renders.
|
||||
await pool.query(
|
||||
`INSERT INTO expense_metadata (transaction_id, source, order_reference, line_items) VALUES ($1, 'pantry', $2, '[]'::jsonb)`,
|
||||
[t, uuid()]
|
||||
);
|
||||
const written = await (await put(t, { line_items: items, source: "intake", replace: true })).json();
|
||||
const panel = await orderPanel(t);
|
||||
expect(panel?.line_items).toHaveLength(2);
|
||||
expect((await metaRows()).find((r) => r.id === written.metadata_id)!.line_items).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("finds the row through matched_transaction_id — a card-settled order has only that", async () => {
|
||||
const statementLine = await seedOrder({ merchant: "UBER *EATS", date: "2026-08-19", amount: 22.4, withMeta: false });
|
||||
await pool.query(
|
||||
`INSERT INTO expense_metadata (transaction_id, matched_transaction_id, source, platform, order_reference, line_items) VALUES (NULL, $1, 'email', 'ubereats', $2, '[]'::jsonb)`,
|
||||
[statementLine, uuid()]
|
||||
);
|
||||
const res = await (await put(statementLine, { line_items: items, source: "intake", replace: true })).json();
|
||||
expect(res.created_metadata).toBe(false);
|
||||
expect((await pool.query(`SELECT count(*)::int AS n FROM expense_metadata`)).rows[0].n).toBe(1);
|
||||
expect(await lineItemsOf(statementLine)).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── C1 ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("POST /api/intake — gates", () => {
|
||||
const pdf = { name: "Receipt_19Aug2026.pdf", type: "application/pdf", bytes: BURGERTORY };
|
||||
|
||||
it("fails closed without INTAKE_TOKEN match", async () => {
|
||||
expect((await intakePOST(multipart({ source: "uber_eats" }, [pdf], null))).status).toBe(401);
|
||||
expect((await intakePOST(multipart({ source: "uber_eats" }, [pdf], "nope"))).status).toBe(401);
|
||||
});
|
||||
|
||||
it("400 on a bad source, no files, too many files, a non-uuid batch_id", async () => {
|
||||
expect((await intakePOST(multipart({ source: "menulog" }, [pdf]))).status).toBe(400);
|
||||
expect((await intakePOST(multipart({ source: "uber_eats" }, []))).status).toBe(400);
|
||||
expect((await intakePOST(multipart({ source: "uber_eats" }, Array(7).fill(pdf)))).status).toBe(400);
|
||||
expect((await intakePOST(multipart({ source: "uber_eats", batch_id: "not-a-uuid" }, [pdf]))).status).toBe(400);
|
||||
expect((await pool.query(`SELECT count(*)::int AS n FROM intake_batches`)).rows[0].n).toBe(0);
|
||||
});
|
||||
|
||||
it("declines a file type it cannot read, after persisting the batch", async () => {
|
||||
const id = uuid();
|
||||
const res = await intakePOST(multipart({ source: "uber_eats", batch_id: id }, [{ name: "clip.mp4", type: "video/mp4", bytes: Buffer.from("x") }]));
|
||||
const body = await res.json();
|
||||
expect(body.status).toBe("unsupported");
|
||||
expect(body.text).toMatch(/not this/);
|
||||
expect((await batch(id)).status).toBe("unsupported");
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/intake — food (enrich vs candidates)", () => {
|
||||
const pdf = { name: "Receipt_19Aug2026.pdf", type: "application/pdf", bytes: BURGERTORY };
|
||||
|
||||
it("food + exact (platform, merchant, date, total) match → enriches the existing order, one model call, PDF as text", async () => {
|
||||
const txn = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
|
||||
const id = uuid();
|
||||
const res = await intakePOST(multipart({ source: "uber_eats", shared_by: "bob@example.test", batch_id: id }, [pdf]));
|
||||
const body = await res.json();
|
||||
expect(res.status).toBe(200);
|
||||
expect(body).toMatchObject({ kind: "food", status: "enriched", transaction_id: txn, batch_id: id });
|
||||
expect(body.text).toBe("Added 2 items to your $22.40 Uber Eats (Burgertory (Werribee)) order ✓");
|
||||
expect(JSON.stringify(body.slack_blocks)).toContain("https://finance.test/transactions?q=Burgertory");
|
||||
|
||||
const stored = await lineItemsOf(txn);
|
||||
expect(stored.map((i: { description: string }) => i.description)).toEqual(["Michael", "Joel"]);
|
||||
expect(stored[0].options).toEqual(["Milk Bun"]);
|
||||
|
||||
// one joint call, the PDF's text in it (not an image)
|
||||
expect(calls).toHaveLength(1);
|
||||
const content = calls[0].body as { messages: { content: { type: string; text?: string }[] }[]; model: string; temperature: number; response_format: { type: string } };
|
||||
expect(content.model).toBe("cloud-budget");
|
||||
expect(content.temperature).toBe(0);
|
||||
expect(content.response_format.type).toBe("json_schema");
|
||||
expect(content.messages[0].content.some((c) => c.type === "image_url")).toBe(false);
|
||||
expect(content.messages[0].content.some((c) => c.text?.includes("1 Michael $14.00"))).toBe(true);
|
||||
|
||||
const row = await batch(id);
|
||||
expect(row).toMatchObject({ source: "uber_eats", shared_by: "bob@example.test", kind: "food", status: "enriched", transaction_id: txn });
|
||||
expect(row.header).toMatchObject({ merchant: "Burgertory (Werribee)", order_date: "2026-08-19", total: 22.4, platform: "ubereats" });
|
||||
expect(row.resolved_at).not.toBeNull();
|
||||
expect((await pool.query(`SELECT count(*)::int AS n FROM transactions`)).rows[0].n).toBe(1);
|
||||
});
|
||||
|
||||
it("the platform is part of the key: the same merchant/date/total on DoorDash is not the match — but it IS offered", async () => {
|
||||
const doordash = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4, platform: "doordash" });
|
||||
const body = await (await intakePOST(multipart({ source: "uber_eats" }, [pdf]))).json();
|
||||
expect(body.status).toBe("candidates");
|
||||
// The candidates card is a human choice, not a decision. The platform is one
|
||||
// of the things that may be wrong, so it must not narrow the list — a
|
||||
// platform-filtered card showed nothing at all here.
|
||||
expect(body.candidates.map((c: { transaction_id: number }) => c.transaction_id)).toEqual([doordash]);
|
||||
});
|
||||
|
||||
it("a NULL platform is 'no platform', not a wildcard — an in-store share does not overwrite the delivery order", async () => {
|
||||
await pool.query(`INSERT INTO intake_merchant_kinds (merchant_key, kind) VALUES ('burgertory werribee', 'food')`);
|
||||
const delivery = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4, platform: "ubereats" });
|
||||
const already = [{ qty: 1, description: "what the delivery order already had", amount: 22.4 }];
|
||||
await pool.query(`UPDATE expense_metadata SET line_items = $2::jsonb WHERE transaction_id = $1`, [delivery, JSON.stringify(already)]);
|
||||
|
||||
// `other` implies no platform; the delivery row carries one, so it is not
|
||||
// the match — it is only a candidate.
|
||||
const body = await (await intakePOST(multipart({ source: "other" }, [pdf]))).json();
|
||||
expect(body.status).toBe("candidates");
|
||||
expect(body.candidates.map((c: { transaction_id: number }) => c.transaction_id)).toEqual([delivery]);
|
||||
expect(await lineItemsOf(delivery)).toEqual(already);
|
||||
});
|
||||
|
||||
it("a reconciled manual row is neither matched nor offered — EXCLUDE_RECONCILED_SOURCE, as everywhere else", async () => {
|
||||
const live = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
|
||||
const reconciled = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
|
||||
await pool.query(`UPDATE transactions SET reconciled_with_id = $1 WHERE id = $2`, [live, reconciled]);
|
||||
const near = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-17", amount: 19.9 });
|
||||
await pool.query(`UPDATE transactions SET reconciled_with_id = $1 WHERE id = $2`, [live, near]);
|
||||
|
||||
// Two rows at the exact key would have read as an ambiguity; one of them is
|
||||
// the same spend counted twice.
|
||||
const body = await (await intakePOST(multipart({ source: "uber_eats" }, [pdf]))).json();
|
||||
expect(body).toMatchObject({ status: "enriched", transaction_id: live });
|
||||
});
|
||||
|
||||
it("the write lands on the row the MATCH resolved through, not on whichever row sorts first", async () => {
|
||||
const t = await seedOrder({ merchant: "UBER *EATS", date: "2026-08-19", amount: 22.4, withMeta: false });
|
||||
// The statement line owns a receipt row of its own (a pantry confirm) …
|
||||
const grocery = [{ qty: 1, description: "a grocery shop", amount: 22.4 }];
|
||||
const own = (await pool.query(
|
||||
`INSERT INTO expense_metadata (transaction_id, source, order_reference, merchant_normalized, line_items) VALUES ($1, 'pantry', $2, 'Woolworths', $3::jsonb) RETURNING id`,
|
||||
[t, uuid(), JSON.stringify(grocery)]
|
||||
)).rows[0].id as number;
|
||||
// … and is also the card leg of the Uber Eats order being enriched.
|
||||
const delivery = (await pool.query(
|
||||
`INSERT INTO expense_metadata (transaction_id, matched_transaction_id, source, platform, order_reference, merchant_normalized, line_items) VALUES (NULL, $1, 'email', 'ubereats', $2, 'Burgertory (Werribee)', '[]'::jsonb) RETURNING id`,
|
||||
[t, uuid()]
|
||||
)).rows[0].id as number;
|
||||
|
||||
const body = await (await intakePOST(multipart({ source: "uber_eats" }, [pdf]))).json();
|
||||
expect(body).toMatchObject({ status: "enriched", transaction_id: t });
|
||||
const rows = await metaRows();
|
||||
expect(rows.find((r) => r.id === delivery)!.line_items.map((i) => i.description)).toEqual(["Michael", "Joel"]);
|
||||
expect(rows.find((r) => r.id === own)!.line_items).toEqual(grocery);
|
||||
});
|
||||
|
||||
it("an upload with no prices never replaces a receipt that already has items", async () => {
|
||||
const priceless = { ...burgertory, subtotal: null, total: 22.4, items: burgertory.items.map((i) => ({ ...i, unit_price: null, line_total: null })) };
|
||||
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)]);
|
||||
|
||||
modelReply = priceless;
|
||||
const body = await (await intakePOST(multipart({ source: "uber_eats" }, [pdf]))).json();
|
||||
expect(body).toMatchObject({ kind: "held", status: "held" });
|
||||
expect(body.text).toMatch(/left the existing items alone/);
|
||||
expect(await lineItemsOf(txn)).toEqual(already);
|
||||
|
||||
// …but an EMPTY receipt is exactly what this slice exists to fill.
|
||||
const empty = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-20", amount: 22.4 });
|
||||
modelReply = { ...priceless, order_date: "2026-08-20" };
|
||||
const second = await (await intakePOST(multipart({ source: "uber_eats" }, [pdf]))).json();
|
||||
expect(second).toMatchObject({ status: "enriched", transaction_id: empty });
|
||||
expect((await lineItemsOf(empty)).map((i: { description: string }) => i.description)).toEqual(["Michael", "Joel"]);
|
||||
});
|
||||
|
||||
it("food + no match → candidates (same merchant ±3 days, any amount) with Use-this / Create buttons; NEVER auto-creates", async () => {
|
||||
const near = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-17", amount: 19.9 });
|
||||
await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-10", amount: 22.4 }); // too far
|
||||
await seedOrder({ merchant: "Mad Mex", date: "2026-08-19", amount: 22.4 }); // wrong merchant
|
||||
const id = uuid();
|
||||
const body = await (await intakePOST(multipart({ source: "uber_eats", batch_id: id }, [pdf]))).json();
|
||||
expect(body.status).toBe("candidates");
|
||||
expect(body.candidates.map((c: { transaction_id: number }) => c.transaction_id)).toEqual([near]);
|
||||
const blocks = JSON.stringify(body.slack_blocks);
|
||||
expect(blocks).toContain('"action_id":"intake_use_candidate"');
|
||||
expect(blocks).toContain('"action_id":"intake_create"');
|
||||
expect(blocks).toContain(JSON.stringify(JSON.stringify({ batch_id: id, transaction_id: near })));
|
||||
expect((await pool.query(`SELECT count(*)::int AS n FROM transactions`)).rows[0].n).toBe(3);
|
||||
expect(await lineItemsOf(near)).toEqual([]);
|
||||
expect((await batch(id)).items).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("two exact hits is an ambiguity, not a coin toss", async () => {
|
||||
const a = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
|
||||
const b = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
|
||||
const body = await (await intakePOST(multipart({ source: "uber_eats" }, [pdf]))).json();
|
||||
expect(body.status).toBe("candidates");
|
||||
expect(body.candidates.map((c: { transaction_id: number }) => c.transaction_id).sort()).toEqual([a, b].sort());
|
||||
});
|
||||
|
||||
it("missing header (no total) → needs_header, nothing written but the batch row", async () => {
|
||||
const txn = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
|
||||
modelReply = { ...burgertory, total: null };
|
||||
const id = uuid();
|
||||
const body = await (await intakePOST(multipart({ source: "uber_eats", batch_id: id }, [pdf]))).json();
|
||||
expect(body.status).toBe("needs_header");
|
||||
expect(body.text).toMatch(/share the top of the screen/);
|
||||
expect(await lineItemsOf(txn)).toEqual([]);
|
||||
expect((await batch(id)).status).toBe("needs_header");
|
||||
});
|
||||
|
||||
it("items that do not add up → held, nothing written", async () => {
|
||||
const txn = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
|
||||
modelReply = { ...burgertory, items: [burgertory.items[0]] };
|
||||
const body = await (await intakePOST(multipart({ source: "uber_eats" }, [pdf]))).json();
|
||||
expect(body).toMatchObject({ kind: "held", status: "held" });
|
||||
expect(body.text).toMatch(/is a shot missing/);
|
||||
expect(await lineItemsOf(txn)).toEqual([]);
|
||||
});
|
||||
|
||||
it("a model failure is a 502 with the batch marked error, not a silent 200", async () => {
|
||||
modelReply = new Error("down");
|
||||
const res = await intakePOST(multipart({ source: "uber_eats" }, [pdf]));
|
||||
expect(res.status).toBe(502);
|
||||
expect((await res.json()).status).toBe("error");
|
||||
});
|
||||
|
||||
it("re-share of the same file after it was handled → duplicate with a pointer", async () => {
|
||||
const txn = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
|
||||
const first = uuid();
|
||||
await intakePOST(multipart({ source: "uber_eats", batch_id: first }, [pdf]));
|
||||
calls.length = 0;
|
||||
const body = await (await intakePOST(multipart({ source: "uber_eats" }, [pdf]))).json();
|
||||
expect(body.status).toBe("duplicate");
|
||||
expect(body.duplicate_of).toEqual({ batch_id: first, transaction_id: txn, capture_id: null });
|
||||
expect(body.text).toMatch(/Already handled/);
|
||||
expect(calls).toHaveLength(0); // no model call spent on a known file
|
||||
});
|
||||
|
||||
it("re-share of the same order under a different file (screenshot after PDF) → duplicate by header", async () => {
|
||||
const txn = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
|
||||
const first = uuid();
|
||||
await intakePOST(multipart({ source: "uber_eats", batch_id: first }, [pdf]));
|
||||
const body = await (await intakePOST(multipart({ source: "uber_eats" }, [{ name: "IMG_1.png", type: "image/png", bytes: Buffer.from("fake-png") }]))).json();
|
||||
expect(body.status).toBe("duplicate");
|
||||
expect(body.duplicate_of.transaction_id).toBe(txn);
|
||||
});
|
||||
|
||||
it("a repeated batch_id is refused, not re-run", async () => {
|
||||
const id = uuid();
|
||||
await intakePOST(multipart({ source: "uber_eats", batch_id: id }, [pdf]));
|
||||
expect((await intakePOST(multipart({ source: "uber_eats", batch_id: id }, [pdf]))).status).toBe(409);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/intake — grocery → pantry", () => {
|
||||
const pdf = { name: "Receipt_18Aug2026.pdf", type: "application/pdf", bytes: COLES };
|
||||
|
||||
it("grocery + match → the ORIGINAL file goes to pantry with the target transaction and action enrich; finance is NOT touched", async () => {
|
||||
modelReply = coles;
|
||||
const txn = await seedOrder({ merchant: "Coles (Wyndham Vale)", date: "2026-08-18", amount: 85.74 });
|
||||
const id = uuid();
|
||||
const body = await (await intakePOST(multipart({ source: "uber_eats", shared_by: "bob@example.test", batch_id: id }, [pdf]))).json();
|
||||
expect(body).toMatchObject({ kind: "grocery", status: "forwarded_to_pantry", capture_id: 901, transaction_id: txn });
|
||||
expect(body.text).toBe("Coles (Wyndham Vale) $85.74 → pantry board (3 items) — finance updates on confirm");
|
||||
|
||||
const pantryCall = calls.find((c) => c.url === "http://pantry.test/api/capture")!.body as { intake: Record<string, unknown>; files: string[]; headers: Record<string, string> };
|
||||
expect(pantryCall.headers["x-ingest-token"]).toBe(RECEIPT);
|
||||
expect(pantryCall.files).toEqual(["Receipt_18Aug2026.pdf"]);
|
||||
expect(pantryCall.intake).toEqual({ batch_id: id, platform: "ubereats", origin: "intake", shared_by: "bob@example.test", finance_target_transaction_id: txn, finance_action: "enrich" });
|
||||
|
||||
expect(await lineItemsOf(txn)).toEqual([]);
|
||||
expect((await batch(id))).toMatchObject({ kind: "grocery", status: "forwarded_to_pantry", capture_id: 901, transaction_id: txn });
|
||||
});
|
||||
|
||||
it("an in-store tap does not hand pantry the delivery order as its finance target", async () => {
|
||||
modelReply = coles;
|
||||
// The Uber Eats Coles order the email pipeline already booked.
|
||||
const delivery = await seedOrder({ merchant: "Coles (Wyndham Vale)", date: "2026-08-18", amount: 85.74, platform: "ubereats" });
|
||||
// The same shop, same day, same money — but shared from the Coles app, so
|
||||
// it carries no platform and is a different purchase.
|
||||
const body = await (await intakePOST(multipart({ source: "coles" }, [pdf]))).json();
|
||||
expect(body.status).toBe("forwarded_to_pantry");
|
||||
const pantryCall = calls.find((c) => c.url === "http://pantry.test/api/capture")!.body as { intake: Record<string, unknown> };
|
||||
expect(pantryCall.intake).toMatchObject({ finance_action: "create", finance_target_transaction_id: null, platform: null });
|
||||
expect(await lineItemsOf(delivery)).toEqual([]);
|
||||
});
|
||||
|
||||
it("grocery + no match → pantry with finance_action create (ING-10 as today)", async () => {
|
||||
modelReply = coles;
|
||||
const body = await (await intakePOST(multipart({ source: "coles" }, [pdf]))).json();
|
||||
expect(body.status).toBe("forwarded_to_pantry");
|
||||
expect(body.text).toMatch(/finance books it on confirm/);
|
||||
const pantryCall = calls.find((c) => c.url.includes("/api/capture"))!.body as { intake: Record<string, unknown> };
|
||||
expect(pantryCall.intake).toMatchObject({ platform: null, finance_target_transaction_id: null, finance_action: "create" });
|
||||
});
|
||||
|
||||
it("pantry 409 → duplicate", async () => {
|
||||
modelReply = coles;
|
||||
pantryReply = { status: 409, body: { error: "This receipt has already been added to the pantry.", captureId: 77 } };
|
||||
const body = await (await intakePOST(multipart({ source: "coles" }, [pdf]))).json();
|
||||
expect(body).toMatchObject({ kind: "grocery", status: "duplicate", capture_id: 77 });
|
||||
});
|
||||
|
||||
it("pantry down → 502 error, batch marked error", async () => {
|
||||
modelReply = coles;
|
||||
pantryReply = { status: 500, body: { error: "nope" } };
|
||||
const res = await intakePOST(multipart({ source: "coles" }, [pdf]));
|
||||
expect(res.status).toBe(502);
|
||||
expect((await res.json()).status).toBe("error");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── C5 ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
const slackPress = (action_id: string, value: unknown) => {
|
||||
const payload = { type: "block_actions", user: { id: "U1", name: "alice" }, response_url: "https://hooks.slack.com/r/1", actions: [{ action_id, value: JSON.stringify(value) }] };
|
||||
const raw = new URLSearchParams({ payload: JSON.stringify(payload) }).toString();
|
||||
return { headers: { get: (h: string) => (h === "x-ingest-token" ? "test-order-token" : "x") }, text: async () => raw } as unknown as NextRequest;
|
||||
};
|
||||
const lastSlackUpdate = () => calls.filter((c) => c.url.includes("hooks.slack.com")).at(-1)?.body as { replace_original?: boolean; text: string; blocks?: unknown[] };
|
||||
|
||||
describe("Slack taps (C5)", () => {
|
||||
const pdf = { name: "Receipt_19Aug2026.pdf", type: "application/pdf", bytes: BURGERTORY };
|
||||
|
||||
it("intake_use_candidate enriches the chosen transaction and replaces the card", async () => {
|
||||
const near = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-17", amount: 19.9 });
|
||||
const id = uuid();
|
||||
await intakePOST(multipart({ source: "uber_eats", batch_id: id }, [pdf]));
|
||||
const res = await slackPOST(slackPress("intake_use_candidate", { batch_id: id, transaction_id: near }));
|
||||
expect(res.status).toBe(200);
|
||||
expect((await lineItemsOf(near)).map((i: { description: string }) => i.description)).toEqual(["Michael", "Joel"]);
|
||||
expect(await batch(id)).toMatchObject({ status: "enriched", transaction_id: near });
|
||||
expect(lastSlackUpdate()).toMatchObject({ replace_original: true, text: "Added 2 items to your $22.40 Uber Eats (Burgertory (Werribee)) order ✓" });
|
||||
});
|
||||
|
||||
it("intake_create creates the transaction ING-10-style: manual, owner from shared_by, items attached; a second press is a no-op", async () => {
|
||||
const id = uuid();
|
||||
await intakePOST(multipart({ source: "uber_eats", shared_by: "bob@example.test", batch_id: id }, [pdf]));
|
||||
expect((await pool.query(`SELECT count(*)::int AS n FROM transactions`)).rows[0].n).toBe(0);
|
||||
|
||||
await slackPOST(slackPress("intake_create", { batch_id: id }));
|
||||
const t = (await pool.query(`SELECT statement_id, owner_id, description, amount::text, category, transaction_date::text, source, source_ref FROM transactions`)).rows;
|
||||
expect(t).toEqual([{ statement_id: null, owner_id: otherId, description: "Order - Burgertory (Werribee)", amount: "22.40", category: "dining", transaction_date: "2026-08-19", source: "intake", source_ref: id }]);
|
||||
const em = (await pool.query(`SELECT source, platform, line_items FROM expense_metadata`)).rows[0];
|
||||
expect(em.source).toBe("intake");
|
||||
expect(em.platform).toBe("ubereats");
|
||||
expect(em.line_items.map((i: { description: string }) => i.description)).toEqual(["Michael", "Joel"]);
|
||||
expect(lastSlackUpdate().text).toMatch(/^Created a \$22\.40 Burgertory \(Werribee\) transaction with 2 items/);
|
||||
|
||||
await slackPOST(slackPress("intake_create", { batch_id: id }));
|
||||
expect((await pool.query(`SELECT count(*)::int AS n FROM transactions`)).rows[0].n).toBe(1);
|
||||
});
|
||||
|
||||
it("intake_create re-runs the match: an order booked between the card and the tap is enriched, not duplicated", async () => {
|
||||
const id = uuid();
|
||||
const body = await (await intakePOST(multipart({ source: "uber_eats", batch_id: id }, [pdf]))).json();
|
||||
expect(body.status).toBe("candidates");
|
||||
|
||||
// The email/order bridge books the same purchase while the card sits in
|
||||
// Slack. The card's "no match" is minutes stale by the time it is pressed.
|
||||
const txn = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
|
||||
|
||||
await slackPOST(slackPress("intake_create", { batch_id: id }));
|
||||
expect((await pool.query(`SELECT count(*)::int AS n FROM transactions`)).rows[0].n).toBe(1);
|
||||
expect(await batch(id)).toMatchObject({ status: "enriched", transaction_id: txn });
|
||||
expect((await lineItemsOf(txn)).map((i: { description: string }) => i.description)).toEqual(["Michael", "Joel"]);
|
||||
expect(lastSlackUpdate().text).toMatch(/^Added 2 items/);
|
||||
});
|
||||
|
||||
it("two pending cards for one order 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") }]));
|
||||
expect((await batch(a)).status).toBe("candidates");
|
||||
expect((await batch(b)).status).toBe("candidates");
|
||||
|
||||
await slackPOST(slackPress("intake_create", { batch_id: a }));
|
||||
await slackPOST(slackPress("intake_create", { batch_id: b }));
|
||||
expect((await pool.query(`SELECT count(*)::int AS n FROM transactions`)).rows[0].n).toBe(1);
|
||||
expect((await batch(b)).transaction_id).toBe((await batch(a)).transaction_id);
|
||||
});
|
||||
|
||||
it("a sibling already resolved to the pantry board makes Create a duplicate, not a second row", async () => {
|
||||
// Two shares of one receipt, resolved out of order: nothing the second card
|
||||
// can match on, because the first one never produced a transaction.
|
||||
modelReply = { ...coles, merchant: "Romeo's Food Hall" };
|
||||
const a = uuid();
|
||||
const b = uuid();
|
||||
await intakePOST(multipart({ source: "other", batch_id: b }, [{ name: "shot.png", type: "image/png", bytes: Buffer.from("shot") }]));
|
||||
await intakePOST(multipart({ source: "other", batch_id: a }, [{ name: "Receipt_18Aug2026.pdf", type: "application/pdf", bytes: COLES }]));
|
||||
expect((await batch(a)).status).toBe("ask");
|
||||
|
||||
await slackPOST(slackPress("intake_grocery", { batch_id: a }));
|
||||
expect((await batch(a)).status).toBe("forwarded_to_pantry");
|
||||
|
||||
await slackPOST(slackPress("intake_meal", { batch_id: b }));
|
||||
expect((await batch(b)).status).toBe("candidates");
|
||||
await slackPOST(slackPress("intake_create", { batch_id: b }));
|
||||
expect((await pool.query(`SELECT count(*)::int AS n FROM transactions`)).rows[0].n).toBe(0);
|
||||
expect(await batch(b)).toMatchObject({ status: "duplicate", capture_id: 901 });
|
||||
expect(lastSlackUpdate().text).toMatch(/^Already handled/);
|
||||
});
|
||||
|
||||
it("two taps that land together still book one transaction, and neither reports an error", async () => {
|
||||
const id = uuid();
|
||||
await intakePOST(multipart({ source: "uber_eats", batch_id: id }, [pdf]));
|
||||
await Promise.all([slackPOST(slackPress("intake_create", { batch_id: id })), slackPOST(slackPress("intake_create", { batch_id: id }))]);
|
||||
expect((await pool.query(`SELECT count(*)::int AS n FROM transactions`)).rows[0].n).toBe(1);
|
||||
const texts = calls.filter((c) => c.url.includes("hooks.slack.com")).map((c) => (c.body as { text: string }).text);
|
||||
expect(texts.every((t) => !/Couldn't process/.test(t))).toBe(true);
|
||||
});
|
||||
|
||||
it("a foreign receipt is booked in its own currency, with no invented AUD figure", async () => {
|
||||
modelReply = { ...burgertory, currency: "USD" };
|
||||
const id = uuid();
|
||||
await intakePOST(multipart({ source: "uber_eats", batch_id: id }, [pdf]));
|
||||
await slackPOST(slackPress("intake_create", { batch_id: id }));
|
||||
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(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 () => {
|
||||
const id = uuid();
|
||||
await intakePOST(multipart({ source: "uber_eats", shared_by: "nobody@example.test", batch_id: id }, [pdf]));
|
||||
await slackPOST(slackPress("intake_create", { batch_id: id }));
|
||||
expect((await pool.query(`SELECT owner_id FROM transactions`)).rows[0].owner_id).toBe(1);
|
||||
});
|
||||
|
||||
it("`other` + unrecognised merchant asks; intake_meal routes it as food and remembers the merchant", async () => {
|
||||
modelReply = { ...burgertory, merchant: "Romeo's Food Hall" };
|
||||
const txn = await seedOrder({ merchant: "Romeo's Food Hall", date: "2026-08-19", amount: 22.4, platform: null });
|
||||
const id = uuid();
|
||||
const body = await (await intakePOST(multipart({ source: "other", batch_id: id }, [pdf]))).json();
|
||||
expect(body).toMatchObject({ kind: "ask", status: "ask" });
|
||||
expect(JSON.stringify(body.slack_blocks)).toContain("intake_grocery");
|
||||
|
||||
await slackPOST(slackPress("intake_meal", { batch_id: id }));
|
||||
expect(await batch(id)).toMatchObject({ status: "enriched", transaction_id: txn });
|
||||
expect(await lineItemsOf(txn)).toHaveLength(2);
|
||||
expect((await pool.query(`SELECT kind FROM intake_merchant_kinds WHERE merchant_key = 'romeo s food hall'`)).rows[0].kind).toBe("food");
|
||||
|
||||
// The answer sticks: the next share of that merchant does not ask.
|
||||
const again = await (await intakePOST(multipart({ source: "other" }, [{ name: "IMG.png", type: "image/png", bytes: Buffer.from("png2") }]))).json();
|
||||
expect(again.status).toBe("duplicate"); // same order — but it was routed, not asked
|
||||
modelReply = { ...burgertory, merchant: "Romeo's Food Hall", order_date: "2026-08-20" };
|
||||
const next = await (await intakePOST(multipart({ source: "other" }, [{ name: "IMG3.png", type: "image/png", bytes: Buffer.from("png3") }]))).json();
|
||||
expect(next).toMatchObject({ kind: "food", status: "candidates" });
|
||||
});
|
||||
|
||||
it("intake_grocery forwards the stored original files to pantry", async () => {
|
||||
modelReply = { ...coles, merchant: "Romeo's Food Hall" };
|
||||
const id = uuid();
|
||||
await intakePOST(multipart({ source: "other", batch_id: id }, [{ name: "Receipt_18Aug2026.pdf", type: "application/pdf", bytes: COLES }]));
|
||||
expect((await batch(id)).status).toBe("ask");
|
||||
await slackPOST(slackPress("intake_grocery", { batch_id: id }));
|
||||
const pantryCall = calls.find((c) => c.url.includes("/api/capture"))!.body as { intake: Record<string, unknown>; files: string[] };
|
||||
expect(pantryCall.files).toEqual(["Receipt_18Aug2026.pdf"]);
|
||||
expect(pantryCall.intake).toMatchObject({ batch_id: id, finance_action: "create" });
|
||||
expect(await batch(id)).toMatchObject({ kind: "grocery", status: "forwarded_to_pantry", capture_id: 901 });
|
||||
expect((await pool.query(`SELECT kind FROM intake_merchant_kinds`)).rows[0].kind).toBe("grocery");
|
||||
});
|
||||
|
||||
it("a tap on a resolved batch returns its card and writes nothing; an unknown batch says so", async () => {
|
||||
const txn = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
|
||||
const id = uuid();
|
||||
await intakePOST(multipart({ source: "uber_eats", batch_id: id }, [pdf]));
|
||||
await slackPOST(slackPress("intake_create", { batch_id: id }));
|
||||
expect((await pool.query(`SELECT count(*)::int AS n FROM transactions`)).rows[0].n).toBe(1);
|
||||
expect(lastSlackUpdate().text).toMatch(/^Added 2 items/);
|
||||
expect(await batch(id)).toMatchObject({ status: "enriched", transaction_id: txn });
|
||||
|
||||
await slackPOST(slackPress("intake_create", { batch_id: uuid() }));
|
||||
expect(lastSlackUpdate()).toMatchObject({ replace_original: false, text: "I can't find that upload any more." });
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect, beforeAll } from "vitest";
|
||||
import { readFileSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { queryRaw } from "../../lib/db";
|
||||
|
||||
/**
|
||||
@@ -13,13 +14,22 @@ const dir = resolve(__dirname, "../fixtures/orders/real");
|
||||
const html = (f: string) => readFileSync(resolve(dir, `${f}.html`), "utf-8");
|
||||
|
||||
const TOKEN = "test-ingest-token";
|
||||
let POST: any;
|
||||
|
||||
// The handler is imported dynamically (after the env var is set), so its type
|
||||
// is declared here rather than inferred. `unknown` for the request is
|
||||
// deliberate: these fixtures are the two properties the route actually reads,
|
||||
// not a whole NextRequest, and widening to `any` would stop the compiler
|
||||
// checking the call sites too.
|
||||
let POST: (req: NextRequest) => Promise<Response>;
|
||||
|
||||
// One cast, at the boundary, instead of `any` everywhere downstream: the
|
||||
// fixture is the two properties the route actually reads, and everything past
|
||||
// this line stays type-checked.
|
||||
const req = (body: unknown, token: string | null = TOKEN) =>
|
||||
({
|
||||
headers: { get: (h: string) => (h === "x-ingest-token" ? token : null) },
|
||||
json: async () => body,
|
||||
}) as any;
|
||||
}) as unknown as NextRequest;
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.ORDER_INGEST_TOKEN = TOKEN;
|
||||
|
||||
@@ -177,21 +177,43 @@ describe("Order ingestion — invariants", () => {
|
||||
expect(b.skipped).toBe("already_ingested");
|
||||
expect(b.metadataId).toBe(a.metadataId);
|
||||
const n = await queryRow<{ c: string }>(
|
||||
`SELECT count(*)::text c FROM transactions WHERE description = 'Order - Mad Mex (DoorDash)'`
|
||||
`SELECT count(*)::text c FROM transactions WHERE description = 'Order - Mad Mex'`
|
||||
);
|
||||
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 res = await processOrderIngestion(p);
|
||||
expect(res.skipped).toBe("pre_cutover");
|
||||
expect(res.transactionId).toBeNull();
|
||||
await expect(
|
||||
queryRaw(
|
||||
`INSERT INTO transactions (transaction_date, amount, payment_method) VALUES ('2025-11-15', 20.00, 'credits')`
|
||||
)
|
||||
).rejects.toThrow();
|
||||
expect(res.skipped).toBeUndefined();
|
||||
expect(res.transactionId).not.toBeNull();
|
||||
expect(res.flags).toContain("pre_cutover_credits_order");
|
||||
|
||||
const txn = await queryRow<{ transaction_date: string; payment_method: string; amount: string }>(
|
||||
`SELECT transaction_date::text, payment_method, amount::text
|
||||
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 () => {
|
||||
@@ -389,15 +411,17 @@ describe("owner scoping", () => {
|
||||
});
|
||||
|
||||
describe("how an ingested order presents in the app", () => {
|
||||
it("names the platform in the description", async () => {
|
||||
// "Order - Burger Corner" gives no way to know where to look for the
|
||||
// detail, and the same restaurant can be on both platforms.
|
||||
it("names the restaurant, not the courier", async () => {
|
||||
// Reversed 2026-07-28. The platform was in the headline on request, but it
|
||||
// fragmented the merchant — the same restaurant read differently depending
|
||||
// on who delivered — and it already has a home: the Order details panel
|
||||
// renders expense_metadata.platform next to its heading.
|
||||
const p = parseOrderHTML(html("dd-01"), meta({ messageId: `desc-${Date.now()}` }));
|
||||
const res = await processOrderIngestion(p);
|
||||
const row = await queryRow<{ description: string }>(
|
||||
`SELECT description FROM transactions WHERE id = $1`, [res.transactionId]
|
||||
);
|
||||
expect(row!.description).toMatch(/\(DoorDash\)$/);
|
||||
expect(row!.description).toBe('Order - Mad Mex');
|
||||
});
|
||||
|
||||
it("reads as 'Gift Card', not 'Manual', and stays out of the reconcile queue", async () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect, beforeAll, beforeEach, vi } from "vitest";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { createPool, mockDbWithPool, resetDB } from "./helpers";
|
||||
|
||||
/**
|
||||
@@ -12,15 +13,41 @@ import { createPool, mockDbWithPool, resetDB } from "./helpers";
|
||||
const pool = createPool();
|
||||
mockDbWithPool(pool);
|
||||
|
||||
let GET: any;
|
||||
let PUT: any;
|
||||
/** What the review route returns, to the extent these tests read it. */
|
||||
type ItemVerdict = { item: string; verdict: string };
|
||||
type Review = {
|
||||
participant_id: number;
|
||||
rating: string | null;
|
||||
note: string | null;
|
||||
order_again: boolean | null;
|
||||
item_verdicts: ItemVerdict[];
|
||||
};
|
||||
type ReviewBody = {
|
||||
reviews: Review[];
|
||||
splits: { participant_id: number; share_percent: number }[];
|
||||
merchant: {
|
||||
warn: boolean;
|
||||
note: string | null;
|
||||
counts: Record<string, number>;
|
||||
history: { transaction_id: number }[];
|
||||
items: { item: string; loved: number; never: number }[];
|
||||
} | null;
|
||||
};
|
||||
type Ctx = { params: Promise<{ id: string }> };
|
||||
|
||||
// Declared rather than inferred because the route is imported dynamically,
|
||||
// after vi.doMock. `unknown` for the request keeps the fixture honest — it is
|
||||
// the two properties the handler reads, not a NextRequest.
|
||||
let GET: (req: NextRequest, ctx: Ctx) => Promise<Response>;
|
||||
let PUT: (req: NextRequest, ctx: Ctx) => Promise<Response>;
|
||||
let ownerId: number;
|
||||
let otherId: number;
|
||||
let txnId: number;
|
||||
|
||||
const req = (body?: unknown) =>
|
||||
({ headers: { get: () => null }, json: async () => body }) as any;
|
||||
const params = (id: number) => ({ params: Promise.resolve({ id: String(id) }) });
|
||||
({ headers: { get: () => null }, json: async () => body }) as unknown as NextRequest;
|
||||
const params = (id: number): Ctx => ({ params: Promise.resolve({ id: String(id) }) });
|
||||
const bodyOf = async (res: Response): Promise<ReviewBody> => (await res.json()) as ReviewBody;
|
||||
|
||||
beforeAll(async () => {
|
||||
vi.doMock("@/lib/auth", () => ({
|
||||
@@ -59,25 +86,25 @@ describe("order verdicts — one per person", () => {
|
||||
it("keeps both people's verdicts on the same order", async () => {
|
||||
await PUT(req({ participant_id: ownerId, rating: "loved" }), params(txnId));
|
||||
const res = await PUT(req({ participant_id: otherId, rating: "never" }), params(txnId));
|
||||
const body = await res.json();
|
||||
const body = await bodyOf(res);
|
||||
|
||||
// The bug this guards: a UNIQUE on transaction_id alone made the second
|
||||
// save overwrite the first, and the disagreement is the useful part.
|
||||
expect(body.reviews).toHaveLength(2);
|
||||
expect(body.reviews.find((r: any) => r.participant_id === ownerId).rating).toBe("loved");
|
||||
expect(body.reviews.find((r: any) => r.participant_id === otherId).rating).toBe("never");
|
||||
expect(body.reviews.find((r) => r.participant_id === ownerId)?.rating).toBe("loved");
|
||||
expect(body.reviews.find((r) => r.participant_id === otherId)?.rating).toBe("never");
|
||||
});
|
||||
|
||||
it("defaults the verdict to the signed-in user, not the owner", async () => {
|
||||
const res = await PUT(req({ rating: "ok" }), params(txnId));
|
||||
const body = await res.json();
|
||||
const body = await bodyOf(res);
|
||||
expect(body.reviews[0].participant_id).toBe(ownerId);
|
||||
});
|
||||
|
||||
it("revising a verdict updates rather than duplicating", async () => {
|
||||
await PUT(req({ participant_id: ownerId, rating: "loved" }), params(txnId));
|
||||
const res = await PUT(req({ participant_id: ownerId, rating: "never" }), params(txnId));
|
||||
const body = await res.json();
|
||||
const body = await bodyOf(res);
|
||||
expect(body.reviews).toHaveLength(1);
|
||||
expect(body.reviews[0].rating).toBe("never");
|
||||
});
|
||||
@@ -88,10 +115,29 @@ describe("order verdicts — one per person", () => {
|
||||
});
|
||||
|
||||
it("derives order_again from the rating", async () => {
|
||||
let body = await (await PUT(req({ rating: "never" }), params(txnId))).json();
|
||||
let body = await bodyOf(await PUT(req({ rating: "never" }), params(txnId)));
|
||||
expect(body.reviews[0].order_again).toBe(false);
|
||||
body = await (await PUT(req({ rating: "ok" }), params(txnId))).json();
|
||||
body = await bodyOf(await PUT(req({ rating: "ok" }), params(txnId)));
|
||||
expect(body.reviews[0].order_again).toBe(true);
|
||||
// "bad" is also a no — you would not choose it again.
|
||||
body = await bodyOf(await PUT(req({ rating: "bad" }), params(txnId)));
|
||||
expect(body.reviews[0].order_again).toBe(false);
|
||||
});
|
||||
|
||||
it("only `never` raises the warning, not `bad`", async () => {
|
||||
// The boundary the five-level scale exists for. A blacklist that fires for
|
||||
// every mediocre meal is one nobody reads, so "bad" records the
|
||||
// disappointment without triggering the alarm.
|
||||
const older = await seedOrder("Thai Palace", "2026-06-01");
|
||||
await PUT(req({ participant_id: ownerId, rating: "bad" }), params(older));
|
||||
|
||||
let body = await bodyOf(await GET(req(), params(txnId)));
|
||||
expect(body.merchant!.counts.bad).toBe(1);
|
||||
expect(body.merchant!.warn).toBe(false);
|
||||
|
||||
await PUT(req({ participant_id: ownerId, rating: "never" }), params(older));
|
||||
body = await bodyOf(await GET(req(), params(txnId)));
|
||||
expect(body.merchant!.warn).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -106,7 +152,7 @@ describe("item verdicts", () => {
|
||||
);
|
||||
// No item_verdicts key at all — the shape a note-only form sends.
|
||||
const res = await PUT(req({ rating: "liked", note: "slow delivery" }), params(txnId));
|
||||
const body = await res.json();
|
||||
const body = await bodyOf(res);
|
||||
expect(body.reviews[0].item_verdicts).toEqual([
|
||||
{ item: "Pad Thai", verdict: "loved" },
|
||||
]);
|
||||
@@ -119,7 +165,7 @@ describe("item verdicts", () => {
|
||||
params(txnId)
|
||||
);
|
||||
const res = await PUT(req({ rating: "liked", item_verdicts: [] }), params(txnId));
|
||||
const body = await res.json();
|
||||
const body = await bodyOf(res);
|
||||
expect(body.reviews[0].item_verdicts).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -135,7 +181,7 @@ describe("item verdicts", () => {
|
||||
}),
|
||||
params(txnId)
|
||||
);
|
||||
const body = await res.json();
|
||||
const body = await bodyOf(res);
|
||||
expect(body.reviews[0].rating).toBe("ok");
|
||||
expect(body.reviews[0].item_verdicts).toEqual([
|
||||
{ item: "Pad Thai", verdict: "loved" },
|
||||
@@ -148,18 +194,31 @@ describe("merchant history", () => {
|
||||
const older = await seedOrder("Thai Palace", "2026-06-01");
|
||||
await PUT(req({ participant_id: ownerId, rating: "never", note: "cold" }), params(older));
|
||||
|
||||
const body = await (await GET(req(), params(txnId))).json();
|
||||
expect(body.merchant.warn).toBe(true);
|
||||
expect(body.merchant.counts.never).toBe(1);
|
||||
expect(body.merchant.history.map((h: any) => h.transaction_id)).toEqual([older]);
|
||||
const body = await bodyOf(await GET(req(), params(txnId)));
|
||||
expect(body.merchant!.warn).toBe(true);
|
||||
expect(body.merchant!.counts.never).toBe(1);
|
||||
expect(body.merchant!.history.map((h) => h.transaction_id)).toEqual([older]);
|
||||
});
|
||||
|
||||
it("treats the same restaurant as one merchant across platforms", async () => {
|
||||
// DoorDash and Uber Eats capitalise differently — "TEG Kebabs & Biryani"
|
||||
// vs "TEG KEBABS & BIRYANI". An exact match split one restaurant's history
|
||||
// in two, so a "never again" recorded through one app never warned in the
|
||||
// other, silently defeating the point of the memory.
|
||||
const shouty = await seedOrder("THAI PALACE", "2026-06-01");
|
||||
await PUT(req({ participant_id: ownerId, rating: "never" }), params(shouty));
|
||||
|
||||
const body = await bodyOf(await GET(req(), params(txnId)));
|
||||
expect(body.merchant!.warn).toBe(true);
|
||||
expect(body.merchant!.history.map((h) => h.transaction_id)).toContain(shouty);
|
||||
});
|
||||
|
||||
it("does not carry a verdict across different merchants", async () => {
|
||||
const other = await seedOrder("Pizza Place", "2026-06-01");
|
||||
await PUT(req({ participant_id: ownerId, rating: "never" }), params(other));
|
||||
|
||||
const body = await (await GET(req(), params(txnId))).json();
|
||||
expect(body.merchant.warn).toBe(false);
|
||||
const body = await bodyOf(await GET(req(), params(txnId)));
|
||||
expect(body.merchant!.warn).toBe(false);
|
||||
});
|
||||
|
||||
it("pools item opinions across the merchant's orders", async () => {
|
||||
@@ -174,10 +233,10 @@ describe("merchant history", () => {
|
||||
params(older2)
|
||||
);
|
||||
|
||||
const body = await (await GET(req(), params(txnId))).json();
|
||||
const body = await bodyOf(await GET(req(), params(txnId)));
|
||||
// Case-folded: the same dish comes back capitalised differently between
|
||||
// receipts, and two entries for one dish is not a track record.
|
||||
expect(body.merchant.items).toEqual([{ item: "Pad Thai", loved: 2, never: 0 }]);
|
||||
expect(body.merchant!.items).toEqual([{ item: "Pad Thai", loved: 2, never: 0 }]);
|
||||
});
|
||||
|
||||
it("keeps item opinions from reviews that have no overall rating", async () => {
|
||||
@@ -186,14 +245,14 @@ describe("merchant history", () => {
|
||||
req({ item_verdicts: [{ item: "Satay", verdict: "never" }] }),
|
||||
params(older)
|
||||
);
|
||||
const body = await (await GET(req(), params(txnId))).json();
|
||||
expect(body.merchant.items).toEqual([{ item: "Satay", loved: 0, never: 1 }]);
|
||||
const body = await bodyOf(await GET(req(), params(txnId)));
|
||||
expect(body.merchant!.items).toEqual([{ item: "Satay", loved: 0, never: 1 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("share state", () => {
|
||||
it("reports splits so the panel can show shared vs just me", async () => {
|
||||
let body = await (await GET(req(), params(txnId))).json();
|
||||
let body = await bodyOf(await GET(req(), params(txnId)));
|
||||
expect(body.splits).toEqual([]);
|
||||
|
||||
await pool.query(
|
||||
@@ -201,7 +260,7 @@ describe("share state", () => {
|
||||
VALUES ($1, $2, 50)`,
|
||||
[txnId, otherId]
|
||||
);
|
||||
body = await (await GET(req(), params(txnId))).json();
|
||||
body = await bodyOf(await GET(req(), params(txnId)));
|
||||
expect(body.splits).toHaveLength(1);
|
||||
expect(body.splits[0].participant_id).toBe(otherId);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { queryRaw, queryRow } from "../../lib/db";
|
||||
import { needsCardMatch } from "../../lib/queries";
|
||||
import {
|
||||
PAYMENT_METHODS,
|
||||
NON_RECONCILING,
|
||||
reconcilesAgainstStatement,
|
||||
} from "../../lib/payment-methods";
|
||||
|
||||
/**
|
||||
* These two lists drifted, and the drift was silent for two months.
|
||||
*
|
||||
* Migration 0018 admitted 'credits' to `transactions_payment_method_check` so
|
||||
* the order pipeline could file gift-card-funded orders. Nothing propagated it
|
||||
* to the hand-written allowlist in `PATCH /api/transactions/[id]` or to the
|
||||
* add-transaction dropdown, so the database held 372 rows carrying a value the
|
||||
* UI could not enter and the API refused to set.
|
||||
*
|
||||
* The failure mode is invisible on screen — a gift-card purchase entered by
|
||||
* hand takes the NULL default, and NULL is *reconcilable*, so it joins the
|
||||
* pending queue and is offered card matches that cannot exist. Accepting one is
|
||||
* destructive: reconciled manual rows are filtered out of every query, so the
|
||||
* spend vanishes while an unrelated card row claims to be it.
|
||||
*
|
||||
* So: assert against the database, not against a second copy of the list.
|
||||
*/
|
||||
describe("payment methods mirror the database", () => {
|
||||
it("PAYMENT_METHODS equals the CHECK constraint's vocabulary", async () => {
|
||||
const row = await queryRow<{ def: string }>(
|
||||
`SELECT pg_get_constraintdef(oid) AS def FROM pg_constraint
|
||||
WHERE conname = 'transactions_payment_method_check'`
|
||||
);
|
||||
expect(row, "constraint transactions_payment_method_check is missing").not.toBeNull();
|
||||
|
||||
const inConstraint = [...row!.def.matchAll(/'([a-z_]+)'::text/g)].map((m) => m[1]);
|
||||
expect(inConstraint.length).toBeGreaterThan(0); // guard against the regex silently matching nothing
|
||||
expect([...inConstraint].sort()).toEqual([...PAYMENT_METHODS].sort());
|
||||
});
|
||||
|
||||
it("reconcilesAgainstStatement() agrees with needsCardMatch() for every method", async () => {
|
||||
// Evaluate the real SQL predicate rather than re-implementing it: this is
|
||||
// the TypeScript mirror, so the SQL is the thing being mirrored.
|
||||
const rows = await queryRaw<{ pm: string | null; sql_reconciles: boolean }>(
|
||||
`SELECT v.payment_method AS pm, ${needsCardMatch("v")} AS sql_reconciles
|
||||
FROM (SELECT unnest($1::text[]) AS payment_method) v`,
|
||||
[[...PAYMENT_METHODS, null]]
|
||||
);
|
||||
expect(rows).toHaveLength(PAYMENT_METHODS.length + 1);
|
||||
|
||||
for (const { pm, sql_reconciles } of rows) {
|
||||
expect(reconcilesAgainstStatement(pm), `payment_method = ${pm ?? "NULL"}`)
|
||||
.toBe(sql_reconciles);
|
||||
}
|
||||
});
|
||||
|
||||
it("holds cash and credits out of the queue, and nothing else", () => {
|
||||
expect([...NON_RECONCILING].sort()).toEqual(["cash", "credits"]);
|
||||
// NULL means *unknown*, never "no card leg is coming" — it must stay a
|
||||
// candidate or every pre-existing row would drop out of reconciliation.
|
||||
expect(reconcilesAgainstStatement(null)).toBe(true);
|
||||
expect(reconcilesAgainstStatement(undefined)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,10 @@ mockDbWithPool(pool);
|
||||
|
||||
// Dynamic import AFTER the mock ensures getTransactions / getParticipantBalances
|
||||
// use the test pool rather than Prisma's singleton.
|
||||
const { getTransactions, getParticipantBalances, getTripAnalytics, getTripById, getStatements } = await import("@/lib/queries");
|
||||
const {
|
||||
getTransactions, getParticipantBalances, getTripAnalytics, getTripById, getStatements,
|
||||
getTrips, isTripParticipant, assignTransactionsToTrip, deleteTrip,
|
||||
} = await import("@/lib/queries");
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDB(pool);
|
||||
@@ -105,6 +108,63 @@ describe("getTransactions — category filter", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTransactions — exclude_categories", () => {
|
||||
it("hides the excluded category", async () => {
|
||||
const { ownerId } = await seedParticipants(pool);
|
||||
await insertTransaction(pool, ownerId, { description: "Grocery run", category: "groceries" });
|
||||
await insertTransaction(pool, ownerId, { description: "Card payment", category: "transfers" });
|
||||
|
||||
const { data, total } = await getTransactions(ownerId, {
|
||||
exclude_categories: ["transfers"], limit: 50, offset: 0,
|
||||
});
|
||||
expect(data).toHaveLength(1);
|
||||
expect(total).toBe(1);
|
||||
expect(data[0].description).toBe("Grocery run");
|
||||
});
|
||||
|
||||
it("keeps uncategorised rows visible", async () => {
|
||||
// NULL <> ALL(...) is NULL, not true. Without the COALESCE an uncategorised
|
||||
// row would vanish from a filter that never named its category.
|
||||
const { ownerId } = await seedParticipants(pool);
|
||||
// insertTransaction defaults category to 'other', so insert directly.
|
||||
await pool.query(
|
||||
`INSERT INTO transactions (owner_id, statement_id, transaction_date, description, amount, transaction_type, category, row_index)
|
||||
VALUES ($1, NULL, '2026-06-15', 'Unknown thing', 100, 'debit', NULL, 0)`,
|
||||
[ownerId]
|
||||
);
|
||||
|
||||
const { data } = await getTransactions(ownerId, {
|
||||
exclude_categories: ["transfers"], limit: 50, offset: 0,
|
||||
});
|
||||
expect(data).toHaveLength(1);
|
||||
expect(data[0].description).toBe("Unknown thing");
|
||||
});
|
||||
|
||||
it("an explicit category pick beats the exclusion", async () => {
|
||||
const { ownerId } = await seedParticipants(pool);
|
||||
await insertTransaction(pool, ownerId, { description: "Card payment", category: "transfers" });
|
||||
|
||||
const { data } = await getTransactions(ownerId, {
|
||||
categories: ["transfers"], exclude_categories: ["transfers"], limit: 50, offset: 0,
|
||||
});
|
||||
expect(data).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("respects the category override, not the raw category", async () => {
|
||||
const { ownerId } = await seedParticipants(pool);
|
||||
const txId = await insertTransaction(pool, ownerId, { description: "Was a transfer", category: "transfers" });
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_overrides (transaction_id, category_override) VALUES ($1, 'investment')`,
|
||||
[txId]
|
||||
);
|
||||
|
||||
const { data } = await getTransactions(ownerId, {
|
||||
exclude_categories: ["transfers"], limit: 50, offset: 0,
|
||||
});
|
||||
expect(data).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTransactions — search filter", () => {
|
||||
it("searches description case-insensitively", async () => {
|
||||
const { ownerId } = await seedParticipants(pool);
|
||||
@@ -721,3 +781,435 @@ describe("the split cutover gates every balance", () => {
|
||||
expect(Number(bob?.total_owed ?? 0)).toBeCloseTo(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Trip participation ────────────────────────────────────────────────────────
|
||||
//
|
||||
// Trips were scoped to `trips.owner_id`, so a co-traveller saw nothing: Sonu
|
||||
// could not open a single trip despite paying for 104 of the tagged rows
|
||||
// herself. Participation is DERIVED from the expenses rather than stored as a
|
||||
// membership list, because a trip is all the expenses on one trip — and two
|
||||
// records of one fact drift apart.
|
||||
|
||||
describe("trip participation — visibility", () => {
|
||||
/** A trip owned by `ownerId` with one row `ownerId` paid for. */
|
||||
async function tripWithOwnerRow(ownerId: number, name = "Owned Trip") {
|
||||
const trip = await pool.query(
|
||||
`INSERT INTO trips (owner_id, name) VALUES ($1, $2) RETURNING id`,
|
||||
[ownerId, name]
|
||||
);
|
||||
const tripId = trip.rows[0].id as number;
|
||||
const txId = await insertTransaction(pool, ownerId, { amount: 200, category: "travel" });
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_overrides (transaction_id, trip_id) VALUES ($1, $2)`,
|
||||
[txId, tripId]
|
||||
);
|
||||
return { tripId, txId };
|
||||
}
|
||||
|
||||
it("shows a trip to its owner", async () => {
|
||||
const { ownerId } = await seedParticipants(pool);
|
||||
const { tripId } = await tripWithOwnerRow(ownerId);
|
||||
|
||||
const trips = await getTrips(ownerId);
|
||||
expect(trips.map((t) => t.id)).toContain(tripId);
|
||||
});
|
||||
|
||||
it("shows a trip to someone holding a split on one of its rows", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
const { tripId, txId } = await tripWithOwnerRow(ownerId);
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
|
||||
VALUES ($1, $2, 50)`,
|
||||
[txId, otherId]
|
||||
);
|
||||
|
||||
const trips = await getTrips(otherId);
|
||||
expect(trips.map((t) => t.id)).toContain(tripId);
|
||||
expect(await isTripParticipant(tripId, otherId)).toBe(true);
|
||||
});
|
||||
|
||||
it("shows a trip to someone who paid for one of its rows but holds no split", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
const { tripId } = await tripWithOwnerRow(ownerId);
|
||||
const theirTx = await insertTransaction(pool, otherId, { amount: 80, category: "travel" });
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_overrides (transaction_id, trip_id) VALUES ($1, $2)`,
|
||||
[theirTx, tripId]
|
||||
);
|
||||
|
||||
expect((await getTrips(otherId)).map((t) => t.id)).toContain(tripId);
|
||||
});
|
||||
|
||||
it("shows a trip to someone whose payment is scoped to it", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
const { tripId } = await tripWithOwnerRow(ownerId);
|
||||
await pool.query(
|
||||
`INSERT INTO split_payments (from_participant_id, to_participant_id, amount, payment_date, trip_id)
|
||||
VALUES ($1, $2, 50, '2026-06-20', $3)`,
|
||||
[otherId, ownerId, tripId]
|
||||
);
|
||||
|
||||
expect((await getTrips(otherId)).map((t) => t.id)).toContain(tripId);
|
||||
});
|
||||
|
||||
// The Singapore + Bangkok 2026 case. A trip nobody else took must not appear
|
||||
// just because trips became shareable — this is the whole reason
|
||||
// participation is derived from the expenses rather than granted.
|
||||
it("HIDES a trip from someone with no split, no row and no payment on it", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
const { tripId } = await tripWithOwnerRow(ownerId, "Solo Trip");
|
||||
|
||||
expect((await getTrips(otherId)).map((t) => t.id)).not.toContain(tripId);
|
||||
expect(await isTripParticipant(tripId, otherId)).toBe(false);
|
||||
expect(await getTripById(tripId, otherId)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("trip owed — both directions, never netted", () => {
|
||||
/** `payerId` paid a $200 travel row on the trip; `splitId` holds 50% of it. */
|
||||
async function seed(payerId: number, splitId: number, ownerId: number) {
|
||||
const trip = await pool.query(
|
||||
`INSERT INTO trips (owner_id, name) VALUES ($1, 'Pair Trip') RETURNING id`,
|
||||
[ownerId]
|
||||
);
|
||||
const tripId = trip.rows[0].id as number;
|
||||
const txId = await insertTransaction(pool, payerId, { amount: 200, category: "travel" });
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_overrides (transaction_id, trip_id) VALUES ($1, $2)`,
|
||||
[txId, tripId]
|
||||
);
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
|
||||
VALUES ($1, $2, 50)`,
|
||||
[txId, splitId]
|
||||
);
|
||||
return tripId;
|
||||
}
|
||||
|
||||
it("the payer sees it as owed to them, with nothing on the mirror", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
const tripId = await seed(ownerId, otherId, ownerId);
|
||||
|
||||
const { participant_splits } = await getTripAnalytics(tripId, ownerId);
|
||||
const bob = participant_splits.find((r) => r.participant_id === otherId)!;
|
||||
expect(Number(bob.owed)).toBeCloseTo(100);
|
||||
expect(Number(bob.i_owe)).toBeCloseTo(0);
|
||||
expect(Number(bob.i_owe_gross)).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
// The figure that could not exist before. An obligation lives on a row someone
|
||||
// ELSE paid for, so a viewer-as-payer query can never contain it — which is
|
||||
// why Sonu's Europe page read "you are owed $2,408.24" while omitting the
|
||||
// $8,004.04 she owed.
|
||||
it("the split holder sees the same figure as owed BY them", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
const tripId = await seed(ownerId, otherId, ownerId);
|
||||
|
||||
const { participant_splits } = await getTripAnalytics(tripId, otherId);
|
||||
const alice = participant_splits.find((r) => r.participant_id === ownerId)!;
|
||||
expect(Number(alice.i_owe)).toBeCloseTo(100);
|
||||
expect(Number(alice.owed)).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
// Europe 2026: paid in full, so the net is zero but the gross is not — the UI
|
||||
// needs both to say "settled" rather than a bare "0.00".
|
||||
it("keeps gross and paid alongside the net so a paid-up trip reads as settled", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
const tripId = await seed(ownerId, otherId, ownerId);
|
||||
await pool.query(
|
||||
`INSERT INTO split_payments (from_participant_id, to_participant_id, amount, payment_date, trip_id)
|
||||
VALUES ($1, $2, 100, '2026-06-20', $3)`,
|
||||
[otherId, ownerId, tripId]
|
||||
);
|
||||
|
||||
const asPayer = await getTripAnalytics(tripId, ownerId);
|
||||
const bob = asPayer.participant_splits.find((r) => r.participant_id === otherId)!;
|
||||
expect(Number(bob.owed)).toBeCloseTo(0);
|
||||
expect(Number(bob.owed_gross)).toBeCloseTo(100);
|
||||
expect(Number(bob.paid_to_me)).toBeCloseTo(100);
|
||||
|
||||
const asDebtor = await getTripAnalytics(tripId, otherId);
|
||||
const alice = asDebtor.participant_splits.find((r) => r.participant_id === ownerId)!;
|
||||
expect(Number(alice.i_owe)).toBeCloseTo(0);
|
||||
expect(Number(alice.i_owe_gross)).toBeCloseTo(100);
|
||||
expect(Number(alice.paid_by_me)).toBeCloseTo(100);
|
||||
});
|
||||
|
||||
// The API returns both halves whole; the trip page nets them for display. The
|
||||
// halves must stay separately available so that net is decomposable — a net
|
||||
// nobody can audit is how a wrong figure survives, and it is what let Europe
|
||||
// read "settled" while concealing 56 rows Sonu had paid.
|
||||
it("returns each direction whole rather than pre-netted", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
const tripId = await seed(ownerId, otherId, ownerId);
|
||||
// A second row, paid the other way, so both directions are live at once.
|
||||
const theirTx = await insertTransaction(pool, otherId, { amount: 60, category: "travel" });
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_overrides (transaction_id, trip_id) VALUES ($1, $2)`,
|
||||
[theirTx, tripId]
|
||||
);
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
|
||||
VALUES ($1, $2, 50)`,
|
||||
[theirTx, ownerId]
|
||||
);
|
||||
|
||||
const { participant_splits } = await getTripAnalytics(tripId, ownerId);
|
||||
const bob = participant_splits.find((r) => r.participant_id === otherId)!;
|
||||
expect(Number(bob.owed)).toBeCloseTo(100);
|
||||
expect(Number(bob.i_owe)).toBeCloseTo(30);
|
||||
// What the page displays as the single settle-up figure.
|
||||
expect(Number(bob.owed) - Number(bob.i_owe)).toBeCloseTo(70);
|
||||
});
|
||||
|
||||
// Europe 2026's shape exactly, and the case the UI must NOT call a debt.
|
||||
//
|
||||
// A payment is allocated to a trip as a lump sum covering the payer's GROSS
|
||||
// share, so netting the other side off leaves the trip negative by whatever
|
||||
// the payment over-covered. That surplus is carried in the overall balance,
|
||||
// not owed to them — which is why the page distinguishes a negative net WITH a
|
||||
// payment into the scope (over-covered) from one WITHOUT (genuinely owed).
|
||||
// `paid_to_me` is what makes the two separable, so it must stay non-zero here.
|
||||
it("goes negative by the over-covered amount when a payment clears the gross", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
const tripId = await seed(ownerId, otherId, ownerId);
|
||||
// Bob pays his $100 share in full, scoped to the trip.
|
||||
await pool.query(
|
||||
`INSERT INTO split_payments (from_participant_id, to_participant_id, amount, payment_date, trip_id)
|
||||
VALUES ($1, $2, 100, '2026-06-20', $3)`,
|
||||
[otherId, ownerId, tripId]
|
||||
);
|
||||
// But Alice holds a share of something Bob paid for, and never settled it.
|
||||
const bobsTx = await insertTransaction(pool, otherId, { amount: 40, category: "travel" });
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_overrides (transaction_id, trip_id) VALUES ($1, $2)`,
|
||||
[bobsTx, tripId]
|
||||
);
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
|
||||
VALUES ($1, $2, 50)`,
|
||||
[bobsTx, ownerId]
|
||||
);
|
||||
|
||||
const { participant_splits } = await getTripAnalytics(tripId, ownerId);
|
||||
const bob = participant_splits.find((r) => r.participant_id === otherId)!;
|
||||
expect(Number(bob.owed)).toBeCloseTo(0); // his gross, fully paid
|
||||
expect(Number(bob.i_owe)).toBeCloseTo(20); // her share of his spending
|
||||
expect(Number(bob.owed) - Number(bob.i_owe)).toBeCloseTo(-20);
|
||||
// The discriminator: he paid into this scope, so the -20 is over-coverage
|
||||
// rather than a bill. Without paid_to_me the page cannot tell the two apart.
|
||||
expect(Number(bob.paid_to_me)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("goes negative with no payment when the viewer's share simply exceeds theirs", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
const trip = await pool.query(
|
||||
`INSERT INTO trips (owner_id, name) VALUES ($1, 'Unpaid Trip') RETURNING id`,
|
||||
[ownerId]
|
||||
);
|
||||
const tripId = trip.rows[0].id as number;
|
||||
// Only one row, paid by Bob, with Alice holding half. Nobody has paid anyone.
|
||||
const bobsTx = await insertTransaction(pool, otherId, { amount: 90, category: "travel" });
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_overrides (transaction_id, trip_id) VALUES ($1, $2)`,
|
||||
[bobsTx, tripId]
|
||||
);
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
|
||||
VALUES ($1, $2, 50)`,
|
||||
[bobsTx, ownerId]
|
||||
);
|
||||
|
||||
const { participant_splits } = await getTripAnalytics(tripId, ownerId);
|
||||
const bob = participant_splits.find((r) => r.participant_id === otherId)!;
|
||||
expect(Number(bob.owed) - Number(bob.i_owe)).toBeCloseTo(-45);
|
||||
// No payment into the scope, so this negative IS Alice's to settle.
|
||||
expect(Number(bob.paid_to_me)).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
it("reports whether the viewer owns the trip", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
const tripId = await seed(ownerId, otherId, ownerId);
|
||||
|
||||
expect((await getTripAnalytics(tripId, ownerId)).viewer_is_owner).toBe(true);
|
||||
expect((await getTripAnalytics(tripId, otherId)).viewer_is_owner).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTransactions — trip_all_rows", () => {
|
||||
async function seedSharedTrip() {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
const trip = await pool.query(
|
||||
`INSERT INTO trips (owner_id, name) VALUES ($1, 'Shared Trip') RETURNING id`,
|
||||
[ownerId]
|
||||
);
|
||||
const tripId = trip.rows[0].id as number;
|
||||
// One row Bob holds a split on — this is what makes him a participant.
|
||||
const shared = await insertTransaction(pool, ownerId, { description: "Shared hotel", category: "travel" });
|
||||
// One row Bob has no stake in whatsoever.
|
||||
const solo = await insertTransaction(pool, ownerId, { description: "Alice solo museum", category: "travel" });
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_overrides (transaction_id, trip_id) VALUES ($1, $2), ($3, $2)`,
|
||||
[shared, tripId, solo]
|
||||
);
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
|
||||
VALUES ($1, $2, 50)`,
|
||||
[shared, otherId]
|
||||
);
|
||||
return { ownerId, otherId, tripId };
|
||||
}
|
||||
|
||||
it("gives a participant every row on the trip", async () => {
|
||||
const { otherId, tripId } = await seedSharedTrip();
|
||||
|
||||
const { data } = await getTransactions(otherId, {
|
||||
trip_id: String(tripId), trip_all_rows: true, limit: 50, offset: 0,
|
||||
});
|
||||
expect(data.map((r) => r.description).sort()).toEqual(["Alice solo museum", "Shared hotel"]);
|
||||
});
|
||||
|
||||
// The main transactions page filters by trip through this same endpoint. If
|
||||
// the widening were implied by trip_id, filtering your own ledger by a trip
|
||||
// would silently fill it with someone else's rows and skew its totals.
|
||||
it("keeps owner scoping when the flag is absent", async () => {
|
||||
const { otherId, tripId } = await seedSharedTrip();
|
||||
|
||||
const { data } = await getTransactions(otherId, {
|
||||
trip_id: String(tripId), limit: 50, offset: 0,
|
||||
});
|
||||
expect(data.map((r) => r.description)).toEqual(["Shared hotel"]);
|
||||
});
|
||||
|
||||
it("returns nothing to a non-participant who passes the flag", async () => {
|
||||
const { ownerId } = await seedSharedTrip();
|
||||
const stranger = await pool.query(
|
||||
`INSERT INTO participants (name) VALUES ('Carol') RETURNING id`
|
||||
);
|
||||
const carolId = stranger.rows[0].id as number;
|
||||
const trip = await pool.query(`SELECT id FROM trips LIMIT 1`);
|
||||
|
||||
const { data } = await getTransactions(carolId, {
|
||||
trip_id: String(trip.rows[0].id), trip_all_rows: true, limit: 50, offset: 0,
|
||||
});
|
||||
expect(data).toHaveLength(0);
|
||||
expect(ownerId).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("does not widen anything when trip_id is 'unassigned'", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
await insertTransaction(pool, ownerId, { description: "Alice untripped" });
|
||||
await insertTransaction(pool, otherId, { description: "Bob untripped" });
|
||||
|
||||
const { data } = await getTransactions(otherId, {
|
||||
trip_id: "unassigned", trip_all_rows: true, limit: 50, offset: 0,
|
||||
});
|
||||
expect(data.map((r) => r.description)).toEqual(["Bob untripped"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("assignTransactionsToTrip — authorisation", () => {
|
||||
it("refuses a trip the caller does not participate in", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
const trip = await pool.query(
|
||||
`INSERT INTO trips (owner_id, name) VALUES ($1, 'Private Trip') RETURNING id`,
|
||||
[ownerId]
|
||||
);
|
||||
const tripId = trip.rows[0].id as number;
|
||||
const bobsTx = await insertTransaction(pool, otherId, { description: "Bob lunch" });
|
||||
|
||||
await expect(assignTransactionsToTrip(tripId, [bobsTx], otherId)).rejects.toThrow(/participant/i);
|
||||
});
|
||||
|
||||
// The hole this closed: the function took no caller at all, so any
|
||||
// authenticated participant could move any transaction id into any trip.
|
||||
it("silently skips transactions the caller cannot see", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
const trip = await pool.query(
|
||||
`INSERT INTO trips (owner_id, name) VALUES ($1, 'Alice Trip') RETURNING id`,
|
||||
[ownerId]
|
||||
);
|
||||
const tripId = trip.rows[0].id as number;
|
||||
const mine = await insertTransaction(pool, ownerId, { description: "Alice flight" });
|
||||
const theirs = await insertTransaction(pool, otherId, { description: "Bob private" });
|
||||
|
||||
const moved = await assignTransactionsToTrip(tripId, [mine, theirs], ownerId);
|
||||
expect(moved).toBe(1);
|
||||
|
||||
const rows = await pool.query(
|
||||
`SELECT transaction_id FROM transaction_overrides WHERE trip_id = $1`, [tripId]
|
||||
);
|
||||
expect(rows.rows.map((r) => r.transaction_id)).toEqual([mine]);
|
||||
});
|
||||
|
||||
it("lets a participant assign their own transaction to the trip", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
const trip = await pool.query(
|
||||
`INSERT INTO trips (owner_id, name) VALUES ($1, 'Joint Trip') RETURNING id`,
|
||||
[ownerId]
|
||||
);
|
||||
const tripId = trip.rows[0].id as number;
|
||||
// Make Bob a participant first.
|
||||
const seedTx = await insertTransaction(pool, ownerId, { category: "travel" });
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_overrides (transaction_id, trip_id) VALUES ($1, $2)`,
|
||||
[seedTx, tripId]
|
||||
);
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
|
||||
VALUES ($1, $2, 50)`,
|
||||
[seedTx, otherId]
|
||||
);
|
||||
|
||||
const bobsTx = await insertTransaction(pool, otherId, { description: "Bob taxi" });
|
||||
expect(await assignTransactionsToTrip(tripId, [bobsTx], otherId)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// Delete is the one thing that stayed owner-only. Both trip foreign keys are
|
||||
// ON DELETE SET NULL, so deleting a trip untags every transaction on it and
|
||||
// drops the trip scope from its payments — including a hand-derived allocation
|
||||
// that nothing recomputes.
|
||||
describe("deleteTrip — owner only", () => {
|
||||
it("does not delete when a non-owner participant asks", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
const trip = await pool.query(
|
||||
`INSERT INTO trips (owner_id, name) VALUES ($1, 'Precious Trip') RETURNING id`,
|
||||
[ownerId]
|
||||
);
|
||||
const tripId = trip.rows[0].id as number;
|
||||
const txId = await insertTransaction(pool, ownerId, { category: "travel" });
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_overrides (transaction_id, trip_id) VALUES ($1, $2)`,
|
||||
[txId, tripId]
|
||||
);
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
|
||||
VALUES ($1, $2, 50)`,
|
||||
[txId, otherId]
|
||||
);
|
||||
// Bob can see it...
|
||||
expect(await getTripById(tripId, otherId)).not.toBeNull();
|
||||
|
||||
await deleteTrip(tripId, otherId);
|
||||
|
||||
// ...and still cannot remove it, nor untag its transaction.
|
||||
expect(await getTripById(tripId, ownerId)).not.toBeNull();
|
||||
const still = await pool.query(
|
||||
`SELECT trip_id FROM transaction_overrides WHERE transaction_id = $1`, [txId]
|
||||
);
|
||||
expect(still.rows[0].trip_id).toBe(tripId);
|
||||
});
|
||||
|
||||
it("deletes when the owner asks", async () => {
|
||||
const { ownerId } = await seedParticipants(pool);
|
||||
const trip = await pool.query(
|
||||
`INSERT INTO trips (owner_id, name) VALUES ($1, 'Doomed Trip') RETURNING id`,
|
||||
[ownerId]
|
||||
);
|
||||
const tripId = trip.rows[0].id as number;
|
||||
|
||||
await deleteTrip(tripId, ownerId);
|
||||
expect(await getTripById(tripId, ownerId)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import { describe, it, expect, beforeAll, beforeEach } from "vitest";
|
||||
import { Pool } from "pg";
|
||||
import { createPool, resetDB, seedParticipants } from "./helpers";
|
||||
|
||||
/**
|
||||
* The receipt lane, exercised against the real schema.
|
||||
*
|
||||
* Every fixture here is a receipt that actually exists: a Coles split-tender e-receipt
|
||||
* (7777 Werribee, 11/07/2026, $114.57 settled $40.75 gift card + $73.82 Mastercard), a
|
||||
* Woolworths e-receipt (3345 Wyndham Vale, 29/06/2026, $40.75, single gift card) and a
|
||||
* Coles photo (556 Manor Lakes, 29/07/2026, $23.18). The split one is the reason the lane
|
||||
* writes one transaction per tender leg rather than one per receipt, so it is the case
|
||||
* these tests are built around.
|
||||
*/
|
||||
|
||||
const TOKEN = "test-receipt-token";
|
||||
let pool: Pool;
|
||||
let POST: typeof import("../../app/api/receipts/ingest/route").POST;
|
||||
|
||||
const req = (body: unknown, token: string | null = TOKEN) =>
|
||||
({
|
||||
headers: { get: (h: string) => (h === "x-ingest-token" ? token : null) },
|
||||
json: async () => body,
|
||||
}) as never;
|
||||
|
||||
const colesSplit = () => ({
|
||||
receipt_uid: "coles:7777:114:2153:2026-07-11",
|
||||
capture_event_id: 412,
|
||||
image_sha256: "sha-coles-split",
|
||||
merchant_name: "Coles",
|
||||
store_detail: "7777",
|
||||
transaction_date: "2026-07-11",
|
||||
total: 114.57,
|
||||
tender_raw: "EFT $40.75\nEFT $73.82\n***** 0443 MASTERCARD\nCREDIT ACCOUNT STORE CARD",
|
||||
tender_legs: [
|
||||
{ leg_index: 1, amount: 40.75, card_last4: "0443", card_product: "STORE CARD", class: "gift_card" as const },
|
||||
{ leg_index: 2, amount: 73.82, card_last4: "3302", card_product: "MASTERCARD", class: "card" as const },
|
||||
],
|
||||
line_items: [
|
||||
{ description: "Pork Loin Roast", qty: 1, unit: "ea", amount: 15.86, category: "meat" },
|
||||
{ description: "Tomatoes", qty: 1, unit: "ea", amount: 98.71, category: "produce" },
|
||||
],
|
||||
});
|
||||
|
||||
const woolworthsGiftCard = () => ({
|
||||
receipt_uid: "woolworths:3345:62:148:2026-06-29",
|
||||
capture_event_id: 500,
|
||||
merchant_name: "Woolworths",
|
||||
store_detail: "3345",
|
||||
transaction_date: "2026-06-29",
|
||||
total: 40.75,
|
||||
tender_legs: [{ leg_index: 1, amount: 40.75, card_last4: "0443", card_product: "STORE CARD", class: "gift_card" as const }],
|
||||
line_items: [{ description: "Oat Milk", qty: 2, unit: "ea", amount: 40.75, category: "dairy" }],
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.RECEIPT_INGEST_TOKEN = TOKEN;
|
||||
pool = createPool();
|
||||
({ POST } = await import("../../app/api/receipts/ingest/route"));
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDB(pool);
|
||||
await pool.query("DELETE FROM expense_metadata");
|
||||
// transactions.owner_id references participants, and resetDB truncates it with RESTART
|
||||
// IDENTITY — so DEFAULT_OWNER_ID (1) has to be re-seeded or every insert here fails the
|
||||
// foreign key. Same dependency the order lane has; it just never had to say so.
|
||||
await seedParticipants(pool);
|
||||
});
|
||||
|
||||
const legsOf = async (group: string) =>
|
||||
(await pool.query(
|
||||
`SELECT t.id, t.amount::text, t.payment_method, t.statement_id, em.order_reference, em.line_items, em.flags
|
||||
FROM expense_metadata em JOIN transactions t ON t.id = em.transaction_id
|
||||
WHERE em.receipt_group = $1 ORDER BY em.order_reference`,
|
||||
[group]
|
||||
)).rows;
|
||||
|
||||
describe("auth", () => {
|
||||
it("rejects a missing or wrong token", async () => {
|
||||
expect((await POST(req(colesSplit(), null))).status).toBe(401);
|
||||
expect((await POST(req(colesSplit(), "nope"))).status).toBe(401);
|
||||
});
|
||||
|
||||
it("rejects a body missing what it needs to book money", async () => {
|
||||
expect((await POST(req({ merchant_name: "Coles" }))).status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a split-tender receipt", () => {
|
||||
it("books one transaction per leg, not one for the total", async () => {
|
||||
const res = await POST(req(colesSplit()));
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body.legs).toHaveLength(2);
|
||||
|
||||
const rows = await legsOf("pantry:coles:7777:114:2153:2026-07-11");
|
||||
expect(rows.map((r) => r.amount)).toEqual(["40.75", "73.82"]);
|
||||
// Not one $114.57 row: marked credits it would let the $73.82 statement line
|
||||
// double-count; marked card it would be searched for at ±1% of $114.57 and never match.
|
||||
expect(rows.some((r) => r.amount === "114.57")).toBe(false);
|
||||
});
|
||||
|
||||
it("sums the legs back to the printed total", async () => {
|
||||
await POST(req(colesSplit()));
|
||||
const rows = await legsOf("pantry:coles:7777:114:2153:2026-07-11");
|
||||
const total = rows.reduce((sum, r) => sum + Number(r.amount), 0);
|
||||
expect(Number(total.toFixed(2))).toBe(114.57);
|
||||
});
|
||||
|
||||
it("marks the gift-card leg credits and the card leg card", async () => {
|
||||
await POST(req(colesSplit()));
|
||||
const rows = await legsOf("pantry:coles:7777:114:2153:2026-07-11");
|
||||
expect(rows.find((r) => r.amount === "40.75")!.payment_method).toBe("credits");
|
||||
expect(rows.find((r) => r.amount === "73.82")!.payment_method).toBe("card");
|
||||
});
|
||||
|
||||
it("leaves every leg as a manual row for the reconciliation queue", async () => {
|
||||
await POST(req(colesSplit()));
|
||||
const rows = await legsOf("pantry:coles:7777:114:2153:2026-07-11");
|
||||
expect(rows.every((r) => r.statement_id === null)).toBe(true);
|
||||
});
|
||||
|
||||
it("puts the line items on the card leg only", async () => {
|
||||
// They describe the whole shop but transaction_id is UNIQUE on expense_metadata, so
|
||||
// they can attach to one row — and the card leg is the one that reconciles onto the
|
||||
// statement line, which is where an unreadable descriptor gets its contents.
|
||||
await POST(req(colesSplit()));
|
||||
const rows = await legsOf("pantry:coles:7777:114:2153:2026-07-11");
|
||||
expect(rows.find((r) => r.amount === "73.82")!.line_items).toHaveLength(2);
|
||||
expect(rows.find((r) => r.amount === "40.75")!.line_items).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("flags the split so it can be shown as one purchase", async () => {
|
||||
await POST(req(colesSplit()));
|
||||
const rows = await legsOf("pantry:coles:7777:114:2153:2026-07-11");
|
||||
expect(rows[0].flags).toContain("split_tender");
|
||||
});
|
||||
});
|
||||
|
||||
describe("a gift-card receipt", () => {
|
||||
it("becomes a visible transaction on the day it is scanned", async () => {
|
||||
const res = await POST(req(woolworthsGiftCard()));
|
||||
expect(res.status).toBe(200);
|
||||
const rows = await legsOf("pantry:woolworths:3345:62:148:2026-06-29");
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].amount).toBe("40.75");
|
||||
expect(rows[0].payment_method).toBe("credits");
|
||||
});
|
||||
|
||||
it("is owned, so it is not invisible in every view", async () => {
|
||||
// Owner scoping is COALESCE(t.owner_id, s.owner_id) and these rows carry no statement;
|
||||
// a NULL owner is how 85 backfilled order rows ended up in the table and on no screen.
|
||||
await POST(req(woolworthsGiftCard()));
|
||||
const { rows } = await pool.query("SELECT owner_id FROM transactions WHERE merchant_name = 'Woolworths'");
|
||||
expect(rows[0].owner_id).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("idempotency", () => {
|
||||
it("keys on the receipt, so the same shop from two files lands once", async () => {
|
||||
// A photo and the store's e-receipt PDF are different files with different hashes.
|
||||
// Keying on the capture would let one purchase arrive twice.
|
||||
await POST(req(colesSplit()));
|
||||
await POST(req({ ...colesSplit(), capture_event_id: 999, image_sha256: "sha-different-file" }));
|
||||
const rows = await legsOf("pantry:coles:7777:114:2153:2026-07-11");
|
||||
expect(rows).toHaveLength(2); // still just the two legs
|
||||
});
|
||||
|
||||
it("reports the replay rather than silently doing nothing", async () => {
|
||||
await POST(req(colesSplit()));
|
||||
const body = await (await POST(req(colesSplit()))).json();
|
||||
expect(body.legs.every((l: { skipped?: string }) => l.skipped === "already_ingested")).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to the capture when the receipt did not identify itself", async () => {
|
||||
// A crumpled photo can lose the header entirely. That receipt still becomes spend; it
|
||||
// just cannot be recognised if the same shop is scanned again from another file.
|
||||
await POST(req({ ...colesSplit(), receipt_uid: null }));
|
||||
expect(await legsOf("pantry:capture:412")).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validation — what becomes money", () => {
|
||||
it("refuses a receipt whose tender does not add up to its total", async () => {
|
||||
// A missed leg is spend that never appears; booking the rest would put a number in the
|
||||
// ledger nobody can stand behind.
|
||||
const broken = { ...colesSplit(), tender_legs: [colesSplit().tender_legs[0]] };
|
||||
const res = await POST(req(broken));
|
||||
expect(res.status).toBe(422);
|
||||
expect((await res.json()).reason).toContain("tender legs sum to 40.75");
|
||||
expect(await legsOf("pantry:coles:7777:114:2153:2026-07-11")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("refuses a receipt with no tender at all", async () => {
|
||||
expect((await POST(req({ ...colesSplit(), tender_legs: [] }))).status).toBe(422);
|
||||
});
|
||||
|
||||
it("flags but accepts lines that do not sum, because promo rows are skipped by design", async () => {
|
||||
const res = await POST(req({ ...colesSplit(), line_items: [{ description: "One line", amount: 10 }] }));
|
||||
expect(res.status).toBe(200);
|
||||
expect((await res.json()).flags.some((f: string) => f.startsWith("line_items_sum_"))).toBe(true);
|
||||
});
|
||||
|
||||
it("writes nothing at all when validation fails", async () => {
|
||||
await POST(req({ ...colesSplit(), tender_legs: [] }));
|
||||
const { rows } = await pool.query("SELECT count(*)::int AS n FROM transactions");
|
||||
expect(rows[0].n).toBe(0);
|
||||
});
|
||||
|
||||
it("marks a leg nobody could classify as reconcilable rather than deciding for them", async () => {
|
||||
const unknown = { ...colesSplit(), tender_legs: [{ leg_index: 1, amount: 114.57, card_last4: "9999", card_product: null, class: null }] };
|
||||
const res = await POST(req(unknown));
|
||||
expect(res.status).toBe(200);
|
||||
const rows = await legsOf("pantry:coles:7777:114:2153:2026-07-11");
|
||||
// NULL is what needsCardMatch() treats as still needing a card match, so it surfaces in
|
||||
// the queue instead of being silently excluded from it.
|
||||
expect(rows[0].payment_method).toBeNull();
|
||||
expect(rows[0].flags).toContain("unclassified_tender");
|
||||
});
|
||||
});
|
||||
|
||||
describe("dry run", () => {
|
||||
it("validates without writing", async () => {
|
||||
const res = await POST(req({ ...colesSplit(), dryRun: true }));
|
||||
expect(res.status).toBe(200);
|
||||
expect((await res.json()).dryRun).toBe(true);
|
||||
const { rows } = await pool.query("SELECT count(*)::int AS n FROM transactions");
|
||||
expect(rows[0].n).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,304 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
|
||||
import type { Pool } from "pg";
|
||||
import {
|
||||
createPool,
|
||||
mockDbWithPool,
|
||||
resetDB,
|
||||
seedParticipants,
|
||||
insertTransaction,
|
||||
} from "./helpers";
|
||||
|
||||
/**
|
||||
* Every split adds up to 100%.
|
||||
*
|
||||
* The failure this guards against is not an arithmetic one — `myShare` has
|
||||
* always treated the payer's share as the remainder, so the numbers were right.
|
||||
* It is that the remainder was never written down, so a 50/50 arrangement was
|
||||
* stored as a single row reading "Sonu 50%" and displayed as half a split.
|
||||
*/
|
||||
describe("completeSplit", () => {
|
||||
let pool: Pool;
|
||||
let completeSplit: (id: number) => Promise<void>;
|
||||
|
||||
beforeAll(async () => {
|
||||
pool = createPool();
|
||||
mockDbWithPool(pool);
|
||||
({ completeSplit } = await import("@/lib/splits"));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDB(pool);
|
||||
});
|
||||
|
||||
const sharesOf = async (txId: number) => {
|
||||
const r = await pool.query(
|
||||
`SELECT participant_id, share_percent::float FROM transaction_splits
|
||||
WHERE transaction_id = $1 ORDER BY participant_id`,
|
||||
[txId]
|
||||
);
|
||||
return r.rows as { participant_id: number; share_percent: number }[];
|
||||
};
|
||||
|
||||
it("writes the payer's half of a 50/50 recorded as one row", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
const txId = await insertTransaction(pool, ownerId);
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
|
||||
VALUES ($1, $2, 50)`,
|
||||
[txId, otherId]
|
||||
);
|
||||
|
||||
await completeSplit(txId);
|
||||
|
||||
expect(await sharesOf(txId)).toEqual([
|
||||
{ participant_id: ownerId, share_percent: 50 },
|
||||
{ participant_id: otherId, share_percent: 50 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves an unsplit transaction unsplit", async () => {
|
||||
// A transaction nobody shares is not a 100% split of itself. Writing one
|
||||
// would put every row in the Shared view.
|
||||
const { ownerId } = await seedParticipants(pool);
|
||||
const txId = await insertTransaction(pool, ownerId);
|
||||
|
||||
await completeSplit(txId);
|
||||
|
||||
expect(await sharesOf(txId)).toEqual([]);
|
||||
});
|
||||
|
||||
it("adds nothing when the other party owes all of it", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
const txId = await insertTransaction(pool, ownerId);
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
|
||||
VALUES ($1, $2, 100)`,
|
||||
[txId, otherId]
|
||||
);
|
||||
|
||||
await completeSplit(txId);
|
||||
|
||||
expect(await sharesOf(txId)).toEqual([
|
||||
{ participant_id: otherId, share_percent: 100 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("removes the owner's row when the others grow to cover the whole amount", async () => {
|
||||
// A 50/50 revised to "they owe all of it". The owner's share becomes zero,
|
||||
// and a 0% row cannot be stored anyway — `share_percent > 0` is a CHECK
|
||||
// constraint — so the row has to go rather than be zeroed.
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
const txId = await insertTransaction(pool, ownerId);
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
|
||||
VALUES ($1, $2, 100), ($1, $3, 50)`,
|
||||
[txId, otherId, ownerId]
|
||||
);
|
||||
|
||||
await completeSplit(txId);
|
||||
|
||||
expect(await sharesOf(txId)).toEqual([
|
||||
{ participant_id: otherId, share_percent: 100 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("fills the remainder for a three-way split, not a half", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
const third = (
|
||||
await pool.query(`INSERT INTO participants (name) VALUES ('Carol') RETURNING id`)
|
||||
).rows[0].id as number;
|
||||
const txId = await insertTransaction(pool, ownerId);
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
|
||||
VALUES ($1, $2, 50), ($1, $3, 25)`,
|
||||
[txId, otherId, third]
|
||||
);
|
||||
|
||||
await completeSplit(txId);
|
||||
|
||||
expect(await sharesOf(txId)).toEqual([
|
||||
{ participant_id: ownerId, share_percent: 25 },
|
||||
{ participant_id: otherId, share_percent: 50 },
|
||||
{ participant_id: third, share_percent: 25 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves an over-allocated split alone instead of trimming someone's share", async () => {
|
||||
// >100% is a caller's mistake. Silently deleting a share to force the total
|
||||
// down would destroy the evidence of it.
|
||||
// No single row may exceed 100 (CHECK constraint), but two can add up past
|
||||
// it — 60 + 60 is how an over-allocated split actually arrives.
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
const third = (
|
||||
await pool.query(`INSERT INTO participants (name) VALUES ('Dave') RETURNING id`)
|
||||
).rows[0].id as number;
|
||||
const txId = await insertTransaction(pool, ownerId);
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
|
||||
VALUES ($1, $2, 60), ($1, $3, 60)`,
|
||||
[txId, otherId, third]
|
||||
);
|
||||
|
||||
await completeSplit(txId);
|
||||
|
||||
expect(await sharesOf(txId)).toEqual([
|
||||
{ participant_id: otherId, share_percent: 60 },
|
||||
{ participant_id: third, share_percent: 60 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("gives the remainder to the statement's owner when the row has none", async () => {
|
||||
// Statement rows carry no owner_id of their own; it comes from the
|
||||
// statement. Transaction 3828 was one of these.
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
const stmt = await pool.query(
|
||||
`INSERT INTO statements (owner_id, filename, bank_name, account_number, billing_start_date, billing_end_date)
|
||||
VALUES ($1, 'test.pdf', 'Test Bank', '0001', '2026-06-01', '2026-06-30') RETURNING id`,
|
||||
[ownerId]
|
||||
);
|
||||
const tx = await pool.query(
|
||||
`INSERT INTO transactions (owner_id, statement_id, transaction_date, description, amount, transaction_type, row_index)
|
||||
VALUES (NULL, $1, '2026-06-15', 'Statement row', 29.17, 'debit', 0) RETURNING id`,
|
||||
[stmt.rows[0].id]
|
||||
);
|
||||
const txId = tx.rows[0].id as number;
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
|
||||
VALUES ($1, $2, 50)`,
|
||||
[txId, otherId]
|
||||
);
|
||||
|
||||
await completeSplit(txId);
|
||||
|
||||
expect(await sharesOf(txId)).toEqual([
|
||||
{ participant_id: ownerId, share_percent: 50 },
|
||||
{ participant_id: otherId, share_percent: 50 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("never puts my share on someone else's transaction", async () => {
|
||||
// The remainder goes to the transaction's owner, never to "me". A row for
|
||||
// me on a transaction I do not own is a debt I owe, and this helper must
|
||||
// not invent one.
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
const txId = await insertTransaction(pool, otherId); // they paid
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
|
||||
VALUES ($1, $2, 50)`,
|
||||
[txId, ownerId]
|
||||
);
|
||||
|
||||
await completeSplit(txId);
|
||||
|
||||
const shares = await sharesOf(txId);
|
||||
expect(shares).toEqual([
|
||||
{ participant_id: ownerId, share_percent: 50 },
|
||||
{ participant_id: otherId, share_percent: 50 },
|
||||
]);
|
||||
// My share is unchanged — the new row belongs to the payer.
|
||||
expect(shares.find((s) => s.participant_id === ownerId)?.share_percent).toBe(50);
|
||||
});
|
||||
|
||||
it("does not disturb a settled split", async () => {
|
||||
// Adding the payer's row must not touch anyone else's `settled` flag —
|
||||
// that is how $37k of discharged debt gets resurrected.
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
const txId = await insertTransaction(pool, ownerId);
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent, settled)
|
||||
VALUES ($1, $2, 50, true)`,
|
||||
[txId, otherId]
|
||||
);
|
||||
|
||||
await completeSplit(txId);
|
||||
|
||||
const r = await pool.query(
|
||||
`SELECT settled FROM transaction_splits
|
||||
WHERE transaction_id = $1 AND participant_id = $2`,
|
||||
[txId, otherId]
|
||||
);
|
||||
expect(r.rows[0].settled).toBe(true);
|
||||
});
|
||||
|
||||
it("is idempotent", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
const txId = await insertTransaction(pool, ownerId);
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
|
||||
VALUES ($1, $2, 50)`,
|
||||
[txId, otherId]
|
||||
);
|
||||
|
||||
await completeSplit(txId);
|
||||
const once = await sharesOf(txId);
|
||||
await completeSplit(txId);
|
||||
expect(await sharesOf(txId)).toEqual(once);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The rule path, which is where ten of the live rules write a single share.
|
||||
*/
|
||||
describe("applyRuleActions completes the split", () => {
|
||||
let pool: Pool;
|
||||
let applyRuleActions: (
|
||||
id: number,
|
||||
actions: { apply_split?: { participant_id: number; share_percent: number }[] }
|
||||
) => Promise<void>;
|
||||
|
||||
beforeAll(async () => {
|
||||
pool = createPool();
|
||||
mockDbWithPool(pool);
|
||||
({ applyRuleActions } = await import("@/lib/rule-actions"));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDB(pool);
|
||||
});
|
||||
|
||||
it("writes the payer's half for a rule that names only the other person", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
const txId = await insertTransaction(pool, ownerId, { description: "Woolworths" });
|
||||
|
||||
await applyRuleActions(txId, {
|
||||
apply_split: [{ participant_id: otherId, share_percent: 50 }],
|
||||
});
|
||||
|
||||
const r = await pool.query(
|
||||
`SELECT participant_id, share_percent::float FROM transaction_splits
|
||||
WHERE transaction_id = $1 ORDER BY participant_id`,
|
||||
[txId]
|
||||
);
|
||||
expect(r.rows).toEqual([
|
||||
{ participant_id: ownerId, share_percent: 50 },
|
||||
{ participant_id: otherId, share_percent: 50 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves a rule that already names both alone", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
const txId = await insertTransaction(pool, ownerId);
|
||||
|
||||
await applyRuleActions(txId, {
|
||||
apply_split: [
|
||||
{ participant_id: ownerId, share_percent: 50 },
|
||||
{ participant_id: otherId, share_percent: 50 },
|
||||
],
|
||||
});
|
||||
|
||||
const r = await pool.query(
|
||||
`SELECT sum(share_percent)::float AS total, count(*)::int AS n
|
||||
FROM transaction_splits WHERE transaction_id = $1`,
|
||||
[txId]
|
||||
);
|
||||
expect(r.rows[0]).toEqual({ total: 100, n: 2 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
import { describe, it, expect, beforeAll } from "vitest";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { queryRaw, queryRow } from "../../lib/db";
|
||||
|
||||
/**
|
||||
* Changing who paid.
|
||||
*
|
||||
* Owner was write-once for every ingestion path — a pantry receipt hardcodes
|
||||
* DEFAULT_OWNER_ID, and no route, modal or bulk action could correct it, so a
|
||||
* shop the other person paid for was permanently filed as yours. There was not
|
||||
* a single `UPDATE ... SET owner_id` anywhere in src/.
|
||||
*
|
||||
* Two things make this more than a field edit, and both are tested here:
|
||||
*
|
||||
* 1. **It is a one-way door.** Access is "owner OR holds a split"
|
||||
* (canAccessTransactions), so handing a row over while holding no split on
|
||||
* it removes it from your list and 404s every route that could put it back.
|
||||
* Only the new owner can undo it.
|
||||
* 2. **It moves money between two ledgers.** Splits record shares, not
|
||||
* direction — getParticipantBalances derives who owes whom from ownership —
|
||||
* so the same 50/50 rows flip sides untouched.
|
||||
*/
|
||||
|
||||
type Ctx = { params: Promise<{ id: string }> };
|
||||
type Handler = (req: NextRequest, ctx: Ctx) => Promise<Response>;
|
||||
|
||||
let txPATCH: Handler;
|
||||
let stmtPATCH: Handler;
|
||||
|
||||
/** Enough of a NextRequest for these routes: the auth header and the body. */
|
||||
const req = (email: string | null, body: unknown) =>
|
||||
({
|
||||
headers: { get: (h: string) => (h.toLowerCase() === "x-forwarded-user" ? email : null) },
|
||||
json: async () => body,
|
||||
}) as unknown as NextRequest;
|
||||
|
||||
const ctx = (id: number) => ({ params: Promise.resolve({ id: String(id) }) });
|
||||
|
||||
let me = 0;
|
||||
let them = 0;
|
||||
let meEmail = "";
|
||||
let themEmail = "";
|
||||
|
||||
beforeAll(async () => {
|
||||
({ PATCH: txPATCH } = await import("../../app/api/transactions/[id]/route"));
|
||||
({ PATCH: stmtPATCH } = await import("../../app/api/statements/[id]/route"));
|
||||
|
||||
// Fixtures are named, not random, so a re-run is idempotent. `participants`
|
||||
// has a unique email and no cascade from `transactions.owner_id`, so the
|
||||
// teardown order is rows → statements → people.
|
||||
await queryRaw(`DELETE FROM transactions WHERE description LIKE 'Owner fixture — %'`);
|
||||
await queryRaw(`DELETE FROM statements WHERE bank_name = 'Owner Fixture Bank'`);
|
||||
await queryRaw(`DELETE FROM participants WHERE email LIKE 'owner-fixture-%@example.test'`);
|
||||
|
||||
const mk = async (label: string) => {
|
||||
const email = `owner-fixture-${label}@example.test`;
|
||||
const row = await queryRow<{ id: number }>(
|
||||
`INSERT INTO participants (name, email) VALUES ($1, $2) RETURNING id`,
|
||||
[`Owner fixture — ${label}`, email]
|
||||
);
|
||||
return { id: row!.id, email };
|
||||
};
|
||||
const a = await mk("me");
|
||||
const b = await mk("them");
|
||||
me = a.id;
|
||||
meEmail = a.email;
|
||||
them = b.id;
|
||||
themEmail = b.email;
|
||||
});
|
||||
|
||||
/** A manual transaction owned by `owner`, with optional splits. */
|
||||
async function manualTxn(label: string, owner: number, splits: [number, number][] = []) {
|
||||
const row = await queryRow<{ id: number }>(
|
||||
`INSERT INTO transactions (transaction_date, description, amount, transaction_type, category, owner_id)
|
||||
VALUES ('2026-06-01', $1, 100.00, 'debit', 'groceries', $2) RETURNING id`,
|
||||
[`Owner fixture — ${label}`, owner]
|
||||
);
|
||||
for (const [pid, pct] of splits) {
|
||||
await queryRaw(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent) VALUES ($1,$2,$3)`,
|
||||
[row!.id, pid, pct]
|
||||
);
|
||||
}
|
||||
return row!.id;
|
||||
}
|
||||
|
||||
const ownerOf = async (id: number) =>
|
||||
(await queryRow<{ owner_id: number | null }>(`SELECT owner_id FROM transactions WHERE id = $1`, [id]))!
|
||||
.owner_id;
|
||||
|
||||
describe("PATCH /api/transactions/[id] — owner", () => {
|
||||
it("reassigns a manual row when I hold a split on it", async () => {
|
||||
const id = await manualTxn("shared shop", me, [[me, 50], [them, 50]]);
|
||||
const res = await txPATCH(req(meEmail, { owner_id: them }), ctx(id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(await ownerOf(id)).toBe(them);
|
||||
});
|
||||
|
||||
it("refuses to hand away a row I hold no split on", async () => {
|
||||
const id = await manualTxn("nothing of mine", me);
|
||||
const res = await txPATCH(req(meEmail, { owner_id: them }), ctx(id));
|
||||
expect(res.status).toBe(409);
|
||||
expect((await res.json()).code).toBe("would_lose_access");
|
||||
// Unchanged — a refusal must not half-apply.
|
||||
expect(await ownerOf(id)).toBe(me);
|
||||
});
|
||||
|
||||
it("hands it away when the release is explicit", async () => {
|
||||
const id = await manualTxn("given away", me);
|
||||
const res = await txPATCH(req(meEmail, { owner_id: them, release: true }), ctx(id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(await ownerOf(id)).toBe(them);
|
||||
});
|
||||
|
||||
it("never blocks taking onto my ledger a row I can already see", async () => {
|
||||
// No `release` needed: I end up the owner, so there is no door to close
|
||||
// behind me. The split is what lets me see it in the first place.
|
||||
const id = await manualTxn("taken on", them, [[me, 50], [them, 50]]);
|
||||
const res = await txPATCH(req(meEmail, { owner_id: me }), ctx(id));
|
||||
expect(res.status).toBe(200);
|
||||
expect(await ownerOf(id)).toBe(me);
|
||||
});
|
||||
|
||||
it("cannot claim a row I cannot see", async () => {
|
||||
// Their transaction, no split of mine — canAccessTransactions rejects
|
||||
// before any owner logic runs. Reassignment is a correction to a row you
|
||||
// already have, never a way to reach one you do not.
|
||||
const id = await manualTxn("out of reach", them);
|
||||
const res = await txPATCH(req(meEmail, { owner_id: me }), ctx(id));
|
||||
expect(res.status).toBe(404);
|
||||
expect(await ownerOf(id)).toBe(them);
|
||||
});
|
||||
|
||||
it("refuses a statement row and says where to change it", async () => {
|
||||
const stmt = await queryRow<{ id: number }>(
|
||||
`INSERT INTO statements (bank_name, account_number, billing_end_date, currency, filename, owner_id)
|
||||
VALUES ('Owner Fixture Bank','1111','2026-06-30','AUD','owner-fixture-1.pdf',$1) RETURNING id`,
|
||||
[me]
|
||||
);
|
||||
const row = await queryRow<{ id: number }>(
|
||||
`INSERT INTO transactions (statement_id, transaction_date, description, amount, transaction_type)
|
||||
VALUES ($1,'2026-06-02','Owner fixture — statement row', 40.00, 'debit') RETURNING id`,
|
||||
[stmt!.id]
|
||||
);
|
||||
const res = await txPATCH(req(meEmail, { owner_id: them }), ctx(row!.id));
|
||||
expect(res.status).toBe(400);
|
||||
expect((await res.json()).code).toBe("statement_owned");
|
||||
});
|
||||
|
||||
it("rejects a participant that does not exist", async () => {
|
||||
const id = await manualTxn("bad participant", me, [[me, 100]]);
|
||||
const res = await txPATCH(req(meEmail, { owner_id: 999999 }), ctx(id));
|
||||
expect(res.status).toBe(400);
|
||||
expect(await ownerOf(id)).toBe(me);
|
||||
});
|
||||
|
||||
it("leaves splits untouched — they record shares, not direction", async () => {
|
||||
const id = await manualTxn("shares survive", me, [[me, 50], [them, 50]]);
|
||||
await txPATCH(req(meEmail, { owner_id: them }), ctx(id));
|
||||
const splits = await queryRaw<{ participant_id: number; share_percent: string; settled: boolean }>(
|
||||
`SELECT participant_id, share_percent, settled FROM transaction_splits
|
||||
WHERE transaction_id = $1 ORDER BY participant_id`,
|
||||
[id]
|
||||
);
|
||||
expect(splits.map((s) => [s.participant_id, Number(s.share_percent)])).toEqual([
|
||||
[me, 50],
|
||||
[them, 50],
|
||||
]);
|
||||
expect(splits.every((s) => s.settled === false)).toBe(true);
|
||||
});
|
||||
|
||||
it("flips which side of the balance the same rows sit on", async () => {
|
||||
const id = await manualTxn("balance flip", me, [[me, 50], [them, 50]]);
|
||||
|
||||
// "They owe me": their split on a row I own.
|
||||
const owedToMe = async () =>
|
||||
Number(
|
||||
(await queryRow<{ v: string | null }>(
|
||||
`SELECT COALESCE(SUM(t.amount * ts.share_percent / 100), 0) AS v
|
||||
FROM transaction_splits ts JOIN transactions t ON t.id = ts.transaction_id
|
||||
WHERE t.id = $1 AND t.owner_id = $2 AND ts.participant_id <> $2`,
|
||||
[id, me]
|
||||
))!.v ?? 0
|
||||
);
|
||||
// "I owe them": my split on a row they own.
|
||||
const owedByMe = async () =>
|
||||
Number(
|
||||
(await queryRow<{ v: string | null }>(
|
||||
`SELECT COALESCE(SUM(t.amount * ts.share_percent / 100), 0) AS v
|
||||
FROM transaction_splits ts JOIN transactions t ON t.id = ts.transaction_id
|
||||
WHERE t.id = $1 AND t.owner_id <> $2 AND ts.participant_id = $2`,
|
||||
[id, me]
|
||||
))!.v ?? 0
|
||||
);
|
||||
|
||||
expect(await owedToMe()).toBe(50);
|
||||
expect(await owedByMe()).toBe(0);
|
||||
|
||||
await txPATCH(req(meEmail, { owner_id: them }), ctx(id));
|
||||
|
||||
expect(await owedToMe()).toBe(0);
|
||||
expect(await owedByMe()).toBe(50);
|
||||
});
|
||||
|
||||
it("rejects an unauthenticated caller", async () => {
|
||||
const id = await manualTxn("no auth", me, [[me, 100]]);
|
||||
const res = await txPATCH(req("nobody@example.test", { owner_id: them }), ctx(id));
|
||||
expect(res.status).toBe(403);
|
||||
expect(await ownerOf(id)).toBe(me);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/statements/[id] — owner", () => {
|
||||
async function statementWithRows(label: string, owner: number) {
|
||||
const stmt = await queryRow<{ id: number }>(
|
||||
`INSERT INTO statements (bank_name, account_number, billing_end_date, currency, filename, owner_id)
|
||||
VALUES ('Owner Fixture Bank', $1, '2026-06-30','AUD', $2, $3) RETURNING id`,
|
||||
[label, `owner-fixture-${label}.pdf`, owner]
|
||||
);
|
||||
// One row inheriting the owner (owner_id NULL) and one carrying its own
|
||||
// copy — the live table holds 1,803 and 2,194 of these respectively, and
|
||||
// updating `statements` alone would move only the first kind.
|
||||
const inheriting = await queryRow<{ id: number }>(
|
||||
`INSERT INTO transactions (statement_id, transaction_date, description, amount, transaction_type)
|
||||
VALUES ($1,'2026-06-03','Owner fixture — inheriting', 10.00, 'debit') RETURNING id`,
|
||||
[stmt!.id]
|
||||
);
|
||||
const carrying = await queryRow<{ id: number }>(
|
||||
`INSERT INTO transactions (statement_id, transaction_date, description, amount, transaction_type, owner_id)
|
||||
VALUES ($1,'2026-06-04','Owner fixture — carrying', 20.00, 'debit', $2) RETURNING id`,
|
||||
[stmt!.id, owner]
|
||||
);
|
||||
return { stmtId: stmt!.id, inheriting: inheriting!.id, carrying: carrying!.id };
|
||||
}
|
||||
|
||||
/** The owner the app actually reads: COALESCE(t.owner_id, s.owner_id). */
|
||||
const effectiveOwner = async (id: number) =>
|
||||
(await queryRow<{ v: number }>(
|
||||
`SELECT COALESCE(t.owner_id, s.owner_id) AS v FROM transactions t
|
||||
LEFT JOIN statements s ON s.id = t.statement_id WHERE t.id = $1`,
|
||||
[id]
|
||||
))!.v;
|
||||
|
||||
it("moves the statement and every row on it, both kinds", async () => {
|
||||
const { stmtId, inheriting, carrying } = await statementWithRows("move", me);
|
||||
const res = await stmtPATCH(req(meEmail, { owner_id: them }), ctx(stmtId));
|
||||
expect(res.status).toBe(200);
|
||||
expect((await res.json()).rows_moved).toBe(1); // only the one carrying a copy
|
||||
|
||||
expect(await effectiveOwner(inheriting)).toBe(them);
|
||||
expect(await effectiveOwner(carrying)).toBe(them);
|
||||
});
|
||||
|
||||
it("refuses a caller who does not own it", async () => {
|
||||
const { stmtId, carrying } = await statementWithRows("not-mine", me);
|
||||
const res = await stmtPATCH(req(themEmail, { owner_id: them }), ctx(stmtId));
|
||||
expect(res.status).toBe(404);
|
||||
expect(await effectiveOwner(carrying)).toBe(me);
|
||||
});
|
||||
|
||||
it("rejects a participant that does not exist", async () => {
|
||||
const { stmtId, carrying } = await statementWithRows("bad-participant", me);
|
||||
const res = await stmtPATCH(req(meEmail, { owner_id: 999999 }), ctx(stmtId));
|
||||
expect(res.status).toBe(400);
|
||||
expect(await effectiveOwner(carrying)).toBe(me);
|
||||
});
|
||||
|
||||
it("is a no-op when the owner is unchanged", async () => {
|
||||
const { stmtId } = await statementWithRows("same-owner", me);
|
||||
const res = await stmtPATCH(req(meEmail, { owner_id: me }), ctx(stmtId));
|
||||
expect(res.status).toBe(200);
|
||||
expect((await res.json()).rows_moved).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
applyMapping,
|
||||
inFileDuplicates,
|
||||
last4,
|
||||
splitByCoverage,
|
||||
type ColumnMapping,
|
||||
type ParsedTransaction,
|
||||
} from "@/lib/csv-parser";
|
||||
|
||||
/**
|
||||
* The statement-coverage rule, which replaced a whole Frollo-specific importer.
|
||||
*
|
||||
* The first aggregator import wrote 422 duplicate rows out of 550 — $1,023,824
|
||||
* of movement counted twice — because "these accounts issue no statements" was
|
||||
* asserted rather than queried. Amount-matching could not fix it either: the two
|
||||
* sources decompose the same event differently, bundling a transfer fee where
|
||||
* the statement itemises it.
|
||||
*
|
||||
* A statement's end date answers the question that actually has an answer: up
|
||||
* to what date is this account complete? On the real 2026-08-13 export it takes
|
||||
* 2,607 rows down to 171, with no account allowlist and no card exclusions.
|
||||
*/
|
||||
|
||||
const tx = (o: Partial<ParsedTransaction> & { date: string }): ParsedTransaction => ({
|
||||
description: "SOMETHING",
|
||||
amount: 10,
|
||||
transaction_type: "debit",
|
||||
...o,
|
||||
});
|
||||
|
||||
describe("last4", () => {
|
||||
it("reads the same account written four different ways", () => {
|
||||
// Every one of these forms appears in the real data.
|
||||
expect(last4("xxxxxxxxxxxx2176")).toBe("2176");
|
||||
expect(last4("235242176")).toBe("2176");
|
||||
expect(last4("xxx-xxx xx4878")).toBe("4878");
|
||||
expect(last4("4085-56264")).toBe("6264");
|
||||
});
|
||||
|
||||
it("returns empty for an unusable identifier rather than guessing", () => {
|
||||
expect(last4("N/A")).toBe("");
|
||||
expect(last4("12")).toBe("");
|
||||
expect(last4("")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("splitByCoverage", () => {
|
||||
const coverage = [{ last4: "2176", coveredTo: "2026-07-26" }];
|
||||
|
||||
it("excludes a row on or before the account's newest statement", () => {
|
||||
const { keep, covered } = splitByCoverage(
|
||||
[tx({ date: "2026-07-25", account: "xxxxxxxxxxxx2176" })],
|
||||
coverage
|
||||
);
|
||||
expect(covered).toHaveLength(1);
|
||||
expect(keep).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("treats the watermark date itself as covered", () => {
|
||||
const { covered } = splitByCoverage(
|
||||
[tx({ date: "2026-07-26", account: "xxxxxxxxxxxx2176" })],
|
||||
coverage
|
||||
);
|
||||
expect(covered).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps a row after the watermark — the whole point of the feed", () => {
|
||||
// The 2026-08-12 salary: the statement has not arrived, so this row is the
|
||||
// only record of it and must survive.
|
||||
const { keep } = splitByCoverage(
|
||||
[tx({ date: "2026-08-12", account: "xxxxxxxxxxxx2176", amount: 10782 })],
|
||||
coverage
|
||||
);
|
||||
expect(keep).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("keeps everything for an account with no statements", () => {
|
||||
const { keep, covered } = splitByCoverage(
|
||||
[tx({ date: "2020-01-01", account: "xxx-xxx xx4878" })],
|
||||
coverage
|
||||
);
|
||||
expect(keep).toHaveLength(1);
|
||||
expect(covered).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("keeps everything when no account column was mapped", () => {
|
||||
// A single-account bank export has no account column, and a row must never
|
||||
// be dropped on a guess about which account it belongs to.
|
||||
const { keep, covered } = splitByCoverage([tx({ date: "2020-01-01" })], coverage);
|
||||
expect(keep).toHaveLength(1);
|
||||
expect(covered).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("applies each account's own watermark, not a global one", () => {
|
||||
// The real export spans watermarks from 2026-03-31 to 2026-07-26. A single
|
||||
// date would either import duplicates or discard real rows.
|
||||
const { keep, covered } = splitByCoverage(
|
||||
[
|
||||
tx({ date: "2026-05-01", account: "xxxx0887" }), // covered to 2026-03-31 → keep
|
||||
tx({ date: "2026-05-01", account: "xxxx2176" }), // covered to 2026-07-26 → drop
|
||||
],
|
||||
[
|
||||
{ last4: "0887", coveredTo: "2026-03-31" },
|
||||
{ last4: "2176", coveredTo: "2026-07-26" },
|
||||
]
|
||||
);
|
||||
expect(keep).toHaveLength(1);
|
||||
expect(keep[0].account).toBe("xxxx0887");
|
||||
expect(covered).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("inFileDuplicates", () => {
|
||||
it("counts twins that a row id cannot catch", () => {
|
||||
// A CDR re-consent re-exports an account's whole history under fresh ids
|
||||
// while the originals survive, so source_ref sees two distinct rows. 385 of
|
||||
// these were in one 2,563-row export and doubled every salary payment.
|
||||
const rows = [
|
||||
tx({ date: "2026-07-07", amount: 10782, description: "HDR", account: "4830" }),
|
||||
tx({ date: "2026-07-07", amount: 10782, description: "HDR", account: "4830" }),
|
||||
];
|
||||
expect(inFileDuplicates(rows)).toBe(1);
|
||||
});
|
||||
|
||||
it("does not flag the same amount on different days", () => {
|
||||
expect(
|
||||
inFileDuplicates([
|
||||
tx({ date: "2026-07-07", amount: 5, description: "COFFEE" }),
|
||||
tx({ date: "2026-07-08", amount: 5, description: "COFFEE" }),
|
||||
])
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("does not flag the same amount on different accounts", () => {
|
||||
// Both legs of an internal transfer: same day, same amount, different
|
||||
// accounts, and both are real.
|
||||
expect(
|
||||
inFileDuplicates([
|
||||
tx({ date: "2026-05-18", amount: 3076.04, description: "Transfer", account: "6264" }),
|
||||
tx({ date: "2026-05-18", amount: 3076.04, description: "Transfer", account: "9940" }),
|
||||
])
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyMapping with account and row-id columns", () => {
|
||||
const labels = ["transaction_id", "description", "amount", "transaction_date", "account_number"];
|
||||
const mapping: ColumnMapping = {
|
||||
dateCol: "transaction_date",
|
||||
descriptionCol: "description",
|
||||
amountMode: "single",
|
||||
amountCol: "amount",
|
||||
accountCol: "account_number",
|
||||
sourceRefCol: "transaction_id",
|
||||
};
|
||||
|
||||
it("carries the account and row id through", () => {
|
||||
const out = applyMapping(
|
||||
[["1326131276", "HDR Global Services", "10782.00", "2026-08-12", "xxxxxxxxxxxx4830"]],
|
||||
labels,
|
||||
mapping,
|
||||
"YYYY-MM-DD"
|
||||
);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0].account).toBe("xxxxxxxxxxxx4830");
|
||||
expect(out[0].source_ref).toBe("1326131276");
|
||||
// A positive single-column amount is money in.
|
||||
expect(out[0].transaction_type).toBe("credit");
|
||||
});
|
||||
|
||||
it("marks a non-AUD row as native and unconverted", () => {
|
||||
// The 2026-08-12 salary is USD. Stored without this it reads as A$10,782 —
|
||||
// about a third under — and sits in a column of AUD figures looking normal.
|
||||
const out = applyMapping(
|
||||
[["1", "HDR Global Services", "10782.00", "2026-08-12", "xxxx4830", "USD"]],
|
||||
[...labels, "currency"],
|
||||
{ ...mapping, currencyCol: "currency" },
|
||||
"YYYY-MM-DD"
|
||||
);
|
||||
expect(out[0].foreign_currency_code).toBe("USD");
|
||||
expect(out[0].foreign_currency_amount).toBe(10782);
|
||||
});
|
||||
|
||||
it("leaves an AUD row unmarked", () => {
|
||||
const out = applyMapping(
|
||||
[["1", "SOMETHING", "-42.50", "2026-08-12", "xxxx4830", "AUD"]],
|
||||
[...labels, "currency"],
|
||||
{ ...mapping, currencyCol: "currency" },
|
||||
"YYYY-MM-DD"
|
||||
);
|
||||
expect(out[0].foreign_currency_code).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores a currency cell that is not a code", () => {
|
||||
const out = applyMapping(
|
||||
[["1", "SOMETHING", "-42.50", "2026-08-12", "xxxx4830", ""]],
|
||||
[...labels, "currency"],
|
||||
{ ...mapping, currencyCol: "currency" },
|
||||
"YYYY-MM-DD"
|
||||
);
|
||||
expect(out[0].foreign_currency_code).toBeUndefined();
|
||||
});
|
||||
|
||||
it("leaves both undefined when the columns are not mapped", () => {
|
||||
const out = applyMapping(
|
||||
[["1", "SOMETHING", "-42.50", "2026-08-12", "xxxx4830"]],
|
||||
labels,
|
||||
{ dateCol: "transaction_date", descriptionCol: "description", amountMode: "single", amountCol: "amount" },
|
||||
"YYYY-MM-DD"
|
||||
);
|
||||
expect(out[0].account).toBeUndefined();
|
||||
expect(out[0].source_ref).toBeUndefined();
|
||||
expect(out[0].transaction_type).toBe("debit");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
import { extractPdfText, liteLLMExtractor } from "../../lib/intake-extract";
|
||||
import { reconcileItems, toLineItems, validateHeader } from "../../lib/intake-rules";
|
||||
|
||||
/**
|
||||
* Opt-in: the real model over the real Burgertory export.
|
||||
*
|
||||
* INTAKE_LIVE_TEST=1 LITELLM_BASE_URL=http://localhost:4200 LITELLM_KEY=... npm test
|
||||
*
|
||||
* Skipped otherwise — CI has no LiteLLM, and a unit run must not depend on one.
|
||||
* What it proves is the thing the mock cannot: that `cloud-budget` keeps the
|
||||
* names verbatim ("Michael", "Joel", not "Burger") under the order-item prompt.
|
||||
*/
|
||||
const live = process.env.INTAKE_LIVE_TEST === "1" && !!process.env.LITELLM_BASE_URL && !!process.env.LITELLM_KEY;
|
||||
|
||||
describe.skipIf(!live)("live extraction via LiteLLM (opt-in)", () => {
|
||||
it("Burgertory PDF: header exact, two items named verbatim, reconciles", async () => {
|
||||
const text = await extractPdfText(readFileSync(resolve(__dirname, "../fixtures/intake/ubereats-burgertory-2026-08-19.pdf")));
|
||||
const extracted = await liteLLMExtractor()([{ kind: "text", text, name: "Receipt_19Aug2026.pdf" }]);
|
||||
const header = validateHeader(extracted);
|
||||
expect(header.ok && header.header).toEqual({ merchant: "Burgertory (Werribee)", order_date: "2026-08-19", total: 22.4 });
|
||||
const items = toLineItems(extracted.items);
|
||||
expect(items.map((i) => i.description)).toEqual(["Michael", "Joel"]);
|
||||
expect(reconcileItems(extracted).ok).toBe(true);
|
||||
}, 120_000);
|
||||
|
||||
it("Coles PDF: header exact, 15 items", async () => {
|
||||
const text = await extractPdfText(readFileSync(resolve(__dirname, "../fixtures/intake/ubereats-coles-2026-08-18.pdf")));
|
||||
const extracted = await liteLLMExtractor()([{ kind: "text", text, name: "Receipt_18Aug2026.pdf" }]);
|
||||
const header = validateHeader(extracted);
|
||||
expect(header.ok && header.header).toEqual({ merchant: "Coles (Wyndham Vale)", order_date: "2026-08-18", total: 85.74 });
|
||||
expect(toLineItems(extracted.items)).toHaveLength(15);
|
||||
expect(reconcileItems(extracted).ok).toBe(true);
|
||||
}, 120_000);
|
||||
});
|
||||
@@ -0,0 +1,300 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { readFileSync } from "fs";
|
||||
import { resolve } from "path";
|
||||
import {
|
||||
classifyInput,
|
||||
GROCERY_PATTERN,
|
||||
merchantKey,
|
||||
merchantMatches,
|
||||
platformForSource,
|
||||
reconcileItems,
|
||||
resolveMerchantKind,
|
||||
toLineItems,
|
||||
validateHeader,
|
||||
type ExtractedOrder,
|
||||
} from "../../lib/intake-rules";
|
||||
import { buildMessageContent, extractPdfText, parseExtraction, EXTRACT_RESPONSE_FORMAT, ExtractionError, liteLLMExtractor } from "../../lib/intake-extract";
|
||||
import { askCard, candidatesCard, enrichedCard, type Block } from "../../lib/intake-blocks";
|
||||
|
||||
/**
|
||||
* The intake router's pure rules, against the strings real receipts print.
|
||||
* The two PDFs are the user's own Uber Eats exports (plan §3): a Coles grocery
|
||||
* run and a Burgertory meal whose burgers are named after people.
|
||||
*/
|
||||
|
||||
const fixtures = resolve(__dirname, "../fixtures/intake");
|
||||
const pdf = (name: string) => readFileSync(resolve(fixtures, name));
|
||||
|
||||
describe("classifyInput — input type picks the extractor", () => {
|
||||
it("PDF by MIME or by name", () => {
|
||||
expect(classifyInput({ type: "application/pdf", name: "x" })).toBe("pdf");
|
||||
expect(classifyInput({ type: "application/octet-stream", name: "Receipt_18Aug2026.pdf" })).toBe("pdf");
|
||||
});
|
||||
it("images and text", () => {
|
||||
expect(classifyInput({ type: "image/png", name: "IMG_1.png" })).toBe("image");
|
||||
expect(classifyInput({ type: "", name: "photo.HEIC" })).toBe("image");
|
||||
expect(classifyInput({ type: "text/plain", name: "" })).toBe("text");
|
||||
});
|
||||
it("declines what it cannot read", () => {
|
||||
expect(classifyInput({ type: "video/mp4", name: "clip.mp4" })).toBe("unsupported");
|
||||
expect(classifyInput({ type: "application/zip", name: "a.zip" })).toBe("unsupported");
|
||||
});
|
||||
});
|
||||
|
||||
describe("platformForSource", () => {
|
||||
it("maps the tap to expense_metadata.platform vocabulary", () => {
|
||||
expect(platformForSource("uber_eats")).toBe("ubereats");
|
||||
expect(platformForSource("doordash")).toBe("doordash");
|
||||
expect(platformForSource("coles")).toBeNull();
|
||||
expect(platformForSource("other")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("merchant pattern — grocery | food | ask", () => {
|
||||
it("the pattern list is a substring match on the normalised name", () => {
|
||||
expect(resolveMerchantKind("uber_eats", "Coles (Wyndham Vale)")).toBe("grocery");
|
||||
expect(resolveMerchantKind("other", "Woolworths Group Limited")).toBe("grocery");
|
||||
expect(resolveMerchantKind("uber_eats", "WOOLIES METRO")).toBe("grocery");
|
||||
expect(resolveMerchantKind("doordash", "ALDI Werribee")).toBe("grocery");
|
||||
expect(resolveMerchantKind("uber_eats", "IGA Xpress")).toBe("grocery");
|
||||
expect(resolveMerchantKind("uber_eats", "FoodWorks Manor Lakes")).toBe("grocery");
|
||||
expect(resolveMerchantKind("uber_eats", "Costco Wholesale")).toBe("grocery");
|
||||
});
|
||||
it("a delivery order that is not a grocer is a meal", () => {
|
||||
expect(resolveMerchantKind("uber_eats", "Burgertory (Werribee)")).toBe("food");
|
||||
expect(resolveMerchantKind("doordash", "Mad Mex")).toBe("food");
|
||||
});
|
||||
it("an empty or unknown merchant asks", () => {
|
||||
expect(resolveMerchantKind("uber_eats", null)).toBe("ask");
|
||||
expect(resolveMerchantKind("uber_eats", " ")).toBe("ask");
|
||||
expect(resolveMerchantKind("doordash", "Unknown")).toBe("ask");
|
||||
});
|
||||
it("the Coles/Woolworths taps are ground truth", () => {
|
||||
expect(resolveMerchantKind("coles", "Romeo's Food Hall")).toBe("grocery");
|
||||
expect(resolveMerchantKind("woolworths", null)).toBe("grocery");
|
||||
});
|
||||
it("`other` with an unrecognised merchant asks; a remembered answer sticks", () => {
|
||||
expect(resolveMerchantKind("other", "Romeo's Food Hall")).toBe("ask");
|
||||
expect(resolveMerchantKind("other", "Romeo's Food Hall", "grocery")).toBe("grocery");
|
||||
expect(resolveMerchantKind("uber_eats", "Romeo's Food Hall", "grocery")).toBe("grocery");
|
||||
});
|
||||
it("merchantKey folds case and punctuation; merchantMatches allows containment only", () => {
|
||||
expect(merchantKey("Coles (Wyndham Vale)")).toBe("coles wyndham vale");
|
||||
expect(merchantMatches("Coles (Wyndham Vale)", "coles wyndham vale")).toBe(true);
|
||||
expect(merchantMatches("Coles (Wyndham Vale)", "Coles")).toBe(true);
|
||||
expect(merchantMatches("Order - Coles (Wyndham Vale)", "Coles (Wyndham Vale)")).toBe(true);
|
||||
expect(merchantMatches("Burgertory (Werribee)", "Coles")).toBe(false);
|
||||
expect(merchantMatches(null, "Coles")).toBe(false);
|
||||
});
|
||||
|
||||
it("whole words, not substrings: `Bigals Pizza` contains `iga` and is neither a grocer nor an IGA", () => {
|
||||
expect(GROCERY_PATTERN.test(merchantKey("Bigals Pizza"))).toBe(false);
|
||||
expect(resolveMerchantKind("uber_eats", "Bigals Pizza")).toBe("food");
|
||||
expect(merchantMatches("Bigals Pizza", "IGA")).toBe(false);
|
||||
// and the grocers a boundary must not cost us
|
||||
expect(resolveMerchantKind("uber_eats", "IGA Wyndham Vale")).toBe("grocery");
|
||||
expect(resolveMerchantKind("uber_eats", "Woolworths Group Limited")).toBe("grocery");
|
||||
expect(resolveMerchantKind("uber_eats", "Coles (Wyndham Vale)")).toBe("grocery");
|
||||
expect(merchantMatches("IGA Wyndham Vale", "IGA")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateHeader — nothing is written from a half-read upload", () => {
|
||||
const base: ExtractedOrder = { merchant: "Burgertory (Werribee)", order_date: "2026-08-19", total: 22.4, items: [] };
|
||||
it("accepts a complete header and rounds the total", () => {
|
||||
const r = validateHeader({ ...base, total: 22.404 });
|
||||
expect(r.ok && r.header.total).toBe(22.4);
|
||||
});
|
||||
it("missing total", () => {
|
||||
const r = validateHeader({ ...base, total: null });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(!r.ok && r.missing).toEqual(["total"]);
|
||||
});
|
||||
it("missing or malformed date", () => {
|
||||
expect(validateHeader({ ...base, order_date: null }).ok).toBe(false);
|
||||
expect(validateHeader({ ...base, order_date: "19/8/26" }).ok).toBe(false);
|
||||
});
|
||||
it("a missing merchant is NOT a missing header — it becomes an ask", () => {
|
||||
const r = validateHeader({ ...base, merchant: null });
|
||||
expect(r.ok && r.header.merchant).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("reconcileItems — the missing-screenshot check", () => {
|
||||
it("reconciles to the printed item subtotal, not the total: fees and promotions are not items", () => {
|
||||
// The real Coles export: 15 lines summing $118.87 against a printed $118.94 subtotal and an $85.74 total.
|
||||
const r = reconcileItems({
|
||||
merchant: "Coles", order_date: "2026-08-18", total: 85.74, subtotal: 118.94,
|
||||
items: [{ quantity: 1, description: "a", line_total: 118.87 }],
|
||||
});
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.basis).toBe("subtotal");
|
||||
});
|
||||
it("falls back to the total when no subtotal is printed", () => {
|
||||
const r = reconcileItems({ merchant: "x", order_date: "2026-08-19", total: 28, items: [{ quantity: 1, description: "Michael", line_total: 14 }, { quantity: 1, description: "Joel", line_total: 14 }] });
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.basis).toBe("total");
|
||||
});
|
||||
it("holds when the lines fall short — a shot is missing", () => {
|
||||
const r = reconcileItems({ merchant: "x", order_date: "2026-08-19", total: 28, subtotal: 28, items: [{ quantity: 1, description: "Michael", line_total: 14 }] });
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toMatch(/14\.00.*28\.00/);
|
||||
});
|
||||
it("tolerance is $1 or 2%, whichever is larger", () => {
|
||||
expect(reconcileItems({ merchant: "x", order_date: "d", total: 10, subtotal: 10, items: [{ quantity: 1, description: "a", line_total: 9.05 }] }).ok).toBe(true);
|
||||
expect(reconcileItems({ merchant: "x", order_date: "d", total: 10, subtotal: 10, items: [{ quantity: 1, description: "a", line_total: 8.9 }] }).ok).toBe(false);
|
||||
expect(reconcileItems({ merchant: "x", order_date: "d", total: 200, subtotal: 200, items: [{ quantity: 1, description: "a", line_total: 196.5 }] }).ok).toBe(true);
|
||||
expect(reconcileItems({ merchant: "x", order_date: "d", total: 200, subtotal: 200, items: [{ quantity: 1, description: "a", line_total: 195 }] }).ok).toBe(false);
|
||||
});
|
||||
it("derives a line from quantity × unit price when no line total is printed", () => {
|
||||
const r = reconcileItems({ merchant: "x", order_date: "d", total: 13.2, subtotal: 13.2, items: [{ quantity: 3, description: "Coke", unit_price: 4.4 }] });
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.items_sum).toBe(13.2);
|
||||
});
|
||||
it("no items at all is held; priceless lines (pasted text) are not", () => {
|
||||
expect(reconcileItems({ merchant: "x", order_date: "d", total: 10, items: [] }).ok).toBe(false);
|
||||
expect(reconcileItems({ merchant: "x", order_date: "d", total: 10, items: [{ quantity: 1, description: "a" }] }).ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toLineItems — names verbatim, options kept", () => {
|
||||
it("keeps a burger called Michael called Michael", () => {
|
||||
const out = toLineItems([{ quantity: 1, description: "Michael", unit_price: 14, line_total: 14, options: ["Milk Bun"] }]);
|
||||
expect(out).toEqual([{ qty: 1, description: "Michael", amount: 14, unit_price: 14, options: ["Milk Bun"] }]);
|
||||
});
|
||||
it("drops nameless lines and defaults quantity to 1", () => {
|
||||
const out = toLineItems([{ quantity: null, description: " ", line_total: 1 }, { quantity: null, description: "Cucumbers Continental", unit_price: 2.9 }]);
|
||||
expect(out).toEqual([{ qty: 1, description: "Cucumbers Continental", amount: 2.9, unit_price: 2.9 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extraction request and reply", () => {
|
||||
it("one call: the prompt first, then every part in the order shared", () => {
|
||||
const content = buildMessageContent([
|
||||
{ kind: "image", mime: "image/png", bytes: Buffer.from("a"), name: "1.png" },
|
||||
{ kind: "text", text: "Total $22.40", name: "r.pdf" },
|
||||
{ kind: "image", mime: "image/jpeg", bytes: Buffer.from("b"), name: "2.jpg" },
|
||||
]) as { type: string; text?: string; image_url?: { url: string } }[];
|
||||
expect(content[0].text).toMatch(/VERBATIM/);
|
||||
const images = content.filter((c) => c.type === "image_url").map((c) => c.image_url!.url);
|
||||
expect(images).toEqual(["data:image/png;base64,YQ==", "data:image/jpeg;base64,Yg=="]);
|
||||
expect(content.findIndex((c) => c.text?.includes("Total $22.40"))).toBeLessThan(content.findIndex((c) => c.image_url?.url.endsWith("Yg==")));
|
||||
expect(EXTRACT_RESPONSE_FORMAT.json_schema.strict).toBe(true);
|
||||
});
|
||||
it("parses a fenced reply and coerces numbers", () => {
|
||||
const r = parseExtraction('```json\n{"merchant":"Burgertory (Werribee)","order_date":"2026-08-19","total":"22.40","subtotal":28,"currency":null,"items":[{"quantity":1,"description":"Michael","unit_price":14,"line_total":14,"options":["Milk Bun"]}],"adjustments":[{"label":"Promotion","amount":-8}]}\n```');
|
||||
expect(r.total).toBe(22.4);
|
||||
expect(r.currency).toBe("AUD");
|
||||
expect(r.items[0]).toEqual({ quantity: 1, description: "Michael", unit_price: 14, line_total: 14, options: ["Milk Bun"] });
|
||||
expect(r.adjustments).toEqual([{ label: "Promotion", amount: -8 }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PDF text — the Uber Eats export is born-digital text", () => {
|
||||
it("Coles (Wyndham Vale) · 2026-08-18 · $85.74, 15 lines, weighed and substituted", async () => {
|
||||
const text = await extractPdfText(pdf("ubereats-coles-2026-08-18.pdf"));
|
||||
expect(text).toContain("Here's your receipt for Coles (Wyndham Vale).");
|
||||
expect(text).toContain("18 Aug, 2026");
|
||||
expect(text).toContain("Total $85.74");
|
||||
expect(text).toContain("Item Subtotal $118.94");
|
||||
expect(text).toContain("Final 1.16 kg $11.00/kg");
|
||||
expect(text).toContain("Replaced Coles Beef Slow Cook Chuck Roast");
|
||||
// 15 item lines: 13 priced per piece, 2 weighed per kg.
|
||||
expect(text.match(/\$\d+\.\d\d\/pc$/gm)?.length).toBe(13);
|
||||
expect(text.match(/\$\d+\.\d\d\/kg$/gm)?.length).toBe(2);
|
||||
});
|
||||
it("Burgertory (Werribee) · 2026-08-19 · $22.40, burgers named Michael and Joel", async () => {
|
||||
const text = await extractPdfText(pdf("ubereats-burgertory-2026-08-19.pdf"));
|
||||
expect(text).toContain("Here's your receipt for Burgertory (Werribee).");
|
||||
expect(text).toContain("Total $22.40");
|
||||
expect(text).toContain("1 Michael $14.00");
|
||||
expect(text).toContain("1 Joel $14.00");
|
||||
expect(text).toContain("Item Subtotal $28.00");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cards carry the C5 action ids and JSON values", () => {
|
||||
const c = { batchId: "11111111-2222-4333-8444-555555555555", merchant: "Burgertory (Werribee)", total: 22.4, orderDate: "2026-08-19", platform: "ubereats", itemCount: 2 };
|
||||
it("candidates: one Use-this per candidate plus an explicit Create", () => {
|
||||
const { blocks } = candidatesCard(c, [{ transaction_id: 42, description: "Order - Burgertory (Werribee)", transaction_date: "2026-08-17", amount: "19.90" }]);
|
||||
const buttons = JSON.stringify(blocks);
|
||||
expect(buttons).toContain('"action_id":"intake_use_candidate"');
|
||||
expect(buttons).toContain('"action_id":"intake_create"');
|
||||
const use = (blocks[1] as { accessory: { value: string } }).accessory.value;
|
||||
expect(JSON.parse(use)).toEqual({ batch_id: c.batchId, transaction_id: 42 });
|
||||
});
|
||||
it("enriched: the confirmation text and a View link", () => {
|
||||
const { text, blocks } = enrichedCard({ ...c, transactionId: 7 });
|
||||
expect(text).toBe("Added 2 items to your $22.40 Uber Eats (Burgertory (Werribee)) order ✓");
|
||||
expect(JSON.stringify(blocks)).toContain("/transactions?q=Burgertory");
|
||||
});
|
||||
it("ask: Groceries / A meal", () => {
|
||||
const ids = JSON.stringify(askCard({ ...c, platform: null }).blocks);
|
||||
expect(ids).toContain('"action_id":"intake_grocery"');
|
||||
expect(ids).toContain('"action_id":"intake_meal"');
|
||||
});
|
||||
|
||||
it("every value the card did not write is escaped, plain_text included", () => {
|
||||
const hostile = { ...c, merchant: "Ben & Jerry's <fake>" };
|
||||
const { text, blocks } = candidatesCard(hostile, [
|
||||
{ transaction_id: 42, description: "SQ *BEN & JERRY <AU>", transaction_date: "2026-08-17", amount: "19.90" },
|
||||
]);
|
||||
const sections = JSON.stringify(blocks.filter((b) => (b as Block).type === "section"));
|
||||
expect(sections).toContain("Ben & Jerry's <fake>");
|
||||
expect(sections).toContain("SQ *BEN & JERRY <AU>");
|
||||
expect(text).toContain("Ben & Jerry's <fake>");
|
||||
// plain_text disables formatting, not entity decoding — &, < and > are
|
||||
// control characters everywhere in a Slack payload.
|
||||
const whole = JSON.stringify(blocks);
|
||||
expect(whole).not.toContain("<fake>");
|
||||
expect(whole).not.toContain("<AU>");
|
||||
});
|
||||
|
||||
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(
|
||||
"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, 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 — 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 extractor = (finish_reason?: string) =>
|
||||
liteLLMExtractor({
|
||||
baseUrl: "http://litellm.test",
|
||||
key: "k",
|
||||
fetchImpl: (async () =>
|
||||
Response.json({
|
||||
choices: [{ message: { content: JSON.stringify(good) }, ...(finish_reason ? { finish_reason } : {}) }],
|
||||
})) as unknown as typeof fetch,
|
||||
});
|
||||
|
||||
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()([{ 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 () => {
|
||||
// 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(/too long/);
|
||||
await expect(extractor("content_filter")([{ kind: "text", text: "x", name: "x" }])).rejects.toThrow(/content_filter/);
|
||||
await expect(extractor("tool_calls")([{ kind: "text", text: "x", name: "x" }])).rejects.toThrow(/tool_calls/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { tidyMerchant } from "@/lib/merchant-label";
|
||||
|
||||
/**
|
||||
* Board 212. Every fixture below is a real `display_name` from order_feed.
|
||||
* The cases that matter most are the ones that must NOT change: this is a
|
||||
* presentational tidy and merging anything here would be a data error wearing
|
||||
* a stylesheet.
|
||||
*/
|
||||
describe("tidyMerchant", () => {
|
||||
it("capitalises a bare domain used as a name", () => {
|
||||
expect(tidyMerchant("amazon.com.au")).toBe("Amazon.com.au");
|
||||
expect(tidyMerchant("cdkeys.com")).toBe("Cdkeys.com");
|
||||
});
|
||||
|
||||
it("NEVER merges the country marketplaces", () => {
|
||||
// The single most important assertion in this file. Amazon.in is a
|
||||
// different marketplace with different currency and geography, and 289
|
||||
// orders sit behind it.
|
||||
expect(tidyMerchant("Amazon.in")).toBe("Amazon.in");
|
||||
expect(tidyMerchant("amazon.com.au")).not.toBe(tidyMerchant("Amazon.in"));
|
||||
});
|
||||
|
||||
it("leaves real brand names that contain a TLD alone", () => {
|
||||
for (const brand of ["Kogan.com", "GOG.com", "AliExpress.com", "Catch.com.au", "GeekBuying.com"]) {
|
||||
expect(tidyMerchant(brand)).toBe(brand);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not uppercase a deliberately lower-cased brand", () => {
|
||||
// The bug the corpus run found: gating on "starts lower-case" rather than
|
||||
// on "is a bare domain" turned this into "EBay Commerce Australia".
|
||||
expect(tidyMerchant("eBay Commerce Australia Pty Ltd.")).toBe("eBay Commerce Australia");
|
||||
expect(tidyMerchant("iRobot Australia Pty Ltd")).toBe("iRobot Australia");
|
||||
});
|
||||
|
||||
it("drops trailing corporate suffixes", () => {
|
||||
expect(tidyMerchant("Apple Pty Ltd.")).toBe("Apple");
|
||||
expect(tidyMerchant("Microsoft Pty. Limited")).toBe("Microsoft");
|
||||
expect(tidyMerchant("Meta Platforms, Inc.")).toBe("Meta Platforms");
|
||||
expect(tidyMerchant("Domino's Pizza Enterprises Limited")).toBe("Domino's Pizza Enterprises");
|
||||
expect(tidyMerchant("Blinks Labs GmbH")).toBe("Blinks Labs");
|
||||
expect(tidyMerchant("Rasier New Zealand Limited")).toBe("Rasier New Zealand");
|
||||
});
|
||||
|
||||
it("strips two suffixes when a name carries two", () => {
|
||||
expect(tidyMerchant("Samsung Electronics Co. Ltd.")).toBe("Samsung Electronics");
|
||||
expect(tidyMerchant("DiDi Mobility Information Technology Pte. Ltd."))
|
||||
.toBe("DiDi Mobility Information Technology");
|
||||
});
|
||||
|
||||
it("does not leave a dangling connector", () => {
|
||||
// 'Co' is part of this brand. Stripping it produced "Coburger &".
|
||||
expect(tidyMerchant("Coburger & Co")).toBe("Coburger & Co");
|
||||
});
|
||||
|
||||
it("keeps a name that would be reduced to almost nothing", () => {
|
||||
// "UT" is too short to stand on its own; better scruffy than cryptic.
|
||||
expect(tidyMerchant("UT LLC")).toBe("UT LLC");
|
||||
});
|
||||
|
||||
it("normalises whitespace and tolerates junk", () => {
|
||||
expect(tidyMerchant(" Yaffa Media Pty Ltd ")).toBe("Yaffa Media");
|
||||
expect(tidyMerchant("")).toBe("");
|
||||
});
|
||||
|
||||
it("is idempotent", () => {
|
||||
for (const n of ["amazon.com.au", "Apple Pty Ltd.", "Coburger & Co", "Amazon.in"]) {
|
||||
expect(tidyMerchant(tidyMerchant(n))).toBe(tidyMerchant(n));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -306,17 +306,220 @@ describe("Uber trips", () => {
|
||||
});
|
||||
|
||||
describe("orderDescription", () => {
|
||||
it("names the platform", () => {
|
||||
expect(orderDescription("doordash", "Mad Mex")).toBe("Order - Mad Mex (DoorDash)");
|
||||
it("names the restaurant, not the courier", () => {
|
||||
// The platform is provenance and lives in the Order details panel, which
|
||||
// already renders expense_metadata.platform. Putting it here fragmented the
|
||||
// merchant — the same restaurant read differently depending on who carried
|
||||
// the bag, which nobody rating the food cares about.
|
||||
expect(orderDescription("doordash", "Mad Mex")).toBe("Order - Mad Mex");
|
||||
expect(orderDescription("ubereats", "Coles (Wyndham Vale)")).toBe(
|
||||
"Order - Coles (Wyndham Vale) (Uber Eats)"
|
||||
"Order - Coles (Wyndham Vale)"
|
||||
);
|
||||
});
|
||||
|
||||
it("does not restate a platform the merchant already names", () => {
|
||||
// A trip's merchant is literally "Uber Trip"; "(Uber)" after it says
|
||||
// nothing. What identifies a trip is its addresses, and those live in the
|
||||
// Order details panel.
|
||||
it("leaves a merchant that already names the platform alone", () => {
|
||||
// A trip's merchant is literally "Uber Trip". What identifies a trip is its
|
||||
// addresses, and those live in the Order details panel.
|
||||
expect(orderDescription("uber", "Uber Trip")).toBe("Order - Uber Trip");
|
||||
});
|
||||
|
||||
it("gives the same description whichever platform delivered it", () => {
|
||||
// The regression this whole change exists to prevent.
|
||||
expect(orderDescription("doordash", "TEG Kebabs & Biryani")).toBe(
|
||||
orderDescription("ubereats", "TEG Kebabs & Biryani")
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Two parse failures that between them accounted for 218 of the 287 unreadable
|
||||
* messages in the 776-message capture set. Neither was an "old template": the
|
||||
* Uber one fails on current 2024-2025 mail, and the DoorDash one fails on every
|
||||
* order paid from credits, in every year.
|
||||
*/
|
||||
describe("currency notations Uber actually sends", () => {
|
||||
const uber = (f: string, subject: string) =>
|
||||
parseOrderHTML(html(f), meta({ subject, sender: "Uber Receipts <noreply@uber.com>" }));
|
||||
|
||||
it("reads a symbol-prefixed Australian total", () => {
|
||||
// "Total A$54.87". The old pattern allowed a 3-letter ISO code or a bare
|
||||
// "$", so A$ — which is what Uber sends for ordinary domestic orders —
|
||||
// matched neither and 98 of 275 Uber Eats mails were unreadable.
|
||||
const o = uber("ue-aud-prefix", "Your Friday evening order with Uber Eats");
|
||||
expect(o.totals.total_charged).toBe(54.87);
|
||||
expect(o.currency).toBe("AUD");
|
||||
});
|
||||
|
||||
it("reads NZ$ as New Zealand dollars, not Australian", () => {
|
||||
// The prefix is the only thing distinguishing them, and getting it wrong
|
||||
// books a Queenstown dinner at the wrong rate rather than failing loudly.
|
||||
const o = uber("ue-nzd-prefix", "Your Saturday evening order with Uber Eats");
|
||||
expect(o.totals.total_charged).toBe(22.83);
|
||||
expect(o.currency).toBe("NZD");
|
||||
});
|
||||
|
||||
it("reads a bare rupee symbol on a trip", () => {
|
||||
const o = uber("ut-inr-symbol", "Your Friday evening trip with Uber");
|
||||
expect(o.totals.total_charged).toBe(622.74);
|
||||
expect(o.currency).toBe("INR");
|
||||
});
|
||||
|
||||
it("still reads the space-separated ISO form", () => {
|
||||
// The [Family] LKR receipts depend on this and must not regress.
|
||||
const o = uber("ut-nzd-prefix", "Your Sunday afternoon trip with Uber");
|
||||
expect(o.totals.total_charged).toBe(10.83);
|
||||
expect(o.currency).toBe("NZD");
|
||||
});
|
||||
});
|
||||
|
||||
describe("credits-funded orders are orders", () => {
|
||||
const credits = () =>
|
||||
parseOrderHTML(
|
||||
html("dd-credits-zero"),
|
||||
meta({ subject: "Order Confirmation for Siddharth from Chilli India" })
|
||||
);
|
||||
|
||||
it("records the subtotal when the card was charged nothing", () => {
|
||||
// The receipt says "Subtotal $71.86 ... Total Charged $0.00" — truthfully,
|
||||
// because credits covered it. Reading that as a $0 order threw away the
|
||||
// credit-funded spend this pipeline exists to surface.
|
||||
const o = credits();
|
||||
expect(o.totals.total_charged).toBe(71.86);
|
||||
expect(o.totals.subtotal).toBe(71.86);
|
||||
expect(o.flags).toContain("credits_funded_zero_charge");
|
||||
});
|
||||
|
||||
it("books the amount as credits, not as a card charge", () => {
|
||||
const o = credits();
|
||||
expect(o.payment.credits_amount).toBe(71.86);
|
||||
expect(o.payment.card_amount).toBeNull();
|
||||
expect(o.payment.card_last4).toBeNull();
|
||||
});
|
||||
|
||||
it("passes validation instead of being rejected as non-positive", () => {
|
||||
// Both stated totals are $0.00 and agree, so the header cross-check has to
|
||||
// stand down here or it rejects the very figure the parser overrode.
|
||||
const o = credits();
|
||||
expect(validateOrderTotals(o, html("dd-credits-zero"))).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("does not invent a total when the receipt never says credits", () => {
|
||||
// The guard that keeps this from becoming "any zero total borrows the
|
||||
// subtotal" — a genuinely empty receipt must still fail.
|
||||
const notCredits = html("dd-credits-zero").replace(/Paid with/gi, "Charged to");
|
||||
const o = parseOrderHTML(notCredits, meta({ subject: "Order Confirmation for Siddharth from Chilli India" }));
|
||||
expect(o.totals.total_charged).toBe(0);
|
||||
expect(validateOrderTotals(o, notCredits).ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Uber Cash is credits, not a card", () => {
|
||||
it("reads a payment line carrying a timestamp and a prefixed currency", () => {
|
||||
// "Payments Uber Cash 10/17/25 8:50 PM A$54.87". The old pattern allowed
|
||||
// neither the timestamp nor the A$ prefix, so credits_amount stayed null
|
||||
// and the order was filed as card-settled — sent looking for a card leg
|
||||
// that does not exist, and left as an orphan with nothing to match on.
|
||||
const o = parseOrderHTML(
|
||||
html("ue-aud-prefix"),
|
||||
meta({
|
||||
subject: "Your Friday evening order with Uber Eats",
|
||||
sender: "Uber Receipts <noreply@uber.com>",
|
||||
})
|
||||
);
|
||||
expect(o.payment.credits_amount).toBe(54.87);
|
||||
expect(o.payment.card_last4).toBeNull();
|
||||
expect(o.payment.ambiguous).toBe(false);
|
||||
});
|
||||
|
||||
it("still reads the plain form, where the timestamp follows the amount", () => {
|
||||
// "Uber Cash $25.33 22/7/26 1:41 pm" — the older layout the widened
|
||||
// pattern must not break.
|
||||
const o = parseOrderHTML(
|
||||
html("ue-00"),
|
||||
meta({
|
||||
subject: "Your order with Uber Eats",
|
||||
sender: "Uber Receipts <noreply@uber.com>",
|
||||
})
|
||||
);
|
||||
expect(o.payment.credits_amount).toBe(25.33);
|
||||
});
|
||||
|
||||
it("still reads the card leg of a mixed payment", () => {
|
||||
// "Uber Cash $1.17 ... Westpac ••••8032 $15.33" — the credits half must
|
||||
// not swallow the card half.
|
||||
const o = parseOrderHTML(
|
||||
html("ue-mixed"),
|
||||
meta({ subject: "Your order with Uber Eats", sender: "Uber Receipts <noreply@uber.com>" })
|
||||
);
|
||||
expect(o.payment.credits_amount).toBe(1.17);
|
||||
expect(o.payment.card_last4).toBe("8032");
|
||||
});
|
||||
});
|
||||
|
||||
describe("payment legs", () => {
|
||||
const trip = (f: string) =>
|
||||
parseOrderHTML(
|
||||
html(f),
|
||||
meta({
|
||||
subject: "Your Wednesday afternoon trip with Uber",
|
||||
sender: "Uber Receipts <noreply@uber.com>",
|
||||
})
|
||||
);
|
||||
|
||||
it("does not add a superseded authorisation to the settled charge", () => {
|
||||
// "Citi Prestige ••••0253 AED 17.67" then the same card "AED 577.83",
|
||||
// against a stated total of 577.83. The first is a hold, not a part
|
||||
// payment; adding it overstates the trip by the held amount.
|
||||
const o = trip("ut-reauth");
|
||||
expect(o.totals.total_charged).toBe(577.83);
|
||||
expect(o.payment.card_amount).toBe(577.83);
|
||||
expect(o.payment.card_last4).toBe("0253");
|
||||
expect(validateOrderTotals(o, html("ut-reauth")).ok).toBe(true);
|
||||
});
|
||||
|
||||
it("adds the legs of a genuinely split payment", () => {
|
||||
// "PayPal - <email> A$78.41" + "Uber Cash A$6.85" = 85.26. Neither leg
|
||||
// equals the total, so both are real and both must be counted — and the
|
||||
// PayPal leg carries no card mask to anchor on.
|
||||
const o = trip("ut-paypal");
|
||||
expect(o.totals.total_charged).toBe(85.26);
|
||||
expect(o.payment.credits_amount).toBe(6.85);
|
||||
expect(o.payment.card_amount).toBe(78.41);
|
||||
expect(validateOrderTotals(o, html("ut-paypal")).ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HTML entities in line items", () => {
|
||||
it("splits options on an encoded bullet instead of swallowing it", () => {
|
||||
// "<b>Sweet & Sour Crunch</b> (…)<br><font>• Sweet & Sour
|
||||
// Crunch 12 Pieces</font>". • was not in the decode table, and
|
||||
// parseDoorDashLineItems splits on the literal "•" — so the option never
|
||||
// separated and the entity was rendered raw in the Order details panel.
|
||||
const o = parseOrderHTML(
|
||||
html("dd-bull-entity"),
|
||||
meta({ subject: "Order Confirmation for Siddharth from Red Rooster" })
|
||||
);
|
||||
const all = JSON.stringify(o.line_items);
|
||||
expect(all).not.toContain("•");
|
||||
expect(all).not.toContain("&");
|
||||
|
||||
const item = o.line_items.find((i) => i.description.startsWith("Sweet"));
|
||||
expect(item).toBeDefined();
|
||||
// The ampersand decodes, and the bullet becomes a boundary, not text.
|
||||
expect(item!.description).toContain("Sweet & Sour Crunch");
|
||||
expect(item!.description).not.toContain("•");
|
||||
// `options` is optional on the parsed item; `?? 0` keeps the assertion honest
|
||||
// (an absent list fails) instead of silencing the check with a non-null bang.
|
||||
expect(item!.options?.length ?? 0).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("leaves a literal &bull; alone rather than turning it into a bullet", () => {
|
||||
// Why & resolves last: decoding it first would rewrite text that was
|
||||
// deliberately escaped.
|
||||
const o = parseOrderHTML(
|
||||
html("dd-01").replace("Mad Mex", "A&amp;bull;B"),
|
||||
meta()
|
||||
);
|
||||
expect(JSON.stringify(o)).not.toContain("A•B".replace("•", "•"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { createHmac } from "node:crypto";
|
||||
import { verifySlackSignature, participantForSlackUser } from "@/lib/slack-verify";
|
||||
|
||||
/**
|
||||
* The signature is one of the two gates on a route that writes splits. Getting
|
||||
* it wrong is not a cosmetic bug — it is an unauthenticated write path — so the
|
||||
* fail-closed cases are tested first and explicitly.
|
||||
*/
|
||||
const SECRET = "test-signing-secret";
|
||||
|
||||
function sign(body: string, ts: string, secret = SECRET) {
|
||||
return "v0=" + createHmac("sha256", secret).update(`v0:${ts}:${body}`).digest("hex");
|
||||
}
|
||||
|
||||
const now = () => String(Math.floor(Date.now() / 1000));
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.SLACK_SIGNING_SECRET = SECRET;
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("verifySlackSignature", () => {
|
||||
it("accepts a correctly signed request", () => {
|
||||
const ts = now();
|
||||
const body = "payload=%7B%22type%22%3A%22block_actions%22%7D";
|
||||
expect(verifySlackSignature(body, ts, sign(body, ts))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects when the signing secret is unset", () => {
|
||||
const ts = now();
|
||||
const body = "x=1";
|
||||
const sig = sign(body, ts);
|
||||
delete process.env.SLACK_SIGNING_SECRET;
|
||||
// Fails CLOSED. An unset secret waving requests through would turn a
|
||||
// misconfigured deploy into an open write endpoint.
|
||||
expect(verifySlackSignature(body, ts, sig)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a tampered body", () => {
|
||||
const ts = now();
|
||||
const sig = sign("payload=original", ts);
|
||||
expect(verifySlackSignature("payload=tampered", ts, sig)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a signature made with a different secret", () => {
|
||||
const ts = now();
|
||||
const body = "x=1";
|
||||
expect(verifySlackSignature(body, ts, sign(body, ts, "wrong-secret"))).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a replay outside the 5 minute window", () => {
|
||||
const old = String(Math.floor(Date.now() / 1000) - 400);
|
||||
const body = "x=1";
|
||||
expect(verifySlackSignature(body, old, sign(body, old))).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts inside the window", () => {
|
||||
const recent = String(Math.floor(Date.now() / 1000) - 60);
|
||||
const body = "x=1";
|
||||
expect(verifySlackSignature(body, recent, sign(body, recent))).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects missing headers", () => {
|
||||
expect(verifySlackSignature("x=1", null, "v0=abc")).toBe(false);
|
||||
expect(verifySlackSignature("x=1", now(), null)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a signature of the wrong length without throwing", () => {
|
||||
// timingSafeEqual throws on length mismatch; a truncated signature must be
|
||||
// a plain false, not a 500.
|
||||
expect(() => verifySlackSignature("x=1", now(), "v0=short")).not.toThrow();
|
||||
expect(verifySlackSignature("x=1", now(), "v0=short")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("participantForSlackUser", () => {
|
||||
beforeEach(() => {
|
||||
process.env.SLACK_USER_MAP = "U111:1, U444:4";
|
||||
});
|
||||
|
||||
it("maps known users", () => {
|
||||
expect(participantForSlackUser("U111")).toBe(1);
|
||||
expect(participantForSlackUser("U444")).toBe(4);
|
||||
});
|
||||
|
||||
it("returns null for an unknown user rather than defaulting to the owner", () => {
|
||||
// A wrong attribution records the other person's opinion under your name,
|
||||
// which is worse than refusing.
|
||||
expect(participantForSlackUser("U999")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the map is unset", () => {
|
||||
delete process.env.SLACK_USER_MAP;
|
||||
expect(participantForSlackUser("U111")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { queryRaw } from "@/lib/db";
|
||||
import {
|
||||
OWNER_SCOPE,
|
||||
MY_SPEND_SCOPE,
|
||||
STATEMENTS_JOIN,
|
||||
EXCLUDE_NON_SPEND,
|
||||
EXCLUDE_RECONCILED_SOURCE,
|
||||
@@ -57,7 +57,7 @@ export async function GET(req: NextRequest) {
|
||||
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||
LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1
|
||||
${STATEMENTS_JOIN}
|
||||
WHERE ${OWNER_SCOPE} = $1
|
||||
WHERE ${MY_SPEND_SCOPE()}
|
||||
AND ${NET_SPEND_ROWS}
|
||||
AND ${EXCLUDE_NON_SPEND}
|
||||
AND ${EXCLUDE_RECONCILED_SOURCE}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { queryRaw } from "@/lib/db";
|
||||
import { OWNER_SCOPE, STATEMENTS_JOIN, EXCLUDE_RECONCILED_SOURCE, mySplitOf, toDateStr } from "@/lib/analytics-sql";
|
||||
import { MY_SPEND_SCOPE, STATEMENTS_JOIN, EXCLUDE_RECONCILED_SOURCE, mySplitOf, toDateStr } from "@/lib/analytics-sql";
|
||||
|
||||
/**
|
||||
* Fees and interest over an explicit window.
|
||||
@@ -71,7 +71,7 @@ export async function GET(req: NextRequest) {
|
||||
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||
LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1
|
||||
${STATEMENTS_JOIN}
|
||||
WHERE ${OWNER_SCOPE} = $1
|
||||
WHERE ${MY_SPEND_SCOPE()}
|
||||
AND t.transaction_type IN ('fee', 'interest')
|
||||
AND ${EXCLUDE_RECONCILED_SOURCE}
|
||||
${txnWindow}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { queryRaw } from "@/lib/db";
|
||||
import { OWNER_SCOPE, STATEMENTS_JOIN, EXCLUDE_RECONCILED_SOURCE, EFFECTIVE_CATEGORY, mySplitOf } from "@/lib/analytics-sql";
|
||||
import { MY_SPEND_SCOPE, STATEMENTS_JOIN, EXCLUDE_RECONCILED_SOURCE, EFFECTIVE_CATEGORY, mySplitOf } from "@/lib/analytics-sql";
|
||||
import { bankLabel } from "@/lib/queries";
|
||||
|
||||
const MY_AMOUNT = mySplitOf(`COALESCE(t.amount_aud, t.amount)`);
|
||||
@@ -46,7 +46,7 @@ export async function GET(
|
||||
${STATEMENTS_JOIN}
|
||||
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||
LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1
|
||||
WHERE ${OWNER_SCOPE} = $1
|
||||
WHERE ${MY_SPEND_SCOPE()}
|
||||
AND t.transaction_type IN ('debit', 'fee', 'interest', 'refund', 'credit')
|
||||
AND COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name, t.description) = $2
|
||||
AND ${EXCLUDE_RECONCILED_SOURCE}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { queryRaw } from "@/lib/db";
|
||||
import { OWNER_SCOPE, STATEMENTS_JOIN, EXCLUDE_NON_SPEND, EXCLUDE_RECONCILED_SOURCE, EFFECTIVE_CATEGORY, mySplitOf, toDateStr } from "@/lib/analytics-sql";
|
||||
import { MY_SPEND_SCOPE, STATEMENTS_JOIN, EXCLUDE_NON_SPEND, EXCLUDE_RECONCILED_SOURCE, EFFECTIVE_CATEGORY, mySplitOf, toDateStr } from "@/lib/analytics-sql";
|
||||
|
||||
// Split-adjusted amount helper (positive for spend, negative for refunds)
|
||||
const MY_AMOUNT = mySplitOf(`COALESCE(t.amount_aud, t.amount)`);
|
||||
@@ -65,7 +65,7 @@ export async function GET(req: NextRequest) {
|
||||
${STATEMENTS_JOIN}
|
||||
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||
LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1
|
||||
WHERE ${OWNER_SCOPE} = $1
|
||||
WHERE ${MY_SPEND_SCOPE()}
|
||||
AND t.transaction_type IN ('debit', 'fee', 'interest', 'refund', 'credit')
|
||||
AND t.transaction_date >= $2
|
||||
AND ${EXCLUDE_NON_SPEND}
|
||||
@@ -91,7 +91,7 @@ export async function GET(req: NextRequest) {
|
||||
${STATEMENTS_JOIN}
|
||||
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||
LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1
|
||||
WHERE ${OWNER_SCOPE} = $1
|
||||
WHERE ${MY_SPEND_SCOPE()}
|
||||
AND t.transaction_type IN ('debit', 'fee', 'interest', 'refund', 'credit')
|
||||
AND t.transaction_date >= $2
|
||||
AND COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name, t.description) = ANY($3)
|
||||
|
||||
@@ -3,12 +3,14 @@ import { getCurrentUser } from "@/lib/auth";
|
||||
import { queryRaw } from "@/lib/db";
|
||||
import {
|
||||
OWNER_SCOPE,
|
||||
MY_SPEND_SCOPE,
|
||||
STATEMENTS_JOIN,
|
||||
EFFECTIVE_CATEGORY,
|
||||
EXCLUDE_NON_SPEND,
|
||||
EXCLUDE_RECONCILED_SOURCE,
|
||||
NET_SPEND_ROWS,
|
||||
SPEND_SIGNED,
|
||||
INVESTMENT_SIGNED,
|
||||
mySplitOf,
|
||||
toDateStr,
|
||||
} from "@/lib/analytics-sql";
|
||||
@@ -45,7 +47,7 @@ export async function GET(req: NextRequest) {
|
||||
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||
LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1
|
||||
${STATEMENTS_JOIN}
|
||||
WHERE ${OWNER_SCOPE} = $1
|
||||
WHERE ${MY_SPEND_SCOPE()}
|
||||
AND ${NET_SPEND_ROWS}
|
||||
AND ${EXCLUDE_NON_SPEND}
|
||||
AND ${EXCLUDE_RECONCILED_SOURCE}
|
||||
@@ -80,7 +82,8 @@ export async function GET(req: NextRequest) {
|
||||
[user.id, startStr, endStr]
|
||||
);
|
||||
|
||||
// Investments: any transaction categorised as investment
|
||||
// Investments: any transaction categorised as investment, signed so that
|
||||
// withdrawals net against contributions (see INVESTMENT_SIGNED).
|
||||
const investmentRows = await queryRaw<{
|
||||
month: string;
|
||||
total_invested: number;
|
||||
@@ -88,7 +91,7 @@ export async function GET(req: NextRequest) {
|
||||
}>(
|
||||
`SELECT
|
||||
TO_CHAR(DATE_TRUNC('month', t.transaction_date::date), 'YYYY-MM') as month,
|
||||
SUM(COALESCE(t.amount_aud, t.amount))::numeric(12,2) as total_invested,
|
||||
SUM(${INVESTMENT_SIGNED})::numeric(12,2) as total_invested,
|
||||
COUNT(*)::int as transaction_count
|
||||
FROM transactions t
|
||||
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { queryRaw } from "@/lib/db";
|
||||
import { OWNER_SCOPE, STATEMENTS_JOIN, EXCLUDE_NON_SPEND, EXCLUDE_RECONCILED_SOURCE, EFFECTIVE_CATEGORY, mySplitOf } from "@/lib/analytics-sql";
|
||||
import { MY_SPEND_SCOPE, STATEMENTS_JOIN, EXCLUDE_NON_SPEND, EXCLUDE_RECONCILED_SOURCE, EFFECTIVE_CATEGORY, mySplitOf } from "@/lib/analytics-sql";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const user = await getCurrentUser(req);
|
||||
@@ -28,7 +28,7 @@ export async function GET(req: NextRequest) {
|
||||
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||
LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1
|
||||
${STATEMENTS_JOIN}
|
||||
WHERE ${OWNER_SCOPE} = $1
|
||||
WHERE ${MY_SPEND_SCOPE()}
|
||||
AND t.transaction_type IN ('debit', 'fee')
|
||||
AND ${EXCLUDE_NON_SPEND}
|
||||
AND ${EXCLUDE_RECONCILED_SOURCE}
|
||||
|
||||
@@ -8,6 +8,12 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
const body = await req.json() as {
|
||||
bank_name: string;
|
||||
/**
|
||||
* Optional provenance label, stored on every row as `source`. With a
|
||||
* `source_ref` per row it makes a re-import a no-op; without it this path
|
||||
* has no idempotency at all.
|
||||
*/
|
||||
source?: string;
|
||||
transactions: {
|
||||
date: string;
|
||||
description: string;
|
||||
@@ -17,6 +23,8 @@ export async function POST(req: NextRequest) {
|
||||
foreign_currency_amount?: number;
|
||||
foreign_currency_code?: string;
|
||||
category?: string;
|
||||
account?: string;
|
||||
source_ref?: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
@@ -25,7 +33,7 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
const tagId = await ensureTag("csv-import", "#8b5cf6");
|
||||
const inserted = await batchInsertCSVTransactions(user.id, body.transactions, tagId);
|
||||
const inserted = await batchInsertCSVTransactions(user.id, body.transactions, tagId, body.source);
|
||||
|
||||
return NextResponse.json({ inserted }, { status: 201 });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { getStatementCoverage } from "@/lib/queries";
|
||||
|
||||
/**
|
||||
* How far each account's statements already reach.
|
||||
*
|
||||
* Read by the CSV import modal so it can drop rows the ledger already holds.
|
||||
* The first Frollo import went in without this and wrote 422 duplicates out of
|
||||
* 550 — the same payments the statements already carried, itemised and
|
||||
* converted. On the 2026-08-13 export the rule takes 2,607 rows down to 171.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const user = await getCurrentUser(req);
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
return NextResponse.json({ coverage: await getStatementCoverage() });
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { sha256Of, type StoredFile } from "@/lib/intake-files";
|
||||
import { liteLLMExtractor } from "@/lib/intake-extract";
|
||||
import { pantryForwarder } from "@/lib/intake-pantry";
|
||||
import { isIntakeSource } from "@/lib/intake-rules";
|
||||
import { MAX_FILES, runIntake } from "@/lib/intake-router";
|
||||
|
||||
/**
|
||||
* The one upload point (plan §11 C1). n8n forwards the iOS Shortcut's
|
||||
* multipart POST here: `source`, `shared_by`, `batch_id`, and 1–6 files under
|
||||
* `file`. The router reads what it is given and does every step that applies.
|
||||
*
|
||||
* Auth is a shared secret, like the two ingest siblings: this is machine-to-
|
||||
* machine and there is no browser session to forward. Its own token so the
|
||||
* Shortcut's credential rotates without touching the order or receipt lanes.
|
||||
* Fails closed when unset.
|
||||
*/
|
||||
function authorised(req: NextRequest): boolean {
|
||||
const expected = process.env.INTAKE_TOKEN;
|
||||
if (!expected) return false;
|
||||
const got = req.headers.get("x-ingest-token");
|
||||
return !!got && got === expected;
|
||||
}
|
||||
|
||||
const MAX_BYTES = 12_000_000;
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!authorised(req)) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
let form: FormData;
|
||||
try {
|
||||
form = await req.formData();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "multipart/form-data body required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const source = form.get("source");
|
||||
if (!isIntakeSource(source)) {
|
||||
return NextResponse.json({ error: "source must be one of uber_eats|doordash|coles|woolworths|other" }, { status: 400 });
|
||||
}
|
||||
const sharedBy = typeof form.get("shared_by") === "string" ? String(form.get("shared_by")).trim() || null : null;
|
||||
const batchIdRaw = form.get("batch_id");
|
||||
const batchId = typeof batchIdRaw === "string" && /^[0-9a-f-]{36}$/i.test(batchIdRaw) ? batchIdRaw.toLowerCase() : null;
|
||||
if (batchIdRaw && !batchId) return NextResponse.json({ error: "batch_id must be a uuid" }, { status: 400 });
|
||||
|
||||
const files: StoredFile[] = [];
|
||||
for (const entry of form.getAll("file")) {
|
||||
if (!(entry instanceof File) || !entry.size) continue;
|
||||
if (entry.size > MAX_BYTES) return NextResponse.json({ error: `${entry.name} is larger than 12 MB` }, { status: 400 });
|
||||
const bytes = Buffer.from(await entry.arrayBuffer());
|
||||
files.push({ name: entry.name || `file-${files.length + 1}`, type: entry.type || "", bytes, sha256: sha256Of(bytes) });
|
||||
}
|
||||
if (!files.length) return NextResponse.json({ error: "at least one file is required under field 'file'" }, { status: 400 });
|
||||
if (files.length > MAX_FILES) return NextResponse.json({ error: `at most ${MAX_FILES} files per batch` }, { status: 400 });
|
||||
|
||||
try {
|
||||
const result = await runIntake(
|
||||
{ batchId, source, sharedBy, files },
|
||||
{ extractor: liteLLMExtractor(), pantry: pantryForwarder() }
|
||||
);
|
||||
const { http_status, ...body } = result;
|
||||
return NextResponse.json(body, { status: http_status });
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { getOrderDetail, canViewOrders } from "@/lib/order-feed";
|
||||
|
||||
/**
|
||||
* GET /api/orders/[entityKey] — one order, its lifecycle, settlement siblings
|
||||
* and any linked ledger transactions.
|
||||
*
|
||||
* Keyed on entity_key (text) rather than entity_id: entity_id is BIGINT and
|
||||
* would break JSON.stringify, and the key is stable, unique and readable.
|
||||
* All 8,147 order keys are URL-safe today, but nothing enforces that, so the
|
||||
* client encodes and we decode.
|
||||
*/
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ entityKey: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser(req);
|
||||
if (!user) return NextResponse.json({ error: "unauthorized" }, { status: 403 });
|
||||
if (!canViewOrders(user.id)) {
|
||||
return NextResponse.json({ error: "forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { entityKey } = await params;
|
||||
const order = await getOrderDetail(decodeURIComponent(entityKey));
|
||||
if (!order) return NextResponse.json({ error: "not found" }, { status: 404 });
|
||||
|
||||
return NextResponse.json(order);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { queryRow } from "@/lib/db";
|
||||
import {
|
||||
parseOrderHTML,
|
||||
parseOrderAmendment,
|
||||
@@ -11,7 +12,8 @@ import {
|
||||
NotAReceiptError,
|
||||
type MessageMeta,
|
||||
} from "@/lib/order-ingestion";
|
||||
import { merchantVerdict } from "@/lib/order-reviews";
|
||||
import { merchantVerdict, SECOND_CONSUMER_ID } from "@/lib/order-reviews";
|
||||
import { nudgeBlocks } from "@/lib/slack-blocks";
|
||||
|
||||
/**
|
||||
* Machine ingest endpoint for order receipts.
|
||||
@@ -31,6 +33,17 @@ function authorised(req: NextRequest): boolean {
|
||||
return !!got && got === expected;
|
||||
}
|
||||
|
||||
/** Does a split with the second consumer already exist on this transaction? */
|
||||
async function isShared(transactionId: number | null): Promise<boolean> {
|
||||
if (!transactionId) return false;
|
||||
const row = await queryRow<{ n: string }>(
|
||||
`SELECT count(*) AS n FROM transaction_splits
|
||||
WHERE transaction_id = $1 AND participant_id = $2`,
|
||||
[transactionId, SECOND_CONSUMER_ID]
|
||||
);
|
||||
return Number(row?.n ?? 0) > 0;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!authorised(req)) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
@@ -99,6 +112,31 @@ export async function POST(req: NextRequest) {
|
||||
counts: verdict.counts,
|
||||
last_note: verdict.history.find((h) => h.note)?.note ?? null,
|
||||
},
|
||||
// The nudge message is built here, not in n8n expressions: Block Kit in a
|
||||
// template string is untestable, and this shape has to stay in step with
|
||||
// what /api/slack/interactive renders after a button press. Null when
|
||||
// there is no transaction to act on — a card-settled order is parked
|
||||
// until its statement arrives, so there is nothing yet to split or rate.
|
||||
slack_blocks:
|
||||
result.transactionId && !result.skipped
|
||||
? nudgeBlocks({
|
||||
transactionId: result.transactionId,
|
||||
merchant: order.merchant_name,
|
||||
currency: order.currency,
|
||||
total: Number(order.totals.total_charged),
|
||||
isFamily: order.is_family,
|
||||
// Read rather than assume. Today a freshly ingested order has no
|
||||
// splits, so `false` would be right — the 140 ingested orders
|
||||
// that do carry splits were split by hand after the backfill,
|
||||
// not by a rule. But the card's label drives a destructive
|
||||
// button: if a split rule is ever added, an assumed `false`
|
||||
// would label a shared order "Not shared" and offer to remove
|
||||
// the split. One query is cheaper than that failure.
|
||||
shared: await isShared(result.transactionId),
|
||||
warn: verdict?.warn ?? false,
|
||||
warnNote: verdict?.history.find((h) => h.note)?.note ?? null,
|
||||
})
|
||||
: null,
|
||||
});
|
||||
} catch (e) {
|
||||
// Not a receipt: promotions, delivery updates, adjustment and refund
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { getOrderFeed, getOrderFacets, canViewOrders } from "@/lib/order-feed";
|
||||
|
||||
/**
|
||||
* GET /api/orders — the order browse list.
|
||||
*
|
||||
* Note this route is a sibling of /api/orders/ingest (the n8n webhook). Next
|
||||
* resolves static segments before dynamic ones, so `ingest` is unaffected by
|
||||
* the [entityKey] route next to it — but it does mean "ingest" is now a
|
||||
* reserved order key. Every entity key starts "order_", so no real collision.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const user = await getCurrentUser(req);
|
||||
if (!user) return NextResponse.json({ error: "unauthorized" }, { status: 403 });
|
||||
// The spine has no owner column — this is a participant gate, not a row
|
||||
// filter. See ORDER_VIEWERS in lib/order-feed.ts for why it is an allowlist.
|
||||
if (!canViewOrders(user.id)) {
|
||||
return NextResponse.json({ error: "forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const p = req.nextUrl.searchParams;
|
||||
const list = (k: string) => p.get(k)?.split(",").filter(Boolean);
|
||||
|
||||
const filters = {
|
||||
lane: p.get("lane") ?? undefined,
|
||||
platforms: list("platforms"),
|
||||
statuses: list("statuses"),
|
||||
from: p.get("from") ?? undefined,
|
||||
to: p.get("to") ?? undefined,
|
||||
search: p.get("search") ?? undefined,
|
||||
currency: p.get("currency") ?? undefined,
|
||||
has_transaction: p.get("has_transaction") ?? undefined,
|
||||
// buildParams encodes booleans as "1" and omits them when false, so an
|
||||
// absent param means "on" here — the default hides lifecycle-only rows.
|
||||
hide_lifecycle_only: p.get("show_lifecycle_only") !== "1",
|
||||
sort_by: p.get("sort_by") ?? undefined,
|
||||
sort_dir: p.get("sort_dir") ?? undefined,
|
||||
limit: p.get("limit") ? Number(p.get("limit")) : undefined,
|
||||
offset: p.get("offset") ? Number(p.get("offset")) : undefined,
|
||||
};
|
||||
|
||||
const [result, facets] = await Promise.all([
|
||||
getOrderFeed(filters),
|
||||
getOrderFacets(filters),
|
||||
]);
|
||||
|
||||
return NextResponse.json({ ...result, facets });
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { processReceiptIngestion, ReceiptValidationError, validateReceipt, type ParsedReceipt } from "@/lib/receipt-ingestion";
|
||||
|
||||
/**
|
||||
* Machine ingest endpoint for grocery receipts scanned in pantry-app.
|
||||
*
|
||||
* Sibling to /api/orders/ingest and deliberately shaped like it. Auth is a shared secret
|
||||
* rather than the Traefik `x-forwarded-user` header: this is called app-to-app, and there
|
||||
* is no browser session to forward.
|
||||
*
|
||||
* Its own token rather than ORDER_INGEST_TOKEN so pantry's credential can be rotated
|
||||
* without touching the n8n order flow, which runs on a schedule nobody is watching.
|
||||
*/
|
||||
function authorised(req: NextRequest): boolean {
|
||||
const expected = process.env.RECEIPT_INGEST_TOKEN;
|
||||
if (!expected) return false; // fail closed when unconfigured
|
||||
const got = req.headers.get("x-ingest-token");
|
||||
return !!got && got === expected;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
if (!authorised(req)) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
let body: ParsedReceipt & { dryRun?: boolean };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!body?.merchant_name || !body?.transaction_date || typeof body?.total !== "number" || !Number.isInteger(body?.capture_event_id)) {
|
||||
return NextResponse.json({ error: "merchant_name, transaction_date, total and capture_event_id are required" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Dry run validates and reports what would be written without writing it — the same
|
||||
// affordance every maintenance script in pantry has, and for the same reason: the first
|
||||
// pass over a new receipt format is worth reading before it becomes money.
|
||||
if (body.dryRun) return NextResponse.json({ kind: "receipt", dryRun: true, flags: validateReceipt(body) });
|
||||
const result = await processReceiptIngestion(body);
|
||||
return NextResponse.json({ kind: "receipt", ...result });
|
||||
} catch (e) {
|
||||
// A receipt that will not validate is the failure that matters: it means the payment
|
||||
// side was read wrong, and booking it anyway would put a number in the ledger nobody
|
||||
// can stand behind. Loud, like OrderParseError.
|
||||
if (e instanceof ReceiptValidationError) {
|
||||
return NextResponse.json({ kind: "rejected", reason: e.message }, { status: 422 });
|
||||
}
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { queryRaw } from "@/lib/db";
|
||||
import { completeSplit } from "@/lib/splits";
|
||||
|
||||
interface SnapshotEntry {
|
||||
transaction_id: number;
|
||||
@@ -91,6 +92,11 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
[txId, s.participant_id, s.share_percent, s.settled]
|
||||
);
|
||||
}
|
||||
// A snapshot taken before splits were required to total 100% holds the old
|
||||
// partial shape, and restoring it verbatim would reintroduce exactly what
|
||||
// this run is being undone from. The owner's share is balance-neutral, so
|
||||
// completing it cannot change what the revert owes anyone.
|
||||
await completeSplit(txId);
|
||||
}
|
||||
|
||||
await queryRaw(
|
||||
|
||||
@@ -0,0 +1,426 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { queryRaw, queryRow } from "@/lib/db";
|
||||
import {
|
||||
verifySlackSignature,
|
||||
participantForSlackUser,
|
||||
slackUserForParticipant,
|
||||
} from "@/lib/slack-verify";
|
||||
import { nudgeBlocks, detailsModal, partnerNudgeBlocks } from "@/lib/slack-blocks";
|
||||
import { handleIntakeAction, isIntakeAction, parseActionValue } from "@/lib/intake-actions";
|
||||
import { pantryForwarder } from "@/lib/intake-pantry";
|
||||
import { completeSplit } from "@/lib/splits";
|
||||
import {
|
||||
RATINGS,
|
||||
OWNER_PARTICIPANT_ID,
|
||||
SECOND_CONSUMER_ID,
|
||||
merchantVerdict,
|
||||
type Rating,
|
||||
type ItemOpinion,
|
||||
} from "@/lib/order-reviews";
|
||||
|
||||
/**
|
||||
* Slack button presses on the order nudge.
|
||||
*
|
||||
* Pressing "Shared 50/50" splits the transaction here and now and edits the
|
||||
* message in place. It deliberately does NOT link back to the app: being sent
|
||||
* to a web app to answer a yes/no question is enough friction that the question
|
||||
* stops getting answered (user, 2026-07-28).
|
||||
*
|
||||
* **Slack does not reach this route directly.** It posts to an n8n webhook,
|
||||
* which forwards the raw body and Slack's signature headers here. That was the
|
||||
* user's suggestion (2026-07-28) and it is the better shape: n8n already
|
||||
* terminates public webhooks, so the app keeps its blanket OAuth chain and
|
||||
* gains no internet-facing unauthenticated route. n8n cannot do the verifying
|
||||
* itself — its Code node sandbox has no `require`, so no `crypto`.
|
||||
*
|
||||
* Two independent gates, both fail closed:
|
||||
* 1. `x-ingest-token`, proving the call came from n8n over the internal
|
||||
* network. Same shared secret as the order ingest route.
|
||||
* 2. Slack's v0 request signature over the forwarded raw body, proving the
|
||||
* payload really came from Slack and is not a replay.
|
||||
*
|
||||
* The card is updated by POSTing to `payload.response_url`, NOT by returning a
|
||||
* message body. Block Kit interactivity ignores the HTTP response body — that
|
||||
* replacement behaviour belongs to legacy attachment-style messages. Assuming
|
||||
* otherwise meant every press wrote correctly and then left the card showing
|
||||
* stale state, so a working button looked dead and got pressed twice, undoing
|
||||
* itself. `response_url` needs no bot token, which is why this stays in the app.
|
||||
*/
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const expected = process.env.ORDER_INGEST_TOKEN;
|
||||
if (!expected || req.headers.get("x-ingest-token") !== expected) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
// Must be the RAW body Slack signed — re-serialising the parsed form changes
|
||||
// the bytes and every signature check fails. n8n forwards it verbatim.
|
||||
const raw = await req.text();
|
||||
if (
|
||||
!verifySlackSignature(
|
||||
raw,
|
||||
req.headers.get("x-slack-request-timestamp"),
|
||||
req.headers.get("x-slack-signature")
|
||||
)
|
||||
) {
|
||||
return NextResponse.json({ error: "bad signature" }, { status: 401 });
|
||||
}
|
||||
|
||||
const payloadRaw = new URLSearchParams(raw).get("payload");
|
||||
if (!payloadRaw) return NextResponse.json({ error: "no payload" }, { status: 400 });
|
||||
|
||||
const payload = JSON.parse(payloadRaw);
|
||||
|
||||
if (payload.type === "view_submission") return handleModalSubmit(payload);
|
||||
if (payload.type !== "block_actions") return new NextResponse(null, { status: 200 });
|
||||
|
||||
const action = payload.actions?.[0];
|
||||
|
||||
// Intake taps (plan §11 C5) act on a persisted batch, not a transaction, so
|
||||
// their `value` is JSON `{batch_id, ...}` rather than `<id>:<verb>`. They are
|
||||
// dispatched on action_id before the order-nudge parse below, which would
|
||||
// otherwise read the JSON as a non-numeric transaction id and give up.
|
||||
if (isIntakeAction(action?.action_id)) {
|
||||
const reply = await handleIntakeAction(action.action_id, parseActionValue(action?.value), { pantry: pantryForwarder() });
|
||||
await updateMessage(
|
||||
payload.response_url,
|
||||
reply.replace
|
||||
? { replace_original: true, blocks: reply.blocks, text: reply.text }
|
||||
: { response_type: "ephemeral", replace_original: false, text: reply.text }
|
||||
);
|
||||
return NextResponse.json({ blocks: reply.blocks, text: reply.text });
|
||||
}
|
||||
|
||||
// A button carries `value`; a select carries it on the chosen option. The
|
||||
// ratings moved to a select because four buttons became four full-width rows
|
||||
// on Slack mobile, so both shapes have to resolve or rating silently stops
|
||||
// working while sharing still does.
|
||||
const rawValue = action?.value ?? action?.selected_option?.value ?? "";
|
||||
const [idStr, verb, arg] = String(rawValue).split(":");
|
||||
const transactionId = Number(idStr);
|
||||
if (!Number.isInteger(transactionId)) {
|
||||
return NextResponse.json({ text: "Could not tell which order that was." });
|
||||
}
|
||||
|
||||
const participantId = participantForSlackUser(payload.user?.id ?? "");
|
||||
if (!participantId) {
|
||||
// Ephemeral: only the presser sees it, so an unmapped colleague does not
|
||||
// rewrite the shared message for everyone.
|
||||
await updateMessage(payload.response_url, {
|
||||
response_type: "ephemeral",
|
||||
replace_original: false,
|
||||
text: `I don't know which participant ${payload.user?.id} is — add them to SLACK_USER_MAP.`,
|
||||
});
|
||||
return NextResponse.json({});
|
||||
}
|
||||
|
||||
// The modal needs `views.open` called with this trigger_id within ~3s. The
|
||||
// app has no Slack bot token — n8n already holds the credential — so the
|
||||
// view is returned and n8n makes the call. That keeps one copy of the token.
|
||||
if (verb === "details") {
|
||||
const view = await buildDetailsModal(transactionId, participantId);
|
||||
if (!view) return NextResponse.json({ text: "That order is no longer in the ledger." });
|
||||
return NextResponse.json({ action: "open_modal", trigger_id: payload.trigger_id, view });
|
||||
}
|
||||
|
||||
let refused: string | null = null;
|
||||
if (verb === "share") {
|
||||
refused = await toggleShare(transactionId);
|
||||
} else if (verb === "rate" && RATINGS.includes(arg as Rating)) {
|
||||
await setRating(transactionId, participantId, arg as Rating);
|
||||
}
|
||||
|
||||
const state = await nudgeState(transactionId);
|
||||
if (!state) return NextResponse.json({ text: "That order is no longer in the ledger." });
|
||||
|
||||
// Ask the other person for their verdict, but only on the press that turned
|
||||
// sharing ON — and only when it was someone else who shared it with them.
|
||||
// Re-notifying on every subsequent rating press would make one shared meal
|
||||
// a stream of DMs, which is how a useful nudge becomes muted.
|
||||
const notify =
|
||||
verb === "share" && state.shared && participantId !== SECOND_CONSUMER_ID
|
||||
? buildPartnerNotify(state, payload.user?.name)
|
||||
: null;
|
||||
|
||||
// Block Kit interactivity does NOT replace the message from the HTTP response
|
||||
// body — that is legacy attachment-style behaviour, and assuming it meant the
|
||||
// splits changed while the card kept showing stale state, so a working button
|
||||
// looked dead and got pressed twice. The update has to go to `response_url`,
|
||||
// which needs no token, so the app can post it directly.
|
||||
if (refused) {
|
||||
await updateMessage(payload.response_url, {
|
||||
response_type: "ephemeral",
|
||||
replace_original: false,
|
||||
text: refused,
|
||||
});
|
||||
}
|
||||
|
||||
await updateMessage(payload.response_url, {
|
||||
replace_original: true,
|
||||
blocks: nudgeBlocks(state),
|
||||
// Notification text for clients that cannot render blocks.
|
||||
text: `${state.merchant} — ${state.currency} ${state.total.toFixed(2)}`,
|
||||
});
|
||||
|
||||
// The blocks are echoed for callers that want the rendered card without
|
||||
// pressing anything — the replay tooling posts them with chat.postMessage, so
|
||||
// a card is never hand-written with a guessed `shared` state again. Slack
|
||||
// ignores the body for block_actions, which is the whole reason the real
|
||||
// update goes to response_url above.
|
||||
return NextResponse.json({
|
||||
...(notify ? { notify } : {}),
|
||||
blocks: nudgeBlocks(state),
|
||||
text: `${state.merchant} — ${state.currency} ${state.total.toFixed(2)}`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the card in place.
|
||||
*
|
||||
* `response_url` is a signed, single-use-ish Slack URL carried in the
|
||||
* interaction payload; it needs no bot token, which is why this can live in the
|
||||
* app rather than being handed back to n8n. Valid for 30 minutes and 5 uses —
|
||||
* ample for a button press, and not something to cache.
|
||||
*
|
||||
* Failures are swallowed deliberately. The write already succeeded; throwing
|
||||
* here would turn a cosmetic staleness into a 500 that Slack shows the user as
|
||||
* a failed action, implying nothing happened when in fact it did.
|
||||
*/
|
||||
async function updateMessage(responseUrl: string | undefined, body: unknown) {
|
||||
if (!responseUrl) return;
|
||||
try {
|
||||
await fetch(responseUrl, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} catch {
|
||||
/* the state is correct even when the card is stale */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The DM payload for the other person, or null if we cannot address them.
|
||||
*
|
||||
* Returns an instruction rather than sending: the app holds no Slack bot token,
|
||||
* so n8n — which already has the credential — makes the call. Same shape as the
|
||||
* modal open.
|
||||
*
|
||||
* Null when SLACK_USER_MAP has no Slack id for the second consumer. Silent
|
||||
* rather than an error: the split is still correct and complete, and failing
|
||||
* the whole press because a DM could not be addressed would be worse than the
|
||||
* missing nudge.
|
||||
*/
|
||||
function buildPartnerNotify(
|
||||
state: Awaited<ReturnType<typeof nudgeState>>,
|
||||
sharerName?: string
|
||||
) {
|
||||
if (!state) return null;
|
||||
const slackUser = slackUserForParticipant(SECOND_CONSUMER_ID);
|
||||
if (!slackUser) return null;
|
||||
return {
|
||||
user: slackUser,
|
||||
text: `${state.merchant} — shared with you 50/50`,
|
||||
blocks: partnerNudgeBlocks(state, sharerName || "It was"),
|
||||
};
|
||||
}
|
||||
|
||||
/** The modal view, pre-filled with whatever this person already said. */
|
||||
async function buildDetailsModal(transactionId: number, participantId: number) {
|
||||
const row = await queryRow<{
|
||||
merchant: string | null;
|
||||
line_items: { description?: string }[] | null;
|
||||
}>(
|
||||
`SELECT merchant_normalized AS merchant, line_items
|
||||
FROM expense_metadata
|
||||
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`,
|
||||
[transactionId]
|
||||
);
|
||||
if (!row) return null;
|
||||
|
||||
const existing = await queryRow<{ note: string | null; item_verdicts: ItemOpinion[] }>(
|
||||
`SELECT note, item_verdicts FROM order_reviews
|
||||
WHERE transaction_id = $1 AND participant_id = $2`,
|
||||
[transactionId, participantId]
|
||||
);
|
||||
|
||||
const items = (row.line_items ?? [])
|
||||
.map((i) => (i?.description ?? "").trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return detailsModal(
|
||||
transactionId,
|
||||
participantId,
|
||||
row.merchant ?? "Order",
|
||||
items,
|
||||
{ note: existing?.note ?? null, itemVerdicts: existing?.item_verdicts ?? [] }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The modal came back. Save the note and the per-item verdicts.
|
||||
*
|
||||
* `response_action: "clear"` closes it. Returning a plain 200 with no body
|
||||
* leaves the modal open with a spinner, which reads as a hang.
|
||||
*
|
||||
* The rating is NOT touched here — it lives on the card, and a modal that
|
||||
* silently reset it would undo a decision the user did not revisit.
|
||||
*/
|
||||
async function handleModalSubmit(payload: {
|
||||
view: { private_metadata: string; state: { values: Record<string, Record<string, { value?: string; selected_option?: { value: string } }>> } };
|
||||
}) {
|
||||
const meta = JSON.parse(payload.view.private_metadata ?? "{}");
|
||||
const transactionId = Number(meta.t);
|
||||
const participantId = Number(meta.p);
|
||||
const items: string[] = Array.isArray(meta.i) ? meta.i : [];
|
||||
if (!Number.isInteger(transactionId) || !Number.isInteger(participantId)) {
|
||||
return NextResponse.json({ response_action: "clear" });
|
||||
}
|
||||
|
||||
const values = payload.view.state.values ?? {};
|
||||
const note = values.note?.value?.value?.trim() || null;
|
||||
|
||||
// Slack returns block ids and values, never the labels, so the item text is
|
||||
// recovered from private_metadata by index.
|
||||
const itemVerdicts: ItemOpinion[] = [];
|
||||
items.forEach((item, i) => {
|
||||
const picked = values[`item_${i}`]?.verdict?.selected_option?.value;
|
||||
if (picked === "loved" || picked === "never") {
|
||||
itemVerdicts.push({ item, verdict: picked });
|
||||
}
|
||||
});
|
||||
|
||||
await queryRaw(
|
||||
`INSERT INTO order_reviews (transaction_id, participant_id, note, item_verdicts)
|
||||
VALUES ($1, $2, $3, $4::jsonb)
|
||||
ON CONFLICT (transaction_id, participant_id) DO UPDATE
|
||||
SET note = EXCLUDED.note,
|
||||
item_verdicts = EXCLUDED.item_verdicts,
|
||||
updated_at = now()`,
|
||||
[transactionId, participantId, note, JSON.stringify(itemVerdicts)]
|
||||
);
|
||||
|
||||
return NextResponse.json({ response_action: "clear" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Share or unshare, as a real 50/50 split.
|
||||
*
|
||||
* The split IS the record that an order was shared, so there is no separate
|
||||
* flag to keep in step. Safe to clear on an ingested order because such a row
|
||||
* is post-cutover by construction — the DB CHECK forbids credits orders before
|
||||
* 2026-01-09 — so there is no settled historical obligation to lose.
|
||||
*/
|
||||
async function toggleShare(transactionId: number): Promise<string | null> {
|
||||
const existing = await queryRaw<{ participant_id: number; share_percent: string }>(
|
||||
`SELECT participant_id, share_percent FROM transaction_splits WHERE transaction_id = $1`,
|
||||
[transactionId]
|
||||
);
|
||||
|
||||
// Refuse to touch an arrangement this button cannot express. Splits are made
|
||||
// by hand here, so a third participant or an uneven share is deliberate — and
|
||||
// a one-tap button that silently flattened it would destroy a decision made
|
||||
// with more care than the tap that undid it.
|
||||
const foreign = existing.filter(
|
||||
(e) => e.participant_id !== SECOND_CONSUMER_ID && e.participant_id !== OWNER_PARTICIPANT_ID
|
||||
);
|
||||
const uneven = existing.some(
|
||||
(e) => e.participant_id === SECOND_CONSUMER_ID && Number(e.share_percent) !== 50
|
||||
);
|
||||
if (foreign.length || uneven) {
|
||||
// Say so. Returning silently left the card unchanged, which reads exactly
|
||||
// like a broken button — and a button that looks broken gets pressed again.
|
||||
return foreign.length
|
||||
? "This one is split with someone else, so I left it alone. Change it in the app."
|
||||
: "This one is not an even 50/50, so I left it alone. Change it in the app.";
|
||||
}
|
||||
|
||||
if (existing.some((e) => e.participant_id === SECOND_CONSUMER_ID)) {
|
||||
await queryRaw(`DELETE FROM transaction_splits WHERE transaction_id = $1`, [
|
||||
transactionId,
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
await queryRaw(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
|
||||
VALUES ($1, $2, 50)
|
||||
ON CONFLICT (transaction_id, participant_id)
|
||||
DO UPDATE SET share_percent = 50`,
|
||||
[transactionId, SECOND_CONSUMER_ID]
|
||||
);
|
||||
// Both halves, not just theirs. The button means "50/50", and a lone row for
|
||||
// the other person renders as a 50% share against a blank. The payer's half
|
||||
// comes from completeSplit rather than a second hardcoded insert, so it lands
|
||||
// on whoever actually owns the row instead of assuming that is me.
|
||||
await completeSplit(transactionId);
|
||||
return null;
|
||||
}
|
||||
|
||||
async function setRating(transactionId: number, participantId: number, rating: Rating) {
|
||||
await queryRaw(
|
||||
`INSERT INTO order_reviews (transaction_id, participant_id, rating, order_again)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (transaction_id, participant_id) DO UPDATE
|
||||
SET rating = EXCLUDED.rating,
|
||||
order_again = EXCLUDED.order_again,
|
||||
updated_at = now()`,
|
||||
// "bad" and "never" both mean no; only "never" warns on a future order.
|
||||
[transactionId, participantId, rating, rating !== "never" && rating !== "bad"]
|
||||
);
|
||||
}
|
||||
|
||||
/** Everything the refreshed message needs, read back after the write. */
|
||||
async function nudgeState(transactionId: number) {
|
||||
const row = await queryRow<{
|
||||
merchant: string | null;
|
||||
currency: string | null;
|
||||
amount: string;
|
||||
}>(
|
||||
`SELECT em.merchant_normalized AS merchant,
|
||||
COALESCE(em.currency, 'AUD') AS currency,
|
||||
t.amount
|
||||
FROM transactions t
|
||||
LEFT JOIN expense_metadata em
|
||||
ON em.transaction_id = t.id OR em.matched_transaction_id = t.id
|
||||
WHERE t.id = $1`,
|
||||
[transactionId]
|
||||
);
|
||||
if (!row) return null;
|
||||
|
||||
const [splits, ratings, verdict] = await Promise.all([
|
||||
queryRaw<{ participant_id: number }>(
|
||||
`SELECT participant_id FROM transaction_splits WHERE transaction_id = $1`,
|
||||
[transactionId]
|
||||
),
|
||||
queryRaw<{ name: string; rating: Rating }>(
|
||||
`SELECT p.name, r.rating FROM order_reviews r
|
||||
JOIN participants p ON p.id = r.participant_id
|
||||
WHERE r.transaction_id = $1 AND r.rating IS NOT NULL
|
||||
ORDER BY r.participant_id`,
|
||||
[transactionId]
|
||||
),
|
||||
merchantVerdict(row.merchant, transactionId),
|
||||
]);
|
||||
|
||||
return {
|
||||
transactionId,
|
||||
merchant: row.merchant ?? "Unknown merchant",
|
||||
currency: row.currency ?? "AUD",
|
||||
total: Number(row.amount),
|
||||
shared: splits.some((s) => s.participant_id === SECOND_CONSUMER_ID),
|
||||
ratings,
|
||||
warn: verdict?.warn ?? false,
|
||||
warnNote: verdict?.history.find((h) => h.note)?.note ?? null,
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { queryRaw } from "@/lib/db";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { isTripParticipant } from "@/lib/queries";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const user = await getCurrentUser(req);
|
||||
@@ -21,15 +22,22 @@ export async function GET(req: NextRequest) {
|
||||
payment_date: string;
|
||||
notes: string | null;
|
||||
linked_transaction_id: number | null;
|
||||
trip_id: number | null;
|
||||
trip_name: string | null;
|
||||
created_at: string;
|
||||
}>(
|
||||
// trip_id was stored but never returned, so history could not show which tab
|
||||
// a payment settled — and a grouped transfer looks like a duplicate until you
|
||||
// can see that its rows carry different scopes.
|
||||
`SELECT sp.id, sp.from_participant_id, pf.name as from_name,
|
||||
sp.to_participant_id, pt.name as to_name,
|
||||
sp.amount, sp.payment_date, sp.notes,
|
||||
sp.linked_transaction_id, sp.created_at
|
||||
sp.linked_transaction_id, sp.trip_id, tr.name as trip_name,
|
||||
sp.created_at
|
||||
FROM split_payments sp
|
||||
JOIN participants pf ON pf.id = sp.from_participant_id
|
||||
JOIN participants pt ON pt.id = sp.to_participant_id
|
||||
LEFT JOIN trips tr ON tr.id = sp.trip_id
|
||||
WHERE (sp.from_participant_id = $1 OR sp.to_participant_id = $1)
|
||||
AND (sp.from_participant_id = $2 OR sp.to_participant_id = $2)
|
||||
ORDER BY sp.payment_date DESC, sp.created_at DESC`,
|
||||
@@ -50,9 +58,10 @@ export async function POST(req: NextRequest) {
|
||||
payment_date: string;
|
||||
notes?: string;
|
||||
linked_transaction_id?: number;
|
||||
trip_id?: number | null;
|
||||
};
|
||||
|
||||
const { from_participant_id, to_participant_id, amount, payment_date, notes, linked_transaction_id } = body;
|
||||
const { from_participant_id, to_participant_id, amount, payment_date, notes, linked_transaction_id, trip_id } = body;
|
||||
|
||||
if (!from_participant_id || !to_participant_id || !amount || !payment_date) {
|
||||
return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
|
||||
@@ -60,6 +69,24 @@ export async function POST(req: NextRequest) {
|
||||
if (amount <= 0) {
|
||||
return NextResponse.json({ error: "Amount must be positive" }, { status: 400 });
|
||||
}
|
||||
if (from_participant_id !== user.id && to_participant_id !== user.id) {
|
||||
return NextResponse.json({ error: "A payment must involve you" }, { status: 403 });
|
||||
}
|
||||
|
||||
// Scope. `trip_id` existed in the schema from migration 0022 but this route
|
||||
// never read it, so every payment recorded in the app landed on the household
|
||||
// tab and the 9 trip-scoped rows had to be written by hand in SQL.
|
||||
//
|
||||
// "Both" needs no extra shape: one transfer becomes one row per scope, all
|
||||
// carrying the same linked_transaction_id — there is deliberately no unique
|
||||
// constraint on it. That is how tx 4121's $4,794.06 sits as $1,145.52 against
|
||||
// Europe — Sonu + Sunny and $3,648.54 against household.
|
||||
if (trip_id != null && !(await isTripParticipant(trip_id, user.id))) {
|
||||
return NextResponse.json(
|
||||
{ error: "Cannot scope a payment to a trip you are not on" },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const payment = await prisma.split_payments.create({
|
||||
data: {
|
||||
@@ -69,6 +96,7 @@ export async function POST(req: NextRequest) {
|
||||
payment_date: new Date(payment_date),
|
||||
notes: notes || null,
|
||||
linked_transaction_id: linked_transaction_id || null,
|
||||
trip_id: trip_id ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -83,6 +111,16 @@ export async function DELETE(req: NextRequest) {
|
||||
const id = Number(sp.get("id"));
|
||||
if (!id) return NextResponse.json({ error: "id required" }, { status: 400 });
|
||||
|
||||
// This deleted by id with no check at all: any authenticated participant could
|
||||
// erase any settlement, which silently resurrects a discharged debt — the same
|
||||
// class of damage as the split rewrite that reset `settled`. Deleting a payment
|
||||
// must be limited to the two people it is between.
|
||||
const existing = await prisma.split_payments.findUnique({ where: { id } });
|
||||
if (!existing) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
if (existing.from_participant_id !== user.id && existing.to_participant_id !== user.id) {
|
||||
return NextResponse.json({ error: "Not your payment to delete" }, { status: 403 });
|
||||
}
|
||||
|
||||
await prisma.split_payments.delete({ where: { id } });
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getStatementById } from "@/lib/queries";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { prisma, queryRaw } from "@/lib/db";
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
@@ -15,3 +16,59 @@ export async function GET(
|
||||
}
|
||||
return NextResponse.json(stmt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reassign a statement — whose account it is.
|
||||
*
|
||||
* The statements page has had an owner dropdown since it was built, wired to
|
||||
* `useUpdateStatement`, which PATCHed a route that had no PATCH handler. Every
|
||||
* change 405'd, and because the hook never checked `res.ok` the failure was
|
||||
* silent: the select snapped back on refetch and looked like a UI glitch.
|
||||
*
|
||||
* Both tables are written because both carry the owner. The effective owner is
|
||||
* `COALESCE(t.owner_id, s.owner_id)`, and 2,194 statement rows carry their own
|
||||
* copy against 1,803 that inherit — so updating `statements` alone would move
|
||||
* less than half the rows and split one account's history between two people.
|
||||
* All 2,194 agree with their statement today; a row that disagrees was set
|
||||
* deliberately and is left alone rather than swept up.
|
||||
*/
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser(req);
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
const { id } = await params;
|
||||
const statementId = Number(id);
|
||||
|
||||
const stmt = await getStatementById(statementId);
|
||||
// Only the current owner reassigns. A statement is an account, not a shared
|
||||
// expense — there is no split that grants a second person a say in it.
|
||||
if (!stmt || stmt.owner_id !== user.id) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const { owner_id } = (await req.json()) as { owner_id?: number };
|
||||
if (typeof owner_id !== "number" || !Number.isInteger(owner_id)) {
|
||||
return NextResponse.json({ error: "owner_id must be a participant id" }, { status: 400 });
|
||||
}
|
||||
const known = await queryRaw(`SELECT id FROM participants WHERE id = $1`, [owner_id]);
|
||||
if (!known.length) {
|
||||
return NextResponse.json({ error: "Unknown participant" }, { status: 400 });
|
||||
}
|
||||
if (owner_id === stmt.owner_id) {
|
||||
return NextResponse.json({ ok: true, statement_id: statementId, rows_moved: 0 });
|
||||
}
|
||||
|
||||
const [, rowsMoved] = await prisma.$transaction([
|
||||
prisma.$executeRawUnsafe(`UPDATE statements SET owner_id = $1 WHERE id = $2`, owner_id, statementId),
|
||||
prisma.$executeRawUnsafe(
|
||||
`UPDATE transactions SET owner_id = $1 WHERE statement_id = $2 AND owner_id = $3`,
|
||||
owner_id,
|
||||
statementId,
|
||||
stmt.owner_id
|
||||
),
|
||||
]);
|
||||
|
||||
return NextResponse.json({ ok: true, statement_id: statementId, rows_moved: rowsMoved });
|
||||
}
|
||||
|
||||
@@ -28,10 +28,14 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
});
|
||||
|
||||
// Assign all transactions with this tag to the new trip
|
||||
// The creator owns the new trip, so they participate in it by definition and
|
||||
// the assignment's participation gate passes. `assigned` is what actually
|
||||
// moved: rows the creator cannot see are skipped, so a tag spanning someone
|
||||
// else's transactions converts to a trip holding only the creator's.
|
||||
const transactionIds = await getTagTransactionIds(tagId);
|
||||
if (transactionIds.length > 0) {
|
||||
await assignTransactionsToTrip(trip.id, transactionIds);
|
||||
}
|
||||
const assigned = transactionIds.length > 0
|
||||
? await assignTransactionsToTrip(trip.id, transactionIds, user.id)
|
||||
: 0;
|
||||
|
||||
return NextResponse.json({ trip, assigned: transactionIds.length }, { status: 201 });
|
||||
return NextResponse.json({ trip, assigned, tagged: transactionIds.length }, { status: 201 });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { LineItemsValidationError, replaceLineItems, TransactionNotFoundError, validateLineItems } from "@/lib/line-items";
|
||||
|
||||
/**
|
||||
* Enrich endpoint (plan §11 C2): idempotent replace of the itemised receipt
|
||||
* behind a transaction.
|
||||
*
|
||||
* Accepts either machine token. Pantry already holds RECEIPT_INGEST_TOKEN for
|
||||
* the ING-10 lane and calls this on every confirm and on undo; the intake
|
||||
* router holds INTAKE_TOKEN. No session auth — there is no browser here.
|
||||
* Fails closed when neither is configured.
|
||||
*
|
||||
* Never creates a transaction: 404 when the id does not exist. Creates the
|
||||
* `expense_metadata` row when the transaction has none.
|
||||
*/
|
||||
function authorised(req: NextRequest): boolean {
|
||||
const got = req.headers.get("x-ingest-token");
|
||||
if (!got) return false;
|
||||
const accepted = [process.env.INTAKE_TOKEN, process.env.RECEIPT_INGEST_TOKEN].filter(Boolean);
|
||||
return accepted.includes(got);
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
if (!authorised(req)) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
|
||||
const { id } = await params;
|
||||
const transactionId = Number(id);
|
||||
if (!Number.isInteger(transactionId) || transactionId <= 0) return NextResponse.json({ error: "invalid transaction id" }, { status: 400 });
|
||||
|
||||
let body: { line_items?: unknown; source?: unknown; batch_id?: unknown; replace?: unknown; platform?: unknown };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (body.replace !== true) return NextResponse.json({ error: "replace: true is required — this endpoint only replaces" }, { status: 400 });
|
||||
if (body.source !== "intake" && body.source !== "pantry") return NextResponse.json({ error: "source must be 'intake' or 'pantry'" }, { status: 400 });
|
||||
const batchId = typeof body.batch_id === "string" && body.batch_id ? body.batch_id : null;
|
||||
const platform = typeof body.platform === "string" && body.platform ? body.platform : null;
|
||||
|
||||
try {
|
||||
const lineItems = validateLineItems(body.line_items);
|
||||
const result = await replaceLineItems(transactionId, lineItems, { source: body.source, platform, batchId });
|
||||
return NextResponse.json({
|
||||
transaction_id: result.transaction_id,
|
||||
metadata_id: result.metadata_id,
|
||||
created_metadata: result.created_metadata,
|
||||
previous_line_items: result.previous_line_items,
|
||||
line_items: result.line_items,
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof LineItemsValidationError) return NextResponse.json({ error: e.message }, { status: 400 });
|
||||
if (e instanceof TransactionNotFoundError) return NextResponse.json({ error: e.message }, { status: 404 });
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ export async function GET(
|
||||
}
|
||||
|
||||
const row = await queryRow(
|
||||
`SELECT platform, order_reference, line_items, route, subtotal, amount,
|
||||
`SELECT source, platform, order_reference, line_items, route, subtotal, amount,
|
||||
currency, card_last4, flags, source_email_subject, transaction_date
|
||||
FROM expense_metadata
|
||||
-- A card-settled order creates no transaction of its own (I5): the
|
||||
@@ -34,11 +34,42 @@ export async function GET(
|
||||
-- through matched_transaction_id. Both directions have to resolve or the
|
||||
-- detail is missing on exactly the orders that were paid by card.
|
||||
WHERE transaction_id = $1 OR matched_transaction_id = $1
|
||||
-- The same order as replaceLineItems in line-items.ts and matchTransaction
|
||||
-- in intake-router.ts. Both directions can resolve at once — a statement
|
||||
-- line owning its own row while a delivery receipt points at it — and an
|
||||
-- unordered LIMIT 1 then reads a different row from the one an enrichment
|
||||
-- wrote, so a PUT that reported success showed nothing on the screen.
|
||||
-- NULLS LAST because DESC is NULLS FIRST and the pointing row's
|
||||
-- transaction_id is NULL.
|
||||
ORDER BY (transaction_id = $1) DESC NULLS LAST, id
|
||||
LIMIT 1`,
|
||||
[Number(id)]
|
||||
);
|
||||
|
||||
// Phase 2 (board 205). A BNPL leg has NO expense_metadata row of its own —
|
||||
// the plan is four transactions against one order — so without this the four
|
||||
// Afterpay debits behind the A$1,599 DJI drone stay four bare "Afterpay
|
||||
// $399.75" rows naming nothing. That is the surface where this was noticed.
|
||||
const link = await queryRow<{
|
||||
entity_key: string; leg_kind: string; leg_index: number | null;
|
||||
leg_count: number | null; canonical_name: string | null;
|
||||
platform: string | null; order_total: string | null; currency: string | null;
|
||||
}>(
|
||||
`SELECT l.entity_key, l.leg_kind, l.leg_index::int, l.leg_count::int,
|
||||
e.canonical_name, o.platform, o.order_total, o.currency
|
||||
FROM order_transaction_links l
|
||||
LEFT JOIN entities e ON e.entity_key = l.entity_key
|
||||
LEFT JOIN entity_orders o ON o.entity_id = e.id
|
||||
WHERE l.transaction_id = $1
|
||||
LIMIT 1`,
|
||||
[Number(id)]
|
||||
);
|
||||
|
||||
// Not an order — most transactions aren't. Null, not 404: the caller is
|
||||
// asking "is there a receipt behind this?", and "no" is a normal answer.
|
||||
return NextResponse.json(row ?? null);
|
||||
if (!row && !link) return NextResponse.json(null);
|
||||
|
||||
// expense_metadata carries the itemised receipt and stays primary; the link
|
||||
// adds what it cannot express — which order this is a leg OF, and which leg.
|
||||
return NextResponse.json({ ...(row ?? {}), order_link: link ?? null });
|
||||
}
|
||||
|
||||
@@ -155,10 +155,12 @@ export async function PUT(
|
||||
)
|
||||
.map((v) => ({ item: v.item.trim(), verdict: v.verdict }));
|
||||
|
||||
// `order_again` is derived when the caller does not say. "never" is the only
|
||||
// rating that answers the question on its own; "ok" is not a refusal.
|
||||
// `order_again` is derived when the caller does not say. "bad" and "never"
|
||||
// both answer no — you would not choose either again — but only "never"
|
||||
// raises the warning on a future order, so the blacklist stays sharp.
|
||||
const orderAgain =
|
||||
body.order_again ?? (rating === null ? null : rating !== "never");
|
||||
body.order_again ??
|
||||
(rating === null ? null : rating !== "never" && rating !== "bad");
|
||||
|
||||
await queryRow(
|
||||
`INSERT INTO order_reviews (transaction_id, participant_id, rating, order_again, note, item_verdicts)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getTransactionById, canAccessTransactions } from "@/lib/queries";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { queryRaw } from "@/lib/db";
|
||||
import { isPaymentMethod } from "@/lib/payment-methods";
|
||||
|
||||
const VALID_TYPES = ["debit", "credit", "payment", "refund", "fee", "interest", "transfer"];
|
||||
|
||||
@@ -34,7 +35,7 @@ export async function PATCH(
|
||||
}
|
||||
const body = await req.json();
|
||||
|
||||
const { category, merchant_normalized, notes, transaction_type, my_share_percent, description, amount, transaction_date, trip_id, payment_method } = body as {
|
||||
const { category, merchant_normalized, notes, transaction_type, my_share_percent, description, amount, transaction_date, trip_id, payment_method, owner_id, release } = body as {
|
||||
category?: string;
|
||||
merchant_normalized?: string;
|
||||
notes?: string;
|
||||
@@ -45,6 +46,9 @@ export async function PATCH(
|
||||
amount?: number;
|
||||
transaction_date?: string;
|
||||
trip_id?: number | null;
|
||||
owner_id?: number;
|
||||
/** Acknowledge that reassigning will remove the row from my own view. */
|
||||
release?: boolean;
|
||||
};
|
||||
|
||||
if (my_share_percent !== undefined && my_share_percent !== null) {
|
||||
@@ -87,9 +91,12 @@ export async function PATCH(
|
||||
|
||||
// payment_method is a property of the transaction itself, not a user override
|
||||
// of extracted data, so it lives on the transactions table.
|
||||
//
|
||||
// The allowlist is PAYMENT_METHODS, not a local literal: this one omitted
|
||||
// 'credits' from migration 0018 until 2026-08-17, so the DB admitted a
|
||||
// gift-card row that this route then refused to set or correct.
|
||||
if (payment_method !== undefined) {
|
||||
const VALID_METHODS = ["card", "cash", "bank_transfer", "other"];
|
||||
if (payment_method !== null && !VALID_METHODS.includes(payment_method)) {
|
||||
if (payment_method !== null && !isPaymentMethod(payment_method)) {
|
||||
return NextResponse.json({ error: "Invalid payment_method" }, { status: 400 });
|
||||
}
|
||||
await queryRaw(
|
||||
@@ -98,6 +105,75 @@ export async function PATCH(
|
||||
);
|
||||
}
|
||||
|
||||
// Owner — who actually paid. A direct column, not an override: it decides
|
||||
// whose account the money left, which every balance and every spend analytic
|
||||
// scopes on (MY_SPEND_SCOPE / OWNER_SCOPE).
|
||||
//
|
||||
// Handled here rather than in the direct-fields block above because it has
|
||||
// its own rules, and because getting it wrong is not a cosmetic error: it
|
||||
// moves money between two people's ledgers.
|
||||
if (owner_id !== undefined) {
|
||||
if (typeof owner_id !== "number" || !Number.isInteger(owner_id)) {
|
||||
return NextResponse.json({ error: "owner_id must be a participant id" }, { status: 400 });
|
||||
}
|
||||
|
||||
const txRows = await queryRaw<{ statement_id: number | null; owner_id: number | null }>(
|
||||
`SELECT statement_id, owner_id FROM transactions WHERE id = $1`,
|
||||
[transactionId]
|
||||
);
|
||||
const tx = txRows[0];
|
||||
if (!tx) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
|
||||
// A statement row's owner is the statement's owner — the effective owner is
|
||||
// COALESCE(t.owner_id, s.owner_id), so setting it here would either be a
|
||||
// no-op or would silently detach one row from the account it was extracted
|
||||
// from. Reassigning the statement is the correct move and moves its rows
|
||||
// with it.
|
||||
if (tx.statement_id) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"This row came from a statement, so its owner is the statement's owner. Change the statement's owner instead — that moves every row on it.",
|
||||
code: "statement_owned",
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const known = await queryRaw(`SELECT id FROM participants WHERE id = $1`, [owner_id]);
|
||||
if (!known.length) {
|
||||
return NextResponse.json({ error: "Unknown participant" }, { status: 400 });
|
||||
}
|
||||
|
||||
// The one-way door. Access is "owner OR holds a split"
|
||||
// (canAccessTransactions), so handing a row to someone else while holding
|
||||
// no split on it removes it from the caller's list and 404s every route
|
||||
// that could put it back — only the new owner can undo it. Splitting first
|
||||
// keeps the row reachable AND is what makes the balance correct, so the
|
||||
// refusal points at the step that was skipped rather than just blocking.
|
||||
if (owner_id !== user.id && !release) {
|
||||
const mine = await queryRaw(
|
||||
`SELECT 1 FROM transaction_splits WHERE transaction_id = $1 AND participant_id = $2`,
|
||||
[transactionId, user.id]
|
||||
);
|
||||
if (!mine.length) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error:
|
||||
"You hold no split on this transaction, so reassigning it would remove it from your view for good — only the new owner could change it back. Add your split first, or confirm you are giving it away entirely.",
|
||||
code: "would_lose_access",
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Existing splits are deliberately left alone. They record shares, not
|
||||
// direction: getParticipantBalances derives who owes whom from ownership,
|
||||
// so a 50/50 row flips from "they owe me" to "I owe them" with no rewrite.
|
||||
await queryRaw(`UPDATE transactions SET owner_id = $1 WHERE id = $2`, [owner_id, transactionId]);
|
||||
}
|
||||
|
||||
// category/merchant/notes/my_share_percent/trip_id go through the overrides table
|
||||
const hasOverride = category !== undefined || merchant_normalized !== undefined || notes !== undefined || my_share_percent !== undefined || trip_id !== undefined;
|
||||
if (!hasOverride) {
|
||||
|
||||
@@ -41,6 +41,30 @@ export async function GET(
|
||||
return NextResponse.json(splits);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove every split — un-share the transaction.
|
||||
*
|
||||
* POST cannot express this: it requires shares totalling 100%, and an empty
|
||||
* array is not that. Without this the order panel's "Shared 50/50" toggle had
|
||||
* no way back, and pressing it to un-share failed with "splits array required".
|
||||
*/
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const user = await getCurrentUser(req);
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
const { id } = await params;
|
||||
const transactionId = Number(id);
|
||||
if (!(await canAccessTransactions(user.id, [transactionId]))) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
const removed = await prisma.transaction_splits.deleteMany({
|
||||
where: { transaction_id: transactionId },
|
||||
});
|
||||
return NextResponse.json({ removed: removed.count });
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
|
||||
@@ -122,8 +122,17 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
if (action === "assign_trip") {
|
||||
const { trip_id } = body as { ids: number[]; trip_id: number | null };
|
||||
await assignTransactionsToTrip(trip_id, ids);
|
||||
return NextResponse.json({ updated: ids.length });
|
||||
try {
|
||||
// `updated` is what actually moved, not what was asked for — ids the
|
||||
// caller cannot see are skipped rather than silently applied.
|
||||
const updated = await assignTransactionsToTrip(trip_id, ids, user.id);
|
||||
return NextResponse.json({ updated, requested: ids.length });
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: e instanceof Error ? e.message : "Failed to assign" },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: "Invalid action" }, { status: 400 });
|
||||
|
||||
@@ -77,6 +77,36 @@ export async function POST(req: NextRequest) {
|
||||
await tx.transaction_splits.deleteMany({ where: { transaction_id: manual_id } });
|
||||
}
|
||||
|
||||
// Move provenance: manual → statement tx.
|
||||
//
|
||||
// Overrides, tags and splits above were always carried across; expense_metadata was
|
||||
// the one child left behind, which did not matter while every metadata row came from
|
||||
// an email that had created its own transaction. It matters now: a scanned grocery
|
||||
// receipt puts its line items here, and reconciliation hides the manual row from
|
||||
// every figure — so without this the shop's contents disappear at exactly the moment
|
||||
// the statement line appears, and `COLES 0556 MANOR LAKES` stays as unreadable as it
|
||||
// was before the receipt was ever scanned.
|
||||
//
|
||||
// transaction_id is UNIQUE, so a statement row that already has metadata (an emailed
|
||||
// or Paperless copy got there first) keeps it. The pantry row stays attached to the
|
||||
// reconciled manual transaction and is flagged, rather than raising a constraint
|
||||
// violation or silently overwriting the other source.
|
||||
const moved = await tx.$executeRawUnsafe(
|
||||
`UPDATE expense_metadata SET transaction_id = $1
|
||||
WHERE transaction_id = $2
|
||||
AND NOT EXISTS (SELECT 1 FROM expense_metadata other WHERE other.transaction_id = $1)`,
|
||||
statement_tx_id,
|
||||
manual_id
|
||||
);
|
||||
if (moved === 0) {
|
||||
await tx.$executeRawUnsafe(
|
||||
`UPDATE expense_metadata
|
||||
SET flags = coalesce(flags, '[]'::jsonb) || '["metadata_collision_on_reconcile"]'::jsonb
|
||||
WHERE transaction_id = $1`,
|
||||
manual_id
|
||||
);
|
||||
}
|
||||
|
||||
// Mark manual tx as reconciled (link to statement tx)
|
||||
await tx.$executeRawUnsafe(
|
||||
`UPDATE transactions SET reconciled_with_id = $1 WHERE id = $2`,
|
||||
|
||||
@@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { getTransactions } from "@/lib/queries";
|
||||
import { queryRaw } from "@/lib/db";
|
||||
import { completeSplit } from "@/lib/splits";
|
||||
import { isPaymentMethod } from "@/lib/payment-methods";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const user = await getCurrentUser(req);
|
||||
@@ -13,6 +15,7 @@ export async function GET(req: NextRequest) {
|
||||
from: sp.get("from") || undefined,
|
||||
to: sp.get("to") || undefined,
|
||||
categories: parseArr("categories"),
|
||||
exclude_categories: parseArr("exclude_categories"),
|
||||
bank_names: parseArr("bank_names"),
|
||||
tag_ids: parseArr("tag_ids"),
|
||||
transaction_types: parseArr("transaction_types"),
|
||||
@@ -26,6 +29,7 @@ export async function GET(req: NextRequest) {
|
||||
amount_max: sp.get("amount_max") ? Number(sp.get("amount_max")) : undefined,
|
||||
has_split: sp.get("has_split") || undefined,
|
||||
trip_id: sp.get("trip_id") || undefined,
|
||||
trip_all_rows: sp.get("trip_all_rows") === "1" || undefined,
|
||||
});
|
||||
|
||||
return NextResponse.json(result);
|
||||
@@ -50,6 +54,12 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json({ error: "date, description, amount are required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Validated here rather than left to the CHECK constraint, which surfaces as
|
||||
// a 500 with a Postgres error rather than a 400 naming the field.
|
||||
if (body.payment_method && !isPaymentMethod(body.payment_method)) {
|
||||
return NextResponse.json({ error: "Invalid payment_method" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Insert manual transaction with no statement (statement_id = NULL, owner_id set directly)
|
||||
const txRows = await queryRaw<{ id: number }>(
|
||||
`INSERT INTO transactions (statement_id, owner_id, transaction_date, description, amount, transaction_type, merchant_normalized, category, payment_method, row_index)
|
||||
@@ -79,6 +89,11 @@ export async function POST(req: NextRequest) {
|
||||
[transactionId, s.participant_id, s.share_percent]
|
||||
);
|
||||
}
|
||||
// The form lets you name just the other person and shows the total in amber
|
||||
// when it is under 100 — which is how "Lawn Mowing, Sonu 50%" was saved with
|
||||
// the other half nowhere. Fill in the payer's share rather than refusing:
|
||||
// naming only the other person is a reasonable thing to mean.
|
||||
await completeSplit(transactionId);
|
||||
}
|
||||
|
||||
return NextResponse.json({ id: transactionId }, { status: 201 });
|
||||
|
||||
@@ -21,10 +21,23 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id
|
||||
return NextResponse.json(trip);
|
||||
}
|
||||
|
||||
// Everything else about a trip is shared; delete is not. Both trip foreign keys
|
||||
// are ON DELETE SET NULL, so this untags every transaction on the trip and drops
|
||||
// the trip scope from its payments — including the hand-derived Europe-first
|
||||
// allocation, which nothing recomputes. A participant gets a 403 that says so
|
||||
// rather than a 404 that pretends the trip is not there.
|
||||
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const user = await getCurrentUser(req);
|
||||
if (!user) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
const { id } = await params;
|
||||
const trip = await getTripById(Number(id), user.id);
|
||||
if (!trip) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
if (trip.owner_id !== user.id) {
|
||||
return NextResponse.json(
|
||||
{ error: "Only the trip owner can delete a trip. Deleting it would untag every transaction on it and unscope its payments." },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
await deleteTrip(Number(id), user.id);
|
||||
return new NextResponse(null, { status: 204 });
|
||||
}
|
||||
|
||||
@@ -10,6 +10,13 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id
|
||||
if (!Array.isArray(transactionIds) || !transactionIds.length) {
|
||||
return NextResponse.json({ error: "transactionIds must be a non-empty array" }, { status: 400 });
|
||||
}
|
||||
await assignTransactionsToTrip(Number(id), transactionIds);
|
||||
return NextResponse.json({ ok: true });
|
||||
try {
|
||||
const assigned = await assignTransactionsToTrip(Number(id), transactionIds, user.id);
|
||||
return NextResponse.json({ ok: true, assigned, requested: transactionIds.length });
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: e instanceof Error ? e.message : "Failed to assign" },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+32
-44
@@ -19,16 +19,20 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useMonthlyAnalytics, useDailySpend, useTransactions, useUpdateTransaction } from "@/lib/hooks";
|
||||
import { formatCategory, CATEGORIES } from "@/lib/categories";
|
||||
import { CATEGORY_COLORS, CHART, TOOLTIP_STYLE } from "@/lib/category-colors";
|
||||
// The partial-month comparison rules live in their own module so they can be
|
||||
// tested. They used to be closures here reading `new Date()` inline, which is
|
||||
// why Priority 0 item 6 had no test (#155). See src/lib/partial-month.ts.
|
||||
import {
|
||||
currentMonthStr,
|
||||
prevMonth,
|
||||
daysInMonthOf,
|
||||
elapsedDays,
|
||||
spendThrough,
|
||||
comparisonBasis,
|
||||
baselineMonths,
|
||||
baselineAverage,
|
||||
} from "@/lib/partial-month";
|
||||
|
||||
function currentMonthStr(): string {
|
||||
const now = new Date();
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
||||
}
|
||||
function prevMonth(m: string): string {
|
||||
const [year, month] = m.split("-").map(Number);
|
||||
const d = new Date(year, month - 2, 1);
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
||||
}
|
||||
function formatMonth(m: string): string {
|
||||
const [year, month] = m.split("-");
|
||||
return new Date(Number(year), Number(month) - 1, 1).toLocaleString("default", { month: "long", year: "numeric" });
|
||||
@@ -37,26 +41,6 @@ function formatShortMonth(m: string): string {
|
||||
const [year, month] = m.split("-");
|
||||
return new Date(Number(year), Number(month) - 1, 1).toLocaleString("default", { month: "short" });
|
||||
}
|
||||
function daysInMonthOf(m: string): number {
|
||||
const [year, month] = m.split("-").map(Number);
|
||||
return new Date(year, month, 0).getDate();
|
||||
}
|
||||
/**
|
||||
* How much of `month` has actually happened. A month in progress is only
|
||||
* complete up to today; every earlier month is complete.
|
||||
*/
|
||||
function elapsedDays(m: string): number {
|
||||
return m === currentMonthStr() ? new Date().getDate() : daysInMonthOf(m);
|
||||
}
|
||||
/** Spend in `month` from day 1 through `throughDay` inclusive. */
|
||||
function spendThrough(days: Record<number, number> | undefined, throughDay: number): number {
|
||||
if (!days) return 0;
|
||||
let sum = 0;
|
||||
for (const [d, v] of Object.entries(days)) {
|
||||
if (Number(d) <= throughDay) sum += v;
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
function fmt(n: number): string { return `$${Math.round(n).toLocaleString()}`; }
|
||||
function fmtExact(n: number): string { return `$${n.toFixed(2)}`; }
|
||||
function fmtSigned(n: number): string { return `${n >= 0 ? "+" : "−"}$${Math.abs(n) >= 100 ? Math.round(Math.abs(n)).toLocaleString() : Math.abs(n).toFixed(0)}`; }
|
||||
@@ -221,8 +205,7 @@ export default function AnalyticsPage() {
|
||||
|
||||
// A month in progress is only comparable to prior months through the same day.
|
||||
// Comparing 27 days of July against a full June always flattered July.
|
||||
const compareDay = elapsedDays(selectedMonth);
|
||||
const selectedIsPartial = selectedMonth === currentMonthStr();
|
||||
const { compareDay, isPartial: selectedIsPartial } = comparisonBasis(selectedMonth);
|
||||
|
||||
// Category rows for selected month
|
||||
const categoryRows = useMemo(() => {
|
||||
@@ -299,8 +282,10 @@ export default function AnalyticsPage() {
|
||||
// Only complete months with data form the baseline. A month still in
|
||||
// progress has no spend recorded past today, so including it would pull the
|
||||
// typical curve down by however much of it has not happened yet.
|
||||
const priorMonths = (analytics?.months ?? []).filter(
|
||||
(m) => m !== selectedMonth && m !== currentMonthStr() && (analytics?.totals[m]?.spent || 0) > 0
|
||||
const priorMonths = baselineMonths(
|
||||
analytics?.months ?? [],
|
||||
selectedMonth,
|
||||
analytics?.totals ?? {}
|
||||
);
|
||||
|
||||
let cum = 0;
|
||||
@@ -331,7 +316,10 @@ export default function AnalyticsPage() {
|
||||
|
||||
const totals = analytics.totals[selectedMonth] ?? { spent: 0, income: 0, investments: 0, net: 0 };
|
||||
const hasIncome = months.some((m) => (analytics.totals[m]?.income || 0) > 0);
|
||||
const hasInvestments = months.some((m) => (analytics.totals[m]?.investments || 0) > 0);
|
||||
// `!== 0`, not `> 0`: the investments line is signed, so a net-disinvesting
|
||||
// month is real data, not an empty one. A window where every month nets
|
||||
// negative would otherwise render as "—".
|
||||
const hasInvestments = months.some((m) => (analytics.totals[m]?.investments || 0) !== 0);
|
||||
|
||||
// Hero delta vs the average of the other *complete* months that have data.
|
||||
//
|
||||
@@ -339,18 +327,16 @@ export default function AnalyticsPage() {
|
||||
// baseline, because most of it has not happened. And when the month in
|
||||
// progress is the one selected, its running total has to be measured against
|
||||
// the same slice of each prior month, not against their full totals.
|
||||
const otherMonths = months.filter(
|
||||
(m) => m !== selectedMonth && m !== currentMonthStr() && (analytics.totals[m]?.spent || 0) > 0
|
||||
);
|
||||
const otherMonths = baselineMonths(months, selectedMonth, analytics.totals);
|
||||
const comparableSpend = selectedIsPartial
|
||||
? spendThrough(dailyData?.daily?.[selectedMonth], compareDay)
|
||||
: totals.spent;
|
||||
const avgSpend = otherMonths.length
|
||||
? otherMonths.reduce(
|
||||
(s, m) => s + (selectedIsPartial ? spendThrough(dailyData?.daily?.[m], compareDay) : analytics.totals[m]?.spent || 0),
|
||||
0
|
||||
) / otherMonths.length
|
||||
: 0;
|
||||
const avgSpend = baselineAverage(otherMonths, {
|
||||
isPartial: selectedIsPartial,
|
||||
compareDay,
|
||||
daily: dailyData?.daily,
|
||||
totals: analytics.totals,
|
||||
});
|
||||
const avgDeltaPct = avgSpend > 0 ? Math.round(((comparableSpend - avgSpend) / avgSpend) * 100) : 0;
|
||||
const throughQualifier = selectedIsPartial ? ` through day ${compareDay}` : "";
|
||||
const heroSentence =
|
||||
@@ -677,7 +663,9 @@ export default function AnalyticsPage() {
|
||||
{tableMonths.map((m) => {
|
||||
const inv = analytics.investments[m];
|
||||
return (
|
||||
<td key={m} className="px-3 py-2 text-right font-mono tabular-nums text-indigo-300">
|
||||
// A net-disinvesting month is a different fact from an
|
||||
// investing one; same-coloured digits hide the sign.
|
||||
<td key={m} className={`px-3 py-2 text-right font-mono tabular-nums ${inv < 0 ? "text-amber-400" : "text-indigo-300"}`}>
|
||||
{inv ? fmt(inv) : "—"}
|
||||
</td>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
import Link from "next/link";
|
||||
import { useOrderDetail } from "@/lib/hooks";
|
||||
import { tidyMerchant } from "@/lib/merchant-label";
|
||||
|
||||
/**
|
||||
* One order: what it was, what happened to it, and what paid for it.
|
||||
*
|
||||
* The lifecycle timeline IS the tracking UI. tracking_url is NULL on all 7,608
|
||||
* spine rows and on all 11,092 order_event payloads, because the shared HTML
|
||||
* renderer strips anchor hrefs before extraction ever sees them (board 208).
|
||||
* Do not build a tracking widget against a field nothing populates.
|
||||
*/
|
||||
|
||||
/** What the classifier thought the source document was — see the list page. */
|
||||
const KIND_LABEL: Record<string, string> = {
|
||||
courier_tracking: "delivery notice",
|
||||
invoice_receipt: "invoice",
|
||||
subscription: "subscription",
|
||||
booking: "booking",
|
||||
account_notice: "account notice",
|
||||
};
|
||||
|
||||
const EVENT_LABEL: Record<string, string> = {
|
||||
placed: "Order placed",
|
||||
shipped: "Dispatched",
|
||||
out_for_delivery: "Out for delivery",
|
||||
delivered: "Delivered",
|
||||
cancelled: "Cancelled",
|
||||
returned: "Returned",
|
||||
refunded: "Refunded",
|
||||
};
|
||||
|
||||
const dateFmt = new Intl.DateTimeFormat("en-AU", { day: "numeric", month: "short", year: "numeric" });
|
||||
|
||||
function fmtMoney(amount: string | null | undefined, currency: string) {
|
||||
if (amount === null || amount === undefined) return null;
|
||||
const n = Number(amount);
|
||||
if (!Number.isFinite(n)) return null;
|
||||
try {
|
||||
return new Intl.NumberFormat("en-AU", { style: "currency", currency, minimumFractionDigits: 2 }).format(n);
|
||||
} catch {
|
||||
return `${n.toFixed(2)} ${currency}`;
|
||||
}
|
||||
}
|
||||
|
||||
function Panel({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section>
|
||||
<h3 className="font-mono text-[10.5px] uppercase tracking-widest text-zinc-500 font-normal pb-2 mb-3 border-b border-zinc-800">
|
||||
{title}
|
||||
</h3>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OrderDetailPage({ params }: { params: Promise<{ entityKey: string }> }) {
|
||||
const { entityKey } = use(params);
|
||||
const key = decodeURIComponent(entityKey);
|
||||
const { data: o, isLoading, error } = useOrderDetail(key);
|
||||
|
||||
if (isLoading) return <div className="p-6 text-zinc-500 text-sm">Loading order…</div>;
|
||||
if (error || !o) {
|
||||
// A missing order and a broken query are different problems and must not
|
||||
// share a sentence. Only a 404 means "no such order"; anything else is this
|
||||
// page failing, and saying so is what sends the next person to the server
|
||||
// log instead of to the spine.
|
||||
const status = (error as (Error & { status?: number }) | null)?.status;
|
||||
return (
|
||||
<div className="max-w-[1180px] mx-auto">
|
||||
<Link href="/orders" className="font-mono text-[11.5px] text-indigo-400">← All orders</Link>
|
||||
{status === 404 ? (
|
||||
<p className="mt-6 text-zinc-400 text-sm">That order could not be found.</p>
|
||||
) : (
|
||||
<p className="mt-6 text-zinc-400 text-sm">
|
||||
This order could not be loaded{status ? ` (server error ${status})` : ""}. The order exists —
|
||||
something in this page failed. Check the finance-app log.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const gross = Number(o.gross_total ?? o.order_total ?? NaN);
|
||||
const refund = Number(o.refunded_amount ?? NaN);
|
||||
const partial = Number.isFinite(gross) && Number.isFinite(refund) && refund > 0 && refund < gross;
|
||||
const linkedTotal = o.transactions.reduce((a, t) => a + Math.abs(Number(t.amount) || 0), 0);
|
||||
|
||||
return (
|
||||
<div className="max-w-[1180px] mx-auto">
|
||||
<Link href="/orders" className="font-mono text-[11.5px] text-indigo-400 hover:text-indigo-300">← All orders</Link>
|
||||
|
||||
{/* A settled rail row must never look like an ordinary order — that is
|
||||
how a human counts the same purchase twice. */}
|
||||
{/* Board 211 — same shape as the settled-rail banner above: a real
|
||||
document with a real figure that is not a purchase. Saying which
|
||||
payments it restates is the point; the total alone reads as a
|
||||
single large order. */}
|
||||
{o.summarises_period && (
|
||||
<div className="mt-4 bg-zinc-900 border-l-2 border-amber-600 px-4 py-3">
|
||||
<span className="block font-mono text-[10px] uppercase tracking-widest text-amber-500 mb-1">
|
||||
Annual summary, not a purchase
|
||||
</span>
|
||||
<span className="text-[12.5px] text-zinc-300">
|
||||
This receipt restates payments already made across the year — the
|
||||
monthly debits are in the ledger under their own dates. It is kept
|
||||
for reference and excluded from spend totals.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{o.is_settled_duplicate && (
|
||||
<div className="mt-4 bg-zinc-900 border-l-2 border-indigo-600 px-4 py-3">
|
||||
<span className="block font-mono text-[10px] uppercase tracking-widest text-indigo-500 mb-1">
|
||||
Payment record, not a separate purchase
|
||||
</span>
|
||||
<span className="text-[12.5px] text-zinc-300">
|
||||
This restates another order and is deliberately excluded from spend totals.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<header className="border-b border-zinc-800 pb-5 mt-4 mb-6">
|
||||
<div className="font-mono text-[11.5px] text-zinc-400 tabular-nums">
|
||||
{o.platform}
|
||||
{o.order_reference && !o.order_reference.startsWith("msg-") && <> · order {o.order_reference}</>}
|
||||
{o.ordered_at && <> · {dateFmt.format(new Date(o.ordered_at))}</>}
|
||||
</div>
|
||||
<h2 className="font-display text-[27px] leading-tight text-zinc-50 my-3 max-w-[26ch] text-balance">
|
||||
{/* canonical_name is the ORDER title ("DJI Air 3 Fly More Combo") and
|
||||
is left alone; only the display_name fallback is a merchant name
|
||||
and wants tidying. */}
|
||||
{o.canonical_name || tidyMerchant(o.display_name ?? "") || "Order"}
|
||||
</h2>
|
||||
<div className="flex gap-2 flex-wrap mb-3">
|
||||
{o.content_class && KIND_LABEL[o.content_class] && (
|
||||
<span className="font-mono text-[10px] uppercase tracking-wider text-zinc-400 border border-zinc-700 rounded-sm px-2 py-0.5">
|
||||
{KIND_LABEL[o.content_class]}
|
||||
</span>
|
||||
)}
|
||||
{o.cadence_days && (
|
||||
<span className="font-mono text-[10px] uppercase tracking-wider text-indigo-400 border border-indigo-800 rounded-sm px-2 py-0.5">
|
||||
recurring · every ~{o.cadence_days} days
|
||||
{o.order_count ? ` · ${o.order_count} orders` : ""}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-6 flex-wrap font-mono text-[11.5px] text-zinc-400 tabular-nums">
|
||||
<div>
|
||||
<span className="block text-[10px] uppercase tracking-widest text-zinc-500 mb-0.5">
|
||||
{partial ? "Charged" : "Total"}
|
||||
</span>
|
||||
{fmtMoney(o.gross_total ?? o.order_total, o.currency) ?? <span className="italic text-zinc-500">not stated</span>}
|
||||
</div>
|
||||
{partial && (
|
||||
<div>
|
||||
<span className="block text-[10px] uppercase tracking-widest text-zinc-500 mb-0.5">Refunded</span>
|
||||
<span className="text-indigo-400">−{fmtMoney(o.refunded_amount, o.currency)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="block text-[10px] uppercase tracking-widest text-zinc-500 mb-0.5">Status</span>
|
||||
{o.status.replace(/_/g, " ")}
|
||||
</div>
|
||||
{o.merchant_name && (
|
||||
<div>
|
||||
<span className="block text-[10px] uppercase tracking-widest text-zinc-500 mb-0.5">Merchant</span>
|
||||
{/* Board 212: the list has tidied this since it shipped and the
|
||||
detail page did not, so following a row through showed
|
||||
"Apple" becoming "Apple Pty Ltd." Presentational only —
|
||||
merges nothing, and Amazon.in stays Amazon.in. */}
|
||||
{tidyMerchant(o.merchant_name)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="grid gap-7 lg:grid-cols-[1.15fr_1fr]">
|
||||
<div className="flex flex-col gap-7">
|
||||
<Panel title="Lifecycle">
|
||||
{/* Ordinal markers are legitimate HERE — the events genuinely are a
|
||||
sequence and order carries meaning. They are absent from the
|
||||
list, where they would only decorate. */}
|
||||
<ol className="list-none m-0 p-0">
|
||||
{o.events.map((e, i) => (
|
||||
<li key={e.fact_id} className="grid grid-cols-[26px_92px_1fr_auto] gap-3 items-baseline py-2 border-b border-zinc-800/60">
|
||||
<span className="font-mono text-[10.5px] text-indigo-600 tabular-nums">{String(i + 1).padStart(2, "0")}</span>
|
||||
<span className="font-mono text-[11.5px] text-zinc-400 tabular-nums">
|
||||
{e.effective_at ? dateFmt.format(new Date(e.effective_at)) : "—"}
|
||||
</span>
|
||||
<span className="text-[13px] text-zinc-100">
|
||||
{EVENT_LABEL[e.event_kind ?? ""] ?? e.event_kind ?? "Event"}
|
||||
</span>
|
||||
<span className="font-mono text-[11.5px] text-zinc-500 tabular-nums">
|
||||
{fmtMoney(e.amount, e.currency ?? o.currency) ?? "—"}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
{!o.events.length && <li className="py-2 text-[13px] text-zinc-500">No lifecycle events recorded.</li>}
|
||||
</ol>
|
||||
</Panel>
|
||||
|
||||
<Panel title={`Contents${o.line_items.length ? ` — ${o.line_items.length}` : ""}`}>
|
||||
{o.line_items.length ? (
|
||||
<ul className="list-none m-0 p-0">
|
||||
{o.line_items.map((li, i) => (
|
||||
<li key={i} className="flex justify-between gap-4 py-2 border-b border-zinc-800/60 text-[13px]">
|
||||
<span className="text-zinc-100">
|
||||
{li.description}
|
||||
{li.quantity ? <span className="font-mono text-[11px] text-zinc-500 ml-2">×{li.quantity}</span> : null}
|
||||
</span>
|
||||
<span className="font-mono text-[12.5px] text-zinc-300 tabular-nums whitespace-nowrap">
|
||||
{fmtMoney(li.amount != null ? String(li.amount) : null, o.currency) ?? ""}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-[13px] text-zinc-500 italic m-0">No itemised list on this receipt.</p>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-7">
|
||||
<Panel title={o.transactions.length ? `Payments — ${o.transactions.length} charge${o.transactions.length > 1 ? "s" : ""}` : "Payments"}>
|
||||
{o.transactions.length ? (
|
||||
<>
|
||||
<ul className="list-none m-0 p-0">
|
||||
{o.transactions.map((t) => (
|
||||
<li key={t.transaction_id} className="grid grid-cols-[1fr_auto] gap-3 items-baseline py-2 border-b border-zinc-800/60">
|
||||
<span className="font-mono text-[12px] text-zinc-300">
|
||||
{t.description}
|
||||
<small className="block text-[10.5px] text-zinc-500">
|
||||
{/* Board 205. Four identical "Afterpay" rows are
|
||||
indistinguishable without their position, and
|
||||
indistinguishable rows are what made this order
|
||||
look like four purchases. */}
|
||||
{t.leg_kind === "instalment" && t.leg_index && t.leg_count
|
||||
? `Instalment ${t.leg_index} of ${t.leg_count} · `
|
||||
: t.leg_kind === "shipment"
|
||||
? "Shipment · "
|
||||
: ""}
|
||||
{dateFmt.format(new Date(t.transaction_date))} · txn {t.transaction_id}
|
||||
</small>
|
||||
</span>
|
||||
<span className="font-mono text-[13px] text-zinc-50 tabular-nums">
|
||||
{fmtMoney(String(Math.abs(Number(t.amount))), o.currency)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex justify-between items-baseline pt-3 font-mono text-xs">
|
||||
<span className="uppercase tracking-widest text-[10px] text-zinc-500">Accounted for</span>
|
||||
<span className="text-indigo-300 text-sm tabular-nums">
|
||||
{fmtMoney(String(linkedTotal), o.currency)}
|
||||
{o.gross_total && <span className="text-zinc-500"> of {fmtMoney(o.gross_total, o.currency)}</span>}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-[13px] text-zinc-500 m-0 leading-relaxed">
|
||||
No transaction linked yet. The ledger is statement-fed, so a recent purchase has
|
||||
nothing to match against until its card statement is imported — often a few weeks.
|
||||
Charges only; refunds and fees are not linked in this phase.
|
||||
</p>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
{o.siblings.length > 0 && (
|
||||
<Panel title="Also recorded as">
|
||||
<ul className="list-none m-0 p-0">
|
||||
{o.siblings.map((s) => (
|
||||
<li key={s.entity_key} className="py-2 border-b border-zinc-800/60">
|
||||
<Link href={`/orders/${encodeURIComponent(s.entity_key)}`}
|
||||
className="text-[13px] text-zinc-100 hover:text-indigo-300">
|
||||
{s.canonical_name || s.entity_key}
|
||||
</Link>
|
||||
<span className="block font-mono text-[11px] text-zinc-500 tabular-nums">
|
||||
{s.relation === "settled_by"
|
||||
? "paid via this record"
|
||||
: "this record pays for that order"}
|
||||
{" · "}{s.platform}
|
||||
{s.order_total && <> · {fmtMoney(s.order_total, s.currency)}</>}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Panel>
|
||||
)}
|
||||
|
||||
<Panel title="Provenance">
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 font-mono text-[11.5px] m-0">
|
||||
<dt className="text-zinc-500">Key</dt>
|
||||
<dd className="text-zinc-300 m-0 break-all">{o.entity_key}</dd>
|
||||
<dt className="text-zinc-500">Reference</dt>
|
||||
<dd className="text-zinc-300 m-0">
|
||||
{o.reference_source === "message_id_fallback"
|
||||
? <span className="text-indigo-500">none printed — cannot merge with siblings</span>
|
||||
: (o.order_reference ?? "—")}
|
||||
</dd>
|
||||
<dt className="text-zinc-500">Lane</dt>
|
||||
<dd className="text-zinc-300 m-0">{o.lane}</dd>
|
||||
<dt className="text-zinc-500">Trust</dt>
|
||||
<dd className="text-zinc-300 m-0">{o.source_trust ?? "—"}</dd>
|
||||
</dl>
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,586 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useOrders } from "@/lib/hooks";
|
||||
import { tidyMerchant } from "@/lib/merchant-label";
|
||||
import type { OrderRow } from "@/lib/order-feed";
|
||||
|
||||
/**
|
||||
* Orders — browse the purchase history the ledger cannot show.
|
||||
*
|
||||
* The spine holds ~6,300 purchase orders back to 2006, ~4,600 of them
|
||||
* itemised, of which a few dozen reach a transaction. Everything else is
|
||||
* visible only here. The point of the page is the MANIFEST: /transactions can
|
||||
* only ever say "AMAZON AU SYDNEY"; this says what was in the box.
|
||||
*/
|
||||
|
||||
const LANES = [
|
||||
{ id: "retail", label: "Retail" },
|
||||
{ id: "food", label: "Food" },
|
||||
{ id: "transport", label: "Transport" },
|
||||
{ id: "digital", label: "Digital" },
|
||||
{ id: "services", label: "Services" },
|
||||
{ id: "grocery", label: "Grocery" },
|
||||
] as const;
|
||||
|
||||
// Twenty-one years is the archive, not the working set. Default to this year.
|
||||
type RangeKey = "m0" | "m1" | "m3" | "y0" | "y1" | "all" | string;
|
||||
const RANGES: { id: RangeKey; label: string }[] = [
|
||||
{ id: "m0", label: "This month" },
|
||||
{ id: "m1", label: "Last month" },
|
||||
{ id: "m3", label: "Last 3 months" },
|
||||
{ id: "y0", label: "This year" },
|
||||
{ id: "y1", label: "Last year" },
|
||||
{ id: "all", label: "All 21 years" },
|
||||
];
|
||||
|
||||
function iso(d: Date) {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function rangeFor(key: RangeKey): { from?: string; to?: string; label: string } {
|
||||
const now = new Date();
|
||||
const y = now.getFullYear();
|
||||
const m = now.getMonth();
|
||||
switch (key) {
|
||||
case "m0": return { from: iso(new Date(y, m, 1)), to: iso(now), label: "this month" };
|
||||
case "m1": return { from: iso(new Date(y, m - 1, 1)), to: iso(new Date(y, m, 0)), label: "last month" };
|
||||
case "m3": return { from: iso(new Date(y, m - 2, 1)), to: iso(now), label: "the last 3 months" };
|
||||
case "y0": return { from: `${y}-01-01`, to: iso(now), label: String(y) };
|
||||
case "y1": return { from: `${y - 1}-01-01`, to: `${y - 1}-12-31`, label: String(y - 1) };
|
||||
case "all": return { label: "2006–present" };
|
||||
default: {
|
||||
// A bar click scopes to that single year.
|
||||
const yr = Number(key);
|
||||
return Number.isFinite(yr)
|
||||
? { from: `${yr}-01-01`, to: `${yr}-12-31`, label: String(yr) }
|
||||
: { label: "2006–present" };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What the source document actually was. The classifier already knew — 188
|
||||
* orders came from courier_tracking documents and 147 from invoice_receipt —
|
||||
* and the first cut of this page showed all of them as retail purchases.
|
||||
* NULL means the interpretation index never saw it (48% of rows, the Takeout
|
||||
* backfill), which is "unknown", not "purchase" — so it gets no badge at all
|
||||
* rather than a confident wrong one.
|
||||
*/
|
||||
const KIND_LABEL: Record<string, string> = {
|
||||
courier_tracking: "delivery",
|
||||
invoice_receipt: "invoice",
|
||||
subscription: "subscription",
|
||||
booking: "booking",
|
||||
account_notice: "notice",
|
||||
};
|
||||
|
||||
const IN_FLIGHT = new Set(["ordered", "shipped", "out_for_delivery"]);
|
||||
const REVERSED = new Set(["refunded", "returned", "cancelled"]);
|
||||
|
||||
const dateFmt = new Intl.DateTimeFormat("en-AU", { day: "numeric", month: "short" });
|
||||
const yearFmt = new Intl.DateTimeFormat("en-AU", { year: "numeric" });
|
||||
|
||||
function fmtMoney(amount: string | null, currency: string) {
|
||||
if (amount === null || amount === undefined) return null;
|
||||
const n = Number(amount);
|
||||
if (!Number.isFinite(n)) return null;
|
||||
try {
|
||||
// narrowSymbol so AUD reads "A$" rather than a bare "$" — the spine holds
|
||||
// 20 currencies and an unqualified dollar sign is ambiguous across them.
|
||||
return new Intl.NumberFormat("en-AU", {
|
||||
style: "currency", currency, currencyDisplay: "narrowSymbol",
|
||||
minimumFractionDigits: 2,
|
||||
}).format(n).replace(/^\$/, "A$");
|
||||
} catch {
|
||||
// 20 currencies live in the spine, including a literal '$', XLM and MANA.
|
||||
// Intl throws on those; show the number and the raw code rather than
|
||||
// blowing up the row.
|
||||
return `${n.toFixed(2)} ${currency}`;
|
||||
}
|
||||
}
|
||||
|
||||
function StatusPill({ status }: { status: string }) {
|
||||
const live = IN_FLIGHT.has(status);
|
||||
const bad = REVERSED.has(status);
|
||||
const label = status.replace(/_/g, " ");
|
||||
const cls = live
|
||||
? "bg-indigo-600 text-zinc-950"
|
||||
: bad
|
||||
? "border border-zinc-500 text-zinc-400"
|
||||
: "border border-zinc-700 text-zinc-400";
|
||||
return (
|
||||
<span className={`inline-block px-2 py-0.5 rounded-sm text-[10px] font-mono uppercase tracking-wider whitespace-nowrap ${cls}`}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Food line items carry the full customisation — one Subway order runs to 450
|
||||
* characters listing every topping — and a raw dump swamps the row. Truncate
|
||||
* per item; the detail page shows them whole.
|
||||
*/
|
||||
const ITEM_MAX = 58;
|
||||
function shorten(d: string) {
|
||||
const clean = d.replace(/\s+/g, " ").trim();
|
||||
if (clean.length <= ITEM_MAX) return clean;
|
||||
// Cut at the first bracket if there is one — "Footlong (Italian Herb…" is
|
||||
// the product; everything inside the bracket is the customisation.
|
||||
const brk = clean.indexOf("(");
|
||||
if (brk > 12 && brk <= ITEM_MAX) return clean.slice(0, brk).trim() + "…";
|
||||
return clean.slice(0, ITEM_MAX).trimEnd() + "…";
|
||||
}
|
||||
|
||||
function Manifest({ row }: { row: OrderRow }) {
|
||||
const all = row.item_preview ?? [];
|
||||
const items = all.slice(0, 3);
|
||||
if (!items.length) {
|
||||
return <span className="text-xs text-zinc-500 italic">No itemised list on this receipt</span>;
|
||||
}
|
||||
const extra = row.line_item_count - items.length;
|
||||
return (
|
||||
<span className="text-[13px] text-zinc-300 leading-snug">
|
||||
{items.map((d, i) => (
|
||||
<span key={i} title={d}>
|
||||
{i > 0 && <span className="text-indigo-800 mx-1.5">·</span>}
|
||||
{shorten(d)}
|
||||
</span>
|
||||
))}
|
||||
{extra > 0 && <span className="font-mono text-xs text-indigo-500 ml-1.5">+{extra}</span>}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The title earns its own line only when it says something the manifest does
|
||||
* not. On 1,964 of 4,649 itemised rows (42%) the order title IS the single
|
||||
* line item — printing both rendered the same text twice.
|
||||
*/
|
||||
function showTitle(row: OrderRow): boolean {
|
||||
const t = row.canonical_name?.replace(/\s+/g, " ").trim();
|
||||
if (!t) return false;
|
||||
if (t === row.display_name) return false;
|
||||
const first = (row.item_preview ?? [])[0];
|
||||
if (!first) return true;
|
||||
const norm = (x: string) => x.toLowerCase().replace(/[^a-z0-9]/g, "").slice(0, 28);
|
||||
return norm(t) !== norm(first);
|
||||
}
|
||||
|
||||
function YearStrip({
|
||||
years, activeFrom, activeTo, onPick,
|
||||
}: {
|
||||
years: { year: number; n: number }[];
|
||||
activeFrom?: string;
|
||||
activeTo?: string;
|
||||
onPick: (y: number) => void;
|
||||
}) {
|
||||
if (!years.length) return <div className="h-[54px]" />;
|
||||
const max = Math.max(...years.map((y) => y.n));
|
||||
const lo = activeFrom ? Number(activeFrom.slice(0, 4)) : -Infinity;
|
||||
const hi = activeTo ? Number(activeTo.slice(0, 4)) : Infinity;
|
||||
return (
|
||||
<div className="flex items-end gap-[3px]">
|
||||
{years.map(({ year, n }) => {
|
||||
const on = year >= lo && year <= hi;
|
||||
return (
|
||||
<button
|
||||
key={year}
|
||||
onClick={() => onPick(year)}
|
||||
title={`${year} — ${n.toLocaleString()} orders`}
|
||||
aria-label={`${year}, ${n} orders`}
|
||||
aria-pressed={on}
|
||||
className="flex-1 min-w-0 flex flex-col items-center gap-1.5 group"
|
||||
>
|
||||
<span
|
||||
style={{ height: `${Math.max(2, Math.round((44 * n) / max))}px` }}
|
||||
className={`w-full rounded-[1px] transition-colors ${
|
||||
on ? "bg-indigo-400" : "bg-indigo-800 group-hover:bg-indigo-500"
|
||||
}`}
|
||||
/>
|
||||
<span className={`font-mono text-[9.5px] tabular-nums ${on ? "text-indigo-300" : "text-zinc-500 group-hover:text-zinc-300"}`}>
|
||||
{String(year).slice(2)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ row, expanded, onToggle }: { row: OrderRow; expanded: boolean; onToggle: () => void }) {
|
||||
const reversed = REVERSED.has(row.status);
|
||||
const gross = Number(row.order_total ?? NaN);
|
||||
const refund = Number(row.refunded_amount ?? NaN);
|
||||
const partial =
|
||||
Number.isFinite(gross) && Number.isFinite(refund) && refund > 0 && refund < gross;
|
||||
// A FULL reversal strikes the figure — the money all came back. A PARTIAL
|
||||
// refund must not: striking $191.40 when $13.33 came back is a lie. The
|
||||
// charge stays primary (it is what hit the card), the credit sits under it.
|
||||
const struck = reversed && !partial;
|
||||
const money = fmtMoney(row.order_total, row.currency);
|
||||
|
||||
return (
|
||||
<tr className={`border-b border-zinc-800/60 hover:bg-zinc-900 ${struck ? "opacity-60" : ""}`}>
|
||||
<td className="p-3 align-top font-mono text-[11px] text-zinc-400 tabular-nums whitespace-nowrap">
|
||||
{row.ordered_at ? (
|
||||
<>
|
||||
{dateFmt.format(new Date(row.ordered_at))}
|
||||
<span className="block text-[10px] text-zinc-500">{yearFmt.format(new Date(row.ordered_at))}</span>
|
||||
</>
|
||||
) : <span className="text-zinc-600">—</span>}
|
||||
</td>
|
||||
<td className="p-3 align-top">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{/* Disclosure only where there is something behind it. Food items run
|
||||
to 450 characters of customisation, so the row shows a short form
|
||||
and the expansion carries the whole receipt. */}
|
||||
{row.line_item_count > 0 && (
|
||||
<button
|
||||
onClick={onToggle}
|
||||
aria-expanded={expanded}
|
||||
aria-label={expanded ? "Hide items" : `Show all ${row.line_item_count} items`}
|
||||
className="text-zinc-500 hover:text-indigo-400 font-mono text-[10px] w-3 shrink-0"
|
||||
>{expanded ? "▾" : "▸"}</button>
|
||||
)}
|
||||
<Link
|
||||
href={`/orders/${encodeURIComponent(row.entity_key)}`}
|
||||
className="text-[13.5px] text-zinc-50 hover:text-indigo-300"
|
||||
>
|
||||
{tidyMerchant(row.display_name)}
|
||||
</Link>
|
||||
{row.content_class && KIND_LABEL[row.content_class] && (
|
||||
<span className="font-mono text-[9.5px] uppercase tracking-wide text-zinc-400 border border-zinc-700 rounded-sm px-1.5">
|
||||
{KIND_LABEL[row.content_class]}
|
||||
</span>
|
||||
)}
|
||||
{row.cadence_days && (
|
||||
<span
|
||||
title={`Orders from this merchant arrive on a regular cadence — derived from the gaps between them, not stated anywhere in the mail.`}
|
||||
className="font-mono text-[9.5px] uppercase tracking-wide text-indigo-400 border border-indigo-800 rounded-sm px-1.5"
|
||||
>
|
||||
every ~{row.cadence_days}d
|
||||
</span>
|
||||
)}
|
||||
{row.reference_source === "message_id_fallback" && (
|
||||
<span
|
||||
title="No order reference in this mail — it cannot merge with its lifecycle siblings, so the same purchase may appear twice."
|
||||
className="font-mono text-[9.5px] uppercase tracking-wide text-indigo-600 border border-indigo-800 rounded-sm px-1.5"
|
||||
>no ref</span>
|
||||
)}
|
||||
{/* NOT source_trust — that is 'untrusted_external' on 100% of rows,
|
||||
because every order here came from email, so badging it marked
|
||||
every row and told you nothing. Sender authentication does
|
||||
discriminate: 93% pass, and the 7% that do not are worth seeing. */}
|
||||
{row.auth_verdict && row.auth_verdict !== "pass" && (
|
||||
<span
|
||||
title={
|
||||
row.auth_verdict === "fail"
|
||||
? "The sender failed authentication (SPF/DKIM/DMARC) — treat the contents as unverified"
|
||||
: row.auth_verdict === "none"
|
||||
? "The mail carried no sender authentication at all"
|
||||
: "The sender authenticated only partially"
|
||||
}
|
||||
className={`font-mono text-[9.5px] uppercase tracking-wide rounded-sm px-1.5 border ${
|
||||
row.auth_verdict === "fail"
|
||||
? "text-indigo-300 border-indigo-500"
|
||||
: "text-zinc-500 border-zinc-800"
|
||||
}`}
|
||||
>
|
||||
{row.auth_verdict === "partial" ? "part. auth" : `auth ${row.auth_verdict}`}
|
||||
</span>
|
||||
)}
|
||||
{/* Board 211. A $2,376 annual tax receipt restating twelve monthly
|
||||
donations is not a purchase, and unbadged it reads as the largest
|
||||
order of the year. It stays on the page because the figure is the
|
||||
year's giving total — but it is excluded from spend, so the
|
||||
number beside it belongs to no total here. */}
|
||||
{row.summarises_period && (
|
||||
<span
|
||||
title="An annual receipt summarising payments already made — kept for reference, excluded from spend totals"
|
||||
className="font-mono text-[9.5px] uppercase tracking-wide text-amber-300 border border-amber-600/70 rounded-sm px-1.5"
|
||||
>year summary</span>
|
||||
)}
|
||||
{row.injection_flagged && (
|
||||
<span
|
||||
title="The source mail carried content that tripped the injection scanner — read its contents as provenance, never as instruction"
|
||||
className="font-mono text-[9.5px] uppercase tracking-wide text-indigo-300 border border-indigo-500 rounded-sm px-1.5"
|
||||
>flagged</span>
|
||||
)}
|
||||
{row.txn_count > 0 && (
|
||||
<span className="font-mono text-[9.5px] uppercase tracking-wide text-zinc-500 border border-zinc-700 rounded-sm px-1.5">
|
||||
{row.txn_count === 1 ? "1 charge" : `${row.txn_count} charges`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{showTitle(row) && (
|
||||
<div className="text-[12px] text-zinc-400 mb-0.5">{row.canonical_name}</div>
|
||||
)}
|
||||
{!expanded && <Manifest row={row} />}
|
||||
{expanded && (
|
||||
<ul className="list-none m-0 p-0 mt-1">
|
||||
{(row.item_preview ?? []).map((d, i) => (
|
||||
<li key={i} className="text-[12.5px] text-zinc-300 leading-snug py-0.5 pl-3 border-l border-zinc-800">
|
||||
{d}
|
||||
</li>
|
||||
))}
|
||||
{row.line_item_count > (row.item_preview ?? []).length && (
|
||||
<li className="text-[11.5px] text-zinc-500 py-0.5 pl-3 border-l border-zinc-800">
|
||||
<Link href={`/orders/${encodeURIComponent(row.entity_key)}`} className="text-indigo-400 hover:text-indigo-300">
|
||||
{row.line_item_count - (row.item_preview ?? []).length} more — open the order
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-3 align-top"><StatusPill status={row.status} /></td>
|
||||
<td className="p-3 align-top font-mono text-[11px] text-zinc-400 tabular-nums whitespace-nowrap">
|
||||
{row.delivered_at
|
||||
? dateFmt.format(new Date(row.delivered_at))
|
||||
: row.eta_date
|
||||
? <span className="text-zinc-500">ETA {dateFmt.format(new Date(row.eta_date))}</span>
|
||||
: <span className="text-zinc-600">—</span>}
|
||||
</td>
|
||||
<td className="p-3 align-top text-right font-mono text-[13.5px] text-zinc-50 tabular-nums whitespace-nowrap">
|
||||
{money
|
||||
? <span className={struck ? "line-through decoration-indigo-600" : ""}>{money}</span>
|
||||
: <span className="text-zinc-500 italic text-xs">not stated</span>}
|
||||
{partial && (
|
||||
<>
|
||||
<span className="block text-[11px] text-indigo-400 mt-0.5">
|
||||
−{fmtMoney(row.refunded_amount, row.currency)} refunded
|
||||
</span>
|
||||
<span className="block text-[10.5px] text-zinc-500">
|
||||
net {fmtMoney(String(gross - refund), row.currency)}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function OrdersContent() {
|
||||
const [lane, setLane] = useState<string>("retail");
|
||||
const [rangeKey, setRangeKey] = useState<RangeKey>("y0");
|
||||
const [search, setSearch] = useState("");
|
||||
const [showLifecycle, setShowLifecycle] = useState(false);
|
||||
const [platform, setPlatform] = useState<string>("");
|
||||
const [status, setStatus] = useState<string>("");
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
|
||||
const range = useMemo(() => rangeFor(rangeKey), [rangeKey]);
|
||||
const limit = 50;
|
||||
|
||||
const filters = useMemo(() => ({
|
||||
lane,
|
||||
from: range.from,
|
||||
to: range.to,
|
||||
search: search || undefined,
|
||||
platforms: platform ? [platform] : undefined,
|
||||
statuses: status ? [status] : undefined,
|
||||
show_lifecycle_only: showLifecycle,
|
||||
limit,
|
||||
offset,
|
||||
}), [lane, range.from, range.to, search, platform, status, showLifecycle, offset]);
|
||||
|
||||
const { data, isLoading, error } = useOrders(filters);
|
||||
|
||||
const set = <T,>(fn: (v: T) => void) => (v: T) => { fn(v); setOffset(0); };
|
||||
const laneCount = (id: string) => data?.facets.lanes.find((l) => l.lane === id)?.n ?? 0;
|
||||
const page = Math.floor(offset / limit) + 1;
|
||||
const pages = Math.max(1, Math.ceil((data?.total ?? 0) / limit));
|
||||
|
||||
return (
|
||||
<div className="max-w-[1180px] mx-auto">
|
||||
<div className="flex items-baseline justify-between gap-4 flex-wrap mb-1">
|
||||
<h2 className="text-[30px] font-display text-zinc-50 tracking-tight leading-none">
|
||||
Orders<span className="text-indigo-400">.</span>
|
||||
</h2>
|
||||
<div className="font-mono text-[11.5px] text-zinc-400 tabular-nums">
|
||||
{data ? (
|
||||
<>
|
||||
<span className="text-zinc-200">{data.total.toLocaleString()}</span> orders in{" "}
|
||||
<span className="text-zinc-200">{range.label}</span>
|
||||
{" · "}{data.facets.all_time.toLocaleString()} all time
|
||||
{data.facets.all_time > 0 && (
|
||||
<>{" · "}<span className="text-zinc-200">
|
||||
{Math.round((100 * data.facets.with_amount) / data.facets.all_time)}%
|
||||
</span> with a known amount</>
|
||||
)}
|
||||
</>
|
||||
) : "—"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* THE HERO. Twenty-one years of buying, and the date filter, are the
|
||||
same object: the strip shows where the active range sits in the whole
|
||||
run, and a bar is how you reach 2016. A spend total would be the
|
||||
template answer here and would also be a lie — 16% of orders have no
|
||||
amount. */}
|
||||
<div className="border-y border-zinc-800 py-3.5 mb-5">
|
||||
<div className="flex items-baseline justify-between mb-3">
|
||||
<span className="font-mono text-[10.5px] uppercase tracking-[0.12em] text-zinc-500">
|
||||
Ordered — {range.label}
|
||||
</span>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{RANGES.map((r) => (
|
||||
<button
|
||||
key={r.id}
|
||||
onClick={() => set(setRangeKey)(r.id)}
|
||||
aria-pressed={rangeKey === r.id}
|
||||
className={`font-mono text-[10.5px] tracking-wide px-2.5 py-1 rounded-sm border ${
|
||||
rangeKey === r.id
|
||||
? "bg-indigo-600 border-indigo-600 text-zinc-950"
|
||||
: "border-zinc-800 text-zinc-400 hover:border-zinc-600 hover:text-zinc-100"
|
||||
}`}
|
||||
>{r.label}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<YearStrip
|
||||
years={data?.facets.years ?? []}
|
||||
activeFrom={range.from}
|
||||
activeTo={range.to}
|
||||
onPick={(y) => set(setRangeKey)(String(y) as RangeKey)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-0.5 flex-wrap mb-3" role="tablist" aria-label="Order lanes">
|
||||
{LANES.map((l) => (
|
||||
<button
|
||||
key={l.id}
|
||||
role="tab"
|
||||
aria-selected={lane === l.id}
|
||||
onClick={() => set(setLane)(l.id)}
|
||||
className={`flex items-baseline gap-2 px-3 py-1.5 text-[13px] border-b-2 ${
|
||||
lane === l.id
|
||||
? "text-zinc-50 border-indigo-500"
|
||||
: "text-zinc-400 border-transparent hover:text-zinc-100"
|
||||
}`}
|
||||
>
|
||||
{l.label}
|
||||
<span className={`font-mono text-[11px] tabular-nums ${lane === l.id ? "text-indigo-400" : "text-zinc-500"}`}>
|
||||
{laneCount(l.id).toLocaleString()}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2.5 items-center flex-wrap py-3 border-b border-zinc-800">
|
||||
<input
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(e) => set(setSearch)(e.target.value)}
|
||||
placeholder="Search items, merchants, order references…"
|
||||
aria-label="Search orders"
|
||||
className="flex-1 min-w-[200px] bg-zinc-900 border border-zinc-800 rounded-sm px-3 py-1.5 text-[13px] text-zinc-100 placeholder:text-zinc-500 focus:border-indigo-600 focus:outline-none"
|
||||
/>
|
||||
<select
|
||||
value={platform}
|
||||
onChange={(e) => set(setPlatform)(e.target.value)}
|
||||
aria-label="Platform"
|
||||
className="bg-zinc-900 border border-zinc-800 rounded-sm px-2 py-1.5 text-xs text-zinc-300"
|
||||
>
|
||||
<option value="">All platforms</option>
|
||||
{data?.facets.platforms.map((p) => (
|
||||
<option key={p.platform} value={p.platform}>{p.platform} ({p.n})</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={status}
|
||||
onChange={(e) => set(setStatus)(e.target.value)}
|
||||
aria-label="Status"
|
||||
className="bg-zinc-900 border border-zinc-800 rounded-sm px-2 py-1.5 text-xs text-zinc-300"
|
||||
>
|
||||
<option value="">Any status</option>
|
||||
{data?.facets.statuses.map((s) => (
|
||||
<option key={s.status} value={s.status}>{s.status.replace(/_/g, " ")} ({s.n})</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="flex items-center gap-2 text-xs text-zinc-400 cursor-pointer select-none"
|
||||
title="Rows with no amount, no order reference and one lifecycle event are not purchases — they are a second entity minted from a mail describing an order that already exists. 620 remain: the review-request mails have been retired and the ones whose parent could be identified have been joined to it, so what is left is mail whose parent is not in the corpus.">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showLifecycle}
|
||||
onChange={(e) => set(setShowLifecycle)(e.target.checked)}
|
||||
className="accent-indigo-500 cursor-pointer"
|
||||
/>
|
||||
Show lifecycle-only records
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse min-w-[720px]">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800">
|
||||
{["Ordered", "Merchant and contents", "Status", "Arrived", "Amount"].map((h, i) => (
|
||||
<th key={h}
|
||||
className={`p-3 pb-2 font-mono text-[10px] uppercase tracking-widest text-zinc-500 font-normal whitespace-nowrap ${i === 4 ? "text-right" : "text-left"}`}>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading && (
|
||||
<tr><td colSpan={5} className="p-10 text-center text-zinc-500 text-[13px]">Loading orders…</td></tr>
|
||||
)}
|
||||
{error && (
|
||||
<tr><td colSpan={5} className="p-10 text-center text-zinc-400 text-[13px]">
|
||||
Could not load orders. The spine views may not be migrated yet — apply migration 018.
|
||||
</td></tr>
|
||||
)}
|
||||
{data?.data.length === 0 && (
|
||||
<tr><td colSpan={5} className="p-10 text-zinc-500 text-[13px]">
|
||||
No orders in {range.label} match these filters. Widen the date range or clear the search.
|
||||
</td></tr>
|
||||
)}
|
||||
{data?.data.map((r) => (
|
||||
<Row
|
||||
key={r.entity_key}
|
||||
row={r}
|
||||
expanded={expanded.has(r.entity_key)}
|
||||
onToggle={() =>
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(r.entity_key)) next.delete(r.entity_key);
|
||||
else next.add(r.entity_key);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{(data?.total ?? 0) > limit && (
|
||||
<div className="flex items-center justify-between gap-4 pt-4 font-mono text-[11.5px] text-zinc-400 tabular-nums">
|
||||
<button
|
||||
onClick={() => setOffset(Math.max(0, offset - limit))}
|
||||
disabled={offset === 0}
|
||||
className="px-3 py-1 border border-zinc-800 rounded-sm disabled:opacity-40 hover:border-zinc-600"
|
||||
>← Previous</button>
|
||||
<span>Page {page} of {pages}</span>
|
||||
<button
|
||||
onClick={() => setOffset(offset + limit)}
|
||||
disabled={page >= pages}
|
||||
className="px-3 py-1 border border-zinc-800 rounded-sm disabled:opacity-40 hover:border-zinc-600"
|
||||
>Next →</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OrdersPage() {
|
||||
return (
|
||||
<Suspense fallback={<div className="p-6 text-zinc-500 text-sm">Loading…</div>}>
|
||||
<OrdersContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -141,7 +141,7 @@ export default function ReconcilePage() {
|
||||
<div className="border border-zinc-800 rounded-xl overflow-hidden">
|
||||
<div className="px-4 py-3 bg-zinc-900/60 border-b border-zinc-800">
|
||||
<p className="text-sm font-medium text-zinc-400">No statement matches found ({noMatches.length})</p>
|
||||
<p className="text-xs text-zinc-600 mt-0.5">These may not have hit a statement yet, or the statement hasn't been imported.</p>
|
||||
<p className="text-xs text-zinc-600 mt-0.5">These may not have hit a statement yet, or the statement hasn't been imported.</p>
|
||||
</div>
|
||||
<div className="divide-y divide-zinc-800">
|
||||
{noMatches.map((tx) => (
|
||||
|
||||
+298
-60
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Fragment, memo, useCallback, useMemo, useState, useRef, useEffect } from "react";
|
||||
import {
|
||||
useSharedTransactions,
|
||||
useParticipantBalances,
|
||||
@@ -11,13 +11,21 @@ import {
|
||||
useDeletePayment,
|
||||
useCurrentUser,
|
||||
useTags,
|
||||
useTrips,
|
||||
type SplitPayment,
|
||||
} from "@/lib/hooks";
|
||||
import type { SharedTransactionRow } from "@/lib/queries";
|
||||
import { EditTransactionModal } from "@/components/edit-transaction-modal";
|
||||
import { OrderDetails } from "@/components/order-details";
|
||||
import { formatCategory } from "@/lib/categories";
|
||||
import { CATEGORY_COLORS } from "@/lib/category-colors";
|
||||
|
||||
// One formatter, reused. `toLocaleDateString` constructs a fresh
|
||||
// Intl.DateTimeFormat per call, and this page renders every split row at once —
|
||||
// 2,558 calls per render was 520ms of the expand-click freeze on its own.
|
||||
const DATE_FMT = new Intl.DateTimeFormat("en-AU", { day: "numeric", month: "short", year: "numeric" });
|
||||
function formatDate(d: string) {
|
||||
return new Date(d).toLocaleDateString("en-AU", { day: "numeric", month: "short", year: "numeric" });
|
||||
return DATE_FMT.format(new Date(d));
|
||||
}
|
||||
|
||||
const SPEND_TYPES = new Set(["debit", "fee", "interest"]);
|
||||
@@ -147,6 +155,7 @@ function RecordPaymentModal({
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const record = useRecordPayment();
|
||||
const { data: trips = [] } = useTrips();
|
||||
const theyOweMe = currentBalance > 0;
|
||||
|
||||
// Default direction matches the debt direction
|
||||
@@ -155,6 +164,8 @@ function RecordPaymentModal({
|
||||
const [notes, setNotes] = useState("");
|
||||
// direction: "received" = they paid me, "sent" = I paid them
|
||||
const [direction, setDirection] = useState<"received" | "sent">(theyOweMe ? "received" : "sent");
|
||||
// Which tab this settles. "" = the ongoing household tab (trip_id NULL).
|
||||
const [tripId, setTripId] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function handleSave() {
|
||||
@@ -168,6 +179,7 @@ function RecordPaymentModal({
|
||||
amount: amt,
|
||||
payment_date: date,
|
||||
notes: notes || undefined,
|
||||
trip_id: tripId ? Number(tripId) : null,
|
||||
});
|
||||
onClose();
|
||||
} catch (e) {
|
||||
@@ -216,6 +228,24 @@ function RecordPaymentModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scope. Until now every payment recorded here landed on the household
|
||||
tab, because the API dropped trip_id — so a $11k Europe settlement
|
||||
silently reduced the ongoing household balance instead. */}
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Settles</label>
|
||||
<select value={tripId} onChange={(e) => setTripId(e.target.value)}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm">
|
||||
<option value="">Household (ongoing)</option>
|
||||
{trips.filter((t) => !t.archived).map((t) => (
|
||||
<option key={t.id} value={t.id}>{t.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-[11px] text-zinc-600 mt-1">
|
||||
Covering more than one tab? Record it once per tab — the parts add back
|
||||
up to the transfer.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Notes (optional)</label>
|
||||
<input value={notes} onChange={(e) => setNotes(e.target.value)}
|
||||
@@ -259,6 +289,12 @@ function PaymentHistory({ participantId, currentUserId }: { participantId: numbe
|
||||
{theyPaidMe ? "+" : "-"}${Number(p.amount).toFixed(2)}
|
||||
</span>
|
||||
<span className="text-zinc-500">{formatDate(p.payment_date)}</span>
|
||||
{/* Scope, so a grouped transfer stops looking like a duplicate: two
|
||||
rows of the same amount and date differ only by which tab they
|
||||
settle, and that was invisible until the API returned trip_id. */}
|
||||
<span className="text-[11px] px-1.5 py-0.5 rounded bg-zinc-800 text-zinc-400 flex-shrink-0">
|
||||
{p.trip_name ?? "Household"}
|
||||
</span>
|
||||
{p.notes && <span className="text-zinc-600 truncate flex-1">{p.notes}</span>}
|
||||
<button
|
||||
onClick={() => deletePayment.mutate(p.id)}
|
||||
@@ -274,33 +310,187 @@ function PaymentHistory({ participantId, currentUserId }: { participantId: numbe
|
||||
);
|
||||
}
|
||||
|
||||
// ── Split transaction row ─────────────────────────────────────────────────────
|
||||
// Memoized: this table renders every split row at once (1,279 today, no
|
||||
// pagination), so any state change on the page — expanding a receipt, each of
|
||||
// its two query results arriving — re-rendered all of them, ~800ms per pass on
|
||||
// a fast machine and a multi-second browser freeze on a slow one. With memo,
|
||||
// only the row whose `isExpanded` flipped re-renders. Every prop must stay
|
||||
// referentially stable: `tx` objects come straight out of the query cache, and
|
||||
// the callbacks are a state setter and a useCallback.
|
||||
const SharedTxRow = memo(function SharedTxRow({
|
||||
tx,
|
||||
isExpanded,
|
||||
meId,
|
||||
onToggle,
|
||||
onEdit,
|
||||
}: {
|
||||
tx: SharedTransactionRow;
|
||||
isExpanded: boolean;
|
||||
meId: number | undefined;
|
||||
onToggle: (id: number) => void;
|
||||
onEdit: (tx: SharedTransactionRow) => void;
|
||||
}) {
|
||||
const splits = Array.isArray(tx.splits) ? tx.splits : [];
|
||||
return (
|
||||
<Fragment>
|
||||
<tr className="border-b border-zinc-800/50 hover:bg-zinc-800/30">
|
||||
<td className="px-4 py-3 text-zinc-400 whitespace-nowrap">{formatDate(tx.transaction_date)}</td>
|
||||
<td className="px-4 py-3 text-zinc-500 text-xs whitespace-nowrap">{formatDate(tx.created_at)}</td>
|
||||
<td className="px-4 py-3 max-w-xs sticky left-0 z-10 bg-zinc-900 border-r border-zinc-800/80">
|
||||
<div className="flex items-start gap-1.5">
|
||||
{tx.order_platform && (
|
||||
<button
|
||||
onClick={() => onToggle(tx.id)}
|
||||
className="text-zinc-600 hover:text-zinc-300 leading-none mt-0.5 shrink-0"
|
||||
title={isExpanded ? "Hide receipt" : "Show the receipt this came from"}
|
||||
aria-expanded={isExpanded}
|
||||
>
|
||||
{isExpanded ? "▾" : "▸"}
|
||||
</button>
|
||||
)}
|
||||
<p className="font-medium break-words">{tx.effective_merchant || tx.description}</p>
|
||||
</div>
|
||||
{tx.effective_merchant && (
|
||||
<p className="text-xs text-zinc-500 break-words">{tx.description}</p>
|
||||
)}
|
||||
{tx.notes && (
|
||||
<p className="text-xs text-zinc-500 italic mt-0.5 break-words">{tx.notes}</p>
|
||||
)}
|
||||
</td>
|
||||
{/* Category is the effective one — the override wins over the
|
||||
extracted value, the same COALESCE every other view uses,
|
||||
so a correction made elsewhere shows up here too. */}
|
||||
<td className="px-4 py-3 whitespace-nowrap">
|
||||
{tx.effective_category ? (
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5 text-xs text-zinc-300"
|
||||
title={formatCategory(tx.effective_category)}
|
||||
>
|
||||
<span
|
||||
className="w-1.5 h-1.5 rounded-full flex-shrink-0"
|
||||
style={{ background: CATEGORY_COLORS[tx.effective_category] ?? "#71717a" }}
|
||||
/>
|
||||
{formatCategory(tx.effective_category)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-zinc-600 italic">uncategorised</span>
|
||||
)}
|
||||
</td>
|
||||
<td className={`px-4 py-3 text-right font-medium tabular-nums ${SPEND_TYPES.has(tx.transaction_type) ? "" : "text-green-400"}`}>
|
||||
{formatAmount(tx.amount, tx.transaction_type, tx.currency)}
|
||||
{tx.currency !== "AUD" && (
|
||||
// Splits settle on the AUD figure, so show it next to the
|
||||
// native one rather than leaving the two to differ silently.
|
||||
<span className="block text-xs font-normal text-zinc-500">
|
||||
{tx.amount_unconverted
|
||||
? "AUD value unknown"
|
||||
: `≈ ${formatAmount(Number(tx.amount_aud), tx.transaction_type, "AUD")} AUD`}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
{/* Whose money actually left. This is the effective owner —
|
||||
COALESCE(t.owner_id, s.owner_id) — so it is the account the
|
||||
spend came out of, which is what every balance on this page
|
||||
is computed from. "Me" matches the split chips rather than
|
||||
printing your own name twice in one row. */}
|
||||
<td className="px-4 py-3 whitespace-nowrap">
|
||||
<span className={`text-xs ${tx.owner_id === meId ? "text-zinc-400" : "text-indigo-300"}`}>
|
||||
{tx.owner_id === meId ? "Me" : tx.owner_name}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{splits.map((s) => (
|
||||
<span key={s.participant_id}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-zinc-800 text-zinc-300">
|
||||
{s.participant_id === meId ? "Me" : s.name} {s.share_percent}%
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => onEdit(tx)}
|
||||
className="text-xs text-zinc-500 hover:text-zinc-200 px-2 py-0.5 rounded hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{isExpanded && (
|
||||
<tr className="border-b border-zinc-800/50 bg-zinc-900/40">
|
||||
<td colSpan={8} className="px-4 py-3">
|
||||
<OrderDetails transactionId={tx.id} currency={tx.currency ?? null} bare />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
});
|
||||
|
||||
// ── Main page ─────────────────────────────────────────────────────────────────
|
||||
type SortCol = "transaction_date" | "created_at" | "amount";
|
||||
|
||||
/**
|
||||
* Sort direction marker for a column header.
|
||||
*
|
||||
* Declared at module scope and given the sort state as props, rather than
|
||||
* closing over it inside SharedPage: a component defined during render is a
|
||||
* new type on every render, so React unmounts and remounts it each time
|
||||
* instead of updating it.
|
||||
*/
|
||||
function SortIcon({ col, sortCol, sortDir }: { col: SortCol; sortCol: SortCol; sortDir: "asc" | "desc" }) {
|
||||
if (sortCol !== col) return <span className="text-zinc-600 ml-0.5">↕</span>;
|
||||
return <span className="ml-0.5">{sortDir === "desc" ? "↓" : "↑"}</span>;
|
||||
}
|
||||
|
||||
export default function SharedPage() {
|
||||
const [tagIds, setTagIds] = useState<string[]>([]);
|
||||
const [participantId, setParticipantId] = useState<number | undefined>(undefined);
|
||||
const [sortCol, setSortCol] = useState<SortCol>("transaction_date");
|
||||
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
|
||||
const [search, setSearch] = useState("");
|
||||
const realTagIds = tagIds.filter((id) => id !== "untagged");
|
||||
const { data: participants = [] } = useParticipants();
|
||||
const { data: rawTransactions = [], isLoading: txLoading } = useSharedTransactions(tagIds, participantId);
|
||||
|
||||
const transactions = [...rawTransactions].sort((a, b) => {
|
||||
// Filtered client-side, like the sort above and unlike the transactions page.
|
||||
// This endpoint returns every split row in one go (1,267 today) with no
|
||||
// pagination, so there is nothing for a server round-trip to narrow — and a
|
||||
// server search would have to be added to a query the balance cards share.
|
||||
//
|
||||
// Deliberately does NOT match participant names: the participant dropdown
|
||||
// already does that properly, and typing "sonu" matching every row she is
|
||||
// split on would make the box look broken. The payer IS matched, because
|
||||
// nothing else on the page filters by who paid.
|
||||
const transactions = useMemo(
|
||||
() =>
|
||||
[...rawTransactions]
|
||||
.filter((tx) => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
return [
|
||||
tx.description,
|
||||
tx.effective_merchant,
|
||||
tx.notes,
|
||||
tx.effective_category ? formatCategory(tx.effective_category) : null,
|
||||
tx.owner_name,
|
||||
].some((f) => f?.toLowerCase().includes(q));
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const av = sortCol === "amount" ? Number(a.amount) : new Date(a[sortCol]).getTime();
|
||||
const bv = sortCol === "amount" ? Number(b.amount) : new Date(b[sortCol]).getTime();
|
||||
return sortDir === "desc" ? bv - av : av - bv;
|
||||
});
|
||||
}),
|
||||
[rawTransactions, search, sortCol, sortDir]
|
||||
);
|
||||
|
||||
function toggleSort(col: SortCol) {
|
||||
if (sortCol === col) setSortDir((d) => (d === "desc" ? "asc" : "desc"));
|
||||
else { setSortCol(col); setSortDir("desc"); }
|
||||
}
|
||||
|
||||
function SortIcon({ col }: { col: SortCol }) {
|
||||
if (sortCol !== col) return <span className="text-zinc-600 ml-0.5">↕</span>;
|
||||
return <span className="ml-0.5">{sortDir === "desc" ? "↓" : "↑"}</span>;
|
||||
}
|
||||
const { data: balances = [], isLoading: balLoading } = useParticipantBalances(realTagIds);
|
||||
const { data: allTags = [] } = useTags();
|
||||
const { data: me } = useCurrentUser();
|
||||
@@ -317,12 +507,55 @@ export default function SharedPage() {
|
||||
const [paymentModal, setPaymentModal] = useState<{ id: number; name: string; balance: number } | null>(null);
|
||||
const [showHistory, setShowHistory] = useState<number | null>(null);
|
||||
const [editModal, setEditModal] = useState<SharedTransactionRow | null>(null);
|
||||
// Rendering is windowed even though the data is not: the query returns every
|
||||
// split row (1,279 as of 2026-08, growing ~600/yr) so search and sort stay
|
||||
// instant over full history, but the DOM stops at visibleCount — an unbounded
|
||||
// table was what made expanding a receipt freeze the browser. "Show more"
|
||||
// extends the window; changing any filter resets it.
|
||||
const [visibleCount, setVisibleCount] = useState(100);
|
||||
// Reset during render behind a previous-value guard rather than in an effect.
|
||||
// Note the key joins `tagIds` by value: as a raw dependency it was a fresh
|
||||
// array on many renders, so the window was being reset more often than
|
||||
// "changing any filter" claims — including after "Show more", which is the
|
||||
// one interaction this must not undo.
|
||||
const windowKey = `${search}|${sortCol}|${sortDir}|${tagIds.join(",")}|${participantId ?? ""}`;
|
||||
const [lastWindowKey, setLastWindowKey] = useState(windowKey);
|
||||
if (windowKey !== lastWindowKey) {
|
||||
setLastWindowKey(windowKey);
|
||||
setVisibleCount(100);
|
||||
}
|
||||
const visible = (transactions as SharedTransactionRow[]).slice(0, visibleCount);
|
||||
|
||||
// Receipt expansion, same pattern as the transactions page. The shared
|
||||
// viewer is a split participant, so /api/transactions/[id]/order
|
||||
// authorises them — the item list is part of what was shared.
|
||||
const [expanded, setExpanded] = useState<Set<number>>(new Set());
|
||||
// Stable identity so SharedTxRow's memo holds — an inline closure here would
|
||||
// change every render and re-render all 1,279 rows anyway.
|
||||
const toggleExpanded = useCallback((id: number) => {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-2xl font-display">Shared Expenses</h2>
|
||||
<div className="flex items-center gap-2 ml-auto flex-wrap">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search description, merchant, category, payer…"
|
||||
aria-label="Search split transactions"
|
||||
className="w-64 bg-zinc-800 border border-zinc-700 rounded-lg pl-8 pr-2 py-1.5 text-sm placeholder:text-zinc-600 focus:outline-none focus:border-zinc-500"
|
||||
/>
|
||||
<span className="absolute left-2.5 top-1/2 -translate-y-1/2 text-zinc-500 text-sm pointer-events-none">⌕</span>
|
||||
</div>
|
||||
<select
|
||||
value={participantId ?? ""}
|
||||
onChange={(e) => setParticipantId(e.target.value ? Number(e.target.value) : undefined)}
|
||||
@@ -415,94 +648,99 @@ export default function SharedPage() {
|
||||
|
||||
{/* Transaction list */}
|
||||
<div className="bg-zinc-900 border border-zinc-700 rounded-xl overflow-x-auto">
|
||||
<div className="px-4 py-3 border-b border-zinc-800">
|
||||
<div className="px-4 py-3 border-b border-zinc-800 flex items-center gap-2">
|
||||
<h3 className="text-sm font-medium">Split Transactions</h3>
|
||||
{search.trim() && !txLoading && (
|
||||
<span className="text-xs text-zinc-500">
|
||||
{transactions.length} of {rawTransactions.length} match “{search.trim()}”
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{txLoading ? (
|
||||
<p className="text-zinc-500 text-sm px-4 py-6">Loading...</p>
|
||||
) : transactions.length === 0 ? (
|
||||
// "None yet" is wrong when a search simply matched nothing, and it reads
|
||||
// as though the splits were lost.
|
||||
search.trim() ? (
|
||||
<p className="text-zinc-500 text-sm px-4 py-6">
|
||||
Nothing matches “{search.trim()}”.{" "}
|
||||
<button onClick={() => setSearch("")} className="text-zinc-400 hover:text-zinc-200 underline">
|
||||
Clear search
|
||||
</button>
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-zinc-500 text-sm px-4 py-6">
|
||||
No split transactions yet. Use the Split button on any transaction.
|
||||
</p>
|
||||
)
|
||||
) : (
|
||||
<table className="w-full text-sm min-w-[520px]">
|
||||
<table className="w-full text-sm min-w-[760px]">
|
||||
{/* min-w raised from 520px with the Category and Paid-by columns: the
|
||||
wrapper scrolls horizontally, so a too-small minimum crushes cells
|
||||
rather than letting them scroll. */}
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800">
|
||||
<th
|
||||
className="text-left px-4 py-2 text-xs text-zinc-500 font-medium cursor-pointer hover:text-white whitespace-nowrap"
|
||||
onClick={() => toggleSort("transaction_date")}
|
||||
>
|
||||
Date <SortIcon col="transaction_date" />
|
||||
Date <SortIcon col="transaction_date" sortCol={sortCol} sortDir={sortDir} />
|
||||
</th>
|
||||
<th
|
||||
className="text-left px-4 py-2 text-xs text-zinc-500 font-medium cursor-pointer hover:text-white whitespace-nowrap"
|
||||
onClick={() => toggleSort("created_at")}
|
||||
>
|
||||
Imported <SortIcon col="created_at" />
|
||||
Imported <SortIcon col="created_at" sortCol={sortCol} sortDir={sortDir} />
|
||||
</th>
|
||||
<th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium sticky left-0 z-10 bg-zinc-900 border-r border-zinc-800/80">Description</th>
|
||||
<th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium">Category</th>
|
||||
<th
|
||||
className="text-right px-4 py-2 text-xs text-zinc-500 font-medium cursor-pointer hover:text-white"
|
||||
onClick={() => toggleSort("amount")}
|
||||
>
|
||||
Amount <SortIcon col="amount" />
|
||||
Amount <SortIcon col="amount" sortCol={sortCol} sortDir={sortDir} />
|
||||
</th>
|
||||
{/* Paid by sits next to Splits deliberately: together they are the
|
||||
two halves of the question this page exists to answer — whose
|
||||
money went out, and whose share it was. */}
|
||||
<th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium whitespace-nowrap">Paid by</th>
|
||||
<th className="text-left px-4 py-2 text-xs text-zinc-500 font-medium">Splits</th>
|
||||
<th className="px-4 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(transactions as SharedTransactionRow[]).map((tx) => {
|
||||
const splits = Array.isArray(tx.splits) ? tx.splits : [];
|
||||
return (
|
||||
<tr key={tx.id} className="border-b border-zinc-800/50 hover:bg-zinc-800/30">
|
||||
<td className="px-4 py-3 text-zinc-400 whitespace-nowrap">{formatDate(tx.transaction_date)}</td>
|
||||
<td className="px-4 py-3 text-zinc-500 text-xs whitespace-nowrap">{formatDate(tx.created_at)}</td>
|
||||
<td className="px-4 py-3 max-w-xs sticky left-0 z-10 bg-zinc-900 border-r border-zinc-800/80">
|
||||
<p className="font-medium break-words">{tx.effective_merchant || tx.description}</p>
|
||||
{tx.effective_merchant && (
|
||||
<p className="text-xs text-zinc-500 break-words">{tx.description}</p>
|
||||
)}
|
||||
{tx.notes && (
|
||||
<p className="text-xs text-zinc-500 italic mt-0.5 break-words">{tx.notes}</p>
|
||||
)}
|
||||
</td>
|
||||
<td className={`px-4 py-3 text-right font-medium tabular-nums ${SPEND_TYPES.has(tx.transaction_type) ? "" : "text-green-400"}`}>
|
||||
{formatAmount(tx.amount, tx.transaction_type, tx.currency)}
|
||||
{tx.currency !== "AUD" && (
|
||||
// Splits settle on the AUD figure, so show it next to the
|
||||
// native one rather than leaving the two to differ silently.
|
||||
<span className="block text-xs font-normal text-zinc-500">
|
||||
{tx.amount_unconverted
|
||||
? "AUD value unknown"
|
||||
: `≈ ${formatAmount(Number(tx.amount_aud), tx.transaction_type, "AUD")} AUD`}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{splits.map((s) => (
|
||||
<span key={s.participant_id}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-zinc-800 text-zinc-300">
|
||||
{s.participant_id === me?.id ? "Me" : s.name} {s.share_percent}%
|
||||
</span>
|
||||
{visible.map((tx) => (
|
||||
<SharedTxRow
|
||||
key={tx.id}
|
||||
tx={tx}
|
||||
isExpanded={expanded.has(tx.id)}
|
||||
meId={me?.id}
|
||||
onToggle={toggleExpanded}
|
||||
onEdit={setEditModal}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => setEditModal(tx)}
|
||||
className="text-xs text-zinc-500 hover:text-zinc-200 px-2 py-0.5 rounded hover:bg-zinc-800 transition-colors"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{!txLoading && transactions.length > visible.length && (
|
||||
<div className="px-4 py-3 border-t border-zinc-800 flex items-center gap-3 text-xs">
|
||||
<button
|
||||
onClick={() => setVisibleCount((c) => c + 100)}
|
||||
className="px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg"
|
||||
>
|
||||
Show more
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setVisibleCount(transactions.length)}
|
||||
className="text-zinc-500 hover:text-zinc-300 underline underline-offset-2"
|
||||
>
|
||||
Show all
|
||||
</button>
|
||||
<span className="text-zinc-600 ml-auto tabular-nums">
|
||||
showing {visible.length} of {transactions.length}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Payment modal */}
|
||||
|
||||
@@ -255,9 +255,24 @@ export default function StatementsPage() {
|
||||
{participants?.length ? (
|
||||
<select
|
||||
value={s.owner_id ?? ""}
|
||||
onChange={(e) =>
|
||||
updateStatement.mutate({ id: s.id, owner_id: Number(e.target.value) })
|
||||
onChange={(e) => {
|
||||
const next = Number(e.target.value);
|
||||
const name = participants.find((p) => p.id === next)?.name ?? "them";
|
||||
// This page only lists statements you own, so handing
|
||||
// one over removes it — and every transaction on it —
|
||||
// from your view, and only they can hand it back.
|
||||
if (
|
||||
!confirm(
|
||||
`Reassign this statement to ${name}?\n\nEvery transaction on it moves to their ledger, and the statement leaves your list — only ${name} can move it back.`
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
updateStatement.mutate(
|
||||
{ id: s.id, owner_id: next },
|
||||
{ onError: (err) => alert(err instanceof Error ? err.message : "Failed to reassign") }
|
||||
);
|
||||
}}
|
||||
className="bg-zinc-800 border border-zinc-700 rounded text-xs px-2 py-1 text-zinc-300 cursor-pointer hover:border-zinc-600 focus:outline-none focus:border-indigo-500"
|
||||
>
|
||||
{participants.map((p) => (
|
||||
|
||||
@@ -59,7 +59,7 @@ function ConvertModal({
|
||||
<div className="px-6 pt-5 pb-4 border-b border-zinc-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-full flex-shrink-0" style={{ backgroundColor: tag.color }} />
|
||||
<h3 className="font-semibold text-sm text-zinc-300">Convert "{tag.name}" to Trip</h3>
|
||||
<h3 className="font-semibold text-sm text-zinc-300">Convert "{tag.name}" to Trip</h3>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-500 mt-1">
|
||||
Creates a trip with the same name and color, and assigns all {tag.transaction_count} tagged transaction{tag.transaction_count !== 1 ? "s" : ""} to it.
|
||||
|
||||
@@ -148,7 +148,7 @@ function SaveAsRulePrompt({
|
||||
Automatically apply this {field === "category" ? "category" : "merchant name"} to future matching transactions.
|
||||
</p>
|
||||
<div className="bg-zinc-900 rounded-lg px-3 py-2 text-xs text-zinc-300 mb-3 space-y-1">
|
||||
<p><span className="text-zinc-500">If</span> {conditionField === "merchant_normalized" ? "merchant" : "description"} {conditionOperator} <span className="text-white">"{conditionValue}"</span></p>
|
||||
<p><span className="text-zinc-500">If</span> {conditionField === "merchant_normalized" ? "merchant" : "description"} {conditionOperator} <span className="text-white">"{conditionValue}"</span></p>
|
||||
<p>
|
||||
<span className="text-zinc-500">Then</span>{" "}
|
||||
{field === "category"
|
||||
@@ -250,11 +250,17 @@ function MarkAsPaymentModal({
|
||||
|
||||
const [participantId, setParticipantId] = useState<number | "">(others[0]?.id ?? "");
|
||||
|
||||
useEffect(() => {
|
||||
// Seed the picker once the participant list arrives — the initial state is
|
||||
// computed before the query resolves, so it starts empty.
|
||||
//
|
||||
// Adjusted during render rather than in an effect, which is also strictly
|
||||
// more correct: `others` is rebuilt by .filter() on every render, so as a
|
||||
// dependency it never compared equal and the effect ran every render anyway
|
||||
// — just one commit later, painting an empty select first. The condition
|
||||
// clears itself on the first non-empty list, so this cannot loop.
|
||||
if (participantId === "" && others.length > 0) {
|
||||
setParticipantId(others[0].id);
|
||||
}
|
||||
}, [others]);
|
||||
// For credits/refunds the default direction is "they paid me"
|
||||
const [direction, setDirection] = useState<"received" | "sent">(
|
||||
SPEND_TYPES.has(transaction.transaction_type) ? "sent" : "received"
|
||||
@@ -518,6 +524,14 @@ function TransactionsContent() {
|
||||
from: "",
|
||||
to: "",
|
||||
categories: [] as string[],
|
||||
// Transfers move money between your own accounts — they are not spending,
|
||||
// and at ~380 rows they crowd out everything that is. Hidden by default,
|
||||
// with a visible toggle: a filter you cannot see is one you forget is on.
|
||||
//
|
||||
// Off when the view is scoped to a statement. That is a reconciliation
|
||||
// view — the row count has to match the statement, and a credit-card
|
||||
// payment is exactly the row you are there to check.
|
||||
exclude_categories: (initialStatementId ? [] : ["transfers"]) as string[],
|
||||
bank_names: [] as string[],
|
||||
tag_ids: [] as string[],
|
||||
transaction_types: [] as string[],
|
||||
@@ -716,9 +730,39 @@ function TransactionsContent() {
|
||||
<MultiSelect
|
||||
options={CATEGORIES.map((c) => ({ value: c, label: formatCategory(c) }))}
|
||||
value={filters.categories}
|
||||
onChange={(v) => setFilters((f) => ({ ...f, categories: v, offset: 0 }))}
|
||||
onChange={(v) =>
|
||||
setFilters((f) => ({
|
||||
...f,
|
||||
categories: v,
|
||||
// Asking for a category you are also hiding is a contradiction the
|
||||
// server resolves in favour of the explicit pick; drop it here too
|
||||
// so the toggle does not claim to be hiding what is on screen.
|
||||
exclude_categories: f.exclude_categories.filter((c) => !v.includes(c)),
|
||||
offset: 0,
|
||||
}))
|
||||
}
|
||||
placeholder="All Categories"
|
||||
/>
|
||||
<label className="flex items-center gap-1.5 px-3 py-1.5 bg-zinc-900 border border-zinc-700 rounded text-sm text-zinc-300 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={filters.exclude_categories.includes("transfers")}
|
||||
onChange={(e) =>
|
||||
setFilters((f) => ({
|
||||
...f,
|
||||
exclude_categories: e.target.checked
|
||||
? [...f.exclude_categories, "transfers"]
|
||||
: f.exclude_categories.filter((c) => c !== "transfers"),
|
||||
categories: e.target.checked
|
||||
? f.categories.filter((c) => c !== "transfers")
|
||||
: f.categories,
|
||||
offset: 0,
|
||||
}))
|
||||
}
|
||||
className="accent-indigo-500"
|
||||
/>
|
||||
Hide transfers
|
||||
</label>
|
||||
<MultiSelect
|
||||
options={(banks ?? []).map((b) => ({ value: b, label: b }))}
|
||||
value={filters.bank_names}
|
||||
@@ -980,6 +1024,17 @@ function TransactionsContent() {
|
||||
</div>
|
||||
{t.notes ? (
|
||||
<p className="truncate text-xs text-zinc-500 italic mt-0.5" title={t.notes}>{t.notes}</p>
|
||||
) : t.order_leg_kind === "instalment" && t.order_name ? (
|
||||
// Board 205. Four rows reading "Afterpay $399.75" name
|
||||
// nothing — this is the surface where that was noticed.
|
||||
// The order is what tells them apart, and "2 of 4" is
|
||||
// what stops the same purchase reading as four.
|
||||
<p className="truncate text-xs text-zinc-500 italic mt-0.5" title={t.order_name}>
|
||||
{t.order_leg_index && t.order_leg_count
|
||||
? `${t.order_leg_index} of ${t.order_leg_count} · `
|
||||
: ""}
|
||||
{t.order_name}
|
||||
</p>
|
||||
) : t.order_platform === "uber" && routeSummary(t.order_route) && (
|
||||
// Five rows all reading "Order - Uber Trip" are
|
||||
// indistinguishable. Where the trip went is what tells
|
||||
@@ -1010,12 +1065,33 @@ function TransactionsContent() {
|
||||
<td className={`p-2 text-right whitespace-nowrap font-mono ${
|
||||
SPEND_TYPES.has(t.transaction_type) ? "text-red-400" : "text-green-400"
|
||||
}`}>
|
||||
{/*
|
||||
A row with no AUD figure must NOT be printed as AUD.
|
||||
`amount_aud ?? amount` formatted with the default currency
|
||||
rendered a USD 10,782.00 Wise credit as "+$10,782.00" in a
|
||||
column of AUD figures — the real value was about $15,500,
|
||||
and the row directly above it (the same payment from the
|
||||
statement) showed exactly that. The native figure is the
|
||||
only true one here, so it becomes the headline and the
|
||||
absence of a rate is stated rather than papered over.
|
||||
*/}
|
||||
{t.amount_unconverted ? (
|
||||
<>
|
||||
{formatAmount(t.amount, t.transaction_type, t.currency)}
|
||||
<div className="text-[10px] text-zinc-500 mt-0.5" title="No AUD figure exists for this row — it is excluded from AUD totals">
|
||||
no AUD rate
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{formatAmount(t.amount_aud ?? t.amount, t.transaction_type)}
|
||||
{t.currency && t.currency !== "AUD" && (
|
||||
<div className="text-[10px] text-zinc-500 mt-0.5">
|
||||
{formatAmount(t.amount, t.transaction_type, t.currency)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<EditableTypeBadge
|
||||
|
||||
+320
-107
@@ -8,36 +8,71 @@ import {
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
ReferenceLine,
|
||||
ResponsiveContainer,
|
||||
Cell,
|
||||
} from "recharts";
|
||||
import { useTripAnalytics, useTrip, useTransactions } from "@/lib/hooks";
|
||||
import { useTripAnalytics, useTrip, useTransactions, useParticipantBalances, useTrips } from "@/lib/hooks";
|
||||
import { CreateTripModal } from "@/components/create-trip-modal";
|
||||
import { formatCategory } from "@/lib/categories";
|
||||
import { CATEGORY_COLORS, TOOLTIP_STYLE } from "@/lib/category-colors";
|
||||
import { CHART, TOOLTIP_STYLE } from "@/lib/category-colors";
|
||||
|
||||
function fmtDate(d: string | null) {
|
||||
if (!d) return null;
|
||||
return new Date(d).toLocaleDateString("en-AU", { day: "2-digit", month: "short", year: "numeric" });
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
function fmt(n: number) {
|
||||
return `$${n.toLocaleString("en-AU", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A labelled horizontal magnitude bar.
|
||||
*
|
||||
* One hue for every row, never a colour per category. The category name is right
|
||||
* there as a direct label, so a hue per row would double-encode identity the label
|
||||
* already carries — and the app's 27-colour CATEGORY_COLORS set fails CVD
|
||||
* separation on this surface anyway (validated: `other` vs `shopping` at ΔE 5.0
|
||||
* protan, below the floor). Length carries the magnitude; that is the whole job.
|
||||
*/
|
||||
function BarRow({
|
||||
label,
|
||||
value,
|
||||
amount,
|
||||
count,
|
||||
max,
|
||||
sub,
|
||||
color,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
amount: number;
|
||||
count: number;
|
||||
max: number;
|
||||
sub?: string;
|
||||
color: string;
|
||||
}) {
|
||||
const pct = max > 0 ? Math.max((Math.abs(amount) / max) * 100, 0.6) : 0;
|
||||
return (
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5 relative overflow-hidden">
|
||||
<div className="absolute top-0 left-0 right-0 h-0.5" style={{ backgroundColor: color }} />
|
||||
<p className="text-xs text-zinc-500 mb-1">{label}</p>
|
||||
<p className="text-2xl font-semibold tabular-nums">{value}</p>
|
||||
{sub && <p className="text-xs text-zinc-600 mt-1 truncate">{sub}</p>}
|
||||
<div className="group grid grid-cols-[minmax(0,1fr)_auto] gap-x-3 gap-y-1 items-baseline">
|
||||
<span className="text-sm text-zinc-300 truncate" title={label}>{label}</span>
|
||||
<span className="text-sm font-mono tabular-nums text-zinc-200">{fmt(amount)}</span>
|
||||
<div className="col-span-2 flex items-center gap-2">
|
||||
<div className="h-1.5 flex-1 bg-zinc-800/70 overflow-hidden rounded-sm">
|
||||
<div
|
||||
className="h-full transition-[width] duration-500 motion-reduce:transition-none"
|
||||
style={{ width: `${pct}%`, background: CHART.accent, borderRadius: "0 4px 4px 0" }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[11px] text-zinc-600 tabular-nums w-16 text-right shrink-0">
|
||||
{sub ?? `${count} ${count === 1 ? "charge" : "charges"}`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A section heading that states what the section is FOR, not just what it holds. */
|
||||
function SectionHead({ title, note }: { title: string; note: string }) {
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<h3 className="text-sm font-display text-zinc-100">{title}</h3>
|
||||
<p className="text-xs text-zinc-500 mt-0.5 leading-relaxed">{note}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -54,16 +89,6 @@ function DailyTooltip({ active, payload, label }: { active?: boolean; payload?:
|
||||
);
|
||||
}
|
||||
|
||||
function CategoryTooltip({ active, payload }: { active?: boolean; payload?: { payload: { category: string }; value: number }[] }) {
|
||||
if (!active || !payload?.length) return null;
|
||||
return (
|
||||
<div style={TOOLTIP_STYLE} className="p-2.5 text-xs">
|
||||
<p className="text-zinc-400 mb-1">{formatCategory(payload[0].payload.category)}</p>
|
||||
<p className="text-zinc-100 font-medium">${Number(payload[0].value).toFixed(2)}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TripDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = use(params);
|
||||
const tripId = Number(id);
|
||||
@@ -73,7 +98,17 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin
|
||||
const [tab, setTab] = useState<"overview" | "transactions">("overview");
|
||||
const [editModal, setEditModal] = useState(false);
|
||||
|
||||
const { data: txData } = useTransactions({ trip_id: id, limit: 500 });
|
||||
// A trip is all the expenses on one trip, so a participant sees every row on
|
||||
// it, not only their own. The server re-checks participation — this flag is a
|
||||
// request, not a grant.
|
||||
const { data: txData } = useTransactions({ trip_id: id, limit: 500, trip_all_rows: true });
|
||||
|
||||
// Unscoped, deliberately: the trip figure alone cannot tell you whether to pay
|
||||
// anyone, because a trip whose payment over-covered it reads negative while the
|
||||
// payer is still in debt overall. This is the number to act on.
|
||||
const { data: balances = [] } = useParticipantBalances();
|
||||
// For the only cross-trip figure worth quoting: the daily rate.
|
||||
const { data: allTrips = [] } = useTrips();
|
||||
|
||||
if (isLoading || !analytics) {
|
||||
return (
|
||||
@@ -86,9 +121,48 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin
|
||||
);
|
||||
}
|
||||
|
||||
const { total_spend, transaction_count, num_days, daily_average, category_breakdown, daily_spend, top_merchants, tag_breakdown, participant_splits } = analytics;
|
||||
const {
|
||||
total_spend, transaction_count, num_days, daily_spend, tag_breakdown, participant_splits,
|
||||
phases, committed_merchants, on_ground_categories, on_ground_daily,
|
||||
} = analytics;
|
||||
const t = analytics.trip;
|
||||
const maxMerchant = top_merchants[0]?.amount ?? 1;
|
||||
|
||||
const committed = Number(phases.committed);
|
||||
const onGround = Number(phases.on_ground);
|
||||
const total = committed + onGround;
|
||||
const committedPct = total > 0 ? (committed / total) * 100 : 0;
|
||||
const onGroundDaily = Number(on_ground_daily);
|
||||
// No start_date means no knowable departure, so there is no split to draw — the
|
||||
// query already folds everything into on-ground in that case.
|
||||
const hasPhases = Boolean(t.start_date);
|
||||
|
||||
const maxCommitted = committed_merchants[0]?.amount ?? 1;
|
||||
const maxOnGround = on_ground_categories[0]?.amount ?? 1;
|
||||
const meanDaily = daily_spend.length
|
||||
? daily_spend.reduce((s, d) => s + Number(d.amount), 0) / daily_spend.length
|
||||
: 0;
|
||||
|
||||
// Where this trip's daily burn sits against the others. $677.88/day in Europe
|
||||
// against $83.39 in Auckland is the kind of thing a single trip page can never
|
||||
// say on its own, and it is the only figure here that is comparable at all —
|
||||
// totals are not, because trips differ in length.
|
||||
const dayRateRank = (() => {
|
||||
const rated = allTrips
|
||||
.filter((x) => x.start_date && x.end_date && Number(x.total_spend) > 0)
|
||||
.map((x) => {
|
||||
const days = Math.max(1, Math.round(
|
||||
(new Date(x.end_date!).getTime() - new Date(x.start_date!).getTime()) / 86400000
|
||||
) + 1);
|
||||
return { id: x.id, rate: Number(x.total_spend) / days };
|
||||
})
|
||||
.sort((a, b) => b.rate - a.rate);
|
||||
if (rated.length < 2) return null;
|
||||
const idx = rated.findIndex((x) => x.id === t.id);
|
||||
if (idx === -1) return null;
|
||||
if (idx === 0) return `your priciest day-to-day of ${rated.length} trips`;
|
||||
if (idx === rated.length - 1) return `your cheapest day-to-day of ${rated.length} trips`;
|
||||
return `${idx + 1}${["st", "nd", "rd"][idx] ?? "th"} priciest of ${rated.length} trips`;
|
||||
})();
|
||||
|
||||
const dateRange = t.start_date && t.end_date
|
||||
? `${fmtDate(t.start_date)} – ${fmtDate(t.end_date)}`
|
||||
@@ -126,15 +200,77 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stat cards */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
{/* Deliberately every payer, not just this owner — a trip cost what the
|
||||
group put into it. The split figures below are owner-scoped, so this
|
||||
says whose money it counts to stop the two being read as one lens. */}
|
||||
<StatCard label="Total Spend" value={`$${Number(total_spend).toFixed(2)}`} sub="all payers, net of refunds" color={t.color} />
|
||||
<StatCard label="Transactions" value={String(transaction_count)} sub="total" color={t.color} />
|
||||
<StatCard label="Daily Average" value={`$${Number(daily_average).toFixed(2)}`} sub="per day" color={t.color} />
|
||||
<StatCard label="Days" value={String(num_days)} sub={dateRange ?? "date range"} color={t.color} />
|
||||
{/* ── Signature: the two economies of a trip ──
|
||||
The page's thesis, and the answer to "travel is 60% and tells me
|
||||
nothing". A trip is paid for twice — once in bookings locked in months
|
||||
ahead, once in daily spending on the ground — and every category except
|
||||
travel belongs wholly to the second. Showing the ratio first makes the
|
||||
rest of the page legible; showing a lone total never did. */}
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5 sm:p-6">
|
||||
<div className="flex flex-wrap items-end justify-between gap-x-8 gap-y-3">
|
||||
<div>
|
||||
<p className="text-[11px] uppercase tracking-[0.16em] text-zinc-500">What the trip cost</p>
|
||||
{/* Sans, not the display face, and proportional figures — a serif or
|
||||
tabular-nums hero reads as decoration at this size. */}
|
||||
<p className="text-4xl font-semibold text-zinc-50 mt-1 leading-none">{fmt(Number(total_spend))}</p>
|
||||
<p className="text-xs text-zinc-500 mt-1.5">
|
||||
all payers, net of refunds · {transaction_count} charges over {num_days} days
|
||||
</p>
|
||||
</div>
|
||||
{hasPhases && (
|
||||
<div className="text-right">
|
||||
<p className="text-[11px] uppercase tracking-[0.16em] text-zinc-500">On the ground</p>
|
||||
<p className="text-2xl font-semibold text-zinc-100 mt-1 leading-none">
|
||||
{fmt(onGroundDaily)}<span className="text-sm font-normal text-zinc-500"> / day</span>
|
||||
</p>
|
||||
{dayRateRank && <p className="text-xs text-zinc-500 mt-1.5">{dayRateRank}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasPhases && total > 0 && (
|
||||
<div className="mt-6">
|
||||
{/* Two ordinal steps of one hue, validated against this surface, with a
|
||||
2px gap so the boundary is a real edge rather than a colour change.
|
||||
Both segments are direct-labelled, so no legend is needed. */}
|
||||
<div className="flex gap-[2px] h-2.5" role="img"
|
||||
aria-label={`${fmt(committed)} committed before departure, ${fmt(onGround)} spent on the ground`}>
|
||||
<div className="rounded-l-sm rounded-r-[1px]" style={{ width: `${committedPct}%`, background: "#7c4820" }} />
|
||||
<div className="rounded-r-sm rounded-l-[1px]" style={{ width: `${100 - committedPct}%`, background: "#d28a47" }} />
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-between gap-x-6 gap-y-2 mt-3">
|
||||
<div>
|
||||
<p className="text-sm text-zinc-200">
|
||||
<span className="inline-block w-2 h-2 rounded-sm mr-1.5 align-middle" style={{ background: "#7c4820" }} />
|
||||
{fmt(committed)}
|
||||
<span className="text-zinc-500"> committed{t.start_date ? ` before ${fmtDate(t.start_date)}` : ""}</span>
|
||||
</p>
|
||||
<p className="text-[11px] text-zinc-600 mt-0.5 ml-3.5">
|
||||
{phases.committed_count} bookings · {committedPct.toFixed(0)}% of the trip
|
||||
</p>
|
||||
</div>
|
||||
<div className="sm:text-right">
|
||||
<p className="text-sm text-zinc-200">
|
||||
<span className="inline-block w-2 h-2 rounded-sm mr-1.5 align-middle" style={{ background: "#d28a47" }} />
|
||||
{fmt(onGround)}
|
||||
<span className="text-zinc-500"> spent on the ground</span>
|
||||
</p>
|
||||
<p className="text-[11px] text-zinc-600 mt-0.5 ml-3.5 sm:ml-0">
|
||||
{phases.on_ground_count} charges · {(100 - committedPct).toFixed(0)}% of the trip
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{committed < 1 && (
|
||||
// Europe — Sonu + Sunny sits at $184.84 committed against Europe 2026's
|
||||
// $22,050.51, because the flights and stays for both legs were filed on
|
||||
// the first trip. Worth saying, or the ratio reads as missing data.
|
||||
<p className="text-[11px] text-zinc-600 mt-3 pt-3 border-t border-zinc-800/70">
|
||||
Almost nothing was booked before this trip started — its flights and
|
||||
stays are likely filed against another trip.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tab bar */}
|
||||
@@ -160,7 +296,16 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin
|
||||
{/* Daily spend */}
|
||||
{daily_spend.length > 0 && (
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5">
|
||||
<h3 className="text-sm font-medium mb-4">Daily Spend</h3>
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap mb-4">
|
||||
<SectionHead
|
||||
title="Day by day"
|
||||
note="Every day money moved, bookings included — the tall early bars are usually the flights."
|
||||
/>
|
||||
<span className="text-[11px] text-zinc-500 shrink-0 flex items-center gap-1.5">
|
||||
<span className="w-4 border-t border-dashed inline-block" style={{ borderColor: CHART.axis }} />
|
||||
mean {fmt(meanDaily)}
|
||||
</span>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<BarChart data={daily_spend} margin={{ top: 4, right: 8, bottom: 0, left: 8 }}>
|
||||
<XAxis
|
||||
@@ -179,69 +324,64 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin
|
||||
width={52}
|
||||
/>
|
||||
<Tooltip content={<DailyTooltip />} cursor={{ fill: "#27272a" }} />
|
||||
<Bar dataKey="amount" fill={t.color} radius={[3, 3, 0, 0]} maxBarSize={40} opacity={0.85} />
|
||||
{/* Same axis, same unit — a mean line, not a second scale. */}
|
||||
{meanDaily > 0 && (
|
||||
<ReferenceLine y={meanDaily} stroke={CHART.axis} strokeDasharray="3 3" strokeWidth={1} />
|
||||
)}
|
||||
<Bar dataKey="amount" fill={CHART.accent} radius={[4, 4, 0, 0]} maxBarSize={40} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||
{/* Category breakdown */}
|
||||
{category_breakdown.length > 0 && (
|
||||
{/* ── The two phases, each on the axis that carries information ──
|
||||
This pairing is the fix for the travel problem. Before departure
|
||||
every row is a flight, a stay or a rail ticket, so `travel` is 99%
|
||||
of it and category says nothing — merchant is what distinguishes
|
||||
Agoda $4,490 from Air India $3,454. After departure travel drops to
|
||||
a peer among dining, transport and groceries, and category is
|
||||
finally worth charting. Same rows, two axes, chosen per phase. */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5 items-start">
|
||||
{hasPhases && committed_merchants.length > 0 && (
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5">
|
||||
<h3 className="text-sm font-medium mb-4">By Category</h3>
|
||||
<ResponsiveContainer width="100%" height={Math.max(120, category_breakdown.length * 32)}>
|
||||
<BarChart
|
||||
data={category_breakdown}
|
||||
layout="vertical"
|
||||
margin={{ top: 0, right: 60, bottom: 0, left: 100 }}
|
||||
>
|
||||
<XAxis type="number" tick={{ fill: "#71717a", fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={(v) => `$${v}`} />
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="category"
|
||||
tick={{ fill: "#a1a1aa", fontSize: 12 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
tickFormatter={formatCategory}
|
||||
width={98}
|
||||
<SectionHead
|
||||
title="Booked ahead"
|
||||
note={`Locked in before ${t.start_date ? fmtDate(t.start_date) : "departure"}. It is all flights and stays here, so the merchant is what tells them apart — not the category.`}
|
||||
/>
|
||||
<div className="space-y-3.5">
|
||||
{committed_merchants.map((m) => (
|
||||
<BarRow
|
||||
key={m.merchant}
|
||||
label={m.merchant || "Unknown"}
|
||||
amount={Number(m.amount)}
|
||||
count={m.count}
|
||||
max={maxCommitted}
|
||||
/>
|
||||
<Tooltip content={<CategoryTooltip />} cursor={{ fill: "#27272a" }} />
|
||||
<Bar dataKey="amount" radius={[0, 3, 3, 0]} maxBarSize={22}>
|
||||
{category_breakdown.map((entry) => (
|
||||
<Cell key={entry.category} fill={CATEGORY_COLORS[entry.category] || "#6366f1"} opacity={0.85} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top merchants */}
|
||||
{top_merchants.length > 0 && (
|
||||
{on_ground_categories.length > 0 && (
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5">
|
||||
<h3 className="text-sm font-medium mb-4">Top Merchants</h3>
|
||||
<div className="space-y-3">
|
||||
{top_merchants.map((m, i) => (
|
||||
<div key={m.merchant} className="flex items-center gap-3">
|
||||
<span className="text-xs text-zinc-600 w-4 tabular-nums text-right">{i + 1}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm truncate">{m.merchant || "Unknown"}</span>
|
||||
<span className="text-sm font-mono tabular-nums ml-2 flex-shrink-0">${Number(m.amount).toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-zinc-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full"
|
||||
style={{
|
||||
width: `${(m.amount / maxMerchant) * 100}%`,
|
||||
backgroundColor: t.color,
|
||||
opacity: 0.7,
|
||||
}}
|
||||
<SectionHead
|
||||
title={hasPhases ? "On the ground" : "By category"}
|
||||
note={
|
||||
hasPhases
|
||||
? "Day-to-day spending once you arrived. With the bookings taken out, travel sits among its peers instead of swamping them."
|
||||
: "This trip has no start date, so there is no departure to split on."
|
||||
}
|
||||
/>
|
||||
<div className="space-y-3.5">
|
||||
{on_ground_categories.map((c) => (
|
||||
<BarRow
|
||||
key={c.category}
|
||||
label={formatCategory(c.category)}
|
||||
amount={Number(c.amount)}
|
||||
count={c.count}
|
||||
max={maxOnGround}
|
||||
sub={onGround > 0 ? `${((Number(c.amount) / onGround) * 100).toFixed(0)}%` : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -280,10 +420,10 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800">
|
||||
{["Person", "Outstanding on this trip"].map((h) => (
|
||||
{["Person", "This trip", "How it adds up", "Overall balance"].map((h) => (
|
||||
<th
|
||||
key={h}
|
||||
className={`px-5 py-2.5 text-xs text-zinc-500 font-medium ${h === "Person" ? "text-left" : "text-right"}`}
|
||||
className={`px-5 py-2.5 text-xs text-zinc-500 font-medium ${h === "Person" || h === "How it adds up" ? "text-left" : "text-right"}`}
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
@@ -291,31 +431,94 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{/* A negative outstanding means they have paid more towards this
|
||||
trip than their share of it — which reads as a typo unless the
|
||||
sign is spelled out. Shown as a magnitude plus a word, the same
|
||||
way Shared does it, so the two pages agree on what a direction
|
||||
means. */}
|
||||
{/* One net figure per person — but a negative one is NOT a bill.
|
||||
This is the distinction the page got wrong twice.
|
||||
|
||||
A payment is allocated to a scope as a lump sum, and the
|
||||
grouped-payment allocation assigned each trip enough to clear
|
||||
the payer's GROSS share. So when the other side of the trip is
|
||||
netted off, a fully-paid trip goes negative by exactly the
|
||||
amount the payer over-covered: Europe reads -$802.75 because
|
||||
Sonu paid $8,004.04 against a net share of $7,201.30.
|
||||
|
||||
That surplus is not a debt the viewer must settle. It is
|
||||
already carried in the overall balance — Sonu still owes
|
||||
$5,313.38 overall — so "you owe them" was flatly wrong. Scope
|
||||
nets sum to the overall figure; a negative here just means this
|
||||
scope was over-covered and the excess sits in another.
|
||||
|
||||
So: negative WITH a payment into the scope is an overpayment,
|
||||
and the overall column is where the actionable number lives.
|
||||
Negative with NO payment is genuinely owed, because then the
|
||||
viewer's share of the other person's spending simply exceeds
|
||||
theirs. All three of today's negatives are the former.
|
||||
|
||||
Sign convention matches Shared: positive means they owe you. */}
|
||||
{participant_splits.map((p) => {
|
||||
const owed = Number(p.owed);
|
||||
const square = Math.abs(owed) < 0.005;
|
||||
const theyOweMe = owed > 0;
|
||||
const owedGross = Number(p.owed_gross);
|
||||
const paidToMe = Number(p.paid_to_me);
|
||||
const iOweGross = Number(p.i_owe_gross);
|
||||
const paidByMe = Number(p.paid_by_me);
|
||||
const net = Number(p.owed) - Number(p.i_owe);
|
||||
const square = Math.abs(net) < 0.005;
|
||||
const overpaid = net < -0.005 && paidToMe > 0.005;
|
||||
const unconverted = p.unconverted_count + p.i_owe_unconverted_count;
|
||||
const parts = [
|
||||
owedGross > 0.005 ? `their share ${fmt(owedGross)}` : null,
|
||||
paidToMe > 0.005 ? `they paid ${fmt(paidToMe)}` : null,
|
||||
iOweGross > 0.005 ? `your share of their spend ${fmt(iOweGross)}` : null,
|
||||
paidByMe > 0.005 ? `you paid ${fmt(paidByMe)}` : null,
|
||||
].filter(Boolean);
|
||||
const overall = balances.find((b) => b.id === p.participant_id);
|
||||
const overallNet = overall ? Number(overall.total_owed) : null;
|
||||
return (
|
||||
<tr key={p.participant_id} className="border-b border-zinc-800/50 last:border-0">
|
||||
<td className="px-5 py-3 font-medium">{p.name}</td>
|
||||
<td className="px-5 py-3 text-right tabular-nums font-mono">
|
||||
<span className={square ? "text-zinc-500" : theyOweMe ? "text-amber-400" : "text-blue-400"}>
|
||||
${Math.abs(owed).toFixed(2)}
|
||||
<td className="px-5 py-3 text-right tabular-nums font-mono whitespace-nowrap">
|
||||
<span className={square ? "text-zinc-500" : net > 0 ? "text-amber-400" : overpaid ? "text-emerald-400" : "text-blue-400"}>
|
||||
${Math.abs(net).toFixed(2)}
|
||||
</span>
|
||||
<span className="block text-[11px] text-zinc-500 mt-0.5 font-sans">
|
||||
{square ? "all square" : theyOweMe ? "owes you" : "ahead — you owe them"}
|
||||
{square
|
||||
? "all square"
|
||||
: net > 0
|
||||
? "still owed"
|
||||
: overpaid
|
||||
? "covered — they paid over"
|
||||
: "you owe them"}
|
||||
</span>
|
||||
{p.unconverted_count > 0 && (
|
||||
{unconverted > 0 && (
|
||||
<span className="block text-[11px] text-amber-500/80 mt-0.5 font-sans">
|
||||
approx · {p.unconverted_count} unconverted
|
||||
approx · {unconverted} unconverted
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-5 py-3 text-[11px] text-zinc-500 leading-relaxed">
|
||||
{parts.length ? parts.join(" · ") : "no split activity on this trip"}
|
||||
{overpaid && (
|
||||
<span className="block text-emerald-500/80 mt-0.5">
|
||||
trip covered; the {fmt(Math.abs(net))} surplus sits on the overall balance,
|
||||
not owing to them
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
{/* The only figure anyone should act on. Without it a
|
||||
over-covered scope reads as "pay them" when they are
|
||||
still in debt to you overall. */}
|
||||
<td className="px-5 py-3 text-right tabular-nums font-mono whitespace-nowrap">
|
||||
{overallNet === null ? (
|
||||
<span className="text-zinc-600 text-[11px] font-sans">—</span>
|
||||
) : (
|
||||
<>
|
||||
<span className={Math.abs(overallNet) < 0.005 ? "text-zinc-500" : overallNet > 0 ? "text-amber-400" : "text-blue-400"}>
|
||||
${Math.abs(overallNet).toFixed(2)}
|
||||
</span>
|
||||
<span className="block text-[11px] text-zinc-500 mt-0.5 font-sans">
|
||||
{Math.abs(overallNet) < 0.005 ? "all square" : overallNet > 0 ? "owes you" : "you owe them"}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
@@ -327,15 +530,25 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin
|
||||
are net of payments scoped to this trip. What the note has to say
|
||||
instead is which payments are NOT in them. */}
|
||||
<p className="px-5 py-2.5 text-xs text-zinc-500 border-t border-zinc-800">
|
||||
Net of payments recorded against this trip. Payments on the ongoing
|
||||
household tab are not counted here —
|
||||
see <Link href="/shared" className="text-zinc-400 hover:text-zinc-200 underline">Shared</Link> for
|
||||
the overall balance.
|
||||
<strong className="font-medium text-zinc-400">This trip</strong> is their
|
||||
share of what you paid, less what they paid you, less your share of what
|
||||
they paid. A payment is allocated to a trip as a lump sum, so one that
|
||||
covered someone’s full share leaves this column negative by whatever
|
||||
it over-covered — that surplus is carried in{" "}
|
||||
<strong className="font-medium text-zinc-400">Overall balance</strong>, and
|
||||
is not money owed to them. Settle against the overall figure, never a
|
||||
single trip.
|
||||
<br />
|
||||
Only payments <em className="not-italic text-zinc-400">scoped to this
|
||||
trip</em> count here, so a debt settled by a payment left on the household
|
||||
tab still reads as outstanding — set the scope when recording one.
|
||||
See <Link href="/shared" className="text-zinc-400 hover:text-zinc-200 underline">Shared</Link> for
|
||||
the full picture.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{category_breakdown.length === 0 && daily_spend.length === 0 && (
|
||||
{on_ground_categories.length === 0 && committed_merchants.length === 0 && daily_spend.length === 0 && (
|
||||
<div className="text-center py-12 text-zinc-600">
|
||||
<p className="text-sm">No transactions assigned to this trip yet.</p>
|
||||
<Link href="/transactions" className="text-indigo-400 hover:text-indigo-300 text-sm mt-1 inline-block">
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
import { useState } from "react";
|
||||
import { useCreateTransaction, useParticipants, useTags } from "@/lib/hooks";
|
||||
import { CATEGORIES, formatCategory } from "@/lib/categories";
|
||||
import {
|
||||
PAYMENT_METHODS,
|
||||
PAYMENT_METHOD_LABELS,
|
||||
NON_RECONCILING_REASON,
|
||||
} from "@/lib/payment-methods";
|
||||
|
||||
const TRANSACTION_TYPES = ["debit", "credit", "payment", "refund", "fee", "interest", "transfer"];
|
||||
|
||||
@@ -35,7 +40,8 @@ export function AddTransactionModal({
|
||||
const [type, setType] = useState(prefill?.transaction_type ?? "debit");
|
||||
const [merchant, setMerchant] = useState(prefill?.merchant_normalized ?? "");
|
||||
const [category, setCategory] = useState(prefill?.category ?? "");
|
||||
// Cash is excluded from reconciliation — it will never appear on a statement.
|
||||
// Cash and gift-card credits are excluded from reconciliation — neither will
|
||||
// ever produce a card line. See needsCardMatch() in queries.ts.
|
||||
const [paymentMethod, setPaymentMethod] = useState("");
|
||||
const [selectedTagIds, setSelectedTagIds] = useState<number[]>([]);
|
||||
const [splits, setSplits] = useState<{ participant_id: number; share_percent: number }[]>(
|
||||
@@ -163,21 +169,23 @@ export function AddTransactionModal({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Paid by</label>
|
||||
{/* "Payment method", not "Paid by" — the edit modal uses that label
|
||||
for the owner, and they are different questions: which instrument
|
||||
the money left by, versus whose money it was. */}
|
||||
<label className="block text-xs text-zinc-500 mb-1">Payment method</label>
|
||||
<select
|
||||
value={paymentMethod}
|
||||
onChange={(e) => setPaymentMethod(e.target.value)}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||
>
|
||||
<option value="">— unknown —</option>
|
||||
<option value="card">Card</option>
|
||||
<option value="cash">Cash</option>
|
||||
<option value="bank_transfer">Bank transfer</option>
|
||||
<option value="other">Other</option>
|
||||
{PAYMENT_METHODS.map((m) => (
|
||||
<option key={m} value={m}>{PAYMENT_METHOD_LABELS[m]}</option>
|
||||
))}
|
||||
</select>
|
||||
{paymentMethod === "cash" && (
|
||||
{NON_RECONCILING_REASON[paymentMethod] && (
|
||||
<p className="text-[11px] text-zinc-500 mt-1">
|
||||
Cash won't be offered for reconciliation — it never appears on a statement.
|
||||
{NON_RECONCILING_REASON[paymentMethod]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useState, useRef } from "react";
|
||||
import { CATEGORIES, formatCategory } from "@/lib/categories";
|
||||
import { useImportCSV } from "@/lib/hooks";
|
||||
import { useImportCSV, useStatementCoverage } from "@/lib/hooks";
|
||||
import {
|
||||
parseCSVRows, detectHasHeaders, getColumnLabels, getDataRows, applyMapping,
|
||||
saveBankPreset, loadBankPresets,
|
||||
saveBankPreset, loadBankPresets, splitByCoverage, inFileDuplicates,
|
||||
type DateFormat, type ColumnMapping, type ParsedTransaction, type BankPreset,
|
||||
} from "@/lib/csv-parser";
|
||||
|
||||
@@ -14,6 +14,8 @@ const TX_TYPES = ["debit", "credit", "payment", "refund", "fee", "interest", "tr
|
||||
|
||||
type Step = "upload" | "map" | "review" | "done";
|
||||
|
||||
|
||||
|
||||
function ColSelect({
|
||||
label, value, onChange, options, required,
|
||||
}: {
|
||||
@@ -52,10 +54,23 @@ export function CsvImportModal({ onClose }: { onClose: () => void }) {
|
||||
const [savePreset, setSavePreset] = useState(false);
|
||||
const [editedRows, setEditedRows] = useState<ParsedTransaction[]>([]);
|
||||
const [error, setError] = useState("");
|
||||
const [presets, setPresets] = useState<BankPreset[]>([]);
|
||||
// Read once, at mount, from localStorage. A lazy initializer rather than an
|
||||
// effect: it runs exactly once per mount — the same semantics as
|
||||
// `useEffect(…, [])` — but before the first paint, so the preset dropdown
|
||||
// does not appear empty and then populate. Nothing in this component writes
|
||||
// `presets` afterwards, so there is no state to keep in sync.
|
||||
//
|
||||
// The `typeof window` guard is for safety only: this modal is mounted from
|
||||
// client state and so is never server-rendered. If that ever changes, it
|
||||
// degrades to an empty list instead of throwing on `localStorage`.
|
||||
const [presets] = useState<BankPreset[]>(() =>
|
||||
typeof window === "undefined" ? [] : loadBankPresets()
|
||||
);
|
||||
const [insertedCount, setInsertedCount] = useState(0);
|
||||
const [coveredRows, setCoveredRows] = useState<ParsedTransaction[]>([]);
|
||||
const [showCovered, setShowCovered] = useState(false);
|
||||
const coverage = useStatementCoverage();
|
||||
|
||||
useEffect(() => { setPresets(loadBankPresets()); }, []);
|
||||
|
||||
function handleFile(file: File) {
|
||||
setError("");
|
||||
@@ -111,7 +126,9 @@ export function CsvImportModal({ onClose }: { onClose: () => void }) {
|
||||
if (savePreset) {
|
||||
saveBankPreset({ bankName: bankName.trim(), mapping, dateFormat });
|
||||
}
|
||||
setEditedRows(parsed);
|
||||
const { keep, covered } = splitByCoverage(parsed, coverage.data ?? []);
|
||||
setEditedRows(keep);
|
||||
setCoveredRows(covered);
|
||||
setStep("review");
|
||||
}
|
||||
|
||||
@@ -120,7 +137,13 @@ export function CsvImportModal({ onClose }: { onClose: () => void }) {
|
||||
const valid = editedRows.filter((r) => r.date && r.amount > 0 && r.description);
|
||||
if (!valid.length) { setError("No valid rows to import"); return; }
|
||||
try {
|
||||
const result = await importCSV.mutateAsync({ bank_name: bankName, transactions: valid });
|
||||
const result = await importCSV.mutateAsync({
|
||||
bank_name: bankName,
|
||||
// Provenance, so a second import of the same file inserts nothing —
|
||||
// paired with each row's source_ref where the file carries an id.
|
||||
source: bankName.trim().toLowerCase().replace(/\s+/g, "-"),
|
||||
transactions: valid,
|
||||
});
|
||||
setInsertedCount(result.inserted);
|
||||
setStep("done");
|
||||
} catch (e) {
|
||||
@@ -286,7 +309,16 @@ export function CsvImportModal({ onClose }: { onClose: () => void }) {
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<ColSelect label="Merchant Column (optional)" value={mapping.merchantCol ?? ""} onChange={(v) => setMapping((m) => ({ ...m, merchantCol: v || undefined }))} options={columnLabels} />
|
||||
<ColSelect label="Category Column (optional)" value={mapping.categoryCol ?? ""} onChange={(v) => setMapping((m) => ({ ...m, categoryCol: v || undefined }))} options={columnLabels} />
|
||||
<ColSelect label="Account Column (optional)" value={mapping.accountCol ?? ""} onChange={(v) => setMapping((m) => ({ ...m, accountCol: v || undefined }))} options={columnLabels} />
|
||||
<ColSelect label="Currency Column (optional)" value={mapping.currencyCol ?? ""} onChange={(v) => setMapping((m) => ({ ...m, currencyCol: v || undefined }))} options={columnLabels} />
|
||||
<ColSelect label="Row ID Column (optional)" value={mapping.sourceRefCol ?? ""} onChange={(v) => setMapping((m) => ({ ...m, sourceRefCol: v || undefined }))} options={columnLabels} />
|
||||
</div>
|
||||
<p className="text-[11px] text-zinc-500 leading-relaxed">
|
||||
Map <b>Account</b> when the file covers more than one account: rows already
|
||||
covered by that account's statements are then left out. Map <b>Row ID</b> to
|
||||
make re-importing the same file do nothing. Map <b>Currency</b> for a
|
||||
multi-currency file, or foreign rows are stored as if they were AUD.
|
||||
</p>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer text-zinc-400">
|
||||
<input type="checkbox" checked={savePreset} onChange={(e) => setSavePreset(e.target.checked)} className="accent-indigo-500" />
|
||||
@@ -300,9 +332,52 @@ export function CsvImportModal({ onClose }: { onClose: () => void }) {
|
||||
{/* Step 3: Review */}
|
||||
{step === "review" && (
|
||||
<div>
|
||||
<p className="text-xs text-zinc-500 mb-3">
|
||||
{editedRows.length} transactions parsed. Edit or remove rows before importing.
|
||||
<p className="text-xs text-zinc-500 mb-2">
|
||||
{editedRows.length} transactions to import. Edit or remove rows before importing.
|
||||
</p>
|
||||
|
||||
{/*
|
||||
Reported, never silent. A row that disappears can mean several
|
||||
things and one of them is worth following up, so the count is
|
||||
always shown and the rows themselves are one click away.
|
||||
*/}
|
||||
{coveredRows.length > 0 && (
|
||||
<div className="mb-3 rounded border border-zinc-800 bg-zinc-800/30 px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCovered((v) => !v)}
|
||||
className="text-xs text-zinc-300 hover:text-white text-left w-full"
|
||||
>
|
||||
{coveredRows.length} row{coveredRows.length === 1 ? "" : "s"} left out — already covered by
|
||||
a statement for that account.{" "}
|
||||
<span className="text-indigo-400">{showCovered ? "hide" : "show"}</span>
|
||||
</button>
|
||||
{showCovered && (
|
||||
<>
|
||||
<ul className="mt-2 max-h-40 overflow-y-auto text-[11px] text-zinc-500 font-mono space-y-0.5">
|
||||
{coveredRows.slice(0, 200).map((r, i) => (
|
||||
<li key={i}>{r.date} {r.amount.toFixed(2).padStart(10)} {r.description.slice(0, 44)}</li>
|
||||
))}
|
||||
</ul>
|
||||
{coveredRows.length > 200 && (
|
||||
<p className="mt-1 text-[11px] text-zinc-600">…and {coveredRows.length - 200} more.</p>
|
||||
)}
|
||||
<p className="mt-2 text-[11px] text-zinc-500">
|
||||
Statements cover:{" "}
|
||||
{(coverage.data ?? []).map((c) => `${c.last4} to ${c.coveredTo}`).join(" · ")}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{inFileDuplicates(editedRows) > 0 && (
|
||||
<p className="mb-3 rounded border border-amber-900/50 bg-amber-950/30 px-3 py-2 text-xs text-amber-300">
|
||||
{inFileDuplicates(editedRows)} row{inFileDuplicates(editedRows) === 1 ? " duplicates another row" : "s duplicate other rows"} in
|
||||
this same file (same date, amount and description). A re-consent can make an
|
||||
aggregator re-export history under new ids — check before importing.
|
||||
</p>
|
||||
)}
|
||||
<div className="overflow-x-auto rounded border border-zinc-800">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="border-b border-zinc-800">
|
||||
|
||||
@@ -8,10 +8,17 @@ import {
|
||||
useRemoveTransactionTag,
|
||||
useTransactionSplits,
|
||||
useTrips,
|
||||
useParticipants,
|
||||
useCurrentUser,
|
||||
} from "@/lib/hooks";
|
||||
import { SplitModal } from "./split-modal";
|
||||
import { OrderDetails } from "./order-details";
|
||||
import { CATEGORIES, formatCategory } from "@/lib/categories";
|
||||
import {
|
||||
PAYMENT_METHODS,
|
||||
PAYMENT_METHOD_LABELS,
|
||||
NON_RECONCILING_REASON,
|
||||
} from "@/lib/payment-methods";
|
||||
import type { TransactionRow, TagRow } from "@/lib/queries";
|
||||
|
||||
const TRANSACTION_TYPES = ["debit", "credit", "payment", "refund", "fee", "interest", "transfer"];
|
||||
@@ -96,6 +103,8 @@ export function EditTransactionModal({
|
||||
const isManual = !transaction.statement_id;
|
||||
const updateTxn = useUpdateTransaction();
|
||||
const { data: trips = [] } = useTrips();
|
||||
const { data: participants = [] } = useParticipants();
|
||||
const { data: me } = useCurrentUser();
|
||||
|
||||
// Editable override fields
|
||||
const [merchant, setMerchant] = useState(transaction.merchant_override ?? transaction.merchant_normalized ?? "");
|
||||
@@ -107,16 +116,29 @@ export function EditTransactionModal({
|
||||
const [date, setDate] = useState(transaction.transaction_date?.slice(0, 10) ?? "");
|
||||
const [description, setDescription] = useState(transaction.description);
|
||||
const [amount, setAmount] = useState(String(transaction.amount));
|
||||
// Manual-only, because the reconciliation queue is `statement_id IS NULL`:
|
||||
// this is the only place payment_method changes what happens to a row.
|
||||
const [paymentMethod, setPaymentMethod] = useState(transaction.payment_method ?? "");
|
||||
|
||||
const [tripId, setTripId] = useState<number | null>(transaction.trip_id ?? null);
|
||||
const [ownerId, setOwnerId] = useState<number | null>(transaction.owner_id ?? null);
|
||||
|
||||
// Splits — live via hook so they refresh after SplitModal saves
|
||||
const { data: liveSplits = [] } = useTransactionSplits(transaction.id);
|
||||
|
||||
const [showSplitModal, setShowSplitModal] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
// Set when the API refuses an owner change that would hide the row from me.
|
||||
// Holding it in state (rather than confirm()) keeps the way out — add a
|
||||
// split — one click away instead of behind a dialog.
|
||||
const [releasePrompt, setReleasePrompt] = useState(false);
|
||||
|
||||
async function handleSave() {
|
||||
const ownerChanged = ownerId !== null && ownerId !== (transaction.owner_id ?? null);
|
||||
const iHoldASplit = liveSplits.some(
|
||||
(s: { participant_id: number }) => s.participant_id === me?.id
|
||||
);
|
||||
|
||||
async function handleSave(release = false) {
|
||||
setError("");
|
||||
try {
|
||||
const patch: Parameters<typeof updateTxn.mutateAsync>[0] = { id: transaction.id };
|
||||
@@ -139,14 +161,27 @@ export function EditTransactionModal({
|
||||
patch.description = description;
|
||||
if (parseFloat(amount) !== transaction.amount)
|
||||
patch.amount = parseFloat(amount);
|
||||
// "" means unknown, which is NULL — not the empty string, which the
|
||||
// CHECK constraint would reject.
|
||||
if (paymentMethod !== (transaction.payment_method ?? ""))
|
||||
patch.payment_method = paymentMethod || null;
|
||||
}
|
||||
|
||||
if (tripId !== (transaction.trip_id ?? null))
|
||||
patch.trip_id = tripId;
|
||||
|
||||
// Owner is manual-only: a statement row's owner comes from its statement.
|
||||
if (isManual && ownerChanged) {
|
||||
patch.owner_id = ownerId!;
|
||||
if (release) patch.release = true;
|
||||
}
|
||||
|
||||
await updateTxn.mutateAsync(patch);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
if ((e as { code?: string })?.code === "would_lose_access") {
|
||||
setReleasePrompt(true);
|
||||
}
|
||||
setError(e instanceof Error ? e.message : "Failed to save");
|
||||
}
|
||||
}
|
||||
@@ -284,6 +319,70 @@ export function EditTransactionModal({
|
||||
<InlineTags transactionId={transaction.id} initialTags={transaction.tags ?? []} />
|
||||
</div>
|
||||
|
||||
{/* Payment method — manual rows only. A statement row is already on
|
||||
a statement, so the field decides nothing there; on a manual row
|
||||
it decides whether reconciliation will ever offer it a match. */}
|
||||
{isManual && (
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Payment method</label>
|
||||
<select
|
||||
value={paymentMethod}
|
||||
onChange={(e) => setPaymentMethod(e.target.value)}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||
>
|
||||
<option value="">— unknown —</option>
|
||||
{PAYMENT_METHODS.map((m) => (
|
||||
<option key={m} value={m}>{PAYMENT_METHOD_LABELS[m]}</option>
|
||||
))}
|
||||
</select>
|
||||
{NON_RECONCILING_REASON[paymentMethod] && (
|
||||
<p className="text-[11px] text-zinc-500 mt-1">
|
||||
{NON_RECONCILING_REASON[paymentMethod]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Paid by — sits next to Splits deliberately: together they are
|
||||
whose money went out and whose share it was. */}
|
||||
<div>
|
||||
<label className="block text-xs text-zinc-500 mb-1">Paid by</label>
|
||||
{isManual ? (
|
||||
<>
|
||||
<select
|
||||
value={ownerId ?? ""}
|
||||
onChange={(e) => {
|
||||
setOwnerId(e.target.value ? Number(e.target.value) : null);
|
||||
setReleasePrompt(false);
|
||||
setError("");
|
||||
}}
|
||||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||||
>
|
||||
{participants.map((p) => (
|
||||
<option key={p.id} value={p.id}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
{ownerChanged && (
|
||||
<p className="text-xs text-amber-400/90 mt-1.5">
|
||||
{ownerId === me?.id
|
||||
? "This moves the spend onto your ledger, and any split you hold becomes their share of it."
|
||||
: iHoldASplit
|
||||
? "This moves the spend onto their ledger. Your split becomes what you owe them rather than what they owe you."
|
||||
: "You hold no split on this. Add one first, or it leaves your view for good."}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="bg-zinc-800/50 rounded px-2 py-1.5">
|
||||
<p className="text-sm text-zinc-300">{transaction.owner_name ?? "—"}</p>
|
||||
<p className="text-xs text-zinc-500 mt-0.5">
|
||||
From a statement, so the owner is the statement's. Change it on the
|
||||
Statements page to move every row on that statement.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Splits */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
@@ -320,9 +419,31 @@ export function EditTransactionModal({
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-zinc-800 flex gap-2">
|
||||
{error && <p className="text-red-400 text-xs flex-1 self-center">{error}</p>}
|
||||
<div className="flex gap-2 ml-auto">
|
||||
<div className="px-6 py-4 border-t border-zinc-800 space-y-3">
|
||||
{error && <p className="text-red-400 text-xs">{error}</p>}
|
||||
{/* The refusal names the step that was skipped, so offer both: add
|
||||
the split (keeps the row reachable and gets the balance right)
|
||||
or hand it over knowingly. */}
|
||||
{releasePrompt && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSplitModal(true)}
|
||||
className="px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-200 rounded-lg text-xs"
|
||||
>
|
||||
Add my split first
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSave(true)}
|
||||
disabled={updateTxn.isPending}
|
||||
className="px-3 py-1.5 bg-amber-700 hover:bg-amber-600 disabled:opacity-50 text-white rounded-lg text-xs"
|
||||
>
|
||||
Give it away anyway
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
@@ -332,7 +453,7 @@ export function EditTransactionModal({
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
onClick={() => handleSave()}
|
||||
disabled={updateTxn.isPending}
|
||||
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white rounded-lg text-sm font-medium"
|
||||
>
|
||||
|
||||
@@ -7,17 +7,15 @@ import {
|
||||
useParticipants,
|
||||
useSetOrderReview,
|
||||
useSetSplits,
|
||||
useClearSplits,
|
||||
type ItemOpinion,
|
||||
type ItemVerdict,
|
||||
type OrderReceipt,
|
||||
type OrderRating,
|
||||
} from "@/lib/hooks";
|
||||
|
||||
const PLATFORM_LABEL: Record<string, string> = {
|
||||
doordash: "DoorDash",
|
||||
ubereats: "Uber Eats",
|
||||
uber: "Uber",
|
||||
};
|
||||
// One platform vocabulary, shared with the intake cards — three copies had
|
||||
// already drifted apart (the intake one was missing `uber`).
|
||||
import { platformLabel } from "@/lib/intake-rules";
|
||||
|
||||
/**
|
||||
* Who records verdicts. A two-person household with one primary user: the
|
||||
@@ -33,6 +31,7 @@ const RATING_LABEL: Record<OrderRating, string> = {
|
||||
loved: "Loved it",
|
||||
liked: "Liked it",
|
||||
ok: "OK",
|
||||
bad: "Bad",
|
||||
never: "Never again",
|
||||
};
|
||||
|
||||
@@ -40,10 +39,11 @@ const RATING_STYLE: Record<OrderRating, string> = {
|
||||
loved: "border-emerald-600 bg-emerald-950 text-emerald-300",
|
||||
liked: "border-emerald-800 bg-emerald-950/50 text-emerald-400",
|
||||
ok: "border-zinc-600 bg-zinc-800 text-zinc-300",
|
||||
bad: "border-amber-800 bg-amber-950 text-amber-300",
|
||||
never: "border-red-800 bg-red-950 text-red-300",
|
||||
};
|
||||
|
||||
const RATING_ORDER: OrderRating[] = ["loved", "liked", "ok", "never"];
|
||||
const RATING_ORDER: OrderRating[] = ["loved", "liked", "ok", "bad", "never"];
|
||||
|
||||
/**
|
||||
* The receipt behind a delivery order: what was actually bought, where it went,
|
||||
@@ -69,7 +69,12 @@ export function OrderDetails({
|
||||
const { data: review } = useOrderReview(transactionId);
|
||||
const [reviewer, setReviewer] = useState(OWNER_PARTICIPANT_ID);
|
||||
|
||||
if (isLoading || !receipt) return null;
|
||||
// `platform` is the marker of a real receipt. Since phase 2 the endpoint also
|
||||
// answers with `{ order_link }` alone for a transaction that is merely LINKED
|
||||
// to an order (a BNPL leg has no receipt of its own), and that object is
|
||||
// truthy — without this check the panel would render a receipt shell with no
|
||||
// merchant, no items and no total.
|
||||
if (isLoading || !receipt || !receipt.platform) return null;
|
||||
|
||||
const cur = receipt.currency ?? currency ?? "AUD";
|
||||
const fmt = (n: number) => (cur === "AUD" ? `$${n.toFixed(2)}` : `${cur} ${n.toFixed(2)}`);
|
||||
@@ -78,13 +83,20 @@ export function OrderDetails({
|
||||
|
||||
const mine = review?.reviews.find((r) => r.participant_id === reviewer);
|
||||
|
||||
// Verdicts are the MEAL lane's feedback loop — "loved / never again" exists
|
||||
// to steer the next restaurant order, and the nudge card warns on re-order.
|
||||
// Spine-bridged retail (Amazon boxes, eBay, Officeworks) has no such loop:
|
||||
// rating a roller-shade motor "order again?" is noise, so the whole review
|
||||
// surface stays hidden for bridge rows.
|
||||
const rateable = receipt.source !== "order-bridge";
|
||||
|
||||
return (
|
||||
<div className={bare ? "" : "border-t border-zinc-800 pt-4"}>
|
||||
<div className="flex items-baseline justify-between mb-2">
|
||||
<p className="text-xs text-zinc-500">
|
||||
Order details
|
||||
{receipt.platform && (
|
||||
<span className="ml-1.5 text-zinc-400">{PLATFORM_LABEL[receipt.platform] ?? receipt.platform}</span>
|
||||
<span className="ml-1.5 text-zinc-400">{platformLabel(receipt.platform)}</span>
|
||||
)}
|
||||
</p>
|
||||
{receipt.card_last4 && (
|
||||
@@ -96,13 +108,14 @@ export function OrderDetails({
|
||||
<ul className="space-y-1.5 mb-3">
|
||||
{items.map((it, i) => (
|
||||
<li key={i} className="flex gap-2 text-xs items-start">
|
||||
<span className="text-zinc-600 tabular-nums shrink-0">{it.qty}×</span>
|
||||
<span className="text-zinc-600 tabular-nums shrink-0">{it.qty ?? 1}×</span>
|
||||
<span className="text-zinc-300 flex-1 min-w-0">
|
||||
{it.description}
|
||||
{it.options && it.options.length > 0 && (
|
||||
<span className="block text-zinc-600">{it.options.join(" · ")}</span>
|
||||
)}
|
||||
</span>
|
||||
{rateable && (
|
||||
<ItemVerdictToggle
|
||||
transactionId={transactionId}
|
||||
reviewer={reviewer}
|
||||
@@ -111,7 +124,13 @@ export function OrderDetails({
|
||||
rating={mine?.rating ?? null}
|
||||
note={mine?.note ?? null}
|
||||
/>
|
||||
)}
|
||||
{/* Spine-bridged receipts (Amazon shipment notices, eBay) often
|
||||
name the goods without per-item prices — a null here is "the
|
||||
mail didn't say", and printing $0.00 would assert it did. */}
|
||||
{it.amount != null && (
|
||||
<span className="text-zinc-400 tabular-nums shrink-0">{fmt(Number(it.amount))}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -144,11 +163,13 @@ export function OrderDetails({
|
||||
</p>
|
||||
)}
|
||||
|
||||
{rateable && (
|
||||
<OrderVerdict
|
||||
transactionId={transactionId}
|
||||
reviewer={reviewer}
|
||||
onReviewerChange={setReviewer}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -247,19 +268,26 @@ function SharedToggle({
|
||||
otherName: string;
|
||||
}) {
|
||||
const setSplits = useSetSplits();
|
||||
const clearSplits = useClearSplits();
|
||||
const shared = splits.some((s) => s.participant_id === SECOND_CONSUMER_ID);
|
||||
|
||||
return (
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={setSplits.isPending}
|
||||
disabled={setSplits.isPending || clearSplits.isPending}
|
||||
onClick={() =>
|
||||
setSplits.mutate({
|
||||
// Both halves on the way in, and a real delete on the way out. This
|
||||
// used to post one 50% row to share and an empty array to un-share,
|
||||
// and the endpoint rejected both — the toggle did nothing either way.
|
||||
shared
|
||||
? clearSplits.mutate(transactionId)
|
||||
: setSplits.mutate({
|
||||
transactionId,
|
||||
splits: shared
|
||||
? []
|
||||
: [{ participant_id: SECOND_CONSUMER_ID, share_percent: 50 }],
|
||||
splits: [
|
||||
{ participant_id: OWNER_PARTICIPANT_ID, share_percent: 50 },
|
||||
{ participant_id: SECOND_CONSUMER_ID, share_percent: 50 },
|
||||
],
|
||||
})
|
||||
}
|
||||
className={`rounded border px-2 py-1 text-xs transition-colors disabled:opacity-50 ${
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useRuleMatches, useApplyRuleToSelection, type RuleMatchChange } from "@/lib/hooks";
|
||||
import { formatCategory } from "@/lib/categories";
|
||||
|
||||
@@ -39,9 +39,17 @@ export function RulePreviewModal({
|
||||
|
||||
// Default to everything the rule would change — the common case is "yes, all
|
||||
// of these", with deselection as the exception.
|
||||
useEffect(() => {
|
||||
if (data?.transactions) setSelected(new Set(data.transactions.map((t) => t.id)));
|
||||
}, [data]);
|
||||
//
|
||||
// Adjusted during render behind a previous-value guard rather than in an
|
||||
// effect. In an effect the modal painted once with nothing ticked and then
|
||||
// again with everything ticked; here the seeded selection is part of the
|
||||
// first commit. The guard is on `data` identity, so a user deselecting a row
|
||||
// is not undone on the next render — only a genuinely new preview reseeds.
|
||||
const [seededFrom, setSeededFrom] = useState<typeof data>(undefined);
|
||||
if (data !== seededFrom) {
|
||||
setSeededFrom(data);
|
||||
setSelected(new Set(data?.transactions?.map((t) => t.id) ?? []));
|
||||
}
|
||||
|
||||
const rows = data?.transactions ?? [];
|
||||
const allSelected = rows.length > 0 && selected.size === rows.length;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useState, useEffect } from "react";
|
||||
|
||||
const NAV_ITEMS = [
|
||||
{ href: "/transactions", label: "Transactions", icon: "receipt" },
|
||||
{ href: "/orders", label: "Orders", icon: "package" },
|
||||
{ href: "/statements", label: "Statements", icon: "file-text" },
|
||||
{ href: "/trips", label: "Trips", icon: "map-pin" },
|
||||
{ href: "/shared", label: "Shared", icon: "users" },
|
||||
@@ -65,6 +66,11 @@ const ICONS: Record<string, React.ReactNode> = {
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 3v12M18 9a3 3 0 100-6 3 3 0 000 6zm0 0v12M6 15a3 3 0 100 6 3 3 0 000-6zm0 0c0-4 3-6 6-6h6" />
|
||||
</svg>
|
||||
),
|
||||
package: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
),
|
||||
store: (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z" />
|
||||
@@ -76,10 +82,19 @@ export function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
// Close drawer on route change
|
||||
useEffect(() => {
|
||||
// Close the drawer on route change.
|
||||
//
|
||||
// Adjusting state during render behind a previous-value guard — the
|
||||
// documented React pattern for "reset state when a prop changes" — rather
|
||||
// than in an effect: React re-runs this component immediately and never
|
||||
// commits the intermediate DOM, so there is no cascading render. Putting it
|
||||
// in each Link's onClick is the other candidate fix and is worse here: it
|
||||
// misses browser back/forward, where the route changes with no click.
|
||||
const [lastPathname, setLastPathname] = useState(pathname);
|
||||
if (pathname !== lastPathname) {
|
||||
setLastPathname(pathname);
|
||||
setOpen(false);
|
||||
}, [pathname]);
|
||||
}
|
||||
|
||||
// Prevent body scroll when drawer is open
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
import { useParticipants, useSetSplits, useTransactionSplits, useBulkAction, useCreateRule, useCurrentUser } from "@/lib/hooks";
|
||||
|
||||
interface Split {
|
||||
@@ -33,22 +33,32 @@ export function SplitModal({ transactionId, transactionIds, amount, description,
|
||||
const [saveAsRule, setSaveAsRule] = useState(false);
|
||||
const [ruleSaved, setRuleSaved] = useState(false);
|
||||
|
||||
// Initialise: bulk always defaults to 100% Me; single loads existing splits
|
||||
useEffect(() => {
|
||||
if (!participants || participants.length === 0 || !currentUser) return;
|
||||
if (isBulk) {
|
||||
setSplitsState([{ participant_id: currentUser.id, share_percent: 100 }]);
|
||||
} else if (existingSplits && existingSplits.length > 0) {
|
||||
// Initialise: bulk always defaults to 100% Me; single loads existing splits.
|
||||
//
|
||||
// Seeded during render behind a previous-value guard rather than in an
|
||||
// effect. Both queries resolve after the first paint, so as an effect this
|
||||
// rendered an empty split table and then replaced it — and, worse, it re-ran
|
||||
// on every identity change of `participants`/`existingSplits`, which would
|
||||
// discard edits in progress if either query refetched while the modal was
|
||||
// open. Keying on the inputs that actually define the seed fixes both.
|
||||
const seedKey =
|
||||
!participants || participants.length === 0 || !currentUser
|
||||
? null
|
||||
: `${isBulk ? "bulk" : singleId}:${currentUser.id}:${
|
||||
existingSplits ? JSON.stringify(existingSplits) : "none"
|
||||
}`;
|
||||
const [seededFrom, setSeededFrom] = useState<string | null>(null);
|
||||
if (seedKey !== null && seedKey !== seededFrom) {
|
||||
setSeededFrom(seedKey);
|
||||
setSplitsState(
|
||||
existingSplits.map((s: { participant_id: number; share_percent: number }) => ({
|
||||
!isBulk && existingSplits && existingSplits.length > 0
|
||||
? existingSplits.map((s: { participant_id: number; share_percent: number }) => ({
|
||||
participant_id: s.participant_id,
|
||||
share_percent: Number(s.share_percent),
|
||||
}))
|
||||
: [{ participant_id: currentUser!.id, share_percent: 100 }]
|
||||
);
|
||||
} else {
|
||||
setSplitsState([{ participant_id: currentUser.id, share_percent: 100 }]);
|
||||
}
|
||||
}, [existingSplits, participants, isBulk, currentUser]);
|
||||
|
||||
const total = splits.reduce((sum, s) => sum + s.share_percent, 0);
|
||||
|
||||
|
||||
@@ -129,10 +129,44 @@ export const SPEND_BASE = `CASE WHEN t.interest_amount IS NOT NULL THEN t.intere
|
||||
/** Effective category, honouring overrides. Never NULL. */
|
||||
export const EFFECTIVE_CATEGORY = `COALESCE(o.category_override, t.category, 'other')`;
|
||||
|
||||
/**
|
||||
* Rows whose cost is partly mine: ones I paid for, plus ones someone else paid
|
||||
* for that I hold a split on.
|
||||
*
|
||||
* Every spend analytic used to gate on `${OWNER_SCOPE} = $1` alone, with
|
||||
* `mySplitOf` scaling *within* that gate. Ownership was therefore a
|
||||
* precondition for an expense being mine — which is backwards for a shared
|
||||
* household. Half a grocery shop is my expense whether the card was mine or
|
||||
* Sonu's; who owns the row says whose *account* the money left, a different
|
||||
* question. It is the same principle the `settled` rule already states: my
|
||||
* share is my expense regardless of who fronted it or whether it has since
|
||||
* been repaid.
|
||||
*
|
||||
* Measured on 2026-08-15 the gate hid 167 rows / $3,210.91 of my own share
|
||||
* (Jan–Jul 2026) from monthly, daily, merchants, subscriptions, fees and the
|
||||
* budget page, while `getParticipantBalances` booked the matching debt
|
||||
* correctly — so the app could tell you that you owed Sonu for a shop while
|
||||
* insisting you had not spent anything on it. Worst month was April 2026
|
||||
* (+$1,354.83), the Europe trips, where she paid for a lot.
|
||||
*
|
||||
* That figure is net of `EXCLUDE_NON_BUDGET_TAGS`: a further $456.50 of
|
||||
* non-owned share in July is `family`-tagged and correctly stays out. Counting
|
||||
* on transaction_type alone gives $3,667.41 and is the wrong comparison.
|
||||
*
|
||||
* Requires `transaction_splits ts` joined on `ts.participant_id =
|
||||
* <participant>` and ${STATEMENTS_JOIN}. That join cannot multiply rows:
|
||||
* `(transaction_id, participant_id)` is UNIQUE.
|
||||
*
|
||||
* Pair it with `myShare`, which returns 0 for a non-owned row with no split —
|
||||
* so admitting a row here can never add more than the share actually held.
|
||||
*/
|
||||
export const MY_SPEND_SCOPE = (participant = "$1") =>
|
||||
`(${OWNER_SCOPE} = ${participant} OR ts.participant_id IS NOT NULL)`;
|
||||
|
||||
/**
|
||||
* My percentage share of a transaction, 0-100.
|
||||
*
|
||||
* Resolution order:
|
||||
* On a row I paid for, resolution order:
|
||||
* 1. An explicit `transaction_splits` row for me.
|
||||
* 2. The `my_share_percent` override.
|
||||
* 3. Whatever is left after everyone else's shares.
|
||||
@@ -142,17 +176,33 @@ export const EFFECTIVE_CATEGORY = `COALESCE(o.category_override, t.category, 'ot
|
||||
* they owe all of it, and there is no row for me to find. Those rows would
|
||||
* otherwise land in my spend at full value.
|
||||
*
|
||||
* Requires `transaction_splits ts` joined on `ts.participant_id = <participant>`
|
||||
* and `transaction_overrides o` joined on the transaction.
|
||||
* On a row SOMEONE ELSE paid for, only an explicit split row counts, and its
|
||||
* absence means 0. Both of the other two steps are meaningless there and one of
|
||||
* them is actively wrong:
|
||||
*
|
||||
* - `my_share_percent` is a single column on `transaction_overrides` with no
|
||||
* participant scoping, writable by anyone who can see the row. "My" can only
|
||||
* coherently mean the owner's. Reading it as the viewer's share on someone
|
||||
* else's transaction would be a guess, and all 402 rows carrying one today
|
||||
* are owner-side, so there is nothing to be gained by guessing.
|
||||
* - `100 - everyone else` is the *payer's* remainder by definition. Applied to
|
||||
* a row I did not pay for it would hand me the payer's share on top of my
|
||||
* own.
|
||||
*
|
||||
* Requires `transaction_splits ts` joined on `ts.participant_id = <participant>`,
|
||||
* `transaction_overrides o` joined on the transaction, and ${STATEMENTS_JOIN}.
|
||||
*/
|
||||
export const myShare = (participant = "$1") => `COALESCE(
|
||||
export const myShare = (participant = "$1") => `CASE
|
||||
WHEN ${OWNER_SCOPE} = ${participant} THEN COALESCE(
|
||||
ts.share_percent,
|
||||
o.my_share_percent,
|
||||
100 - COALESCE((
|
||||
SELECT SUM(x.share_percent) FROM transaction_splits x
|
||||
WHERE x.transaction_id = t.id AND x.participant_id <> ${participant}
|
||||
), 0)
|
||||
)`;
|
||||
)
|
||||
ELSE COALESCE(ts.share_percent, 0)
|
||||
END`;
|
||||
|
||||
/** `base` scaled to my share. Use for every per-user spend total. */
|
||||
export const mySplitOf = (base: string, participant = "$1") =>
|
||||
@@ -192,6 +242,33 @@ export const SPEND_SIGNED = `CASE
|
||||
ELSE (${SPEND_BASE})
|
||||
END`;
|
||||
|
||||
/**
|
||||
* The investment line, signed: withdrawals come back as negatives so they net
|
||||
* against contributions.
|
||||
*
|
||||
* A withdrawal from a fund is a disinvestment — units converted back to cash,
|
||||
* net worth unchanged. Summed unsigned it read as *more* money invested: March
|
||||
* 2026 showed $38,615.34 of investing in a month that was net -$11,384.66,
|
||||
* because a $25,000 Raiz withdrawal was added to a $8,563.80 IBKR deposit
|
||||
* instead of cancelling it. Overstated by $50,000 in that month alone — each
|
||||
* credit costs twice, once for being added and once for not being subtracted.
|
||||
*
|
||||
* Filing withdrawals as `income` instead is the other tempting answer and is
|
||||
* worse: it books an asset disposal as earnings and feeds the same figure into
|
||||
* `net = income - spent - investments` with a flattering sign. Same reason the
|
||||
* Up item sales in Known Gaps do not belong on the income line.
|
||||
*
|
||||
* Caveat this cannot resolve: part of a withdrawal genuinely is income — the
|
||||
* capital gain. The bank descriptor is a single gross figure with no cost base,
|
||||
* so it cannot be decomposed here. Netting tracks cash committed against cash
|
||||
* returned and leaves the gain for holdings data to surface; it does not claim
|
||||
* the gain is zero.
|
||||
*/
|
||||
export const INVESTMENT_SIGNED = `CASE
|
||||
WHEN t.transaction_type IN ('refund', 'credit') THEN -COALESCE(t.amount_aud, t.amount)
|
||||
ELSE COALESCE(t.amount_aud, t.amount)
|
||||
END`;
|
||||
|
||||
/**
|
||||
* A split that still counts towards what someone owes.
|
||||
*
|
||||
|
||||
@@ -9,6 +9,32 @@ export interface ColumnMapping {
|
||||
creditCol?: string;
|
||||
merchantCol?: string;
|
||||
categoryCol?: string;
|
||||
/**
|
||||
* Column holding the account number, when the file covers more than one.
|
||||
*
|
||||
* Optional and usually absent — a bank's own export is one account per file.
|
||||
* An aggregator export (Frollo) is not, and without this every row would be
|
||||
* measured against the same statement coverage.
|
||||
*/
|
||||
accountCol?: string;
|
||||
/**
|
||||
* Column holding the ISO currency code, when the file is multi-currency.
|
||||
*
|
||||
* Without it a foreign row is stored as if it were AUD — a USD 10,782 salary
|
||||
* lands as A$10,782, understating it by about a third. Mapping it records the
|
||||
* native figure with `foreign_currency_code` set and `amount_aud` left NULL,
|
||||
* which is the same shape order ingestion uses and what `AMOUNT_UNCONVERTED`
|
||||
* looks for.
|
||||
*/
|
||||
currencyCol?: string;
|
||||
/**
|
||||
* Column holding the provider's own row id, when it has one.
|
||||
*
|
||||
* Stored as `source_ref` so re-importing the same file inserts nothing. The
|
||||
* importer has no other idempotency: `row_index` is assigned MAX+1 on every
|
||||
* run, which makes `uq_transaction_identity` structurally unable to fire.
|
||||
*/
|
||||
sourceRefCol?: string;
|
||||
}
|
||||
|
||||
export interface BankPreset {
|
||||
@@ -24,6 +50,13 @@ export interface ParsedTransaction {
|
||||
transaction_type: string;
|
||||
merchant_name?: string;
|
||||
category?: string;
|
||||
/** Raw account identifier from the file, when a column was mapped. */
|
||||
account?: string;
|
||||
/** Provider row id, when a column was mapped. */
|
||||
source_ref?: string;
|
||||
/** Set only for a non-AUD row: the native amount and its code. */
|
||||
foreign_currency_amount?: number;
|
||||
foreign_currency_code?: string;
|
||||
}
|
||||
|
||||
export function parseCSVRows(text: string): string[][] {
|
||||
@@ -108,6 +141,9 @@ export function applyMapping(
|
||||
const descIdx = idx(mapping.descriptionCol);
|
||||
const merchantIdx = mapping.merchantCol ? idx(mapping.merchantCol) : -1;
|
||||
const categoryIdx = mapping.categoryCol ? idx(mapping.categoryCol) : -1;
|
||||
const accountIdx = mapping.accountCol ? idx(mapping.accountCol) : -1;
|
||||
const sourceRefIdx = mapping.sourceRefCol ? idx(mapping.sourceRefCol) : -1;
|
||||
const currencyIdx = mapping.currencyCol ? idx(mapping.currencyCol) : -1;
|
||||
|
||||
const results: ParsedTransaction[] = [];
|
||||
for (const row of dataRows) {
|
||||
@@ -138,6 +174,17 @@ export function applyMapping(
|
||||
const tx: ParsedTransaction = { date, description, amount, transaction_type };
|
||||
if (merchantIdx >= 0 && row[merchantIdx]) tx.merchant_name = row[merchantIdx].trim();
|
||||
if (categoryIdx >= 0 && row[categoryIdx]) tx.category = row[categoryIdx].trim();
|
||||
if (accountIdx >= 0 && row[accountIdx]) tx.account = row[accountIdx].trim();
|
||||
if (sourceRefIdx >= 0 && row[sourceRefIdx]) tx.source_ref = row[sourceRefIdx].trim();
|
||||
if (currencyIdx >= 0) {
|
||||
const code = (row[currencyIdx] ?? "").trim().toUpperCase();
|
||||
// AUD needs no marking; anything else is native and unconverted. No rate
|
||||
// is invented — the statement supplies the AUD figure when it arrives.
|
||||
if (/^[A-Z]{3}$/.test(code) && code !== "AUD") {
|
||||
tx.foreign_currency_code = code;
|
||||
tx.foreign_currency_amount = amount;
|
||||
}
|
||||
}
|
||||
results.push(tx);
|
||||
}
|
||||
return results;
|
||||
@@ -154,3 +201,57 @@ export function loadBankPresets(): BankPreset[] {
|
||||
try { return JSON.parse(localStorage.getItem("csv-presets") || "[]"); }
|
||||
catch { return []; }
|
||||
}
|
||||
|
||||
/** Last four digits of an account identifier, however it is written. */
|
||||
export function last4(s: string): string {
|
||||
const d = s.replace(/\D/g, "");
|
||||
return d.length >= 4 ? d.slice(-4) : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits parsed rows into what to import and what the statements already have.
|
||||
*
|
||||
* A statement's end date is a hard watermark: everything on that account up to
|
||||
* it is already in the ledger, itemised and converted. Without this the first
|
||||
* aggregator import wrote 422 duplicates out of 550 rows; with it the same
|
||||
* 2,607-row export offers 171.
|
||||
*
|
||||
* Rows are only excluded when an account column was mapped AND that account has
|
||||
* statements. Anything else is imported — a row is never dropped on a guess.
|
||||
*/
|
||||
export function splitByCoverage(
|
||||
rows: ParsedTransaction[],
|
||||
coverage: { last4: string; coveredTo: string }[]
|
||||
): { keep: ParsedTransaction[]; covered: ParsedTransaction[] } {
|
||||
const wm = new Map(coverage.map((c) => [c.last4, c.coveredTo]));
|
||||
const keep: ParsedTransaction[] = [];
|
||||
const covered: ParsedTransaction[] = [];
|
||||
for (const r of rows) {
|
||||
const to = r.account ? wm.get(last4(r.account)) : undefined;
|
||||
// Both are YYYY-MM-DD, so a string compare is a date compare.
|
||||
if (to && r.date <= to) covered.push(r);
|
||||
else keep.push(r);
|
||||
}
|
||||
return { keep, covered };
|
||||
}
|
||||
|
||||
/**
|
||||
* Rows that duplicate another row in the SAME file.
|
||||
*
|
||||
* Distinct from the coverage check, and not catchable by `source_ref`: a CDR
|
||||
* re-consent makes the provider re-export an account's whole history under
|
||||
* fresh ids while the originals survive, so the twins are identical in
|
||||
* everything except the id. That put 385 duplicates in one 2,563-row export and
|
||||
* doubled every salary payment. 385 rows is not something you spot by eye in a
|
||||
* review table, so it is counted here.
|
||||
*/
|
||||
export function inFileDuplicates(rows: ParsedTransaction[]): number {
|
||||
const seen = new Set<string>();
|
||||
let n = 0;
|
||||
for (const r of rows) {
|
||||
const key = [r.date, r.description, r.amount, r.transaction_type, r.account ?? ""].join("|");
|
||||
if (seen.has(key)) n += 1;
|
||||
else seen.add(key);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
@@ -20,3 +20,33 @@ export async function queryRow<T>(sql: string, params: unknown[] = []): Promise<
|
||||
const rows = await prisma.$queryRawUnsafe<T[]>(sql, ...params);
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
+142
-4
@@ -16,6 +16,7 @@ interface TransactionFilters {
|
||||
from?: string;
|
||||
to?: string;
|
||||
categories?: string[];
|
||||
exclude_categories?: string[];
|
||||
bank_names?: string[];
|
||||
tag_ids?: string[];
|
||||
transaction_types?: string[];
|
||||
@@ -29,6 +30,8 @@ interface TransactionFilters {
|
||||
amount_max?: number;
|
||||
has_split?: string;
|
||||
trip_id?: string;
|
||||
/** Only the trip detail view sets this — see the note on the server-side filter. */
|
||||
trip_all_rows?: boolean;
|
||||
}
|
||||
|
||||
function buildParams(filters: TransactionFilters): string {
|
||||
@@ -37,6 +40,9 @@ function buildParams(filters: TransactionFilters): string {
|
||||
if (val === undefined || val === "") return;
|
||||
if (Array.isArray(val)) {
|
||||
if (val.length > 0) params.set(key, val.join(","));
|
||||
} else if (typeof val === "boolean") {
|
||||
// The route reads "1", not "true" — String(true) would silently not match.
|
||||
if (val) params.set(key, "1");
|
||||
} else {
|
||||
params.set(key, String(val));
|
||||
}
|
||||
@@ -142,18 +148,31 @@ export function useUpdateTransaction() {
|
||||
transaction_date?: string;
|
||||
trip_id?: number | null;
|
||||
payment_method?: string | null;
|
||||
owner_id?: number;
|
||||
release?: boolean;
|
||||
}) => {
|
||||
const res = await fetch(`/api/transactions/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
// Without this every rejection resolved as success: the modal closed, the
|
||||
// list refetched, and the edit silently vanished. The owner guard returns
|
||||
// 409 with a `code`, so the caller needs both the message and the code.
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw Object.assign(new Error(body.error || "Failed to update transaction"), {
|
||||
code: body.code as string | undefined,
|
||||
});
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["transactions"] });
|
||||
qc.invalidateQueries({ queryKey: ["transaction"] });
|
||||
qc.invalidateQueries({ queryKey: ["analytics"] });
|
||||
// An owner change flips which side of a balance the splits sit on.
|
||||
qc.invalidateQueries({ queryKey: ["participant-balances"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -251,7 +270,9 @@ export function useTransactionSplits(transactionId: number) {
|
||||
}
|
||||
|
||||
export interface OrderReceipt {
|
||||
platform: "doordash" | "ubereats" | "uber" | null;
|
||||
/** 'email' | 'receipt-scan' | 'order-bridge' | ... — which lane wrote the row. */
|
||||
source: string | null;
|
||||
platform: string | null;
|
||||
order_reference: string | null;
|
||||
line_items: { qty: number; description: string; amount: number; options?: string[] }[];
|
||||
route: { label: string; time: string | null; address: string }[];
|
||||
@@ -277,7 +298,7 @@ export function useOrderReceipt(transactionId: number) {
|
||||
});
|
||||
}
|
||||
|
||||
export type OrderRating = "loved" | "liked" | "ok" | "never";
|
||||
export type OrderRating = "loved" | "liked" | "ok" | "bad" | "never";
|
||||
export type ItemVerdict = "loved" | "never";
|
||||
|
||||
export interface ItemOpinion {
|
||||
@@ -405,6 +426,36 @@ export function useSetSplits() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove every split from a transaction — the un-share half of a toggle.
|
||||
*
|
||||
* Separate from `useSetSplits` because that endpoint requires a set of shares
|
||||
* totalling 100%, and "no split at all" is not a set of shares. Posting `[]` to
|
||||
* it was rejected, which is why the order panel's toggle could not be turned
|
||||
* off.
|
||||
*/
|
||||
export function useClearSplits() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (transactionId: number) => {
|
||||
const res = await fetch(`/api/transactions/${transactionId}/splits`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.error || "Failed to clear splits");
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["splits"] });
|
||||
qc.invalidateQueries({ queryKey: ["shared-transactions"] });
|
||||
qc.invalidateQueries({ queryKey: ["participant-balances"] });
|
||||
qc.invalidateQueries({ queryKey: ["order-review"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export interface SplitPayment {
|
||||
id: number;
|
||||
from_participant_id: number;
|
||||
@@ -415,6 +466,9 @@ export interface SplitPayment {
|
||||
payment_date: string;
|
||||
notes: string | null;
|
||||
linked_transaction_id: number | null;
|
||||
/** Which tab this payment settles. null = the ongoing household tab. */
|
||||
trip_id: number | null;
|
||||
trip_name: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -440,17 +494,25 @@ export function useRecordPayment() {
|
||||
payment_date: string;
|
||||
notes?: string;
|
||||
linked_transaction_id?: number;
|
||||
/** Which tab this settles. null/omitted = the ongoing household tab. */
|
||||
trip_id?: number | null;
|
||||
}) => {
|
||||
const res = await fetch("/api/split-payments", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.error || "Failed to record payment");
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["participant-balances"] });
|
||||
qc.invalidateQueries({ queryKey: ["split-payments"] });
|
||||
// A trip-scoped payment changes that trip's owed figures too.
|
||||
qc.invalidateQueries({ queryKey: ["trip-analytics"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -489,11 +551,18 @@ export function useUpdateStatement() {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ owner_id }),
|
||||
});
|
||||
return res.json();
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || "Failed to reassign statement");
|
||||
}
|
||||
return res.json() as Promise<{ statement_id: number; rows_moved: number }>;
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["statements"] });
|
||||
qc.invalidateQueries({ queryKey: ["transactions"] });
|
||||
// The rows moved with it, so every per-person total changed.
|
||||
qc.invalidateQueries({ queryKey: ["analytics"] });
|
||||
qc.invalidateQueries({ queryKey: ["participant-balances"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1004,9 +1073,11 @@ export function useImportCSV() {
|
||||
return useMutation({
|
||||
mutationFn: async (body: {
|
||||
bank_name: string;
|
||||
source?: string;
|
||||
transactions: {
|
||||
date: string; description: string; amount: number; transaction_type: string;
|
||||
merchant_name?: string; foreign_currency_amount?: number; foreign_currency_code?: string; category?: string;
|
||||
merchant_name?: string; foreign_currency_amount?: number; foreign_currency_code?: string;
|
||||
category?: string; account?: string; source_ref?: string;
|
||||
}[];
|
||||
}) => {
|
||||
const res = await fetch("/api/import/csv", {
|
||||
@@ -1158,3 +1229,70 @@ export function useAssignTransactionsToTrip() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- orders --
|
||||
// Browse surface over the ingestion spine. See lib/order-feed.ts for why the
|
||||
// spine is read raw and why /orders is gated by an explicit viewer allowlist.
|
||||
|
||||
import type { OrderRow, OrderFacets, OrderDetail, OrderFilters } from "./order-feed";
|
||||
export type { OrderRow, OrderFacets, OrderDetail, OrderFilters };
|
||||
|
||||
interface OrdersResponse {
|
||||
data: OrderRow[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
facets: OrderFacets;
|
||||
}
|
||||
|
||||
export function useOrders(filters: Record<string, unknown>) {
|
||||
return useQuery<OrdersResponse>({
|
||||
queryKey: ["orders", filters],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/orders?${buildParams(filters as never)}`);
|
||||
if (!res.ok) throw new Error("Failed to load orders");
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useOrderDetail(entityKey: string | null) {
|
||||
return useQuery<OrderDetail>({
|
||||
queryKey: ["order", entityKey],
|
||||
enabled: Boolean(entityKey),
|
||||
// An order's history does not change while you are looking at it; the
|
||||
// lifecycle only moves when the ingestion runner ticks.
|
||||
staleTime: 60_000,
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/orders/${encodeURIComponent(entityKey!)}`);
|
||||
if (!res.ok) {
|
||||
// Carry the status. Collapsing every failure into one Error is how a
|
||||
// 500 on this route rendered as "that order could not be found" on
|
||||
// every single order for weeks — a server fault wearing the costume of
|
||||
// a data condition, which reads as "the spine is empty" and gets
|
||||
// investigated nowhere near the actual bug.
|
||||
const err = new Error(res.status === 404 ? "Order not found" : "Failed to load order");
|
||||
(err as Error & { status?: number }).status = res.status;
|
||||
throw err;
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* How far each account's statements already reach, for the CSV import modal.
|
||||
*
|
||||
* Fetched once when the modal opens: the review step uses it to drop rows the
|
||||
* ledger already holds rather than offering them for import.
|
||||
*/
|
||||
export function useStatementCoverage() {
|
||||
return useQuery({
|
||||
queryKey: ["statement-coverage"],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/import/statement-coverage");
|
||||
if (!res.ok) throw new Error("Could not load statement coverage");
|
||||
return (await res.json()).coverage as { last4: string; coveredTo: string }[];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import { loadFiles } from "@/lib/intake-files";
|
||||
import type { PantryForward } from "@/lib/intake-pantry";
|
||||
import type { IntakeSource } from "@/lib/intake-rules";
|
||||
import {
|
||||
createFromBatch,
|
||||
loadBatch,
|
||||
rememberKind,
|
||||
resolvedCard,
|
||||
routeFood,
|
||||
routeGrocery,
|
||||
type BatchRow,
|
||||
type RouteContext,
|
||||
} from "@/lib/intake-router";
|
||||
import { replaceLineItems, TransactionNotFoundError } from "@/lib/line-items";
|
||||
import { enrichedCard, errorCard, type Block } from "@/lib/intake-blocks";
|
||||
import { queryRaw } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* The four intake taps (plan §11 C5), as one function the Slack route calls.
|
||||
*
|
||||
* Every tap acts on the persisted batch, never on the card's text. A tap on a
|
||||
* batch that is already resolved returns its current card rather than acting
|
||||
* twice — a pressed button that looked dead and got pressed again is how three
|
||||
* splits were lost on the order nudge, and the same shape applies here.
|
||||
*/
|
||||
|
||||
export const INTAKE_ACTIONS = ["intake_use_candidate", "intake_create", "intake_grocery", "intake_meal"] as const;
|
||||
export type IntakeActionId = (typeof INTAKE_ACTIONS)[number];
|
||||
|
||||
export function isIntakeAction(id: unknown): id is IntakeActionId {
|
||||
return typeof id === "string" && (INTAKE_ACTIONS as readonly string[]).includes(id);
|
||||
}
|
||||
|
||||
export type IntakeActionValue = { batch_id?: string; transaction_id?: number };
|
||||
|
||||
export function parseActionValue(raw: unknown): IntakeActionValue {
|
||||
if (typeof raw !== "string") return {};
|
||||
try {
|
||||
const v = JSON.parse(raw) as IntakeActionValue;
|
||||
return v && typeof v === "object" ? v : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
const note = (text: string): { blocks: Block[]; text: string; replace: boolean } => ({
|
||||
text,
|
||||
blocks: [{ type: "section", text: { type: "mrkdwn", text } }],
|
||||
replace: false,
|
||||
});
|
||||
|
||||
const ctxFor = (batch: BatchRow): RouteContext | null =>
|
||||
batch.header
|
||||
? {
|
||||
batchId: batch.id,
|
||||
source: batch.source as IntakeSource,
|
||||
sharedBy: batch.shared_by,
|
||||
platform: batch.header.platform,
|
||||
header: batch.header,
|
||||
items: batch.items ?? [],
|
||||
currency: batch.header.currency,
|
||||
}
|
||||
: null;
|
||||
|
||||
export async function handleIntakeAction(
|
||||
actionId: IntakeActionId,
|
||||
value: IntakeActionValue,
|
||||
deps: { pantry: PantryForward }
|
||||
): Promise<{ blocks: Block[]; text: string; replace: boolean }> {
|
||||
if (!value.batch_id) return note("That button carried no batch id — I can't tell which upload it was.");
|
||||
const batch = await loadBatch(value.batch_id);
|
||||
if (!batch) return note("I can't find that upload any more.");
|
||||
if (batch.status === "enriched" || batch.status === "created" || batch.status === "forwarded_to_pantry") {
|
||||
return { ...resolvedCard(batch), replace: true };
|
||||
}
|
||||
const ctx = ctxFor(batch);
|
||||
if (!ctx) return note("That upload never got a readable header — share it again with the top of the screen.");
|
||||
|
||||
try {
|
||||
if (actionId === "intake_use_candidate") {
|
||||
if (!Number.isInteger(value.transaction_id)) return note("That button carried no transaction id.");
|
||||
const txnId = value.transaction_id as number;
|
||||
await replaceLineItems(txnId, ctx.items, { source: "intake", platform: ctx.platform, batchId: batch.id, currency: ctx.currency });
|
||||
await queryRaw(
|
||||
`UPDATE intake_batches SET status = 'enriched', kind = COALESCE(kind, 'food'), transaction_id = $2, resolved_at = now() WHERE id = $1::uuid`,
|
||||
[batch.id, txnId]
|
||||
);
|
||||
return {
|
||||
...enrichedCard({ batchId: batch.id, merchant: ctx.header.merchant, total: ctx.header.total, orderDate: ctx.header.order_date, platform: ctx.platform, itemCount: ctx.items.length, currency: ctx.currency, transactionId: txnId }),
|
||||
replace: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (actionId === "intake_create") {
|
||||
const kind = batch.kind === "grocery" ? "grocery" : "food";
|
||||
const r = await createFromBatch(batch, kind);
|
||||
return { blocks: r.slack_blocks ?? [], text: r.text, replace: true };
|
||||
}
|
||||
|
||||
if (actionId === "intake_meal") {
|
||||
await rememberKind(ctx.header.merchant, "food");
|
||||
const r = await routeFood(ctx);
|
||||
return { blocks: r.slack_blocks ?? [], text: r.text, replace: true };
|
||||
}
|
||||
|
||||
// intake_grocery
|
||||
await rememberKind(ctx.header.merchant, "grocery");
|
||||
const files = await loadFiles(batch.file_sha256);
|
||||
if (!files.length) {
|
||||
return note("I remembered that as groceries, but the original files are gone — share it again and it will go straight to the pantry board.");
|
||||
}
|
||||
const r = await routeGrocery(ctx, files, deps.pantry);
|
||||
return { blocks: r.slack_blocks ?? [], text: r.text, replace: r.status !== "error" };
|
||||
} catch (e) {
|
||||
if (e instanceof TransactionNotFoundError) return note("That transaction is no longer in the ledger.");
|
||||
const detail = e instanceof Error ? e.message : String(e);
|
||||
return { ...errorCard(detail), replace: false };
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user