Compare commits
69
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
b8919a4775 | ||
|
|
0cb46a087b | ||
|
|
d081d80a3f | ||
|
|
db6b7f8375 | ||
|
|
788219b9fd | ||
|
|
6add958132 | ||
|
|
3339a0b9b7 | ||
|
|
3b9d302ce2 | ||
|
|
4fc8eeac95 | ||
|
|
89300450a7 | ||
|
|
b4a116c134 | ||
|
|
c9b000a428 | ||
|
|
dbfbd5196d | ||
|
|
d5589b2980 | ||
|
|
7a1acc32a9 | ||
|
|
e92fcb709f | ||
|
|
8c21893cc2 | ||
|
|
4fcb135805 | ||
|
|
ff0629462c | ||
|
|
ae23b03d5d | ||
|
|
689fadc8b9 | ||
|
|
a4ab543a6c | ||
|
|
5ee5ee24cf | ||
|
|
3bb67f370d | ||
|
|
6161ddc9de | ||
|
|
c656f5d26b | ||
|
|
4febf38292 | ||
|
|
b6cd62f7b5 | ||
|
|
df4b875b82 | ||
|
|
ae0c34fce7 | ||
|
|
5db42f086f |
@@ -13,6 +13,15 @@ jobs:
|
||||
node-version: 22
|
||||
- name: Install
|
||||
run: npm ci
|
||||
# The Prisma client is generated into src/generated/prisma, which is
|
||||
# gitignored — so a fresh checkout has no client and every test that
|
||||
# reaches src/lib/db.ts dies on "Cannot find package
|
||||
# '@/generated/prisma/client'". Schema-only, so it needs no database.
|
||||
# Without this the pipeline had been red on every run since at least
|
||||
# ae0c34f, which is how the failure stayed invisible: it looked like
|
||||
# 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)
|
||||
|
||||
@@ -45,3 +45,7 @@ next-env.d.ts
|
||||
|
||||
# Raw statement exports — real financial data, never commit
|
||||
dump/
|
||||
|
||||
# Python tooling for scripts/ (split_csv_match.py)
|
||||
.venv/
|
||||
__pycache__/
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
|
||||
Application code lives in `src/`. Next.js App Router pages and API route handlers belong in `src/app/`; reusable UI components are in `src/components/`; database access, query functions, hooks, authentication, and domain helpers are in `src/lib/`. Tests are separated into `src/__tests__/unit/` and `src/__tests__/integration/`. PostgreSQL schema and numbered SQL migrations live under `prisma/`, static assets under `public/`, operational scripts under `scripts/`, and design notes under `docs/`.
|
||||
|
||||
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 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:all` runs both test suites.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
|
||||
Use strict TypeScript, two-space indentation, semicolons, and double quotes, matching existing files. Name React components and types in PascalCase, functions and variables in camelCase, and files/routes in kebab-case. Use the `@/` alias for imports from `src/`. Preserve owner scoping and prefer transaction overrides with `COALESCE` in financial queries. Every API route must authenticate before accessing data.
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
Vitest is the test framework. Name tests `*.test.ts` and place pure logic tests under `unit/`; put PostgreSQL-dependent behavior under `integration/`. Add regression coverage for query, rule, reconciliation, and category changes. No numeric coverage threshold is configured; focus on meaningful edge cases and run `npm run test:all` before submitting database-related changes.
|
||||
|
||||
## Database, Security & Configuration
|
||||
|
||||
Add schema changes as the next numbered `prisma/migrations/NNNN_description/migration.sql`. Never commit `.env`, `.env.test`, raw statements in `dump/`, or other financial data. Consult `CLAUDE.md` and relevant `docs/` notes before changing splits, settlements, loans, reconciliation, or statement accounting.
|
||||
|
||||
## 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.
|
||||
@@ -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
|
||||
@@ -68,6 +95,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
|
||||
@@ -111,29 +161,301 @@ it silently deletes the unlogged remainder from spend totals.
|
||||
|
||||
### Shared expenses and settlement — read before touching
|
||||
|
||||
The model is under active redesign. See `docs/shared-expenses-design.md` for the
|
||||
proposal and what is already decided. Three traps:
|
||||
Rebuilt 2026-07-28. `docs/shared-expenses-design.md` describes the live model;
|
||||
it is no longer a proposal. Everything the old version of this section warned
|
||||
about has changed — if you are working from memory of it, re-read.
|
||||
|
||||
**`transaction_splits.settled` is dead data.** It is `false` on every row. Its
|
||||
only writer was `/api/splits/settle`, removed in `3f04cbd` because nothing called
|
||||
it and one request could mark all of a participant's splits settled. Do not build
|
||||
on this flag until settlement contexts exist.
|
||||
**The cutover date is the primary balance gate, not the `settled` flag.**
|
||||
`ACTIVE_OBLIGATION` (`src/lib/analytics-sql.ts`) is
|
||||
`ts.settled = false AND t.transaction_date >= '2026-01-09'`. Nothing dated
|
||||
before the cutover can ever be owed, because carryover transaction **2348**
|
||||
(dated 2026-01-09, $1,093.22) already carries the whole pre-cutover balance as
|
||||
one figure. The bound is **inclusive** — 2348 is itself dated 2026-01-09, so an
|
||||
exclusive bound would drop the carryover and the entire pre-cutover balance.
|
||||
|
||||
**`getParticipantBalances` computes `splits − payments` and is correct.** Do not
|
||||
"fix" it to exclude settled splits — the payments that settled them are still
|
||||
subtracted, so you would double-count. The two settlement models (running tab vs
|
||||
per-split flag) must not be mixed.
|
||||
Consequence: **pre-2026 transactions can be split freely.** A split on a 2024
|
||||
grocery shop describes how the expense was shared — which is what stops it
|
||||
inflating spend — without asserting a debt. 657 pre-2026 transactions carry
|
||||
1,266 such splits, imported from SplitMyExpenses and marked `settled`.
|
||||
|
||||
**Settlement cannot be attributed per trip.** `split_payments` records only
|
||||
from/to/amount/date. Any per-trip settled/unsettled figure is fabricated; the
|
||||
trip view used to show one and always reported 100% unsettled. Trips show share
|
||||
only, and point at `/shared` for real balances.
|
||||
**Spend counts settled splits; owed does not.** `myShare`/`mySplitOf` must NOT
|
||||
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.
|
||||
|
||||
Also: settlements already exist twice. Four of eight `split_payments` match an
|
||||
offset-account credit exactly on amount and date, with `linked_transaction_id`
|
||||
populated on only one. And Sonu's loan contributions (`…emi` in the offset
|
||||
account, 39 rows, $37,980.24) are categorised `transfers`, indistinguishable from
|
||||
ordinary internal transfers.
|
||||
**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`.
|
||||
`rule-actions.ts` is safe only by the shape of its upsert
|
||||
(`ON CONFLICT DO UPDATE SET share_percent` never touches the flag).
|
||||
The rules-apply revert route restores it explicitly.
|
||||
|
||||
**Settling up is recording a payment.** There is deliberately no "mark settled"
|
||||
action. `settled` marks obligations discharged *outside* this app; doing both
|
||||
would subtract the settlement twice.
|
||||
|
||||
**Payments carry scope, and one transfer can carry several rows.**
|
||||
`split_payments.trip_id` (migration 0022) says which tab a payment settles;
|
||||
NULL is the ongoing household tab. There is no unique constraint on
|
||||
`linked_transaction_id`, so a grouped transfer is recorded as one row per scope
|
||||
that re-add to the transfer — that is how Sonu's $3,779.33 and $4,794.06 were
|
||||
allocated Europe-first with the remainder to household.
|
||||
|
||||
**Trip owed must be owner-scoped; trip cost must not be.** The owed query
|
||||
applies `OWNER_SCOPE`; without it a debt between the *other two* participants
|
||||
reads as owed to you ($1,605.49 on Europe 2026). Trip *cost* deliberately counts
|
||||
every payer — a trip cost what the group put into it — which is why the stat card
|
||||
says "all payers, net of refunds". Do not "fix" the missing scoping there.
|
||||
|
||||
**Duplicates are superseded, never deleted.** `transactions.superseded_by_id`
|
||||
(migration 0023); 31 rows / $42,040.68 from overlapping ANZ statements 107/142/143.
|
||||
Every child of `transactions` is `ON DELETE CASCADE`. The exclusion lives *inside*
|
||||
`EXCLUDE_RECONCILED_SOURCE`, so any query applying that fragment gets it free —
|
||||
and any query that does not still double-counts.
|
||||
|
||||
**Refunds:** a *partial* refund is netted in SQL (`NET_SPEND_ROWS`/`SPEND_SIGNED`);
|
||||
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),
|
||||
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
|
||||
@@ -144,9 +466,79 @@ Billdu, Spotify and Patreon are not. `fees` and `charity` are 100% yours and
|
||||
correct. Check the merchants before concluding a rule was never applied — a
|
||||
category-level ratio that "looks wrong" usually is not.
|
||||
|
||||
Splits exist in this app from **2026-01-09** only; earlier splits lived in
|
||||
SplitMyExpenses. So a trailing-12-month per-person series splices six months of
|
||||
gross onto six months of net. Use Feb–Jun 2026 for anything per-person.
|
||||
**Still true, and still a caveat:** Sonu's loan contributions (`…emi` in the
|
||||
offset account, 39 rows, $37,980.24) are categorised `transfers`, indistinguishable
|
||||
from ordinary internal transfers. The loan model below is unbuilt.
|
||||
|
||||
### 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.
|
||||
|
||||
### The shared loan
|
||||
|
||||
@@ -343,6 +735,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:
|
||||
@@ -370,11 +789,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,
|
||||
|
||||
+192
-233
@@ -1,57 +1,159 @@
|
||||
# Shared expenses, settlement, and the shared loan — design proposal
|
||||
# Shared expenses and settlement
|
||||
|
||||
Status: **proposal, nothing built**. Written 2026-07-26 for review.
|
||||
Status: **built and live**, as of 2026-07-28. The loan section at the end is
|
||||
still a proposal — nothing there is built.
|
||||
|
||||
## Why this exists
|
||||
|
||||
Three questions have no answer in the current model:
|
||||
|
||||
1. Which splits does a settlement payment settle?
|
||||
2. Is the Europe trip settled, separately from the ongoing household tab?
|
||||
3. Whose expense is a $2,500 loan repayment when Sonu funds part of it?
|
||||
|
||||
They look like three problems. They are one: **the app records money moving, and
|
||||
separately records who owes whom, and the two never meet.**
|
||||
This replaces the 2026-07-26 proposal. That document described three problems
|
||||
and proposed a `settlement_contexts` table to solve them. The problems were
|
||||
real; the table was not built, and the reasoning for not building it is
|
||||
recorded under [What was rejected](#what-was-rejected).
|
||||
|
||||
---
|
||||
|
||||
## What is actually broken
|
||||
## The one rule
|
||||
|
||||
### Two settlement models, neither finished
|
||||
**Spend and owed are different questions asked of the same table, and the line
|
||||
between them is the cutover date, refined by `transaction_splits.settled`.**
|
||||
|
||||
| Model | Where | State |
|
||||
`ACTIVE_OBLIGATION` is `ts.settled = false AND t.transaction_date >=
|
||||
'2026-01-09'`. Nothing before the cutover can be owed, because carryover
|
||||
transaction 2348 already carries the entire pre-cutover balance as one figure —
|
||||
so a split on an older transaction describes only *how an expense was shared*.
|
||||
That is what makes splitting history safe, and it is why the flag is a
|
||||
refinement rather than the guard: any delete-and-recreate write path resets a
|
||||
boolean, and one did.
|
||||
|
||||
| | Counts settled splits? | Why |
|
||||
|---|---|---|
|
||||
| Running tab | `split_payments` (from, to, amount, date) | **in use** — 8 payments, $37,881.10 |
|
||||
| Per-split flag | `transaction_splits.settled` | **never used** — all 673 splits are `false` |
|
||||
| **Spend** (`myShare`, `mySplitOf`) | **Yes** | Half a 2025 grocery shop was my expense whether or not the other half was ever repaid. |
|
||||
| **Owed** (balances, trip figures) | **No** | A discharged obligation is not outstanding. |
|
||||
|
||||
They are honoured inconsistently:
|
||||
Getting this backwards in either direction is the failure the model exists to
|
||||
prevent. Filtering settled rows out of spend would re-inflate exactly the
|
||||
figures that importing settled history exists to correct.
|
||||
|
||||
- `getParticipantBalances` (the shared page) ignores `settled` entirely
|
||||
- `/api/participants/[id]/balance` filters on `settled = false`
|
||||
- `getTripAnalytics` reports settled/unsettled **from the unused flag**
|
||||
The predicate is `ACTIVE_OBLIGATION` in `src/lib/analytics-sql.ts`.
|
||||
|
||||
The third is a live bug. Every trip shows 100% unsettled forever, even though
|
||||
Molina has paid $20,782.79 against $19,556.07 of splits and is square.
|
||||
## Two orthogonal axes
|
||||
|
||||
### Settlements exist twice, unlinked
|
||||
`settled` and `trip_id` answer different questions and neither implies the
|
||||
other:
|
||||
|
||||
Four of the eight recorded payments match an offset-account credit exactly:
|
||||
- **`transaction_splits.settled`** — *is this obligation still live?*
|
||||
- **`split_payments.trip_id`** — *which tab does this payment settle?*
|
||||
NULL means the ongoing household tab.
|
||||
|
||||
| Payment date | From | Amount | Offset transaction |
|
||||
|---|---|---:|---|
|
||||
| 2026-02-02 | Molina | 7,500.00 | `Transfer from - MEGHALEE BOSE mummy Pa…` |
|
||||
| 2026-04-12 | Sonu | 3,779.33 | `Transfer from - MEGHALEE BOSE transfer` |
|
||||
| 2026-04-21 | Molina | 1,685.24 | `Transfer from - MEGHALEE BOSE mummy split` |
|
||||
| 2026-05-16 | Sonu | 4,794.06 | `Transfer from - MEGHALEE BOSE transfer` |
|
||||
A trip can be fully paid while the household tab runs a balance, and vice
|
||||
versa. Before migration 0022 there was one global pool and this could not be
|
||||
expressed, so every trip reported 100% unsettled forever — including trips paid
|
||||
in full.
|
||||
|
||||
The same money is a `split_payments` row *and* a `transactions` row.
|
||||
`split_payments.linked_transaction_id` exists but only 1 of 8 rows uses it. So a
|
||||
settlement is bookkeeping that happens to resemble a bank credit, rather than
|
||||
being that credit.
|
||||
## How settling up actually works
|
||||
|
||||
### The shared loan is invisible
|
||||
**By recording a payment.** There is deliberately no "mark settled" action
|
||||
anywhere in the app.
|
||||
|
||||
Sonu's contributions are already in the ledger and unrecognised:
|
||||
`settled` marks obligations discharged *outside* this app — the imported
|
||||
SplitMyExpenses history, whose repayments happened on a platform we no longer
|
||||
run and which therefore have no `split_payments` row here. A live obligation is
|
||||
settled by recording the payment, and the balance nets to zero on its own.
|
||||
|
||||
Doing both would subtract the settlement twice: the splits leave the sum *and*
|
||||
the payment is deducted, driving the balance negative by the amount repaid.
|
||||
|
||||
## What is built
|
||||
|
||||
| Piece | Where | Note |
|
||||
|---|---|---|
|
||||
| `settled` as the single balance gate | `ACTIVE_OBLIGATION` | Applied in both arms of the balances UNION and in the trip owed query |
|
||||
| Payment scope | `split_payments.trip_id` (migration 0022) | Household payments do not settle a trip, and vice versa |
|
||||
| Owner-scoped owed | `OWNER_SCOPE` in the trip owed query | Without it, a debt between the *other two* participants was reported as owed to the owner — $1,605.49 on Europe 2026 |
|
||||
| Direction on screen | `/trips/[id]`, `/shared` | all square / owes you / ahead — you owe them |
|
||||
| Historical splits | `scripts/split_csv_match.py` | 1,242 rows across 657 transactions, all `settled` |
|
||||
| Duplicate suppression | `transactions.superseded_by_id` (migration 0023) | 31 rows, $42,040.68 |
|
||||
| Overlap detection | `STATEMENT_OVERLAPS` → statements page | Red badge; catches the cause rather than the symptom |
|
||||
|
||||
## The historical import
|
||||
|
||||
The five SplitMyExpenses CSVs are the record of how expenses were shared before
|
||||
this app existed. 676 of 1,536 shareable rows matched (44%), and 1,242 split
|
||||
rows were written as `settled = true`.
|
||||
|
||||
**The deliverable is historical spend, not balances.** $35,259 left my spend —
|
||||
$13,088 in 2024 and $22,117 in 2025 — because a $200 grocery shop that was
|
||||
always half hers no longer reads as $200 of mine. Balances were byte-identical
|
||||
before and after, which is the assertion that mattered.
|
||||
|
||||
Three things the matcher has to get right, each of which has bitten:
|
||||
|
||||
1. **Date format is decided per file.** The household export writes D/M/YYYY and
|
||||
the four trip exports write ISO; 474 rows parse validly under both readings.
|
||||
Guessing per row silently swaps January and February for some rows and not
|
||||
others.
|
||||
2. **A person's column is net balance impact, not their share.** The payer is
|
||||
whoever is positive; the other's share is `|their negative| / cost`. So a
|
||||
`+cost / -cost` row means the other party owes **100%** — not that the
|
||||
expense was unshared, which is the reading that fakes an arrangement change.
|
||||
3. **Matching is one-to-one, best pair first.** The NZ trip has two identical
|
||||
$10.16 Uber rows against three ledger rows; without this a ledger row is
|
||||
claimed repeatedly while the second CSV row looks matched and is not.
|
||||
|
||||
**The 44% is a coverage ceiling, not a matcher weakness.** The CSVs describe 678
|
||||
shared expenses in 2024; the ledger holds 591 rows for all of 2024, 3 to 72 a
|
||||
month, far less than a household actually spends. South Korea April 2024 matches
|
||||
4 of 158. Chasing a higher rate is chasing transactions that were never
|
||||
imported.
|
||||
|
||||
### A reversed recommendation
|
||||
|
||||
The 2026-07-26 proposal said, under *What I would not do*: "**Do not** restate
|
||||
history from the SplitMyExpenses CSVs… the value is low: those balances are
|
||||
settled and will not change."
|
||||
|
||||
That was overturned on 2026-07-28, and it was wrong in an instructive way: it
|
||||
measured the value in *balances*, where it is indeed nil, and missed the value
|
||||
in *spend*, where it is $35,259. Importing as `settled` gets the second without
|
||||
touching the first. The "combining problem" it cited is real and is why the
|
||||
match rate is capped — but a partial restatement of spend beats none, and rows
|
||||
that cannot be matched simply keep their current treatment.
|
||||
|
||||
## What was rejected
|
||||
|
||||
**`settlement_contexts` as a table.** The need was real — a payment must say
|
||||
what it settles. But trips already exist and already carry membership on
|
||||
`transaction_overrides.trip_id`, so scope is a read of existing data rather
|
||||
than a new grouping key. One nullable column on `split_payments` expressed it.
|
||||
|
||||
A general context table would have meant a new entity to create and maintain
|
||||
before a payment could be recorded, in a two-person household with two trips.
|
||||
|
||||
**Deleting duplicate transactions.** Every child of `transactions` is
|
||||
`ON DELETE CASCADE`, and which member of a duplicate pair holds the curation is
|
||||
an accident of import order. Duplicates are superseded instead: the row stays,
|
||||
keeps its children, and points at the row that replaces it.
|
||||
|
||||
**Reusing `reconciled_with_id` for duplicates.** Its predicate is scoped to
|
||||
`statement_id IS NULL` on purpose — a statement line pointing at something else
|
||||
is the survivor, not the duplicate. In the duplicate-import case both rows are
|
||||
statement lines, so that predicate can never hide either.
|
||||
|
||||
## Scale note
|
||||
|
||||
This is a home app for one user, occasionally two, and the second user consumes
|
||||
the splits view and little else. Reviews of this subsystem have repeatedly
|
||||
proposed enterprise-grade reconciliation, lineage and audit machinery; the
|
||||
*findings* are often right and the *sizing* is not. A one-column solution a
|
||||
person can hold in their head beats a correct-but-unmaintainable one here.
|
||||
|
||||
---
|
||||
|
||||
## Still a proposal: the shared loan
|
||||
|
||||
**Nothing in this section is built.**
|
||||
|
||||
Sonu's contributions are in the ledger and unrecognised. All are categorised
|
||||
`transfers` — correct for spend, but it makes a loan contribution and an expense
|
||||
settlement indistinguishable:
|
||||
|
||||
| Pattern in offset credits | Rows | Total | Meaning |
|
||||
|---|---:|---:|---|
|
||||
@@ -59,162 +161,15 @@ Sonu's contributions are already in the ledger and unrecognised:
|
||||
| `…mummy…` | 6 | $29,721.24 | Molina's money, forwarded by Sonu |
|
||||
| other Meghalee | 15 | $71,130.27 | Sonu's own settlements |
|
||||
|
||||
All are categorised `transfers` — correct for spend purposes, but it means a
|
||||
loan contribution and an expense settlement are indistinguishable.
|
||||
|
||||
Meanwhile the loan itself, over the 12 imported months:
|
||||
|
||||
| | |
|
||||
|---|---:|
|
||||
| Principal repaid (`investment`, excluded from spend) | $63,500.00 |
|
||||
| Interest charged (`loan_interest`, the only part counted as spend) | $16,523.64 |
|
||||
|
||||
At roughly $25,000/year of `emi` against ~$80,000 of annual repayments, Sonu
|
||||
funds about **31%** — of both the equity being built and the interest being paid.
|
||||
Today 100% of the interest counts as your spend and 100% of the equity as yours.
|
||||
|
||||
---
|
||||
|
||||
## The core problem
|
||||
|
||||
The model conflates two different things:
|
||||
|
||||
- **Money movement** — a credit landed in the offset account
|
||||
- **Obligation** — someone owed someone else, and now owes less
|
||||
|
||||
A settlement is both. A loan contribution is both. Right now movement lives in
|
||||
`transactions` and obligation lives in `transaction_splits` / `split_payments`,
|
||||
with nothing joining them. That is why a payment cannot say what it settles: it
|
||||
was never attached to anything in the first place.
|
||||
|
||||
---
|
||||
|
||||
## Proposed model
|
||||
|
||||
### 1. Settlement contexts
|
||||
|
||||
Splits belong to something that is settled **as a unit**. Payments name which
|
||||
unit they settle. Balance is computed per context, not globally.
|
||||
|
||||
| Context | Splits | Settled by | State |
|
||||
|---|---|---|---|
|
||||
| Household (default) | ongoing | periodic payments | running tab |
|
||||
| Europe 2026 | trip-bound | lump sum | closeable |
|
||||
| Pre-2026 (SplitMyExpenses) | historical | settled elsewhere | **born closed** |
|
||||
|
||||
A closed context still contributes to analytics — you see your true share — but
|
||||
contributes nothing to what anyone owes.
|
||||
|
||||
This answers all three opening questions, and it dissolves the `splitFrom` date
|
||||
cutoff: pre-2026 splits can be applied retroactively **because they are born
|
||||
into a closed context**, so they fix the analytics without creating debt. No date
|
||||
guard needed, no risk of resurrecting settled obligations.
|
||||
|
||||
Mechanically: `settlement_contexts` table; `transaction_splits.context_id`;
|
||||
`split_payments.context_id`. `transaction_splits.settled` becomes derived
|
||||
("is my context closed?") or is dropped.
|
||||
|
||||
### 2. Payments are transactions, not a side table
|
||||
|
||||
A settlement is the offset-account credit. `split_payments` becomes a thin
|
||||
attribution layer over a real transaction rather than a parallel record of it:
|
||||
|
||||
- Populate `linked_transaction_id` on all existing payments where a match exists
|
||||
- On ingestion, an incoming credit that looks like a settlement is *proposed* as
|
||||
one for confirmation, rather than silently becoming `transfers`
|
||||
- A payment with no matching transaction (cash, or an account not imported)
|
||||
stays as a manual row — the model must tolerate that
|
||||
|
||||
### 3. The shared loan — a separate ledger
|
||||
|
||||
Not a settlement context. The loan is a jointly funded asset with its own
|
||||
obligation, and mixing it with expense settlement would let a contribution
|
||||
accidentally settle a dinner.
|
||||
|
||||
- `emi` credits are recognised as **contributions**, not generic transfers
|
||||
- A **contribution schedule** states what is owed per period (50% of the
|
||||
repayment), independent of what was actually paid
|
||||
- The running difference is a **receivable** — currently $4,000.00
|
||||
|
||||
The schedule matters: during Sonu's leave the obligation did not change, only the
|
||||
payment did. A percentage-of-actual model would silently redefine her share as
|
||||
30% and make the shortfall disappear.
|
||||
|
||||
---
|
||||
|
||||
## Migration path
|
||||
|
||||
### Phase 0 — DONE (2026-07-26, commit `3f04cbd`)
|
||||
|
||||
Stop the trip view reporting a settlement breakdown it cannot compute.
|
||||
|
||||
The original plan was "make `getTripAnalytics` and `getParticipantBalances`
|
||||
agree". **That plan was wrong** and reading the code before building is what
|
||||
caught it:
|
||||
|
||||
- `getParticipantBalances` is *not* buggy. It computes `splits − payments`,
|
||||
which is coherent. Excluding settled splits there while still subtracting the
|
||||
payments that settled them would have double-counted and broken a working page.
|
||||
- The real defect was narrower: the trip view showed Settled/Unsettled from
|
||||
`transaction_splits.settled`, which nothing sets. A correct per-trip figure is
|
||||
not computable at all, because `split_payments` has no trip attribution.
|
||||
|
||||
So the fix was **subtractive**: the trip view now shows each participant's share
|
||||
and points at Shared for what is actually owed.
|
||||
|
||||
Also removed `/api/splits/settle` — unreachable from the UI but live on its URL,
|
||||
where one call with `participant_id` would mark every one of that person's splits
|
||||
settled, writing a flag nothing reads.
|
||||
|
||||
`transaction_splits.settled` / `settled_at` still exist and are now pure dead
|
||||
data. Phase 1 either repurposes them ("is my context closed?") or drops them.
|
||||
|
||||
### Phase 1 — settlement contexts
|
||||
|
||||
Add contexts; put every existing split in "Household"; every payment likewise.
|
||||
Balance queries group by context. Touches `queries.ts` (both balance CTEs),
|
||||
`shared/page.tsx`, `trips/[id]/page.tsx`, `split-payments/route.ts`. ~1 day.
|
||||
|
||||
### Phase 2 — link payments to transactions
|
||||
|
||||
Backfill `linked_transaction_id` for the four exact matches; flag the other four
|
||||
for manual linking. On ingestion, propose a matching credit as a settlement
|
||||
rather than silently categorising it `transfers`. ~half a day.
|
||||
|
||||
### Phase 3 — retroactive pre-2026 split
|
||||
|
||||
Create the "Pre-2026" closed context. Apply household split rules into it via the
|
||||
rule preview (`/api/rules/[id]/matches`, built 2026-07-26) — fixes ~$97,627 of
|
||||
the trailing 12 months currently shown as 100% yours. Then delete the `splitFrom`
|
||||
cutoff entirely.
|
||||
|
||||
**Validate the ratio first.** This assumes today's 50/50 held through 2025. The
|
||||
SplitMyExpenses CSVs should be used to *check* that assumption — not to
|
||||
reconcile, since transactions were sometimes combined and exact matching is
|
||||
impossible.
|
||||
|
||||
### Phase 4 — loan ledger
|
||||
|
||||
Contribution schedule, contributions recognised from `emi` credits, running
|
||||
receivable. Independent of contexts — the loan is a separate ledger. ~1–2 days.
|
||||
|
||||
---
|
||||
|
||||
## Decisions taken (2026-07-26)
|
||||
|
||||
### The loan is separate from shared expenses
|
||||
|
||||
Different obligations, different rhythms, different nature: one funds an asset,
|
||||
the other funds consumption. They do not share a settlement context and a
|
||||
contribution is never a settlement.
|
||||
the other funds consumption. They do not share a settlement scope, and a
|
||||
contribution must never settle a dinner.
|
||||
|
||||
### The share is 50/50, fixed — with the shortfall tracked
|
||||
### The share is 50/50 fixed, with the shortfall tracked
|
||||
|
||||
Not derived from actual payments, which fluctuate. Sonu's obligation is half the
|
||||
repayment; the difference between obligation and actual is a **receivable**, and
|
||||
it is the interesting number.
|
||||
|
||||
Over 2025-07-01 → 2026-06-30:
|
||||
Not derived from actual payments, which fluctuate. Over 2025-07-01 → 2026-06-30:
|
||||
|
||||
| | |
|
||||
|---|---:|
|
||||
@@ -223,65 +178,69 @@ Over 2025-07-01 → 2026-06-30:
|
||||
| Actually contributed (26 payments) | $27,750.00 |
|
||||
| **Shortfall** | **$4,000.00** |
|
||||
|
||||
She never missed a fortnight; the rate changed:
|
||||
|
||||
| Rate | Payments | Period |
|
||||
|---|---:|---|
|
||||
| $1,250 | 15 | Aug 2025 – Feb 2026 (the correct 50%) |
|
||||
| $1,000 | 3 | Jul 2025 (pre-adjustment) |
|
||||
| $750 | 8 | Mar – Jun 2026 (leave) |
|
||||
She never missed a fortnight; the rate changed — $1,250 × 15 (Aug 2025–Feb
|
||||
2026, the correct 50%), $1,000 × 3 (Jul 2025, pre-adjustment), $750 × 8
|
||||
(Mar–Jun 2026, leave).
|
||||
|
||||
So the model needs a **contribution schedule** (expected per period) alongside
|
||||
actual contributions, with the running difference as a tracked balance. A flat
|
||||
percentage cannot express "obligation unchanged, payment temporarily reduced,
|
||||
difference owed".
|
||||
actual contributions, with the running difference as a tracked receivable. A
|
||||
flat percentage-of-actual cannot express "obligation unchanged, payment
|
||||
temporarily reduced, difference owed" — it would silently redefine her share as
|
||||
30% and make the shortfall disappear.
|
||||
|
||||
### Interest: recommended as expense, pending final call
|
||||
|
||||
The mechanics are as described — interest is debited to the loan and repayments
|
||||
pay down the combined balance. Reconciles exactly:
|
||||
Over 12 months $63,500 of cash left and debt fell by $44,127.36. The $16,523.64
|
||||
difference bought nothing and is not recoverable — an expense by definition.
|
||||
Excluding it leaves the balance sheet unable to reconcile cash out against
|
||||
equity gained, and understates annual cost by ~10%.
|
||||
|
||||
134: 31,000.00 − 8,553.27 = 22,446.73 = balance reduction
|
||||
133: 32,500.00 − 7,970.37 − 2,849.00 = 21,680.63 = balance reduction
|
||||
The legitimate concern is that interest is non-discretionary. The answer is a
|
||||
fixed-commitments grouping alongside rent, insurance and utilities — a
|
||||
presentation change, not an exclusion.
|
||||
|
||||
But mechanics are not the same as economics. Over 12 months $63,500 of cash left
|
||||
and debt fell by $44,127.36. The $16,523.64 difference bought nothing and is not
|
||||
recoverable — that is an expense by definition. Excluding it leaves the balance
|
||||
sheet unable to reconcile cash out against equity gained, and understates annual
|
||||
cost by ~10%.
|
||||
|
||||
The legitimate concern is that interest is **non-discretionary**. The answer to
|
||||
that is a fixed-commitments grouping alongside rent, insurance and utilities —
|
||||
a presentation change, not an exclusion.
|
||||
|
||||
**Recommendation: keep `loan_interest` as spend, add a fixed/discretionary
|
||||
split.** Flagged rather than settled: it is a judgement about what "spend" means
|
||||
in your own reporting.
|
||||
**Do not** model the loan as a recurring split: that would put $2,500 a
|
||||
fortnight of principal into spend, the error migration 0014 exists to prevent.
|
||||
|
||||
## Open questions
|
||||
|
||||
1. **Does equity need tracking per person?** If Sonu accrues a share of the
|
||||
principal, that is a balance-sheet item the app has no concept of. Probably
|
||||
belongs in the net-worth view rather than here.
|
||||
|
||||
4. **Attribution of forwarded payments.** `mummy` in the description reliably
|
||||
belongs in a net-worth view rather than here.
|
||||
2. **Attribution of forwarded payments.** `mummy` in the description reliably
|
||||
marks Molina's money in all six known cases, but it is a description match on
|
||||
a free-text field. Acceptable as a *suggestion* requiring confirmation; not as
|
||||
an automatic rule.
|
||||
a free-text field. Acceptable as a *suggestion* requiring confirmation, not
|
||||
as an automatic rule.
|
||||
3. **The solo leg.** A Qantas booking on 23 Apr (txn 2849, $1,366.40) is the
|
||||
flight to Bangkok that begins a solo leg, and the Singapore spending
|
||||
($2,242.05, 40 rows, to 9 May) is solo — not shared. It has no trip record.
|
||||
Worth one if trip *cost* is wanted for it; nothing about sharing depends on
|
||||
it.
|
||||
|
||||
5. **Retroactive split ratios.** Applying today's household rules to 2025
|
||||
assumes the arrangement has not changed. The SplitMyExpenses CSVs could give
|
||||
real historical shares, but transactions were sometimes combined, so matching
|
||||
is imperfect. Recommendation: use today's ratios, accept the approximation —
|
||||
the goal is a truer analytics picture, not a restated ledger.
|
||||
## Resolved
|
||||
|
||||
---
|
||||
- **Europe — Sonu + Sunny** (trip 3, 2026-04-12 → 2026-04-28, 124 rows,
|
||||
$9,914.24). The leg after the group trip, previously marked only by tag 5 and
|
||||
invisible to trip analytics. Includes one advance booking on 17 Mar
|
||||
(Ticketmaster Nanterre) and the 12 Apr handover-day rows, which were already
|
||||
held out of Europe 2026.
|
||||
|
||||
## What I would not do
|
||||
- **Grouped payments, split by scope.** Payments are made grouped — one transfer
|
||||
covers several tabs — and that needs no schema change, because
|
||||
`split_payments` has no unique constraint on `linked_transaction_id`. So one
|
||||
bank transfer carries one row per scope, and the rows re-add to the transfer.
|
||||
|
||||
- **Do not** restate history from the SplitMyExpenses CSVs. The combining problem
|
||||
makes exact reconciliation impossible, and the value is low: those balances are
|
||||
settled and will not change.
|
||||
- **Do not** make the loan a shared *expense*. It is a funded asset. Modelling it
|
||||
as a recurring split would put $2,500 a fortnight of principal into spend,
|
||||
which is the error migration 0014 was written to prevent.
|
||||
Sonu's two "transfer" payments were allocated Europe-first, remainder to
|
||||
household, chronologically so each settles what was outstanding when it was
|
||||
made:
|
||||
|
||||
| Transfer | Scope | Amount |
|
||||
|---|---|---:|
|
||||
| $3,779.33, 12 Apr (txn 4111) | Europe 2026 | 1,084.61 |
|
||||
| | Europe — Sonu + Sunny | 2,694.72 |
|
||||
| $4,794.06, 16 May (txn 4121) | Europe — Sonu + Sunny | 1,145.52 |
|
||||
| | household | 3,648.54 |
|
||||
|
||||
Both Europe tabs now read $0.00 and her overall balance is unchanged at
|
||||
$5,428.08 — allocation moves money between tabs, never between people. That
|
||||
invariance is the check worth repeating on any future re-allocation.
|
||||
|
||||
@@ -0,0 +1,742 @@
|
||||
# UI and information architecture review
|
||||
|
||||
**Date:** 2026-07-26
|
||||
**Status:** Priority 0 implemented 2026-07-27 (see below). Priorities 1–4 remain
|
||||
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)
|
||||
|
||||
All six Priority 0 items landed, with three amendments found while verifying the
|
||||
proposals against the code:
|
||||
|
||||
1. **Reconciled source rows** — the exclusion was missing from *all five*
|
||||
analytics routes, not only `/monthly`. It is now one fragment
|
||||
(`EXCLUDE_RECONCILED_SOURCE`) that `queries.ts` also imports, so the two
|
||||
halves cannot drift apart again. Real effect: 48 rows, **$4,474.79** of
|
||||
double-counted spend removed from every category total, mover, Pareto and
|
||||
merchant ranking.
|
||||
2. **Spend pace** — now served by `/api/analytics/daily`, built from the same
|
||||
fragments as the headline. Measured on live data, the old client-side series
|
||||
ended July at **$4,747.31** against a headline of **$3,597.10** — a 32%
|
||||
overstatement of the number directly above it.
|
||||
3. **Fees and interest** — bounded by an explicit period (default 12 months,
|
||||
`months=0` for all time), with the range shown and selectable. The unbounded
|
||||
figure was overstating the last 12 months by roughly **$2,700 of fees**.
|
||||
4. **Split-coverage warning** — *deliberately not implemented* (user decision,
|
||||
2026-07-27).
|
||||
5. **Shared foreign currency** — amended. The obvious fix, reading `s.currency`,
|
||||
would have mislabelled every order row as AUD, because an order receipt has
|
||||
no statement and carries its own currency. Sourcing is now
|
||||
`NATIVE_CURRENCY = COALESCE(s.currency, t.foreign_currency_code, 'AUD')`,
|
||||
whose COALESCE order keeps two opposite denomination conventions apart. Note
|
||||
this change is **latent on today's data**: no foreign transaction is
|
||||
currently split, so nothing on Shared looks different yet.
|
||||
6. **Partial-month comparisons** — the hero average, the top movers and the pace
|
||||
baseline now exclude the in-progress month, and compare through the same day
|
||||
of the month when the selected month is the current one.
|
||||
|
||||
Also fixed while in here, both found by checking rather than by proposal:
|
||||
|
||||
- **Every analytics window was a day early.** `toISOString()` on a local-midnight
|
||||
`Date` converts backwards through UTC in any timezone east of Greenwich. Now
|
||||
`toDateStr()`. This was pre-existing in `/monthly` and `/merchants`.
|
||||
- **Rounding grain.** `/monthly` rounded per category and `/daily` per
|
||||
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.
|
||||
|
||||
Partially guarded by `src/__tests__/integration/analytics-sql.test.ts`, which
|
||||
covers the SQL fragments — `EXCLUDE_RECONCILED_SOURCE`, `NATIVE_CURRENCY` and
|
||||
`INVESTMENT_SIGNED` — and nothing else. **Items 2, 3 and 6 have no test.** Those
|
||||
are the three where a regression is silent rather than loud: the pace chart would
|
||||
simply go 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 — all without anything failing. Tracked as #155.
|
||||
|
||||
### 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
|
||||
subscriptions, not the 8 listed. The case for replacing it stands — a flat
|
||||
binary cannot express obligation — but that is the reason, not arbitrary
|
||||
membership. Note too that the proposed Fixed/Essential/Lifestyle model needs a
|
||||
commitment dimension that does not exist yet: `fees` cannot be split into
|
||||
avoidable versus known-annual, `subscriptions` cannot be split into contractual
|
||||
versus cancellable, and the contracted loan repayment is not in the spend stream
|
||||
at all (`SPEND_BASE` keeps only the interest portion). That is a data-model
|
||||
change, not an Insights rework.
|
||||
|
||||
## Executive summary
|
||||
|
||||
The July 19 UI refresh gave the app a cohesive and distinctive visual identity.
|
||||
The ink-and-copper palette, typography, financial number treatment, month spine,
|
||||
and transaction drill-downs are all strong foundations.
|
||||
|
||||
The larger remaining issue is not appearance. It is information hierarchy.
|
||||
Analytics and Insights contain useful data, but they are reporting-heavy rather
|
||||
than decision-oriented. Shared communicates the immediate running balance, but
|
||||
the current settlement model prevents it from answering which expenses a payment
|
||||
settled, whether a trip is closed, or how the shared loan should be represented.
|
||||
|
||||
The product should make four questions easy to answer:
|
||||
|
||||
1. Am I financially okay?
|
||||
2. What changed and why?
|
||||
3. What needs my attention?
|
||||
4. Who owes what, and for which expenses?
|
||||
|
||||
Today there is no single page that answers the first three. The app opens on
|
||||
Transactions and presents ten equally weighted navigation items.
|
||||
|
||||
The recommended direction is:
|
||||
|
||||
- Add an Overview as the default landing page.
|
||||
- Keep Analytics focused on historical exploration: **what happened?**
|
||||
- Rebuild Insights around decisions and attention: **what should I know or do?**
|
||||
- Rebuild Shared around settlement contexts: **who owes what, and why?**
|
||||
- Keep the shared loan as a separate ledger from shared consumption expenses.
|
||||
- Fix calculation and coverage inconsistencies before adding more visualisations.
|
||||
|
||||
## Context reviewed
|
||||
|
||||
This review covered:
|
||||
|
||||
- The current Next.js pages and shared components.
|
||||
- Analytics SQL and API calculations.
|
||||
- Shared-expense balance and transaction queries.
|
||||
- `CLAUDE.md`.
|
||||
- `docs/shared-expenses-design.md`.
|
||||
- `docs/expense-baseline.md`.
|
||||
- Recent repository history.
|
||||
- Recent finance-app memories retrieved from OpenViking.
|
||||
|
||||
The OpenViking history confirmed:
|
||||
|
||||
- The July 19 redesign intentionally introduced the ink-and-copper theme,
|
||||
Fraunces display type, month-spine navigation, top movers, category
|
||||
sparklines, and heat-tinted ledger tables.
|
||||
- The user prefers a modern, high-fidelity interface and actionable analytics.
|
||||
- Later July 25–26 work changed the financial meaning under those screens:
|
||||
split-aware personal spend, AUD-aware settlement, refund netting, loan
|
||||
principal/interest separation, rule previews, and the proposed contextual
|
||||
settlement model.
|
||||
- The preferred settlement model links payments to real transactions, separates
|
||||
Household, Trip, and Historical contexts, and keeps the shared loan separate.
|
||||
|
||||
## What already works
|
||||
|
||||
### Visual system
|
||||
|
||||
- The dark ink-and-copper theme is coherent and distinctive.
|
||||
- Serif headings and mono financial figures create useful hierarchy.
|
||||
- The copper accent is used consistently for selection and emphasis.
|
||||
- The design feels like one application rather than a collection of unrelated
|
||||
pages.
|
||||
|
||||
### Analytics interactions
|
||||
|
||||
- The month spine is an effective year-at-a-glance navigation control.
|
||||
- “What changed” is more useful than a generic category chart.
|
||||
- Category sparklines make direction visible without creating a large
|
||||
multi-series chart.
|
||||
- Category rows can be expanded into their transactions.
|
||||
- Inline recategorisation allows users to correct the data while investigating
|
||||
it.
|
||||
|
||||
### Shared workflow
|
||||
|
||||
- “Owes you,” “you owe,” and “all square” communicate the immediate relationship
|
||||
balance clearly.
|
||||
- Payment history is preserved rather than reducing settlement to a boolean.
|
||||
- Participant and tag filters support practical investigation.
|
||||
- Split transactions can be edited without returning to the main transaction
|
||||
page.
|
||||
|
||||
## App-wide information architecture
|
||||
|
||||
### Current problem
|
||||
|
||||
The app redirects `/` to `/transactions`. This makes the operational ledger the
|
||||
default product surface. Transactions are important, but they do not tell the
|
||||
user whether anything needs attention or what the current financial position
|
||||
means.
|
||||
|
||||
The sidebar also gives equal weight to:
|
||||
|
||||
- operational screens such as Reconcile;
|
||||
- analytical screens such as Analytics;
|
||||
- configuration screens such as Rules;
|
||||
- organisational screens such as Tags.
|
||||
|
||||
This makes the product feel like a database administration interface even when
|
||||
the individual pages are well designed.
|
||||
|
||||
The `/budget` route is labelled Analytics in navigation. This is a leftover from
|
||||
an older product concept and should become `/analytics`.
|
||||
|
||||
### Recommended navigation
|
||||
|
||||
Group navigation by intent:
|
||||
|
||||
**Overview**
|
||||
|
||||
- Overview
|
||||
|
||||
**Money**
|
||||
|
||||
- Transactions
|
||||
- Statements
|
||||
- Reconcile
|
||||
|
||||
**Understand**
|
||||
|
||||
- Analytics
|
||||
- Insights
|
||||
- Merchants
|
||||
|
||||
**Shared**
|
||||
|
||||
- Shared
|
||||
- Trips
|
||||
- Loan
|
||||
|
||||
**Organise**
|
||||
|
||||
- Tags
|
||||
- Rules
|
||||
|
||||
Lower-frequency configuration items can be visually separated or collapsed.
|
||||
|
||||
### Recommended Overview
|
||||
|
||||
The default landing page should be a concise status and attention surface, not
|
||||
another full analytics dashboard.
|
||||
|
||||
Suggested structure:
|
||||
|
||||
1. **This month**
|
||||
- Personal spend to date
|
||||
- Expected baseline at this point in the month
|
||||
- Income
|
||||
- Net cash
|
||||
|
||||
2. **Financial resilience**
|
||||
- Realistic monthly baseline
|
||||
- Cash coverage in months
|
||||
- Redraw shown separately from cash
|
||||
|
||||
3. **Needs attention**
|
||||
- Uncategorised or `other` transactions
|
||||
- Unreconciled transactions
|
||||
- Statements failing balance assertions
|
||||
- New or unusual recurring charges
|
||||
- Shared expenses added since the last settlement
|
||||
|
||||
4. **Shared**
|
||||
- Current balances by person and context
|
||||
- Loan contribution shortfall shown separately
|
||||
|
||||
5. **Recent change**
|
||||
- The two or three categories that explain the largest movement
|
||||
|
||||
The Overview should link into Analytics, Insights, Shared, and Reconcile rather
|
||||
than reproduce their complete tables.
|
||||
|
||||
## Analytics review
|
||||
|
||||
### What the current page does
|
||||
|
||||
The current Analytics page includes:
|
||||
|
||||
- selected-month spend hero;
|
||||
- twelve-month month spine;
|
||||
- income, expenses, invested, and net-cash strip;
|
||||
- top category movers;
|
||||
- eight category sparkline cards;
|
||||
- spend-concentration Pareto chart;
|
||||
- cumulative spend pace;
|
||||
- expandable category table;
|
||||
- six-month heat-tinted category ledger.
|
||||
|
||||
Each component is defensible in isolation. Together, they create too many
|
||||
competing summaries of the same category data.
|
||||
|
||||
### What Analytics should answer
|
||||
|
||||
Analytics should answer:
|
||||
|
||||
> What happened during this period, how does it compare, and what explains the
|
||||
> difference?
|
||||
|
||||
Recommended primary structure:
|
||||
|
||||
1. Period and comparison controls.
|
||||
2. Personal spend, income, invested, and net cash.
|
||||
3. Explanation of the change versus the selected comparison.
|
||||
4. One main category/trend visualisation.
|
||||
5. Category breakdown with transaction drill-down.
|
||||
6. An optional Explore section for detailed tables.
|
||||
|
||||
### Recommended removals and consolidation
|
||||
|
||||
- Keep either category sparklines or the six-month ledger as the primary
|
||||
category-trend representation, not both.
|
||||
- Move the Pareto chart behind an Explore section. It describes concentration
|
||||
but rarely produces an immediate decision.
|
||||
- Retain “What changed,” but make each item clickable and explain which
|
||||
transactions caused the movement.
|
||||
- Avoid comparing a partial current month with full prior months unless values
|
||||
are projected or compared through the same day.
|
||||
- Add gross-versus-personal-share switching only if it is clearly labelled.
|
||||
Personal share should remain the default.
|
||||
|
||||
### Calculation and trust issues
|
||||
|
||||
#### Reconciled source rows can be double-counted
|
||||
|
||||
`/api/analytics/monthly` does not currently exclude manual source rows where
|
||||
`reconciled_with_id IS NOT NULL`. The baseline analysis identified 48
|
||||
double-counted rows.
|
||||
|
||||
The analytics query should apply the same reconciled-row exclusion used by the
|
||||
main transaction queries.
|
||||
|
||||
#### Spend pace compares unlike numbers
|
||||
|
||||
The Analytics headline uses:
|
||||
|
||||
- split-adjusted personal share;
|
||||
- fees and interest;
|
||||
- refund and credit netting;
|
||||
- loan interest rather than principal;
|
||||
- non-spend-category exclusions.
|
||||
|
||||
The cumulative spend-pace chart uses only `transaction_type === "debit"` and
|
||||
adds gross `amount_aud ?? amount`. It does not use personal share and does not
|
||||
apply the same refund, fee, interest, or loan semantics.
|
||||
|
||||
The chart can therefore disagree with the headline while both appear to
|
||||
represent “spend.” The cumulative series should be produced by the same
|
||||
server-side spend semantics as the monthly total.
|
||||
|
||||
#### Split coverage changes mid-series
|
||||
|
||||
Reliable in-app split data begins on 2026-01-09. A trailing twelve-month personal
|
||||
series currently combines older gross spending with newer split-adjusted
|
||||
spending.
|
||||
|
||||
Until historical splits are restored:
|
||||
|
||||
- default personal trend analysis to February–June 2026;
|
||||
- visibly mark periods with incomplete split coverage; or
|
||||
- offer gross-only twelve-month comparison separately.
|
||||
|
||||
Do not present the mixed series as one comparable personal-spend trend.
|
||||
|
||||
#### Comparison baseline is too naive
|
||||
|
||||
The selected month is compared against the average of all other months with
|
||||
data. That average can include travel, annual fees, tax payments, incomplete
|
||||
current periods, and months with incompatible split coverage.
|
||||
|
||||
Better comparison choices:
|
||||
|
||||
- previous month;
|
||||
- same month last year;
|
||||
- median of comparable complete months;
|
||||
- recurring baseline;
|
||||
- user-selected comparison.
|
||||
|
||||
### Data trust indicator
|
||||
|
||||
Analytics should include a compact methodology and coverage indicator:
|
||||
|
||||
> Personal share · refunds netted · investments excluded · split coverage
|
||||
> reliable from Feb 2026 · 12 transactions need classification
|
||||
|
||||
This makes the meaning of the numbers inspectable without overwhelming the page.
|
||||
|
||||
## Insights review
|
||||
|
||||
### Current problem
|
||||
|
||||
The current Insights page contains:
|
||||
|
||||
- Regular versus occasional spending;
|
||||
- another monthly category breakdown;
|
||||
- recurring charges;
|
||||
- fees and interest.
|
||||
|
||||
The monthly breakdown duplicates Analytics. The page does not yet surface the
|
||||
most decision-relevant findings already known from the data: sustainable monthly
|
||||
cost, liquidity, the loan-overpayment lever, data-quality weaknesses, or unusual
|
||||
changes requiring attention.
|
||||
|
||||
### “Regular” is not the same as committed or essential
|
||||
|
||||
`REGULAR_CATEGORIES` includes:
|
||||
|
||||
- groceries;
|
||||
- dining;
|
||||
- transport;
|
||||
- health;
|
||||
- personal care;
|
||||
- government;
|
||||
- charity;
|
||||
- pets.
|
||||
|
||||
These may recur, but they have very different flexibility and obligation.
|
||||
“Regular” describes transaction behaviour, not financial necessity.
|
||||
|
||||
The current chart therefore cannot answer:
|
||||
|
||||
- What is the minimum monthly cost?
|
||||
- What can be cut?
|
||||
- What is contractually committed?
|
||||
- What is lifestyle spending?
|
||||
- What is a one-off?
|
||||
|
||||
### Recommended model
|
||||
|
||||
Replace Regular versus Occasional with:
|
||||
|
||||
1. **Fixed commitments**
|
||||
- Contracted loan repayment
|
||||
- Insurance
|
||||
- Rates and registration
|
||||
- Known annual fees
|
||||
- Contractual subscriptions
|
||||
|
||||
2. **Essential variable spending**
|
||||
- Utilities
|
||||
- Groceries
|
||||
- Transport
|
||||
- Health
|
||||
|
||||
3. **Lifestyle and discretionary**
|
||||
- Dining
|
||||
- Shopping
|
||||
- Entertainment
|
||||
- Personal care
|
||||
|
||||
4. **One-offs and travel**
|
||||
|
||||
5. **Investments and transfers**
|
||||
- Shown for cashflow context, excluded from spending
|
||||
|
||||
This should support scenario views rather than claiming there is one true
|
||||
baseline.
|
||||
|
||||
### Recommended Insights structure
|
||||
|
||||
#### 1. Financial baseline
|
||||
|
||||
Show the scenarios already established by the expense-baseline analysis:
|
||||
|
||||
- Survival: contracted loan repayment and essentials only.
|
||||
- Realistic: contracted loan repayment plus ordinary dining and charity.
|
||||
- Status quo: current loan overpayment and normal life excluding travel.
|
||||
|
||||
For each scenario show:
|
||||
|
||||
- monthly amount;
|
||||
- six-month reserve;
|
||||
- twelve-month reserve.
|
||||
|
||||
#### 2. Liquidity and resilience
|
||||
|
||||
Show:
|
||||
|
||||
- cash available;
|
||||
- redraw available separately;
|
||||
- months covered under each baseline;
|
||||
- a warning that redraw is lender-controlled and not equivalent to cash.
|
||||
|
||||
#### 3. Biggest flexible levers
|
||||
|
||||
Examples:
|
||||
|
||||
- voluntary loan overpayment;
|
||||
- dining;
|
||||
- shopping;
|
||||
- subscriptions;
|
||||
- travel.
|
||||
|
||||
The loan should always show both the contracted floor and actual repayment.
|
||||
|
||||
#### 4. Attention and anomalies
|
||||
|
||||
Examples:
|
||||
|
||||
- a new recurring charge;
|
||||
- a charge larger than its prior range;
|
||||
- a category materially above baseline;
|
||||
- a fee increase;
|
||||
- an unexpected incoming credit categorised as spend;
|
||||
- a merchant still classified as `other`;
|
||||
- an investment incorrectly counted as spending.
|
||||
|
||||
Each insight should link directly to the affected transactions.
|
||||
|
||||
#### 5. Data-quality work queue
|
||||
|
||||
The baseline analysis found that data quality is currently a larger blocker than
|
||||
visualisation:
|
||||
|
||||
- `other` remains a large unresolved category;
|
||||
- Raiz, Vanguard Super, and moomoo need investment classification;
|
||||
- incoming `other` credits can make spending negative;
|
||||
- `government` conflates tax with rates and registration;
|
||||
- annual fees distort short-window monthly averages.
|
||||
|
||||
Insights should make these visible as fixable tasks.
|
||||
|
||||
### Recurring charges
|
||||
|
||||
The current detector identifies merchants with regular transaction intervals.
|
||||
That does not necessarily mean a subscription or commitment. Weekly grocery
|
||||
shopping can look recurring.
|
||||
|
||||
Recommended changes:
|
||||
|
||||
- Rename the section **Recurring patterns** unless contractual charges can be
|
||||
distinguished.
|
||||
- Show confidence and the basis for classification.
|
||||
- Show the next expected charge date.
|
||||
- Separate likely subscriptions from recurring merchants.
|
||||
- Allow dismissing or confirming a detected pattern.
|
||||
- Highlight price changes.
|
||||
- Collapse inactive patterns by default.
|
||||
|
||||
The current eight-column table is also too wide for a primary page. Put secondary
|
||||
fields such as first seen, total paid, and count into an expandable detail row.
|
||||
|
||||
### Fees and interest
|
||||
|
||||
The current fees query aggregates statement summary values across all available
|
||||
statements without a date filter. The UI does not label the period, so the total
|
||||
looks like a current-period figure even though it is effectively lifetime to
|
||||
date.
|
||||
|
||||
Recommended presentation:
|
||||
|
||||
- Explicit date range.
|
||||
- Avoidable fees.
|
||||
- Known annual fees.
|
||||
- Credit-card interest.
|
||||
- Loan interest.
|
||||
- Change versus prior comparable period.
|
||||
- Drill-down transactions.
|
||||
|
||||
Loan interest should remain spending, but appear under fixed or
|
||||
non-discretionary costs rather than being hidden.
|
||||
|
||||
## Shared review
|
||||
|
||||
### What the current page answers well
|
||||
|
||||
The unfiltered balance cards correctly implement a running ledger:
|
||||
|
||||
> splits minus payments
|
||||
|
||||
*(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
|
||||
|
||||
- Which split expenses did a payment settle?
|
||||
- Is a particular trip settled?
|
||||
- Can a trip be closed without closing Household?
|
||||
- Is an imported offset-account credit already represented by a manual payment?
|
||||
- What remains open inside one settlement context?
|
||||
- How should the shared-loan contribution shortfall be shown?
|
||||
|
||||
The page should not imply answers that the data model cannot support.
|
||||
|
||||
### Tag-filtered balance cards are semantically misleading
|
||||
|
||||
When a tag filter is active, participant balance queries intentionally stop
|
||||
subtracting payments because payments are not attributable to a tag. The cards
|
||||
then show raw split totals for the tag.
|
||||
|
||||
This behavior is explained in small text, but the card still says “owes you” or
|
||||
“you owe.” That looks like a real payable balance when it is not.
|
||||
|
||||
When filtered, relabel the cards:
|
||||
|
||||
> Split total in Europe 2026
|
||||
|
||||
Do not show payment or settlement actions from that state.
|
||||
|
||||
### Recommended settlement-context design
|
||||
|
||||
Use explicit settlement contexts:
|
||||
|
||||
- Household
|
||||
- Individual trips
|
||||
- Historical / Pre-2026
|
||||
- Closed contexts
|
||||
|
||||
Recommended Shared navigation:
|
||||
|
||||
- All
|
||||
- Household
|
||||
- Trips
|
||||
- Closed
|
||||
|
||||
Within a context show:
|
||||
|
||||
1. Net balance and direction.
|
||||
2. Expenses added since the last settlement.
|
||||
3. Payments attributed to that context.
|
||||
4. A chronological activity ledger combining expenses and payments.
|
||||
5. Context status: running, ready to settle, or closed.
|
||||
6. Settlement action.
|
||||
|
||||
### Payments should link to transactions
|
||||
|
||||
An offset-account credit and a manual `split_payments` row can represent the same
|
||||
money. The page should:
|
||||
|
||||
- propose matching an imported credit to a settlement;
|
||||
- display the linked transaction;
|
||||
- prevent silent duplication;
|
||||
- allow a manual payment only when no matching transaction exists.
|
||||
|
||||
“Record Payment” should become a context-aware settlement flow:
|
||||
|
||||
1. Choose what is being settled.
|
||||
2. Match an existing incoming transaction where possible.
|
||||
3. Confirm amount and residual balance.
|
||||
4. Preserve an auditable history.
|
||||
|
||||
### Shared transaction table
|
||||
|
||||
The table currently shows raw `tx.amount` with a dollar sign and no currency
|
||||
indicator. Participant balances correctly convert to AUD.
|
||||
|
||||
For foreign transactions, show:
|
||||
|
||||
- the native amount and currency;
|
||||
- the AUD equivalent;
|
||||
- splits based on the AUD settlement amount.
|
||||
|
||||
This prevents a visible mismatch between transaction rows and participant
|
||||
balances.
|
||||
|
||||
### Shared loan
|
||||
|
||||
The loan is not a shared-expense settlement context. It funds an asset rather
|
||||
than consumption, and a loan contribution must never settle a dinner or utility
|
||||
bill.
|
||||
|
||||
Give it a separate page or clearly separated ledger showing:
|
||||
|
||||
- expected contribution by period;
|
||||
- actual contribution;
|
||||
- running shortfall or receivable;
|
||||
- principal reduction;
|
||||
- interest expense;
|
||||
- contracted repayment;
|
||||
- actual repayment;
|
||||
- voluntary overpayment;
|
||||
- redraw movement.
|
||||
|
||||
The partner obligation is a fixed 50% of the repayment schedule, not a percentage
|
||||
inferred from actual contributions.
|
||||
|
||||
## Responsive and interaction improvements
|
||||
|
||||
- Replace wide eight-column primary tables with compact rows and expandable
|
||||
details.
|
||||
- Keep financial summaries readable at mobile widths without horizontal
|
||||
scrolling.
|
||||
- Add explicit loading skeletons rather than only text.
|
||||
- Add error states for failed analytics requests.
|
||||
- Ensure chart meaning is not conveyed by colour alone.
|
||||
- Give interactive chart regions keyboard-accessible equivalents.
|
||||
- Confirm material deletions, including payment-history deletion.
|
||||
- Make expandable table rows use buttons with appropriate accessibility state.
|
||||
- Use consistent labels for personal share, gross amount, native currency, and
|
||||
AUD equivalent.
|
||||
|
||||
## Recommended implementation order
|
||||
|
||||
### 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.
|
||||
3. Add date ranges to fees and interest.
|
||||
4. Add split-coverage warnings to historical personal-share analysis.
|
||||
5. Fix Shared foreign-currency presentation.
|
||||
6. Avoid partial-month versus full-month comparisons.
|
||||
|
||||
### 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 *(#157)*
|
||||
|
||||
1. Simplify Analytics around period, comparison, change explanation, trend, and
|
||||
drill-down.
|
||||
2. Remove the duplicate monthly breakdown from Insights.
|
||||
3. Add baseline scenarios and liquidity coverage.
|
||||
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 *(#158; items 2 and 5 landed with the shared-expenses rebuild)*
|
||||
|
||||
1. Add settlement contexts.
|
||||
2. Link payments to real transactions.
|
||||
3. Add context activity ledgers and closeable trip contexts.
|
||||
4. Introduce the separate loan contribution ledger.
|
||||
5. Backfill historical closed-context splits so long-range personal analytics
|
||||
become comparable.
|
||||
|
||||
### Priority 4 — polish *(#159)*
|
||||
|
||||
1. Improve mobile layouts.
|
||||
2. Add accessibility semantics.
|
||||
3. Add richer loading, error, and empty states.
|
||||
4. Consolidate repeated card, table, filter, and page-header patterns into shared
|
||||
components.
|
||||
|
||||
## Proposed success criteria
|
||||
|
||||
The redesign is successful when:
|
||||
|
||||
- The first page explains current status and outstanding actions without opening
|
||||
multiple screens.
|
||||
- Analytics can explain why one comparable period differs from another.
|
||||
- Insights identifies baseline cost, financial resilience, flexible levers, and
|
||||
data-quality problems.
|
||||
- Every displayed total states or clearly implies its period and whether it is
|
||||
gross or personal share.
|
||||
- Historical charts do not silently combine incompatible split coverage.
|
||||
- Shared can distinguish Household, Trip, and Historical balances.
|
||||
- A settlement can be traced to both the obligation it reduces and the real
|
||||
transaction representing the payment.
|
||||
- Loan contributions cannot affect ordinary shared-expense balances.
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
-- Order provenance: which platform the receipt came from, and the message it
|
||||
-- came from.
|
||||
--
|
||||
-- The parser has always known the platform (it has to, to read the template)
|
||||
-- and then threw it away. Without it a transaction reads "Order - Burger
|
||||
-- Corner" with no way to tell whether to look in DoorDash or Uber Eats for the
|
||||
-- detail, and no way to answer "how much of this is DoorDash?" at all.
|
||||
--
|
||||
-- `source_email_subject` / `source_email_from` already existed for the
|
||||
-- Paperless expense path and were simply never populated by order ingestion.
|
||||
|
||||
ALTER TABLE expense_metadata ADD COLUMN IF NOT EXISTS platform text;
|
||||
|
||||
COMMENT ON COLUMN expense_metadata.platform IS
|
||||
'doordash | ubereats | uber — the receipt template the order was read from.';
|
||||
|
||||
-- Backfill the 101 rows written by the 2026-07-27 backfill. DoorDash receipts
|
||||
-- carry no order id of their own, so ingestion synthesises `msg:<message-id>`;
|
||||
-- Uber receipts carry a real trip UUID. That is the only surviving
|
||||
-- discriminator, and it is exact.
|
||||
UPDATE expense_metadata
|
||||
SET platform = CASE WHEN order_reference LIKE 'msg:%' THEN 'doordash' ELSE 'ubereats' END
|
||||
WHERE platform IS NULL
|
||||
AND source = 'email'
|
||||
AND paperless_doc_id IS NULL -- exclude the Paperless expense path
|
||||
AND order_reference IS NOT NULL;
|
||||
|
||||
-- Pick-up / delivery stops, as the receipt prints them. Uber puts these on
|
||||
-- every order under `Order details`; DoorDash prints no addresses at all, so
|
||||
-- this stays '[]' there. Same block a *trip* receipt uses for start and
|
||||
-- destination, so this column already fits rides when they come into scope.
|
||||
ALTER TABLE expense_metadata ADD COLUMN IF NOT EXISTS route jsonb NOT NULL DEFAULT '[]'::jsonb;
|
||||
|
||||
COMMENT ON COLUMN expense_metadata.route IS
|
||||
'Uber only: [{label, time, address}] — pick-up and delivery stops as printed.';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_expense_metadata_platform
|
||||
ON expense_metadata (platform)
|
||||
WHERE platform IS NOT NULL;
|
||||
@@ -0,0 +1,48 @@
|
||||
-- Settlement scope: which tab a payment settles.
|
||||
--
|
||||
-- `split_payments` has carried from/to/amount/date since it was written and
|
||||
-- nothing else. That is the whole reason a per-trip balance has never been
|
||||
-- computable — `getTripAnalytics` says so in a comment where the figure should
|
||||
-- be: "split_payments carries no trip attribution, so a payment cannot be
|
||||
-- assigned to a trip. Settlement is a property of the whole relationship."
|
||||
--
|
||||
-- It is also the reason the Shared page silently drops payments the moment a
|
||||
-- tag filter is applied (`getParticipantBalances`): with one global payments
|
||||
-- pool there is no honest way to show a filtered balance, so it showed gross
|
||||
-- splits under the same label instead. A tag is a view; a scope is a ledger.
|
||||
--
|
||||
-- The scope is a *trip*, not a new `settlement_contexts` table. `trips` already
|
||||
-- has owner_id, dates and an archived flag, and `transaction_overrides.trip_id`
|
||||
-- already decides which transactions belong to it. A second grouping beside it
|
||||
-- would be two unsynchronised scopes over the same rows — a trip could hold a
|
||||
-- mix of contexts and a context could span trips, with no invariant saying
|
||||
-- which one governs.
|
||||
--
|
||||
-- NULL means the ongoing household tab. That tab never closes, which is why
|
||||
-- this is nullable rather than defaulted to some "general" row: absence is the
|
||||
-- honest representation of "not attached to a trip", and it keeps every
|
||||
-- existing payment correct without a backfill.
|
||||
|
||||
ALTER TABLE split_payments
|
||||
ADD COLUMN IF NOT EXISTS trip_id integer REFERENCES trips(id) ON DELETE SET NULL;
|
||||
|
||||
COMMENT ON COLUMN split_payments.trip_id IS
|
||||
'The trip this payment settles. NULL = the ongoing household tab.';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_split_payments_trip
|
||||
ON split_payments (trip_id)
|
||||
WHERE trip_id IS NOT NULL;
|
||||
|
||||
-- `settled` answers a different question and the two must not be collapsed:
|
||||
-- trip_id is *which tab*, settled is *is this obligation still live*. A
|
||||
-- pre-2026 historical split is settled with no tab; a Europe split becomes
|
||||
-- settled when Europe's payment lands; a household split stays unsettled and
|
||||
-- open indefinitely.
|
||||
--
|
||||
-- Nothing writes `settled` today. The comment in queries.ts claims
|
||||
-- /api/splits/settle does — that route does not exist, and the column is false
|
||||
-- on all 1,279 rows, which is why every trip has always reported 100%
|
||||
-- unsettled including trips paid in full.
|
||||
|
||||
COMMENT ON COLUMN transaction_splits.settled IS
|
||||
'Obligation discharged. Excluded from owed figures; still counted in spend analytics.';
|
||||
@@ -0,0 +1,37 @@
|
||||
-- A transaction imported twice cannot simply be deleted.
|
||||
--
|
||||
-- Every child of `transactions` is ON DELETE CASCADE — splits, tags, overrides,
|
||||
-- expense_metadata, order_reviews. Deleting a row said to be "the duplicate"
|
||||
-- therefore destroys whatever curation happens to sit on it, silently and
|
||||
-- unrecoverably. The curation is not reliably on the surviving side either: of
|
||||
-- the 31 known duplicate pairs, one carries splits and six carry overrides, and
|
||||
-- which member holds them is an accident of import order.
|
||||
--
|
||||
-- So a duplicate is superseded, never removed. The row stays, keeps its
|
||||
-- children, and points at the row that replaces it. Reversing a mistake is then
|
||||
-- one UPDATE rather than a restore from backup.
|
||||
--
|
||||
-- This is the statement-vs-statement case. `reconciled_with_id` already covers
|
||||
-- manual-vs-statement, and deliberately cannot be reused: the predicate that
|
||||
-- hides a reconciled row is scoped to `statement_id IS NULL`, because a
|
||||
-- statement line pointing at something else is the survivor, not the duplicate.
|
||||
-- Both of these rows are statement lines.
|
||||
|
||||
ALTER TABLE transactions
|
||||
ADD COLUMN IF NOT EXISTS superseded_by_id integer
|
||||
REFERENCES transactions(id) ON DELETE SET NULL;
|
||||
|
||||
COMMENT ON COLUMN transactions.superseded_by_id IS
|
||||
'This row was imported twice; the named row is the one that counts. Excluded from every figure, kept for its children and its audit trail. NULL = live.';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_superseded
|
||||
ON transactions (superseded_by_id)
|
||||
WHERE superseded_by_id IS NOT NULL;
|
||||
|
||||
-- A row cannot supersede itself, and a survivor cannot itself be superseded
|
||||
-- (that would hide both members of the pair and lose the amount entirely).
|
||||
ALTER TABLE transactions
|
||||
DROP CONSTRAINT IF EXISTS transactions_no_self_supersede;
|
||||
ALTER TABLE transactions
|
||||
ADD CONSTRAINT transactions_no_self_supersede
|
||||
CHECK (superseded_by_id IS NULL OR superseded_by_id <> id);
|
||||
@@ -0,0 +1,52 @@
|
||||
-- A four-level verdict, per-item opinions, and one verdict per PERSON.
|
||||
--
|
||||
-- Three levels collapsed the distinction that actually drives a re-order:
|
||||
-- "loved" and "liked" are both "would order again", but only one is worth a
|
||||
-- detour, and "ok" is not a recommendation. Asked for by the user 2026-07-28.
|
||||
--
|
||||
-- Safe as a straight swap: order_reviews had 0 rows when this was written, so
|
||||
-- there are no old values to map. If that ever stops being true, map
|
||||
-- again->liked, fine->ok, never->never BEFORE adding the constraint.
|
||||
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', 'never'));
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- A verdict belongs to a person, not to an order.
|
||||
--
|
||||
-- A shared meal produces two opinions and they routinely disagree — that
|
||||
-- disagreement is the useful part, and one row per transaction cannot hold it.
|
||||
-- The Slack nudge asks whether the order was shared; a yes both splits the
|
||||
-- expense and asks the other person for their verdict, so the second row is
|
||||
-- the normal case for anything shared, not an edge case.
|
||||
--
|
||||
-- No DEFAULT on participant_id on purpose: a verdict silently attributed to
|
||||
-- whoever happens to be id 1 is worse than an insert that fails loudly.
|
||||
ALTER TABLE order_reviews
|
||||
ADD COLUMN IF NOT EXISTS participant_id integer
|
||||
REFERENCES participants(id) ON DELETE CASCADE;
|
||||
|
||||
UPDATE order_reviews SET participant_id = 1 WHERE participant_id IS NULL;
|
||||
|
||||
ALTER TABLE order_reviews ALTER COLUMN participant_id SET NOT NULL;
|
||||
|
||||
-- Replace the per-transaction uniqueness with per-transaction-per-person.
|
||||
-- Dropping this is what allows the second opinion to exist at all.
|
||||
ALTER TABLE order_reviews DROP CONSTRAINT IF EXISTS order_reviews_transaction_id_key;
|
||||
|
||||
ALTER TABLE order_reviews
|
||||
ADD CONSTRAINT order_reviews_transaction_participant_key
|
||||
UNIQUE (transaction_id, participant_id);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- item_verdicts already exists as jsonb DEFAULT '[]'. It has never been
|
||||
-- written. The shape is now fixed as:
|
||||
-- [{"item": "<line item description>", "verdict": "loved"|"never"}]
|
||||
--
|
||||
-- Keyed by description rather than by position in line_items: an index is
|
||||
-- meaningless across orders, and the reusable signal is "the Pad Thai here is
|
||||
-- good", which has to survive into the next order from the same merchant.
|
||||
-- Only the poles are offered — a per-item "ok" is noise nobody would ever read.
|
||||
ALTER TABLE order_reviews ADD CONSTRAINT chk_order_review_item_verdicts
|
||||
CHECK (jsonb_typeof(item_verdicts) = 'array');
|
||||
@@ -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';
|
||||
+32
-2
@@ -18,6 +18,7 @@ model trips {
|
||||
archived Boolean @default(false)
|
||||
created_at DateTime @default(now())
|
||||
overrides transaction_overrides[]
|
||||
payments split_payments[]
|
||||
}
|
||||
|
||||
model transaction_overrides {
|
||||
@@ -41,6 +42,7 @@ model participants {
|
||||
account_owner_mappings account_owner_mappings[]
|
||||
payments_sent split_payments[] @relation("payments_from")
|
||||
payments_received split_payments[] @relation("payments_to")
|
||||
order_reviews order_reviews[]
|
||||
}
|
||||
|
||||
model account_owner_mappings {
|
||||
@@ -75,9 +77,13 @@ model split_payments {
|
||||
payment_date DateTime @db.Date
|
||||
notes String?
|
||||
linked_transaction_id Int?
|
||||
trip_id Int?
|
||||
created_at DateTime @default(now())
|
||||
from_participant participants @relation("payments_from", fields: [from_participant_id], references: [id])
|
||||
to_participant participants @relation("payments_to", fields: [to_participant_id], references: [id])
|
||||
trip trips? @relation(fields: [trip_id], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([trip_id])
|
||||
}
|
||||
|
||||
model tags {
|
||||
@@ -178,13 +184,19 @@ model transactions {
|
||||
payment_method String? // card | cash | bank_transfer | other; NULL = unknown (migration 0016)
|
||||
owner_id Int?
|
||||
reconciled_with_id Int?
|
||||
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")
|
||||
superseded_by transactions? @relation("superseded", fields: [superseded_by_id], references: [id], onDelete: SetNull)
|
||||
supersedes transactions[] @relation("superseded")
|
||||
expense_metadata expense_metadata?
|
||||
order_review order_reviews?
|
||||
order_reviews order_reviews[]
|
||||
}
|
||||
|
||||
model expense_metadata {
|
||||
@@ -206,6 +218,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")
|
||||
@@ -213,7 +239,8 @@ model expense_metadata {
|
||||
|
||||
model order_reviews {
|
||||
id Int @id @default(autoincrement())
|
||||
transaction_id Int @unique
|
||||
transaction_id Int
|
||||
participant_id Int
|
||||
rating String?
|
||||
order_again Boolean?
|
||||
note String?
|
||||
@@ -221,6 +248,9 @@ model order_reviews {
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @updatedAt
|
||||
transaction transactions @relation(fields: [transaction_id], references: [id], onDelete: Cascade)
|
||||
participant participants @relation(fields: [participant_id], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([transaction_id, participant_id], name: "order_reviews_transaction_participant_key")
|
||||
}
|
||||
|
||||
model rule_apply_runs {
|
||||
|
||||
@@ -0,0 +1,376 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Match SplitMyExpenses CSV rows to transactions already in the ledger.
|
||||
|
||||
Dry-run by default. It prints what it would do and writes nothing; `--write`
|
||||
is a separate step (task #9) and is deliberately not implemented here.
|
||||
|
||||
Why this exists
|
||||
---------------
|
||||
The CSVs are the record of how expenses were actually shared before this app
|
||||
existed. Importing them is what makes historical *spend* correct: without a
|
||||
split row, a $200 grocery shop counts as $200 of my spending when half of it
|
||||
was never mine. The balances are already settled by carryover transaction 2348,
|
||||
so these splits are imported as `settled = true` and move no balance.
|
||||
|
||||
Three things make the matching harder than "same date, same amount":
|
||||
|
||||
1. **Dates are ambiguous across files.** The household file writes D/M/YYYY;
|
||||
the four trip files write ISO. 474 rows parse validly under both readings,
|
||||
so the format is decided per file, from the file, and never guessed per row.
|
||||
|
||||
2. **The sign convention is not "who paid".** A person's column is their net
|
||||
balance impact: positive means they are owed. So the payer is whoever is
|
||||
positive, and the other person's share is |their negative| / cost. A row
|
||||
reading +cost / -cost therefore means the other party owes 100% -- NOT that
|
||||
the expense was unshared, which is the reading that would fake an
|
||||
arrangement change.
|
||||
|
||||
3. **A settlement is not an expense.** Rows where one person hands the other
|
||||
money must not become split transactions; they are already represented by
|
||||
the carryover.
|
||||
|
||||
Usage:
|
||||
.venv/bin/python scripts/split_csv_match.py [--verbose] [--file NAME]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import glob
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
|
||||
DUMP_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "dump")
|
||||
|
||||
# Read from the same place the app does rather than hardcoding a container IP,
|
||||
# which changes on every recreate.
|
||||
def db_url() -> str:
|
||||
url = os.environ.get("DATABASE_URL")
|
||||
if url:
|
||||
return url
|
||||
for envfile in (".env", ".env.test"):
|
||||
path = os.path.join(os.path.dirname(DUMP_DIR), envfile)
|
||||
if not os.path.exists(path):
|
||||
continue
|
||||
for line in open(path):
|
||||
if line.startswith("DATABASE_URL"):
|
||||
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
||||
sys.exit("No DATABASE_URL found (env, .env, or .env.test)")
|
||||
|
||||
|
||||
# The two people in these files. The CSV writes full names; the ledger uses
|
||||
# first names.
|
||||
CSV_ME = "Siddharth Bose"
|
||||
CSV_THEM = "Meghalee"
|
||||
PARTICIPANT_ME = 1
|
||||
PARTICIPANT_THEM = 4
|
||||
|
||||
# A settlement transfers money; it is not a shared cost. These are the
|
||||
# descriptions SplitMyExpenses uses for them.
|
||||
SETTLEMENT_PAT = re.compile(
|
||||
r"payment|settle|debts? remainder|reimburse|transfer to|paid back", re.I
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CsvRow:
|
||||
source: str
|
||||
line: int
|
||||
when: date
|
||||
description: str
|
||||
category: str
|
||||
cost: float
|
||||
currency: str
|
||||
net_me: float
|
||||
net_them: float
|
||||
|
||||
# Filled in by classify()
|
||||
mode: str = ""
|
||||
payer: int = 0 # participant id who paid
|
||||
ower: int = 0 # participant id who owes
|
||||
ower_share: float = 0.0 # 0-100
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.source}:{self.line} {self.when} {self.description[:38]!r} ${self.cost:.2f} [{self.mode}]"
|
||||
|
||||
|
||||
@dataclass
|
||||
class MatchReport:
|
||||
rows: list = field(default_factory=list)
|
||||
matched: list = field(default_factory=list)
|
||||
ambiguous: list = field(default_factory=list)
|
||||
unmatched: list = field(default_factory=list)
|
||||
skipped: list = field(default_factory=list)
|
||||
|
||||
|
||||
def sniff_date_format(sample: list[str]) -> str:
|
||||
"""Decide ISO vs D/M/YYYY for a whole file.
|
||||
|
||||
Deciding per row is what produces a ledger where January and February are
|
||||
silently swapped for some rows and not others. A file is written by one
|
||||
exporter in one format, so the file is the unit of decision.
|
||||
"""
|
||||
if not sample:
|
||||
return "%Y-%m-%d"
|
||||
slashes = sum(1 for s in sample if "/" in s)
|
||||
return "%d/%m/%Y" if slashes > len(sample) / 2 else "%Y-%m-%d"
|
||||
|
||||
|
||||
def parse_rows(path: str) -> list[CsvRow]:
|
||||
with open(path, newline="", encoding="utf-8-sig") as fh:
|
||||
reader = list(csv.DictReader(fh))
|
||||
if not reader:
|
||||
return []
|
||||
|
||||
# Header names vary in quoting between exports.
|
||||
def col(row: dict, *names: str):
|
||||
for n in names:
|
||||
for k in row:
|
||||
if k.strip().strip('"') == n:
|
||||
return row[k]
|
||||
return None
|
||||
|
||||
fmt = sniff_date_format([col(r, "Date") or "" for r in reader[:40]])
|
||||
out: list[CsvRow] = []
|
||||
for i, r in enumerate(reader, start=2):
|
||||
raw_date = (col(r, "Date") or "").strip()
|
||||
try:
|
||||
when = datetime.strptime(raw_date, fmt).date()
|
||||
except ValueError:
|
||||
continue
|
||||
try:
|
||||
cost = float(col(r, "Cost") or 0)
|
||||
net_me = float(col(r, CSV_ME) or 0)
|
||||
net_them = float(col(r, CSV_THEM) or 0)
|
||||
except ValueError:
|
||||
continue
|
||||
out.append(
|
||||
CsvRow(
|
||||
source=os.path.basename(path),
|
||||
line=i,
|
||||
when=when,
|
||||
description=(col(r, "Description") or "").strip(),
|
||||
category=(col(r, "Category") or "").strip(),
|
||||
cost=cost,
|
||||
currency=(col(r, "Currency") or "AUD").strip(),
|
||||
net_me=net_me,
|
||||
net_them=net_them,
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def classify(row: CsvRow) -> CsvRow:
|
||||
"""Work out who paid and what share the other person owes.
|
||||
|
||||
A person's column is their net balance impact, not their share: positive
|
||||
means they are owed money. So the payer is whoever is positive.
|
||||
"""
|
||||
if row.cost == 0:
|
||||
row.mode = "zero-cost"
|
||||
return row
|
||||
|
||||
if SETTLEMENT_PAT.search(row.description):
|
||||
row.mode = "settlement"
|
||||
return row
|
||||
|
||||
# Both zero against a real cost: recorded but not shared.
|
||||
if abs(row.net_me) < 0.005 and abs(row.net_them) < 0.005:
|
||||
row.mode = "unshared"
|
||||
return row
|
||||
|
||||
if row.net_me > 0:
|
||||
row.payer, row.ower, owed = PARTICIPANT_ME, PARTICIPANT_THEM, abs(row.net_them)
|
||||
else:
|
||||
row.payer, row.ower, owed = PARTICIPANT_THEM, PARTICIPANT_ME, abs(row.net_me)
|
||||
|
||||
row.ower_share = round(owed / row.cost * 100, 2)
|
||||
if abs(row.ower_share - 50) < 0.6:
|
||||
row.mode = "50/50"
|
||||
elif abs(row.ower_share - 100) < 0.6:
|
||||
row.mode = "other-owes-all"
|
||||
else:
|
||||
row.mode = f"uneven-{row.ower_share:.0f}"
|
||||
return row
|
||||
|
||||
|
||||
def norm(s: str) -> set[str]:
|
||||
return {w for w in re.split(r"[^a-z0-9]+", (s or "").lower()) if len(w) > 2}
|
||||
|
||||
|
||||
def score(row: CsvRow, tx: dict) -> float:
|
||||
"""How well a ledger row matches a CSV row. Amount and date gate it;
|
||||
description only ranks among survivors."""
|
||||
days = abs((tx["transaction_date"] - row.when).days)
|
||||
s = 100.0 - days * 4
|
||||
overlap = norm(row.description) & (norm(tx["description"]) | norm(tx["merchant_normalized"]))
|
||||
s += 12 * len(overlap)
|
||||
return s
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--verbose", action="store_true")
|
||||
ap.add_argument("--file", help="only this CSV (substring match)")
|
||||
ap.add_argument("--window", type=int, default=5, help="date tolerance in days")
|
||||
ap.add_argument(
|
||||
"--write", action="store_true",
|
||||
help="actually insert the splits (settled=true). Without this, nothing is written.",
|
||||
)
|
||||
args = ap.parse_args()
|
||||
|
||||
paths = sorted(glob.glob(os.path.join(DUMP_DIR, "*SplitMyExpenses*.csv")))
|
||||
if args.file:
|
||||
paths = [p for p in paths if args.file in os.path.basename(p)]
|
||||
if not paths:
|
||||
sys.exit("No SplitMyExpenses CSVs found in dump/")
|
||||
|
||||
conn = psycopg2.connect(db_url())
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
# Superseded rows are duplicates; matching against them would attach a split
|
||||
# to a row nothing else counts.
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT t.id, t.transaction_date, COALESCE(t.description,'') AS description,
|
||||
COALESCE(t.merchant_normalized,'') AS merchant_normalized,
|
||||
COALESCE(t.amount_aud, t.amount)::float AS amount,
|
||||
t.superseded_by_id,
|
||||
EXISTS(SELECT 1 FROM transaction_splits x WHERE x.transaction_id=t.id) AS has_split
|
||||
FROM transactions t
|
||||
LEFT JOIN statements s ON s.id = t.statement_id
|
||||
WHERE COALESCE(t.owner_id, s.owner_id) IN (%s, %s)
|
||||
AND t.superseded_by_id IS NULL
|
||||
AND NOT (t.statement_id IS NULL AND t.reconciled_with_id IS NOT NULL)
|
||||
AND t.transaction_type IN ('debit','fee','interest')
|
||||
""",
|
||||
(PARTICIPANT_ME, PARTICIPANT_THEM),
|
||||
)
|
||||
txs = cur.fetchall()
|
||||
|
||||
# Index by rounded amount: the amount must agree, so it is the only cheap
|
||||
# gate that never needs fuzzy comparison.
|
||||
by_amount: dict[float, list[dict]] = {}
|
||||
for t in txs:
|
||||
by_amount.setdefault(round(t["amount"], 2), []).append(t)
|
||||
|
||||
rep = MatchReport()
|
||||
modes: Counter = Counter()
|
||||
candidates: list = []
|
||||
for path in paths:
|
||||
for row in parse_rows(path):
|
||||
classify(row)
|
||||
rep.rows.append(row)
|
||||
modes[row.mode] += 1
|
||||
|
||||
if row.mode in ("settlement", "zero-cost", "unshared"):
|
||||
rep.skipped.append(row)
|
||||
continue
|
||||
if row.currency != "AUD":
|
||||
rep.skipped.append(row)
|
||||
continue
|
||||
|
||||
cands = [
|
||||
t for t in by_amount.get(round(row.cost, 2), [])
|
||||
if abs((t["transaction_date"] - row.when).days) <= args.window
|
||||
]
|
||||
if not cands:
|
||||
rep.unmatched.append(row)
|
||||
else:
|
||||
candidates.append((row, cands))
|
||||
|
||||
# Assign one-to-one, best pair first.
|
||||
#
|
||||
# Without this a ledger row can be claimed by several CSV rows. That is not
|
||||
# hypothetical: the NZ trip has two identical $10.16 Uber trips on one day
|
||||
# and three matching ledger rows, and four PayMyPark rows in the same shape.
|
||||
# Attaching a split twice is harmless (the unique key absorbs it) but it
|
||||
# leaves the second CSV row silently unrepresented while looking matched,
|
||||
# which is a lie in the report rather than a defect in the data.
|
||||
scored = sorted(
|
||||
((score(row, t), row, t) for row, cands in candidates for t in cands),
|
||||
key=lambda x: -x[0],
|
||||
)
|
||||
taken_tx: set[int] = set()
|
||||
taken_row: set[int] = set()
|
||||
for s, row, t in scored:
|
||||
if id(row) in taken_row or t["id"] in taken_tx:
|
||||
continue
|
||||
taken_row.add(id(row))
|
||||
taken_tx.add(t["id"])
|
||||
rep.matched.append((row, t))
|
||||
for row, cands in candidates:
|
||||
if id(row) not in taken_row:
|
||||
rep.ambiguous.append((row, cands))
|
||||
|
||||
total = len(rep.rows)
|
||||
considered = total - len(rep.skipped)
|
||||
print(f"CSV rows {total}")
|
||||
print(f" skipped {len(rep.skipped)} (settlements, zero-cost, unshared, non-AUD)")
|
||||
print(f" considered {considered}")
|
||||
print(f" matched {len(rep.matched)} ({len(rep.matched)/max(considered,1)*100:.1f}%)")
|
||||
print(f" ambiguous {len(rep.ambiguous)}")
|
||||
print(f" unmatched {len(rep.unmatched)}")
|
||||
print()
|
||||
print("Row modes:")
|
||||
for m, n in modes.most_common():
|
||||
print(f" {m:<18} {n}")
|
||||
|
||||
already = sum(1 for _, t in rep.matched if t["has_split"])
|
||||
print()
|
||||
print(f"Of the matched, {already} already carry a split and would be left alone;")
|
||||
print(f"{len(rep.matched) - already} would gain one.")
|
||||
|
||||
if args.write:
|
||||
# Imported as settled: these obligations were discharged on a platform
|
||||
# we no longer run, and the residual is already carried by transaction
|
||||
# 2348. Writing them unsettled would re-open ~$40k of debts that were
|
||||
# paid years ago. See ACTIVE_OBLIGATION in analytics-sql.ts -- settled
|
||||
# rows stay in spend and leave every owed figure, which is exactly the
|
||||
# point: this import exists to correct historical SPEND.
|
||||
SETTLED_ON = "2026-01-09" # the carryover's date
|
||||
written = 0
|
||||
for row, tx in rep.matched:
|
||||
if tx["has_split"]:
|
||||
continue
|
||||
payer_share = round(100 - row.ower_share, 2)
|
||||
pairs = [(row.ower, row.ower_share)]
|
||||
if payer_share > 0:
|
||||
pairs.append((row.payer, payer_share))
|
||||
for pid, share in pairs:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO transaction_splits
|
||||
(transaction_id, participant_id, share_percent, settled, settled_at)
|
||||
VALUES (%s, %s, %s, true, %s)
|
||||
ON CONFLICT (transaction_id, participant_id) DO NOTHING
|
||||
""",
|
||||
(tx["id"], pid, share, SETTLED_ON),
|
||||
)
|
||||
written += cur.rowcount
|
||||
conn.commit()
|
||||
print(f"\nWROTE {written} split rows (settled=true, settled_at={SETTLED_ON}).")
|
||||
|
||||
if args.verbose:
|
||||
print("\n--- ambiguous ---")
|
||||
for row, cands in rep.ambiguous[:40]:
|
||||
print(f" {row}")
|
||||
for t in cands[:3]:
|
||||
print(f" -> #{t['id']} {t['transaction_date']} {t['description'][:44]!r}")
|
||||
print("\n--- unmatched ---")
|
||||
for row in rep.unmatched[:60]:
|
||||
print(f" {row}")
|
||||
|
||||
conn.close()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -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,20 @@
|
||||
{
|
||||
"ut-00": {
|
||||
"messageId": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkH8zVAAAAA",
|
||||
"subject": "Your Sunday evening trip with Uber",
|
||||
"receivedAt": "2026-06-21T10:11:16Z",
|
||||
"sender": "noreply@uber.com"
|
||||
},
|
||||
"ut-01": {
|
||||
"messageId": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkH8zU-AAAA",
|
||||
"subject": "Your Sunday morning trip with Uber",
|
||||
"receivedAt": "2026-06-21T09:02:09Z",
|
||||
"sender": "noreply@uber.com"
|
||||
},
|
||||
"ut-summary": {
|
||||
"messageId": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkH8zU3AAAA",
|
||||
"subject": "Your Sunday morning trip with Uber",
|
||||
"receivedAt": "2026-06-20T22:27:54Z",
|
||||
"sender": "noreply@uber.com"
|
||||
}
|
||||
}
|
||||
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,185 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { queryRaw, queryRow } from "../../lib/db";
|
||||
import {
|
||||
EXCLUDE_RECONCILED_SOURCE,
|
||||
NATIVE_CURRENCY,
|
||||
AMOUNT_UNCONVERTED,
|
||||
INVESTMENT_SIGNED,
|
||||
} from "../../lib/analytics-sql";
|
||||
|
||||
/**
|
||||
* These fragments are the ones that drifted.
|
||||
*
|
||||
* The reconciled-row exclusion lived only in `queries.ts` for months while every
|
||||
* analytics route counted the superseded manual rows as spend — 48 rows, $4,474
|
||||
* of double count, invisible because the transaction list (which did exclude
|
||||
* them) looked right. The currency expression has the same shape of risk: it is
|
||||
* read by two call sites with two different denomination conventions.
|
||||
*
|
||||
* Assertions are per-row against known fixtures rather than against aggregate
|
||||
* totals, so a change in unrelated data cannot mask a regression.
|
||||
*/
|
||||
|
||||
async function scratchTxn(cols: string, vals: string, params: unknown[] = []) {
|
||||
const row = await queryRow<{ id: number }>(
|
||||
`INSERT INTO transactions (${cols}) VALUES (${vals}) RETURNING id`,
|
||||
params
|
||||
);
|
||||
return row!.id;
|
||||
}
|
||||
|
||||
/** Does this row survive the predicate? */
|
||||
async function passes(predicate: string, id: number): Promise<boolean> {
|
||||
const rows = await queryRaw(
|
||||
`SELECT t.id FROM transactions t
|
||||
LEFT JOIN statements s ON s.id = t.statement_id
|
||||
WHERE t.id = $1 AND (${predicate})`,
|
||||
[id]
|
||||
);
|
||||
return rows.length === 1;
|
||||
}
|
||||
|
||||
describe("EXCLUDE_RECONCILED_SOURCE", () => {
|
||||
it("drops a manual row that a statement line has superseded", async () => {
|
||||
const survivor = await scratchTxn(
|
||||
"transaction_date, description, amount, transaction_type",
|
||||
"'2026-03-01','Analytics fixture — survivor', 10.00, 'debit'"
|
||||
);
|
||||
const superseded = await scratchTxn(
|
||||
"transaction_date, description, amount, transaction_type, reconciled_with_id",
|
||||
"'2026-03-01','Analytics fixture — superseded', 10.00, 'debit', $1",
|
||||
[survivor]
|
||||
);
|
||||
|
||||
expect(await passes(EXCLUDE_RECONCILED_SOURCE, superseded)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps an ordinary manual row that was never reconciled", async () => {
|
||||
const id = await scratchTxn(
|
||||
"transaction_date, description, amount, transaction_type",
|
||||
"'2026-03-01','Analytics fixture — unreconciled', 10.00, 'debit'"
|
||||
);
|
||||
expect(await passes(EXCLUDE_RECONCILED_SOURCE, id)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps a credits order row — nothing ever sets reconciled_with_id on one", async () => {
|
||||
// The order slice records the card match in expense_metadata, not on the
|
||||
// transaction, and needsCardMatch() holds these out of the reconcile queue.
|
||||
// If that ever changes, this exclusion would start eating real spend.
|
||||
const id = await scratchTxn(
|
||||
"transaction_date, description, amount, transaction_type, payment_method",
|
||||
"'2026-03-01','Order - Analytics fixture', 25.00, 'debit', 'credits'"
|
||||
);
|
||||
expect(await passes(EXCLUDE_RECONCILED_SOURCE, id)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("NATIVE_CURRENCY", () => {
|
||||
async function currencyOf(id: number) {
|
||||
const row = await queryRow<{ ccy: string; unconverted: boolean }>(
|
||||
`SELECT ${NATIVE_CURRENCY} AS ccy, ${AMOUNT_UNCONVERTED} AS unconverted
|
||||
FROM transactions t LEFT JOIN statements s ON s.id = t.statement_id
|
||||
WHERE t.id = $1`,
|
||||
[id]
|
||||
);
|
||||
return row!;
|
||||
}
|
||||
|
||||
it("reads a statement-less order row's own currency, not 'AUD'", async () => {
|
||||
// The bug this guards: sourcing currency from s.currency alone labelled
|
||||
// every foreign order row AUD, because an order has no statement.
|
||||
const id = await scratchTxn(
|
||||
"transaction_date, description, amount, transaction_type, payment_method, foreign_currency_amount, foreign_currency_code",
|
||||
"'2026-03-01','Order - Foreign fixture', 3500.00, 'debit', 'credits', 3500.00, 'LKR'"
|
||||
);
|
||||
const { ccy, unconverted } = await currencyOf(id);
|
||||
expect(ccy).toBe("LKR");
|
||||
// No amount_aud: the ingest path refuses to assert an FX rate it lacks, so
|
||||
// this row's AUD value is genuinely unknown and must be reported as such.
|
||||
expect(unconverted).toBe(true);
|
||||
});
|
||||
|
||||
it("prefers the statement's currency over the foreign-charge record", async () => {
|
||||
// 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.
|
||||
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`
|
||||
);
|
||||
const id = await scratchTxn(
|
||||
"transaction_date, description, amount, amount_aud, transaction_type, statement_id, foreign_currency_amount, foreign_currency_code",
|
||||
"'2026-03-01','Overseas purchase fixture', 45.00, 45.00, 'debit', $1, 30.00, 'USD'",
|
||||
[stmt!.id]
|
||||
);
|
||||
const { ccy, unconverted } = await currencyOf(id);
|
||||
expect(ccy).toBe("AUD");
|
||||
expect(unconverted).toBe(false);
|
||||
});
|
||||
|
||||
it("defaults a plain manual row to AUD", async () => {
|
||||
const id = await scratchTxn(
|
||||
"transaction_date, description, amount, transaction_type",
|
||||
"'2026-03-01','Plain manual fixture', 12.00, 'debit'"
|
||||
);
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -16,12 +16,47 @@ export function mockDbWithPool(p: Pool) {
|
||||
const result = await p.query(sql, params);
|
||||
return result.rows;
|
||||
},
|
||||
// Mirrors the real module: a mock that omits an export makes it `undefined`
|
||||
// at the call site, so any route using queryRow fails with a confusing
|
||||
// "not a function" rather than a query error.
|
||||
queryRow: async (sql: string, params: unknown[] = []) => {
|
||||
const result = await p.query(sql, params);
|
||||
return result.rows[0] ?? null;
|
||||
},
|
||||
prisma: p,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse to truncate anything that is not the test database.
|
||||
*
|
||||
* `DATABASE_URL` in `.env.test` names the Postgres container by IP, and
|
||||
* container IPs move 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.
|
||||
*
|
||||
* 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". ` +
|
||||
`Check DATABASE_URL in .env.test — the Postgres container IP may have changed ` +
|
||||
`(docker inspect postgres-personal --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}').`
|
||||
);
|
||||
}
|
||||
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,
|
||||
@@ -35,6 +70,7 @@ export async function resetDB(pool: Pool) {
|
||||
transactions,
|
||||
statements,
|
||||
tags,
|
||||
trips,
|
||||
participants
|
||||
RESTART IDENTITY CASCADE
|
||||
`);
|
||||
@@ -72,7 +108,10 @@ export async function insertTransaction(
|
||||
VALUES ($1, NULL, $2, $3, $4, $5, $6, 0) RETURNING id`,
|
||||
[
|
||||
ownerId,
|
||||
overrides.transaction_date ?? "2024-06-15",
|
||||
// Post-cutover by default: a split on an older transaction never counts
|
||||
// towards a balance (ACTIVE_OBLIGATION), so a pre-cutover default would
|
||||
// make every balance fixture silently read zero.
|
||||
overrides.transaction_date ?? "2026-06-15",
|
||||
overrides.description ?? "Test transaction",
|
||||
overrides.amount ?? 100,
|
||||
overrides.transaction_type ?? "debit",
|
||||
|
||||
@@ -9,11 +9,11 @@ import {
|
||||
reconcilePendingOrders,
|
||||
parseOrderAmendment,
|
||||
applyOrderAmendment,
|
||||
OrderParseError,
|
||||
NotAReceiptError,
|
||||
type MessageMeta,
|
||||
} from "../../lib/order-ingestion";
|
||||
import { EXCLUDE_NON_SPEND } from "../../lib/analytics-sql";
|
||||
import { bankLabel, needsCardMatch } from "../../lib/queries";
|
||||
|
||||
/**
|
||||
* These run against REAL captured receipts, not synthetic fixtures. The earlier
|
||||
@@ -142,6 +142,19 @@ describe("Order ingestion — invariants", () => {
|
||||
beforeEach(async () => {
|
||||
await queryRaw(`DELETE FROM expense_metadata WHERE source = 'email'`);
|
||||
await queryRaw(`DELETE FROM transactions WHERE description LIKE 'Order - %'`);
|
||||
// The statement fixtures these tests insert survived into the next run, and
|
||||
// reconcileCardLeg matched a leftover charge at ingest time — so an order
|
||||
// meant to park "awaiting_card_statement" resolved immediately instead.
|
||||
// That is the whole story behind the intermittent failure in "parks an
|
||||
// unresolvable split": not a race, just fixtures that were never cleaned.
|
||||
// Must happen BEFORE ingest, which is why cleaning up at the end of the
|
||||
// test was not enough.
|
||||
await queryRaw(
|
||||
`DELETE FROM statements WHERE filename IN ('test-westpac-2026-03.pdf', 'panel-cba.pdf', 'panel-plain.pdf')`
|
||||
);
|
||||
await queryRaw(
|
||||
`DELETE FROM transactions WHERE description IN ('DD *DOORDASH WOOLWORTHS MELBOURNE AUS', 'UBER *EATS ZURICH')`
|
||||
);
|
||||
});
|
||||
|
||||
it("I6: a credits order creates one transaction at face value", async () => {
|
||||
@@ -169,26 +182,56 @@ describe("Order ingestion — invariants", () => {
|
||||
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("I11: a [Family] order is imported, tagged, and excluded from spend", async () => {
|
||||
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 () => {
|
||||
// Requirement was "import them but tag so they're excluded from budgets".
|
||||
// Correct mechanism: the CARD statement line is the transaction and carries
|
||||
// the family tag. Creating a second, credits-flavoured row duplicated it.
|
||||
const p = parseOrderHTML(
|
||||
html("ue-04"),
|
||||
meta({ subject: "[Family] Your Tuesday evening order with Uber Eats", sender: "uber.com", receivedAt: "2026-07-07T10:08:00Z" })
|
||||
);
|
||||
const res = await processOrderIngestion(p);
|
||||
expect(res.transactionId).not.toBeNull();
|
||||
expect(res.flags).toContain("family_payment_assumed_credits");
|
||||
expect(res.transactionId).toBeNull();
|
||||
expect(res.flags).toContain("family_card_settled_no_transaction");
|
||||
|
||||
const meta_ = await queryRow<{ currency: string }>(
|
||||
`SELECT currency FROM expense_metadata WHERE id = $1`, [res.metadataId]
|
||||
);
|
||||
expect(meta_!.currency).toBe("LKR");
|
||||
});
|
||||
|
||||
it("a foreign-currency order records the original amount and code", async () => {
|
||||
@@ -227,8 +270,11 @@ describe("Order ingestion — invariants", () => {
|
||||
);
|
||||
|
||||
const out = await reconcilePendingOrders();
|
||||
expect(out.resolved).toBeGreaterThanOrEqual(1);
|
||||
expect(out.created).toBeGreaterThanOrEqual(1);
|
||||
// Deliberately not asserting global counts: reconcilePendingOrders() scans
|
||||
// every pending row in the database, so another test's leftovers change the
|
||||
// totals. Assert on THIS order's outcome instead — that is what the test is
|
||||
// actually about, and it does not depend on what else is in the table.
|
||||
expect(out.examined).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const credits = await queryRow<{ amount: string }>(
|
||||
`SELECT t.amount::text FROM transactions t
|
||||
@@ -240,9 +286,12 @@ describe("Order ingestion — invariants", () => {
|
||||
});
|
||||
|
||||
it("reconciliation is idempotent — a second pass creates nothing", async () => {
|
||||
const before = await queryRow<{ c: string }>(`SELECT count(*)::text c FROM transactions`);
|
||||
// A first pass has already run above; a second must add nothing. Scoped to
|
||||
// 'Order - %' rows so unrelated fixtures cannot move the number.
|
||||
const q = `SELECT count(*)::text c FROM transactions WHERE description LIKE 'Order - %'`;
|
||||
const before = await queryRow<{ c: string }>(q);
|
||||
const out = await reconcilePendingOrders();
|
||||
const after = await queryRow<{ c: string }>(`SELECT count(*)::text c FROM transactions`);
|
||||
const after = await queryRow<{ c: string }>(q);
|
||||
expect(out.created).toBe(0);
|
||||
expect(after!.c).toBe(before!.c);
|
||||
});
|
||||
@@ -320,39 +369,164 @@ describe("Refund amendments", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("[Family] orders import rather than park", () => {
|
||||
it("records a family order as credits and tags it", async () => {
|
||||
describe("[Family] orders are card-settled, not credits", () => {
|
||||
it("creates provenance but NO transaction — the statement line is the transaction", async () => {
|
||||
// Regression: these were assumed credits-funded because the receipt names
|
||||
// the payer and no instrument. The card statement carries all four (CBA
|
||||
// ...3893, exact LKR matches), so creating a transaction double-counted
|
||||
// spend already recorded.
|
||||
const p = parseOrderHTML(
|
||||
html("ue-04"),
|
||||
meta({ subject: "[Family] Your Tuesday evening order with Uber Eats", sender: "uber.com", receivedAt: "2026-07-07T10:08:00Z" })
|
||||
);
|
||||
expect(p.flags).toContain("family_payment_assumed_credits");
|
||||
expect(p.flags).toContain("family_card_settled_no_transaction");
|
||||
expect(p.payment.credits_amount).toBeNull();
|
||||
|
||||
const res = await processOrderIngestion(p);
|
||||
expect(res.transactionId).not.toBeNull();
|
||||
expect(res.transactionId).toBeNull();
|
||||
expect(res.metadataId).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
const tag = await queryRow<{ name: string }>(
|
||||
`SELECT tg.name FROM transaction_tags tt JOIN tags tg ON tg.id = tt.tag_id
|
||||
WHERE tt.transaction_id = $1`,
|
||||
describe("owner scoping", () => {
|
||||
it("sets owner_id so the row is visible to the app", async () => {
|
||||
// Regression: analytics scope on COALESCE(t.owner_id, s.owner_id). An
|
||||
// ingested order has no statement, so a NULL owner_id made all 85
|
||||
// backfilled rows invisible in every view while sitting in the table.
|
||||
const p = parseOrderHTML(html("dd-01"), meta({ messageId: `owner-${Date.now()}` }));
|
||||
const res = await processOrderIngestion(p);
|
||||
const row = await queryRow<{ owner_id: number | null }>(
|
||||
`SELECT owner_id FROM transactions WHERE id = $1`,
|
||||
[res.transactionId]
|
||||
);
|
||||
expect(tag!.name).toBe("family");
|
||||
expect(row!.owner_id).not.toBeNull();
|
||||
|
||||
// LKR is preserved, and amount_aud stays NULL — no FX rate is available.
|
||||
const txn = await queryRow<{ foreign_currency_code: string; amount_aud: string | null }>(
|
||||
`SELECT foreign_currency_code, amount_aud::text FROM transactions WHERE id = $1`,
|
||||
[res.transactionId]
|
||||
);
|
||||
expect(txn!.foreign_currency_code).toBe("LKR");
|
||||
expect(txn!.amount_aud).toBeNull();
|
||||
|
||||
// And it must not reach spend.
|
||||
const visible = await queryRaw(
|
||||
`SELECT t.id FROM transactions t
|
||||
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||
WHERE t.id = $1 AND (${EXCLUDE_NON_SPEND})`,
|
||||
`SELECT t.id FROM transactions t LEFT JOIN statements s ON s.id = t.statement_id
|
||||
WHERE t.id = $1 AND COALESCE(t.owner_id, s.owner_id) = $2`,
|
||||
[res.transactionId, row!.owner_id]
|
||||
);
|
||||
expect(visible).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("how an ingested order presents in the app", () => {
|
||||
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).toBe('Order - Mad Mex');
|
||||
});
|
||||
|
||||
it("reads as 'Gift Card', not 'Manual', and stays out of the reconcile queue", async () => {
|
||||
// bank_name is derived — no statement means "Manual", which reads as
|
||||
// "hand-entered, awaiting a card line". A credits order has no card line
|
||||
// coming, ever; 81 of them sat in the queue waiting for one.
|
||||
const p = parseOrderHTML(html("dd-01"), meta({ messageId: `bank-${Date.now()}` }));
|
||||
const res = await processOrderIngestion(p);
|
||||
|
||||
const row = await queryRow<{ bank_name: string }>(
|
||||
`SELECT ${bankLabel()} as bank_name
|
||||
FROM transactions t LEFT JOIN statements s ON s.id = t.statement_id
|
||||
WHERE t.id = $1`,
|
||||
[res.transactionId]
|
||||
);
|
||||
expect(visible).toHaveLength(0);
|
||||
expect(row!.bank_name).toBe("Gift Card");
|
||||
|
||||
const queued = await queryRaw(
|
||||
`SELECT t.id FROM transactions t
|
||||
WHERE t.id = $1 AND t.statement_id IS NULL AND ${needsCardMatch("t")}`,
|
||||
[res.transactionId]
|
||||
);
|
||||
expect(queued).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("records the platform and the message it came from", async () => {
|
||||
const p = parseOrderHTML(
|
||||
html("ue-00"),
|
||||
meta({ messageId: `prov-${Date.now()}`, subject: "Your Wednesday order with Uber Eats", sender: "uber.com" })
|
||||
);
|
||||
const res = await processOrderIngestion(p, {
|
||||
messageId: `prov-${Date.now()}`,
|
||||
subject: "Your Wednesday order with Uber Eats",
|
||||
sender: "uber.com",
|
||||
});
|
||||
const row = await queryRow<{
|
||||
platform: string; source_email_from: string; route: { label: string }[];
|
||||
}>(
|
||||
`SELECT platform, source_email_from, route FROM expense_metadata WHERE id = $1`,
|
||||
[res.metadataId]
|
||||
);
|
||||
expect(row!.platform).toBe("ubereats");
|
||||
expect(row!.source_email_from).toBe("uber.com");
|
||||
expect(row!.route.map((r) => r.label)).toEqual(["Pick-up", "Delivery"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("receipt lookup for the transaction detail panel", () => {
|
||||
// Mirrors /api/transactions/[id]/order — the panel resolves a receipt from
|
||||
// either side, and a card-settled order only has the matched_transaction_id
|
||||
// side, which is exactly where the detail would otherwise go missing.
|
||||
const receiptFor = (txnId: number) =>
|
||||
queryRow<{ platform: string; route: { label: string; address: string }[] }>(
|
||||
`SELECT platform, route FROM expense_metadata
|
||||
WHERE transaction_id = $1 OR matched_transaction_id = $1 LIMIT 1`,
|
||||
[txnId]
|
||||
);
|
||||
|
||||
it("finds the receipt for a credits order", async () => {
|
||||
// ue-00 is Uber Cash — credits, so it creates a transaction. ue-09 names a
|
||||
// payer with no instrument and correctly parks awaiting a card statement,
|
||||
// which would leave nothing to look the receipt up by.
|
||||
const p = parseOrderHTML(
|
||||
html("ue-00"),
|
||||
meta({ messageId: `panel-${Date.now()}`, subject: "Your Wednesday order with Uber Eats", sender: "uber.com" })
|
||||
);
|
||||
const res = await processOrderIngestion(p);
|
||||
const r = await receiptFor(res.transactionId!);
|
||||
expect(r!.platform).toBe("ubereats");
|
||||
expect(r!.route.map((x) => x.label)).toEqual(["Pick-up", "Delivery"]);
|
||||
});
|
||||
|
||||
it("finds it from the statement line for a card-settled order", async () => {
|
||||
const st = await queryRow<{ id: number }>(
|
||||
`INSERT INTO statements (bank_name, account_number, filename)
|
||||
VALUES ('CBA', '5523504401723893', 'panel-cba.pdf') RETURNING id`
|
||||
);
|
||||
const card = await queryRow<{ id: number }>(
|
||||
`INSERT INTO transactions (statement_id, transaction_date, description, amount, transaction_type)
|
||||
VALUES ($1, '2026-04-07', 'UBER *EATS ZURICH', 51.23, 'debit') RETURNING id`,
|
||||
[st!.id]
|
||||
);
|
||||
const m = await queryRow<{ id: number }>(
|
||||
`INSERT INTO expense_metadata (source, order_reference, platform, route, matched_transaction_id)
|
||||
VALUES ('email', $1, 'ubereats', '[{"label":"Pick-up","time":null,"address":"Ebikon"}]'::jsonb, $2)
|
||||
RETURNING id`,
|
||||
[`panel-card-${Date.now()}`, card!.id]
|
||||
);
|
||||
expect(m).not.toBeNull();
|
||||
|
||||
const r = await receiptFor(card!.id);
|
||||
expect(r!.platform).toBe("ubereats");
|
||||
expect(r!.route[0].address).toBe("Ebikon");
|
||||
});
|
||||
|
||||
it("returns nothing for an ordinary transaction", async () => {
|
||||
const st = await queryRow<{ id: number }>(
|
||||
`INSERT INTO statements (bank_name, account_number, filename)
|
||||
VALUES ('CBA', '1111', 'panel-plain.pdf') RETURNING id`
|
||||
);
|
||||
const t = await queryRow<{ id: number }>(
|
||||
`INSERT INTO transactions (statement_id, transaction_date, description, amount, transaction_type)
|
||||
VALUES ($1, '2026-04-07', 'COLES 1234', 12.00, 'debit') RETURNING id`,
|
||||
[st!.id]
|
||||
);
|
||||
expect(await receiptFor(t!.id)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import { describe, it, expect, beforeAll, beforeEach, vi } from "vitest";
|
||||
import { createPool, mockDbWithPool, resetDB } from "./helpers";
|
||||
|
||||
/**
|
||||
* Verdicts on orders — the "never order from here again" memory (ING-9).
|
||||
*
|
||||
* These cover the two things that are easy to get silently wrong and invisible
|
||||
* on screen when you do: a second person's verdict overwriting the first, and a
|
||||
* note-only save wiping the per-item opinions. Both are the same shape as the
|
||||
* bug that reset `settled` on split rewrites.
|
||||
*/
|
||||
const pool = createPool();
|
||||
mockDbWithPool(pool);
|
||||
|
||||
let GET: any;
|
||||
let PUT: any;
|
||||
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) }) });
|
||||
|
||||
beforeAll(async () => {
|
||||
vi.doMock("@/lib/auth", () => ({
|
||||
getCurrentUser: async () => ({ id: ownerId, name: "Owner", email: "o@x" }),
|
||||
}));
|
||||
vi.doMock("@/lib/queries", () => ({ canAccessTransactions: async () => true }));
|
||||
({ GET, PUT } = await import("../../app/api/transactions/[id]/review/route"));
|
||||
});
|
||||
|
||||
/** An ingested order: a transaction plus the expense_metadata behind it. */
|
||||
async function seedOrder(merchant: string, date = "2026-07-01") {
|
||||
const t = await pool.query(
|
||||
`INSERT INTO transactions (transaction_date, description, amount, transaction_type, merchant_name, owner_id)
|
||||
VALUES ($1, $2, 48.20, 'debit', $3, $4) RETURNING id`,
|
||||
[date, `DoorDash ${merchant}`, merchant, ownerId]
|
||||
);
|
||||
const id = t.rows[0].id as number;
|
||||
await pool.query(
|
||||
`INSERT INTO expense_metadata (transaction_id, source, order_reference, merchant_normalized, line_items)
|
||||
VALUES ($1, 'email', $2, $3, '[]'::jsonb)`,
|
||||
[id, `ref-${id}`, merchant]
|
||||
);
|
||||
return id;
|
||||
}
|
||||
|
||||
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;
|
||||
txnId = await seedOrder("Thai Palace");
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
// 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");
|
||||
});
|
||||
|
||||
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();
|
||||
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();
|
||||
expect(body.reviews).toHaveLength(1);
|
||||
expect(body.reviews[0].rating).toBe("never");
|
||||
});
|
||||
|
||||
it("rejects a rating outside the scale", async () => {
|
||||
const res = await PUT(req({ rating: "amazing" }), params(txnId));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("derives order_again from the rating", async () => {
|
||||
let body = await (await PUT(req({ rating: "never" }), params(txnId))).json();
|
||||
expect(body.reviews[0].order_again).toBe(false);
|
||||
body = await (await PUT(req({ rating: "ok" }), params(txnId))).json();
|
||||
expect(body.reviews[0].order_again).toBe(true);
|
||||
// "bad" is also a no — you would not choose it again.
|
||||
body = await (await PUT(req({ rating: "bad" }), params(txnId))).json();
|
||||
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 (await GET(req(), params(txnId))).json();
|
||||
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 (await GET(req(), params(txnId))).json();
|
||||
expect(body.merchant.warn).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("item verdicts", () => {
|
||||
it("a note-only save does not wipe item opinions", async () => {
|
||||
await PUT(
|
||||
req({
|
||||
rating: "liked",
|
||||
item_verdicts: [{ item: "Pad Thai", verdict: "loved" }],
|
||||
}),
|
||||
params(txnId)
|
||||
);
|
||||
// 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();
|
||||
expect(body.reviews[0].item_verdicts).toEqual([
|
||||
{ item: "Pad Thai", verdict: "loved" },
|
||||
]);
|
||||
expect(body.reviews[0].note).toBe("slow delivery");
|
||||
});
|
||||
|
||||
it("an explicit empty array does clear them", async () => {
|
||||
await PUT(
|
||||
req({ rating: "liked", item_verdicts: [{ item: "Pad Thai", verdict: "loved" }] }),
|
||||
params(txnId)
|
||||
);
|
||||
const res = await PUT(req({ rating: "liked", item_verdicts: [] }), params(txnId));
|
||||
const body = await res.json();
|
||||
expect(body.reviews[0].item_verdicts).toEqual([]);
|
||||
});
|
||||
|
||||
it("drops malformed entries without failing the save", async () => {
|
||||
const res = await PUT(
|
||||
req({
|
||||
rating: "ok",
|
||||
item_verdicts: [
|
||||
{ item: "Pad Thai", verdict: "loved" },
|
||||
{ item: "", verdict: "loved" },
|
||||
{ item: "Curry", verdict: "middling" },
|
||||
],
|
||||
}),
|
||||
params(txnId)
|
||||
);
|
||||
const body = await res.json();
|
||||
expect(body.reviews[0].rating).toBe("ok");
|
||||
expect(body.reviews[0].item_verdicts).toEqual([
|
||||
{ item: "Pad Thai", verdict: "loved" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("merchant history", () => {
|
||||
it("warns when the merchant was ever marked never, and excludes this order", async () => {
|
||||
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]);
|
||||
});
|
||||
|
||||
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 (await GET(req(), params(txnId))).json();
|
||||
expect(body.merchant.warn).toBe(true);
|
||||
expect(body.merchant.history.map((h: any) => 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);
|
||||
});
|
||||
|
||||
it("pools item opinions across the merchant's orders", async () => {
|
||||
const older = await seedOrder("Thai Palace", "2026-06-01");
|
||||
await PUT(
|
||||
req({ item_verdicts: [{ item: "Pad Thai", verdict: "loved" }] }),
|
||||
params(older)
|
||||
);
|
||||
const older2 = await seedOrder("Thai Palace", "2026-05-01");
|
||||
await PUT(
|
||||
req({ item_verdicts: [{ item: "pad thai", verdict: "loved" }] }),
|
||||
params(older2)
|
||||
);
|
||||
|
||||
const body = await (await GET(req(), params(txnId))).json();
|
||||
// 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 }]);
|
||||
});
|
||||
|
||||
it("keeps item opinions from reviews that have no overall rating", async () => {
|
||||
const older = await seedOrder("Thai Palace", "2026-06-01");
|
||||
await PUT(
|
||||
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 }]);
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
expect(body.splits).toEqual([]);
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
|
||||
VALUES ($1, $2, 50)`,
|
||||
[txnId, otherId]
|
||||
);
|
||||
body = await (await GET(req(), params(txnId))).json();
|
||||
expect(body.splits).toHaveLength(1);
|
||||
expect(body.splits[0].participant_id).toBe(otherId);
|
||||
});
|
||||
});
|
||||
@@ -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 } = 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);
|
||||
@@ -253,3 +313,903 @@ describe("getParticipantBalances", () => {
|
||||
expect(bobBalance!.unsettled_count).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTransactions — order provenance for the description sub-line", () => {
|
||||
it("carries the route and platform of an order-derived row", async () => {
|
||||
// Five rows all reading "Order - Uber Trip" are indistinguishable in the
|
||||
// list; where the trip went is the only thing that separates them, and it
|
||||
// was already stored.
|
||||
const { ownerId } = await seedParticipants(pool);
|
||||
const txId = await insertTransaction(pool, ownerId, { description: "Order - Uber Trip" });
|
||||
await pool.query(
|
||||
`INSERT INTO expense_metadata (source, order_reference, platform, route, transaction_id)
|
||||
VALUES ('email', $1, 'uber',
|
||||
'[{"label":"Pick-up","time":"7:32 pm","address":"Terminal 2, Melbourne Airport (MEL), Tullamarine VIC 3045, Australia"},
|
||||
{"label":"Drop-off","time":"8:10 pm","address":"19 Lady Penrhyn Dr, Wyndham Vale VIC 3024, Australia"}]'::jsonb,
|
||||
$2)`,
|
||||
[`route-${Date.now()}`, txId]
|
||||
);
|
||||
|
||||
const { data } = await getTransactions(ownerId, { limit: 50, offset: 0 });
|
||||
const row = data.find((r) => r.id === txId)!;
|
||||
expect(row.order_platform).toBe("uber");
|
||||
expect(row.order_route).toHaveLength(2);
|
||||
expect(row.order_route![0].address).toContain("Melbourne Airport");
|
||||
});
|
||||
|
||||
it("resolves from the statement line for a card-settled order", async () => {
|
||||
// A card-settled order creates no transaction of its own (I5) — the
|
||||
// receipt points at the statement line through matched_transaction_id.
|
||||
const { ownerId } = await seedParticipants(pool);
|
||||
const txId = await insertTransaction(pool, ownerId, { description: "UBER *TRIP AUCKLAND" });
|
||||
await pool.query(
|
||||
`INSERT INTO expense_metadata (source, order_reference, platform, route, matched_transaction_id)
|
||||
VALUES ('email', $1, 'uber',
|
||||
'[{"label":"Pick-up","time":null,"address":"64 Federal Street, Auckland 1010, NZ"},
|
||||
{"label":"Drop-off","time":null,"address":"International Terminal, Auckland 2022, New Zealand"}]'::jsonb,
|
||||
$2)`,
|
||||
[`route-card-${Date.now()}`, txId]
|
||||
);
|
||||
|
||||
const { data } = await getTransactions(ownerId, { limit: 50, offset: 0 });
|
||||
const row = data.find((r) => r.id === txId)!;
|
||||
expect(row.order_route).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("leaves an ordinary transaction with no route", async () => {
|
||||
const { ownerId } = await seedParticipants(pool);
|
||||
const txId = await insertTransaction(pool, ownerId, { description: "COLES 1234" });
|
||||
const { data } = await getTransactions(ownerId, { limit: 50, offset: 0 });
|
||||
const row = data.find((r) => r.id === txId)!;
|
||||
expect(row.order_route).toBeNull();
|
||||
expect(row.order_platform).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── settlement scope: settled + split_payments.trip_id (migration 0022) ───────
|
||||
|
||||
describe("getParticipantBalances — settled", () => {
|
||||
it("excludes a settled split from what is owed", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
const txId = await insertTransaction(pool, ownerId, { amount: 100 });
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent, settled)
|
||||
VALUES ($1, $2, 50, true)`,
|
||||
[txId, otherId]
|
||||
);
|
||||
|
||||
const balances = await getParticipantBalances(ownerId);
|
||||
const bob = balances.find((b) => b.id === otherId);
|
||||
expect(Number(bob!.total_owed)).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
it("still counts an unsettled split alongside a settled one", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
const settledTx = await insertTransaction(pool, ownerId, { amount: 100 });
|
||||
const liveTx = await insertTransaction(pool, ownerId, { amount: 40 });
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent, settled)
|
||||
VALUES ($1, $2, 50, true), ($3, $2, 50, false)`,
|
||||
[settledTx, otherId, liveTx]
|
||||
);
|
||||
|
||||
const balances = await getParticipantBalances(ownerId);
|
||||
const bob = balances.find((b) => b.id === otherId);
|
||||
// Only the live split counts: 50% of 40.
|
||||
expect(Number(bob!.total_owed)).toBeCloseTo(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTripAnalytics — per-trip settlement", () => {
|
||||
async function seedTrip(ownerId: number, otherId: number) {
|
||||
const trip = await pool.query(
|
||||
`INSERT INTO trips (owner_id, name, start_date, end_date)
|
||||
VALUES ($1, 'Test Trip', '2026-03-01', '2026-03-10') RETURNING id`,
|
||||
[ownerId]
|
||||
);
|
||||
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]
|
||||
);
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
|
||||
VALUES ($1, $2, 50)`,
|
||||
[txId, otherId]
|
||||
);
|
||||
return tripId;
|
||||
}
|
||||
|
||||
it("reports the gross share before any payment", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
const tripId = await seedTrip(ownerId, otherId);
|
||||
|
||||
const { participant_splits } = await getTripAnalytics(tripId, ownerId);
|
||||
const bob = participant_splits.find((r) => r.participant_id === otherId);
|
||||
expect(Number(bob!.owed)).toBeCloseTo(100);
|
||||
});
|
||||
|
||||
it("nets off a payment scoped to that trip", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
const tripId = await seedTrip(ownerId, otherId);
|
||||
await pool.query(
|
||||
`INSERT INTO split_payments (from_participant_id, to_participant_id, amount, payment_date, trip_id)
|
||||
VALUES ($1, $2, 60, '2026-03-15', $3)`,
|
||||
[otherId, ownerId, tripId]
|
||||
);
|
||||
|
||||
const { participant_splits } = await getTripAnalytics(tripId, ownerId);
|
||||
const bob = participant_splits.find((r) => r.participant_id === otherId);
|
||||
expect(Number(bob!.owed)).toBeCloseTo(40);
|
||||
});
|
||||
|
||||
// The point of the whole scope column: settling the household tab must not
|
||||
// make a trip look paid. Before trip_id existed there was one global pool and
|
||||
// this distinction could not be expressed.
|
||||
it("ignores a household payment when reporting the trip", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
const tripId = await seedTrip(ownerId, otherId);
|
||||
await pool.query(
|
||||
`INSERT INTO split_payments (from_participant_id, to_participant_id, amount, payment_date, trip_id)
|
||||
VALUES ($1, $2, 60, '2026-03-15', NULL)`,
|
||||
[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);
|
||||
});
|
||||
|
||||
it("drops a settled split from the trip figure", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
const tripId = await seedTrip(ownerId, otherId);
|
||||
await pool.query(`UPDATE transaction_splits SET settled = true WHERE participant_id = $1`, [otherId]);
|
||||
|
||||
const { participant_splits } = await getTripAnalytics(tripId, ownerId);
|
||||
const bob = participant_splits.find((r) => r.participant_id === otherId);
|
||||
expect(bob === undefined || Number(bob.owed) === 0).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTripAnalytics — owner scoping", () => {
|
||||
it("ignores a trip expense someone else paid for", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
const trip = await pool.query(
|
||||
`INSERT INTO trips (owner_id, name) VALUES ($1, 'Owner Scope Trip') RETURNING id`,
|
||||
[ownerId]
|
||||
);
|
||||
const tripId = trip.rows[0].id as number;
|
||||
|
||||
// Bob paid this one. Alice's share of it is a debt Alice owes Bob — it is
|
||||
// not something Bob owes Alice, so it must not appear on Alice's trip view.
|
||||
const bobPaid = await insertTransaction(pool, otherId, { amount: 500, category: "travel" });
|
||||
await pool.query(`INSERT INTO transaction_overrides (transaction_id, trip_id) VALUES ($1, $2)`, [bobPaid, tripId]);
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
|
||||
VALUES ($1, $2, 50), ($1, $3, 50)`,
|
||||
[bobPaid, ownerId, otherId]
|
||||
);
|
||||
|
||||
const { participant_splits } = await getTripAnalytics(tripId, ownerId);
|
||||
const bob = participant_splits.find((r) => r.participant_id === otherId);
|
||||
expect(bob === undefined || Number(bob.owed) === 0).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores a payment settled between the other two participants", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
const third = await pool.query(
|
||||
`INSERT INTO participants (name, email) VALUES ('Carol', 'carol@example.com') RETURNING id`
|
||||
);
|
||||
const carolId = third.rows[0].id as number;
|
||||
const trip = await pool.query(
|
||||
`INSERT INTO trips (owner_id, name) VALUES ($1, 'Third Party Trip') RETURNING id`,
|
||||
[ownerId]
|
||||
);
|
||||
const tripId = trip.rows[0].id as number;
|
||||
|
||||
const txId = await insertTransaction(pool, ownerId, { amount: 300, 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, carolId]
|
||||
);
|
||||
// Carol pays Bob, not the owner. Carol still owes the owner $150.
|
||||
await pool.query(
|
||||
`INSERT INTO split_payments (from_participant_id, to_participant_id, amount, payment_date, trip_id)
|
||||
VALUES ($1, $2, 150, '2026-03-15', $3)`,
|
||||
[carolId, otherId, tripId]
|
||||
);
|
||||
|
||||
const { participant_splits } = await getTripAnalytics(tripId, ownerId);
|
||||
const carol = participant_splits.find((r) => r.participant_id === carolId);
|
||||
expect(Number(carol!.owed)).toBeCloseTo(150);
|
||||
});
|
||||
});
|
||||
|
||||
// A refunded trip expense must not still read as trip cost. These queries
|
||||
// filtered on debit/fee/interest, so a refund was dropped entirely and the
|
||||
// original purchase stood at full value.
|
||||
describe("getTripAnalytics — refunds reduce trip cost", () => {
|
||||
async function seedTripWithRefund(ownerId: number) {
|
||||
const trip = await pool.query(
|
||||
`INSERT INTO trips (owner_id, name, start_date, end_date)
|
||||
VALUES ($1, 'Refund Trip', '2026-03-01', '2026-03-10') RETURNING id`,
|
||||
[ownerId]
|
||||
);
|
||||
const tripId = trip.rows[0].id as number;
|
||||
|
||||
const spend = await insertTransaction(pool, ownerId, {
|
||||
amount: 200, category: "travel", description: "Hotel booking", transaction_date: "2026-03-02",
|
||||
});
|
||||
const refund = await insertTransaction(pool, ownerId, {
|
||||
amount: 50, category: "travel", description: "Hotel partial refund",
|
||||
transaction_type: "refund", transaction_date: "2026-03-05",
|
||||
});
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_overrides (transaction_id, trip_id) VALUES ($1, $3), ($2, $3)`,
|
||||
[spend, refund, tripId]
|
||||
);
|
||||
return tripId;
|
||||
}
|
||||
|
||||
it("nets the refund out of the category total", async () => {
|
||||
const { ownerId } = await seedParticipants(pool);
|
||||
const tripId = await seedTripWithRefund(ownerId);
|
||||
|
||||
const { category_breakdown } = await getTripAnalytics(tripId, ownerId);
|
||||
const travel = category_breakdown.find((c) => c.category === "travel");
|
||||
expect(Number(travel!.amount)).toBeCloseTo(150);
|
||||
});
|
||||
|
||||
it("nets the refund out of the trip's headline total_spend", async () => {
|
||||
const { ownerId } = await seedParticipants(pool);
|
||||
const tripId = await seedTripWithRefund(ownerId);
|
||||
|
||||
const trip = await getTripById(tripId, ownerId);
|
||||
expect(Number(trip!.total_spend)).toBeCloseTo(150);
|
||||
});
|
||||
|
||||
it("shows the refund as a negative on its own day", async () => {
|
||||
const { ownerId } = await seedParticipants(pool);
|
||||
const tripId = await seedTripWithRefund(ownerId);
|
||||
|
||||
const { daily_spend } = await getTripAnalytics(tripId, ownerId);
|
||||
const refundDay = daily_spend.find((d) => d.date === "2026-03-05");
|
||||
expect(Number(refundDay!.amount)).toBeCloseTo(-50);
|
||||
});
|
||||
|
||||
// The owed side must be untouched: a refund carries no split, and the owed
|
||||
// query deliberately excludes credits. Netting cost must not move a balance.
|
||||
it("leaves what the other participant owes unchanged", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
const tripId = await seedTripWithRefund(ownerId);
|
||||
const rows = await pool.query(
|
||||
`SELECT transaction_id FROM transaction_overrides WHERE trip_id = $1 ORDER BY transaction_id`,
|
||||
[tripId]
|
||||
);
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent) VALUES ($1, $2, 50)`,
|
||||
[rows.rows[0].transaction_id, otherId]
|
||||
);
|
||||
|
||||
const { participant_splits } = await getTripAnalytics(tripId, ownerId);
|
||||
const bob = participant_splits.find((r) => r.participant_id === otherId);
|
||||
expect(Number(bob!.owed)).toBeCloseTo(100);
|
||||
});
|
||||
});
|
||||
|
||||
// An account cannot be billed twice for the same day. The boundary handling is
|
||||
// the whole difficulty: these statements are issued back-to-back with one
|
||||
// period ending the day the next begins, so naive inclusive ranges flag every
|
||||
// consecutive pair.
|
||||
describe("getStatements — overlapping billing periods", () => {
|
||||
async function addStatement(
|
||||
ownerId: number, account: string, start: string | null, end: string | null
|
||||
): Promise<number> {
|
||||
const r = await pool.query(
|
||||
`INSERT INTO statements (filename, bank_name, account_number, owner_id,
|
||||
billing_start_date, billing_end_date)
|
||||
VALUES ($1, 'ANZ', $2, $3, $4, $5) RETURNING id`,
|
||||
[`stmt-${account}-${start}.pdf`, account, ownerId, start, end]
|
||||
);
|
||||
return r.rows[0].id as number;
|
||||
}
|
||||
|
||||
it("does not flag statements that merely touch at a boundary", async () => {
|
||||
const { ownerId } = await seedParticipants(pool);
|
||||
await addStatement(ownerId, "4085-56264", "2025-05-16", "2025-11-14");
|
||||
await addStatement(ownerId, "4085-56264", "2025-11-14", "2026-05-15");
|
||||
|
||||
const rows = await getStatements(ownerId);
|
||||
expect(rows.every((r) => r.overlaps.length === 0)).toBe(true);
|
||||
});
|
||||
|
||||
it("flags a genuine overlap on both statements, with the day count", async () => {
|
||||
const { ownerId } = await seedParticipants(pool);
|
||||
const a = await addStatement(ownerId, "4085-56264", "2025-11-12", "2026-03-12");
|
||||
const b = await addStatement(ownerId, "4085-56264", "2025-11-14", "2026-05-15");
|
||||
|
||||
const rows = await getStatements(ownerId);
|
||||
const rowA = rows.find((r) => r.id === a)!;
|
||||
const rowB = rows.find((r) => r.id === b)!;
|
||||
expect(rowA.overlaps).toEqual([{ id: b, days: 118 }]);
|
||||
expect(rowB.overlaps).toEqual([{ id: a, days: 118 }]);
|
||||
});
|
||||
|
||||
// The real duplicate got in because the existing key compared raw text and
|
||||
// ANZ wrote the same account both ways.
|
||||
it("matches the same account written with and without punctuation", async () => {
|
||||
const { ownerId } = await seedParticipants(pool);
|
||||
const a = await addStatement(ownerId, "408556264", "2025-11-12", "2026-03-12");
|
||||
const b = await addStatement(ownerId, "4085-56264", "2025-11-14", "2026-05-15");
|
||||
|
||||
const rows = await getStatements(ownerId);
|
||||
expect(rows.find((r) => r.id === a)!.overlaps).toEqual([{ id: b, days: 118 }]);
|
||||
});
|
||||
|
||||
it("ignores a different account billing the same days", async () => {
|
||||
const { ownerId } = await seedParticipants(pool);
|
||||
await addStatement(ownerId, "4085-56264", "2025-11-12", "2026-03-12");
|
||||
await addStatement(ownerId, "9999-11111", "2025-11-12", "2026-03-12");
|
||||
|
||||
const rows = await getStatements(ownerId);
|
||||
expect(rows.every((r) => r.overlaps.length === 0)).toBe(true);
|
||||
});
|
||||
|
||||
// NULL is unbounded to daterange, which would make an undated statement
|
||||
// overlap the entire history.
|
||||
it("does not treat an undated statement as overlapping everything", async () => {
|
||||
const { ownerId } = await seedParticipants(pool);
|
||||
await addStatement(ownerId, "4085-56264", "2025-11-12", "2026-03-12");
|
||||
await addStatement(ownerId, "4085-56264", null, null);
|
||||
|
||||
const rows = await getStatements(ownerId);
|
||||
expect(rows.every((r) => r.overlaps.length === 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// A statement imported twice puts every transaction in the overlap in the
|
||||
// ledger twice. The duplicate is superseded rather than deleted, because every
|
||||
// child of `transactions` cascades on delete.
|
||||
describe("superseded duplicates are excluded but kept", () => {
|
||||
it("hides a superseded row from the transaction list", async () => {
|
||||
const { ownerId } = await seedParticipants(pool);
|
||||
const keep = await insertTransaction(pool, ownerId, { description: "RAIZ INVESTMENT", amount: 1500 });
|
||||
const dup = await insertTransaction(pool, ownerId, { description: "RAIZ INVESTMENT", amount: 1500 });
|
||||
await pool.query(`UPDATE transactions SET superseded_by_id = $1 WHERE id = $2`, [keep, dup]);
|
||||
|
||||
const { data, total } = await getTransactions(ownerId, { limit: 50, offset: 0 });
|
||||
expect(total).toBe(1);
|
||||
expect(data.map((t) => t.id)).toEqual([keep]);
|
||||
});
|
||||
|
||||
it("keeps the superseded row and its children in the database", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
const keep = await insertTransaction(pool, ownerId, { amount: 100 });
|
||||
const dup = await insertTransaction(pool, ownerId, { amount: 100 });
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent) VALUES ($1, $2, 50)`,
|
||||
[dup, otherId]
|
||||
);
|
||||
await pool.query(`UPDATE transactions SET superseded_by_id = $1 WHERE id = $2`, [keep, dup]);
|
||||
|
||||
const rows = await pool.query(`SELECT superseded_by_id FROM transactions WHERE id = $1`, [dup]);
|
||||
expect(rows.rows[0].superseded_by_id).toBe(keep);
|
||||
const kids = await pool.query(`SELECT count(*)::int AS n FROM transaction_splits WHERE transaction_id = $1`, [dup]);
|
||||
expect(kids.rows[0].n).toBe(1);
|
||||
});
|
||||
|
||||
// The point of excluding it: a split on a duplicate must not be owed twice.
|
||||
it("does not count a superseded row towards what someone owes", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
const keep = await insertTransaction(pool, ownerId, { amount: 100 });
|
||||
const dup = await insertTransaction(pool, ownerId, { amount: 100 });
|
||||
for (const id of [keep, dup]) {
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent) VALUES ($1, $2, 50)`,
|
||||
[id, otherId]
|
||||
);
|
||||
}
|
||||
await pool.query(`UPDATE transactions SET superseded_by_id = $1 WHERE id = $2`, [keep, dup]);
|
||||
|
||||
const balances = await getParticipantBalances(ownerId);
|
||||
const bob = balances.find((b) => b.id === otherId);
|
||||
expect(Number(bob!.total_owed)).toBeCloseTo(50);
|
||||
});
|
||||
|
||||
it("refuses to let a row supersede itself", async () => {
|
||||
const { ownerId } = await seedParticipants(pool);
|
||||
const id = await insertTransaction(pool, ownerId);
|
||||
await expect(
|
||||
pool.query(`UPDATE transactions SET superseded_by_id = $1 WHERE id = $1`, [id])
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// Nothing before the cutover can be owed: carryover transaction 2348 already
|
||||
// carries the entire pre-cutover balance as one figure. Splits on older
|
||||
// transactions exist to describe how an expense was shared -- which keeps it
|
||||
// out of spend -- without asserting a debt.
|
||||
describe("the split cutover gates every balance", () => {
|
||||
it("ignores a split on a transaction before the cutover", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
const txId = await insertTransaction(pool, ownerId, {
|
||||
amount: 100, transaction_date: "2026-01-08",
|
||||
});
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent) VALUES ($1, $2, 50)`,
|
||||
[txId, otherId]
|
||||
);
|
||||
|
||||
const balances = await getParticipantBalances(ownerId);
|
||||
const bob = balances.find((b) => b.id === otherId);
|
||||
expect(Number(bob?.total_owed ?? 0)).toBeCloseTo(0);
|
||||
});
|
||||
|
||||
// Inclusive: transaction 2348, which carries the whole pre-cutover balance,
|
||||
// is itself dated 2026-01-09. An exclusive bound would drop it.
|
||||
it("counts a split dated exactly on the cutover", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
const txId = await insertTransaction(pool, ownerId, {
|
||||
amount: 100, transaction_date: "2026-01-09",
|
||||
});
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent) VALUES ($1, $2, 50)`,
|
||||
[txId, otherId]
|
||||
);
|
||||
|
||||
const balances = await getParticipantBalances(ownerId);
|
||||
const bob = balances.find((b) => b.id === otherId);
|
||||
expect(Number(bob!.total_owed)).toBeCloseTo(50);
|
||||
});
|
||||
|
||||
// The point of the date guard: it does not depend on `settled` surviving.
|
||||
it("still ignores a pre-cutover split whose settled flag was lost", async () => {
|
||||
const { ownerId, otherId } = await seedParticipants(pool);
|
||||
const txId = await insertTransaction(pool, ownerId, {
|
||||
amount: 200, transaction_date: "2025-06-01",
|
||||
});
|
||||
await pool.query(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent, settled)
|
||||
VALUES ($1, $2, 50, false)`,
|
||||
[txId, otherId]
|
||||
);
|
||||
|
||||
const balances = await getParticipantBalances(ownerId);
|
||||
const bob = balances.find((b) => b.id === otherId);
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -5,8 +5,8 @@ import {
|
||||
parseOrderHTML,
|
||||
validateOrderTotals,
|
||||
resolveCategory,
|
||||
OrderParseError,
|
||||
NotAReceiptError,
|
||||
orderDescription,
|
||||
type MessageMeta,
|
||||
type ParsedOrder,
|
||||
} from "../../lib/order-ingestion";
|
||||
@@ -71,6 +71,7 @@ describe("validateOrderTotals", () => {
|
||||
service_fee: null, tip: null, discounts: null, total_charged: 10,
|
||||
},
|
||||
line_items: [],
|
||||
route: [],
|
||||
is_family: false,
|
||||
flags: [],
|
||||
...over,
|
||||
@@ -164,3 +165,359 @@ describe("order_reference anchoring", () => {
|
||||
expect(p.flags).toContain("order_uuid_ambiguous");
|
||||
});
|
||||
});
|
||||
|
||||
describe("mixed Uber payment (issuer-named card leg)", () => {
|
||||
it("captures both legs when the card is labelled by issuer, not brand", () => {
|
||||
// Real receipt: Uber Cash $1.17 + Westpac ••••8032 $15.33 = $16.50.
|
||||
// A brand allowlist (Visa|MasterCard|Amex) misses "Westpac" and drops the
|
||||
// card half, leaving payments that do not account for the total.
|
||||
const p = parseOrderHTML(
|
||||
readFileSync(resolve(dir, "ue-mixed.html"), "utf-8"),
|
||||
meta({ subject: "Your Friday morning order with Uber Eats", sender: "uber.com", receivedAt: "2026-01-09T09:26:44Z" })
|
||||
);
|
||||
expect(p.totals.total_charged).toBeCloseTo(16.50, 2);
|
||||
expect(p.payment.credits_amount).toBeCloseTo(1.17, 2);
|
||||
expect(p.payment.card_amount).toBeCloseTo(15.33, 2);
|
||||
expect(p.payment.card_last4).toBe("8032");
|
||||
expect(validateOrderTotals(p).ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Uber route (pick-up / delivery)", () => {
|
||||
const uber = (f: string, subject = "Your Wednesday order with Uber Eats") =>
|
||||
parseOrderHTML(html(f), meta({ subject, sender: "uber.com" }));
|
||||
|
||||
it("reads both stops with their times, as printed", () => {
|
||||
const p = uber("ue-00");
|
||||
expect(p.route).toEqual([
|
||||
{ label: "Pick-up", time: "1:20 pm", address: "197 Watton St, Werribee VIC 3030, Australia" },
|
||||
{ label: "Delivery", time: "1:40 pm", address: "19 Lady Penrhyn Dr, Wyndham Vale VIC 3024, Australia" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("de-duplicates the block Uber renders twice", () => {
|
||||
// The receipt emits the whole address section a second time for narrow
|
||||
// screens. Without de-duplication every trip has four stops.
|
||||
expect(uber("ue-00").route).toHaveLength(2);
|
||||
expect(uber("ue-26").route).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps the receipt's own wording rather than normalising it", () => {
|
||||
// Uber is not internally consistent: "Pick-up" on some receipts,
|
||||
// "Pickup" on others. Inventing a canonical spelling would hide that a
|
||||
// template changed.
|
||||
expect(uber("ue-mixed", "Your Friday morning order with Uber Eats").route[0].label).toBe("Pickup");
|
||||
});
|
||||
|
||||
it("works on an international receipt", () => {
|
||||
const p = uber("ue-26");
|
||||
expect(p.route[1].address).toContain("Luzern, Switzerland");
|
||||
});
|
||||
|
||||
it("DoorDash has no route — its receipts carry no addresses", () => {
|
||||
expect(parseOrderHTML(html("dd-01"), meta()).route).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Uber line items", () => {
|
||||
it("itemises a grocery order, binding qty/title/amount by item id", () => {
|
||||
const p = parseOrderHTML(
|
||||
html("ue-09"),
|
||||
meta({ subject: "Your Sunday evening order with Uber Eats", sender: "uber.com" })
|
||||
);
|
||||
expect(p.line_items).toHaveLength(5);
|
||||
expect(p.line_items[0]).toMatchObject({
|
||||
qty: 1,
|
||||
description: "Highland Brewing MILK FULL CREAM U H T 900ML",
|
||||
amount: 440,
|
||||
});
|
||||
// A sold-out item prints 0.00 and is kept: it is why the total is lower
|
||||
// than what was ordered, and dropping it makes the receipt unexplainable.
|
||||
expect(p.line_items.map((i) => i.amount)).toContain(0);
|
||||
});
|
||||
|
||||
it("a restaurant order legitimately has none", () => {
|
||||
// Uber itemises groceries only; a restaurant receipt states a total and
|
||||
// nothing else. Empty here is the receipt, not a parse failure — so it
|
||||
// must not raise no_line_items_parsed either.
|
||||
const p = parseOrderHTML(
|
||||
html("ue-00"),
|
||||
meta({ subject: "Your Wednesday order with Uber Eats", sender: "uber.com" })
|
||||
);
|
||||
expect(p.line_items).toEqual([]);
|
||||
expect(p.flags).not.toContain("no_line_items_parsed");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Uber trips. Captured 2026-06 via a dry-run against the real mailbox after the
|
||||
* user pointed out that only *overseas* rides go on a card — local rides are
|
||||
* paid with credits, which puts them in the same class as delivery orders.
|
||||
*/
|
||||
describe("Uber trips", () => {
|
||||
const utMeta: Record<string, MessageMeta> = JSON.parse(
|
||||
readFileSync(resolve(dir, "ut-meta.json"), "utf-8")
|
||||
);
|
||||
const trip = (f: string) => parseOrderHTML(html(f), utMeta[f]);
|
||||
|
||||
it("a local trip is credits-funded", () => {
|
||||
const p = trip("ut-00");
|
||||
expect(p.platform).toBe("uber");
|
||||
expect(p.currency).toBe("AUD");
|
||||
expect(p.totals.total_charged).toBeCloseTo(84.78, 2);
|
||||
expect(p.payment.credits_amount).toBeCloseTo(84.78, 2);
|
||||
expect(p.payment.card_last4).toBeNull();
|
||||
expect(validateOrderTotals(p).ok).toBe(true);
|
||||
});
|
||||
|
||||
it("an overseas trip is card-settled", () => {
|
||||
const p = trip("ut-01");
|
||||
expect(p.currency).toBe("NZD");
|
||||
expect(p.totals.total_charged).toBeCloseTo(55.51, 2);
|
||||
expect(p.payment.card_amount).toBeCloseTo(55.51, 2);
|
||||
expect(p.payment.card_last4).toBe("3893");
|
||||
});
|
||||
|
||||
it("labels the two ends of a trip, which the receipt does not", () => {
|
||||
// Delivery receipts write "1:20 pm - Pick-up"; trip receipts print the time
|
||||
// alone. The naive split put the time in `label` and left `time` null.
|
||||
const p = trip("ut-00");
|
||||
expect(p.route).toEqual([
|
||||
{
|
||||
label: "Pick-up",
|
||||
time: "7:32 pm",
|
||||
address: "Terminal 2, Melbourne Airport (MEL), Tullamarine VIC 3045, Australia",
|
||||
},
|
||||
{
|
||||
label: "Drop-off",
|
||||
time: "8:10 pm",
|
||||
address: "19 Lady Penrhyn Dr, Wyndham Vale VIC 3024, Australia",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects the charge summary Uber sends before the receipt", () => {
|
||||
// Uber sends two mails per trip with the same subject and the same total.
|
||||
// The first says "This is not a payment receipt" and carries no
|
||||
// tripReference, so order_reference would fall back to msg:<id> and I7
|
||||
// could not dedupe it — every trip would be recorded twice.
|
||||
expect(() => trip("ut-summary")).toThrow(NotAReceiptError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("orderDescription", () => {
|
||||
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)"
|
||||
);
|
||||
});
|
||||
|
||||
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("•");
|
||||
expect(item!.options.length).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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
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,
|
||||
NET_SPEND_ROWS,
|
||||
SPEND_SIGNED,
|
||||
mySplitOf,
|
||||
toDateStr,
|
||||
} from "@/lib/analytics-sql";
|
||||
|
||||
/**
|
||||
* Daily net spend, by month, day-of-month and category.
|
||||
*
|
||||
* This exists so the spend-pace chart stops computing its own totals. It used to
|
||||
* sum gross `amount_aud ?? amount` over `transaction_type = 'debit'` in the
|
||||
* browser, which meant it ignored personal share, refunds, fees, interest, and
|
||||
* itemised loan repayments — every rule the headline applies. The two numbers
|
||||
* could disagree while both were labelled "spend", and the chart's own baseline
|
||||
* line was drawn from the split-adjusted monthly totals, so the two series in
|
||||
* one chart were on different bases.
|
||||
*
|
||||
* Day-of-month granularity is also what lets the page compare a partial current
|
||||
* month against prior months *through the same day*, instead of against their
|
||||
* full-month totals — which always made a month in progress look thrifty.
|
||||
*
|
||||
* Same fragments as /api/analytics/monthly. If that route's semantics change,
|
||||
* this one changes with it.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const user = await getCurrentUser(req);
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const monthCount = Math.min(Math.max(Number(searchParams.get("months") || "12"), 1), 24);
|
||||
|
||||
const now = new Date();
|
||||
const endDate = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||
const startDate = new Date(now.getFullYear(), now.getMonth() - monthCount + 1, 1);
|
||||
|
||||
const rows = await queryRaw<{ month: string; day: number; category: string; spent: string }>(
|
||||
`SELECT
|
||||
TO_CHAR(DATE_TRUNC('month', t.transaction_date::date), 'YYYY-MM') as month,
|
||||
EXTRACT(DAY FROM t.transaction_date::date)::int as day,
|
||||
${EFFECTIVE_CATEGORY} as category,
|
||||
-- 4dp, not 2. This is grouped finer than /monthly (by day as well as
|
||||
-- category), so rounding each bucket to cents and summing accumulates a
|
||||
-- different error than rounding per category does — the pace chart ended
|
||||
-- the month a few cents off the headline it sits under. Round once, at
|
||||
-- display time.
|
||||
SUM(${mySplitOf(SPEND_SIGNED)})::numeric(14,4) as spent
|
||||
FROM transactions t
|
||||
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
|
||||
AND ${NET_SPEND_ROWS}
|
||||
AND ${EXCLUDE_NON_SPEND}
|
||||
AND ${EXCLUDE_RECONCILED_SOURCE}
|
||||
AND t.transaction_date >= $2
|
||||
AND t.transaction_date < $3
|
||||
GROUP BY 1, 2, 3
|
||||
ORDER BY 1, 2`,
|
||||
[user.id, toDateStr(startDate), toDateStr(endDate)]
|
||||
);
|
||||
|
||||
// Sparse by design — a day with no spend has no entry, and the client treats
|
||||
// a missing day as zero. Emitting 31 zeroes per month per category would
|
||||
// dominate the payload.
|
||||
const daily: Record<string, Record<number, number>> = {};
|
||||
const byCategory: Record<string, Record<string, Record<number, number>>> = {};
|
||||
|
||||
for (const r of rows) {
|
||||
const spent = Number(r.spent);
|
||||
const m = (daily[r.month] ??= {});
|
||||
m[r.day] = (m[r.day] ?? 0) + spent;
|
||||
|
||||
const c = ((byCategory[r.month] ??= {})[r.category] ??= {});
|
||||
c[r.day] = (c[r.day] ?? 0) + spent;
|
||||
}
|
||||
|
||||
return NextResponse.json({ daily, byCategory });
|
||||
}
|
||||
@@ -1,12 +1,35 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { queryRaw } from "@/lib/db";
|
||||
import { OWNER_SCOPE, STATEMENTS_JOIN, mySplitOf } from "@/lib/analytics-sql";
|
||||
import { OWNER_SCOPE, STATEMENTS_JOIN, EXCLUDE_RECONCILED_SOURCE, mySplitOf, toDateStr } from "@/lib/analytics-sql";
|
||||
|
||||
/**
|
||||
* Fees and interest over an explicit window.
|
||||
*
|
||||
* This 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. `months=0` asks for all time
|
||||
* deliberately, which is a different claim from asking for it by accident.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const user = await getCurrentUser(req);
|
||||
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
|
||||
|
||||
const { searchParams } = new URL(req.url);
|
||||
const monthsParam = Number(searchParams.get("months") ?? "12");
|
||||
const months = Number.isFinite(monthsParam) ? Math.min(Math.max(monthsParam, 0), 120) : 12;
|
||||
const allTime = months === 0;
|
||||
|
||||
const now = new Date();
|
||||
const from = new Date(now.getFullYear(), now.getMonth() - months + 1, 1);
|
||||
const fromStr = toDateStr(from);
|
||||
const toStr = toDateStr(new Date(now.getFullYear(), now.getMonth() + 1, 1));
|
||||
|
||||
// A statement is dated by the period it covers, not by when it was imported.
|
||||
const stmtWindow = allTime ? "" : `AND billing_end_date >= $2 AND billing_end_date < $3`;
|
||||
const txnWindow = allTime ? "" : `AND t.transaction_date >= $2 AND t.transaction_date < $3`;
|
||||
const windowParams = allTime ? [] : [fromStr, toStr];
|
||||
|
||||
// Statement-level fees and interest (aggregated by Gemini from the PDF)
|
||||
const stmtRows = await queryRaw<{
|
||||
bank_name: string;
|
||||
@@ -19,10 +42,11 @@ export async function GET(req: NextRequest) {
|
||||
SUM(COALESCE(interest_charged, 0))::numeric(12,2) AS interest
|
||||
FROM statements
|
||||
WHERE owner_id = $1
|
||||
${stmtWindow}
|
||||
GROUP BY bank_name
|
||||
HAVING SUM(COALESCE(fees_charged, 0)) + SUM(COALESCE(interest_charged, 0)) > 0
|
||||
ORDER BY (SUM(COALESCE(fees_charged, 0)) + SUM(COALESCE(interest_charged, 0))) DESC`,
|
||||
[user.id]
|
||||
[user.id, ...windowParams]
|
||||
);
|
||||
|
||||
// Transaction-level fee and interest line items (split-adjusted)
|
||||
@@ -49,8 +73,10 @@ export async function GET(req: NextRequest) {
|
||||
${STATEMENTS_JOIN}
|
||||
WHERE ${OWNER_SCOPE} = $1
|
||||
AND t.transaction_type IN ('fee', 'interest')
|
||||
AND ${EXCLUDE_RECONCILED_SOURCE}
|
||||
${txnWindow}
|
||||
ORDER BY t.transaction_date DESC`,
|
||||
[user.id]
|
||||
[user.id, ...windowParams]
|
||||
);
|
||||
|
||||
const by_bank = stmtRows.map((r) => ({
|
||||
@@ -69,5 +95,13 @@ export async function GET(req: NextRequest) {
|
||||
const total_fees = by_bank.reduce((s, r) => s + r.fees, 0);
|
||||
const total_interest = by_bank.reduce((s, r) => s + r.interest, 0);
|
||||
|
||||
return NextResponse.json({ by_bank, transactions, total_fees, total_interest });
|
||||
return NextResponse.json({
|
||||
by_bank,
|
||||
transactions,
|
||||
total_fees,
|
||||
total_interest,
|
||||
// The period is part of the answer — the client must be able to say what
|
||||
// window these totals cover rather than implying "now".
|
||||
period: { months, from: allTime ? null : fromStr, to: allTime ? null : toStr, all_time: allTime },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { queryRaw } from "@/lib/db";
|
||||
import { OWNER_SCOPE, STATEMENTS_JOIN, EFFECTIVE_CATEGORY, mySplitOf } from "@/lib/analytics-sql";
|
||||
import { OWNER_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)`);
|
||||
|
||||
@@ -39,7 +40,7 @@ export async function GET(
|
||||
END::numeric(10,2) as my_amount,
|
||||
t.transaction_type,
|
||||
${EFFECTIVE_CATEGORY} as category,
|
||||
COALESCE(s.bank_name, 'Manual') as bank_name,
|
||||
${bankLabel()} as bank_name,
|
||||
t.statement_id
|
||||
FROM transactions t
|
||||
${STATEMENTS_JOIN}
|
||||
@@ -48,6 +49,7 @@ export async function GET(
|
||||
WHERE ${OWNER_SCOPE} = $1
|
||||
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}
|
||||
ORDER BY t.transaction_date DESC
|
||||
LIMIT 500
|
||||
`, [user.id, decoded]);
|
||||
|
||||
@@ -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, EFFECTIVE_CATEGORY, mySplitOf } from "@/lib/analytics-sql";
|
||||
import { OWNER_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)`);
|
||||
@@ -21,7 +21,7 @@ export async function GET(req: NextRequest) {
|
||||
|
||||
const cutoff = new Date();
|
||||
cutoff.setMonth(cutoff.getMonth() - months);
|
||||
const fromDate = cutoff.toISOString().slice(0, 10);
|
||||
const fromDate = toDateStr(cutoff);
|
||||
|
||||
// Merchant aggregates — net spend (debits + fees - refunds/credits)
|
||||
const rows = await queryRaw<{
|
||||
@@ -69,6 +69,7 @@ export async function GET(req: NextRequest) {
|
||||
AND t.transaction_type IN ('debit', 'fee', 'interest', 'refund', 'credit')
|
||||
AND t.transaction_date >= $2
|
||||
AND ${EXCLUDE_NON_SPEND}
|
||||
AND ${EXCLUDE_RECONCILED_SOURCE}
|
||||
GROUP BY 1
|
||||
HAVING SUM(${SPEND_EXPR}) > 0
|
||||
ORDER BY net_spend DESC
|
||||
@@ -95,6 +96,7 @@ export async function GET(req: NextRequest) {
|
||||
AND t.transaction_date >= $2
|
||||
AND COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name, t.description) = ANY($3)
|
||||
AND ${EXCLUDE_NON_SPEND}
|
||||
AND ${EXCLUDE_RECONCILED_SOURCE}
|
||||
GROUP BY 1, 2
|
||||
ORDER BY 1, 2
|
||||
`, [user.id, fromDate, topMerchants]);
|
||||
|
||||
@@ -6,9 +6,12 @@ import {
|
||||
STATEMENTS_JOIN,
|
||||
EFFECTIVE_CATEGORY,
|
||||
EXCLUDE_NON_SPEND,
|
||||
EXCLUDE_RECONCILED_SOURCE,
|
||||
NET_SPEND_ROWS,
|
||||
SPEND_SIGNED,
|
||||
INVESTMENT_SIGNED,
|
||||
mySplitOf,
|
||||
toDateStr,
|
||||
} from "@/lib/analytics-sql";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
@@ -22,8 +25,8 @@ export async function GET(req: NextRequest) {
|
||||
const endDate = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||
const startDate = new Date(now.getFullYear(), now.getMonth() - monthCount + 1, 1);
|
||||
|
||||
const startStr = startDate.toISOString().slice(0, 10);
|
||||
const endStr = endDate.toISOString().slice(0, 10);
|
||||
const startStr = toDateStr(startDate);
|
||||
const endStr = toDateStr(endDate);
|
||||
|
||||
// Expenses: debits excluding transfers and investments, split-adjusted
|
||||
const spendRows = await queryRaw<{
|
||||
@@ -35,7 +38,9 @@ export async function GET(req: NextRequest) {
|
||||
`SELECT
|
||||
TO_CHAR(DATE_TRUNC('month', t.transaction_date::date), 'YYYY-MM') as month,
|
||||
${EFFECTIVE_CATEGORY} as category,
|
||||
SUM(${mySplitOf(SPEND_SIGNED)})::numeric(12,2) as total_spent,
|
||||
-- 4dp so the month total is summed from unrounded parts; every consumer
|
||||
-- rounds for display. See the note in /api/analytics/daily.
|
||||
SUM(${mySplitOf(SPEND_SIGNED)})::numeric(14,4) as total_spent,
|
||||
COUNT(*)::int as transaction_count
|
||||
FROM transactions t
|
||||
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
|
||||
@@ -44,6 +49,7 @@ export async function GET(req: NextRequest) {
|
||||
WHERE ${OWNER_SCOPE} = $1
|
||||
AND ${NET_SPEND_ROWS}
|
||||
AND ${EXCLUDE_NON_SPEND}
|
||||
AND ${EXCLUDE_RECONCILED_SOURCE}
|
||||
AND t.transaction_date >= $2
|
||||
AND t.transaction_date < $3
|
||||
GROUP BY 1, 2
|
||||
@@ -67,6 +73,7 @@ export async function GET(req: NextRequest) {
|
||||
WHERE ${OWNER_SCOPE} = $1
|
||||
AND t.transaction_type IN ('credit', 'payment')
|
||||
AND ${EFFECTIVE_CATEGORY} = 'income'
|
||||
AND ${EXCLUDE_RECONCILED_SOURCE}
|
||||
AND t.transaction_date >= $2
|
||||
AND t.transaction_date < $3
|
||||
GROUP BY 1
|
||||
@@ -74,7 +81,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;
|
||||
@@ -82,13 +90,14 @@ 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
|
||||
${STATEMENTS_JOIN}
|
||||
WHERE ${OWNER_SCOPE} = $1
|
||||
AND ${EFFECTIVE_CATEGORY} = 'investment'
|
||||
AND ${EXCLUDE_RECONCILED_SOURCE}
|
||||
AND t.transaction_date >= $2
|
||||
AND t.transaction_date < $3
|
||||
GROUP BY 1
|
||||
|
||||
@@ -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, EFFECTIVE_CATEGORY, mySplitOf } from "@/lib/analytics-sql";
|
||||
import { OWNER_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);
|
||||
@@ -31,6 +31,7 @@ export async function GET(req: NextRequest) {
|
||||
WHERE ${OWNER_SCOPE} = $1
|
||||
AND t.transaction_type IN ('debit', 'fee')
|
||||
AND ${EXCLUDE_NON_SPEND}
|
||||
AND ${EXCLUDE_RECONCILED_SOURCE}
|
||||
AND COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) IS NOT NULL
|
||||
),
|
||||
merchant_with_lag AS (
|
||||
|
||||
@@ -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,6 +12,8 @@ import {
|
||||
NotAReceiptError,
|
||||
type MessageMeta,
|
||||
} from "@/lib/order-ingestion";
|
||||
import { merchantVerdict, SECOND_CONSUMER_ID } from "@/lib/order-reviews";
|
||||
import { nudgeBlocks } from "@/lib/slack-blocks";
|
||||
|
||||
/**
|
||||
* Machine ingest endpoint for order receipts.
|
||||
@@ -30,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 });
|
||||
@@ -72,7 +86,19 @@ export async function POST(req: NextRequest) {
|
||||
|
||||
if (dryRun) return NextResponse.json({ kind: "order", order });
|
||||
|
||||
const result = await processOrderIngestion(order, { messageId: meta.messageId });
|
||||
const result = await processOrderIngestion(order, {
|
||||
messageId: meta.messageId,
|
||||
subject: meta.subject,
|
||||
sender: meta.sender,
|
||||
});
|
||||
// What we said about this merchant before, so the Slack nudge can warn at
|
||||
// the moment the order lands rather than waiting for someone to open the
|
||||
// app. `result.transactionId` is excluded because a brand-new order has no
|
||||
// verdict yet — anything found is genuinely a previous visit.
|
||||
const verdict = result.skipped
|
||||
? null
|
||||
: await merchantVerdict(order.merchant_name, result.transactionId);
|
||||
|
||||
return NextResponse.json({
|
||||
kind: "order",
|
||||
order_reference: order.order_reference,
|
||||
@@ -81,6 +107,36 @@ export async function POST(req: NextRequest) {
|
||||
currency: order.currency,
|
||||
is_family: order.is_family,
|
||||
...result,
|
||||
prior_verdict: verdict && {
|
||||
warn: verdict.warn,
|
||||
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 });
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { queryRaw } from "@/lib/db";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
|
||||
interface BalanceRow {
|
||||
participant_id: number;
|
||||
name: string;
|
||||
total_owed: number;
|
||||
transaction_count: number;
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
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 rows = await queryRaw<BalanceRow>(
|
||||
`SELECT ts.participant_id, p.name,
|
||||
SUM(COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100)::numeric(12,2) as total_owed,
|
||||
COUNT(*)::int as transaction_count
|
||||
FROM transaction_splits ts
|
||||
JOIN transactions t ON t.id = ts.transaction_id
|
||||
JOIN participants p ON p.id = ts.participant_id
|
||||
WHERE ts.participant_id = $1 AND ts.settled = false
|
||||
GROUP BY ts.participant_id, p.name`,
|
||||
[Number(id)]
|
||||
);
|
||||
|
||||
return NextResponse.json(
|
||||
rows[0] ?? { participant_id: Number(id), total_owed: 0, transaction_count: 0 }
|
||||
);
|
||||
}
|
||||
@@ -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,398 @@
|
||||
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 { 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];
|
||||
// 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
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -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,44 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { queryRow } from "@/lib/db";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { canAccessTransactions } from "@/lib/queries";
|
||||
|
||||
/**
|
||||
* Order provenance for one transaction.
|
||||
*
|
||||
* `expense_metadata` has held the itemised receipt since ingestion started and
|
||||
* nothing in the UI ever read it — a transaction that came from a DoorDash or
|
||||
* Uber Eats receipt showed a merchant and an amount, with the item list and the
|
||||
* delivery addresses sitting unread in the row behind it (user, 2026-07-27).
|
||||
*
|
||||
* Read-only. The receipt is a record of what a provider sent; editing it here
|
||||
* would make provenance mean nothing.
|
||||
*/
|
||||
export async function GET(
|
||||
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;
|
||||
if (!(await canAccessTransactions(user.id, [Number(id)]))) {
|
||||
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
|
||||
}
|
||||
|
||||
const row = await queryRow(
|
||||
`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
|
||||
-- statement line is the transaction, and the receipt points at it
|
||||
-- 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
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { queryRaw, queryRow } from "@/lib/db";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import { canAccessTransactions } from "@/lib/queries";
|
||||
import {
|
||||
ITEM_VERDICTS,
|
||||
RATINGS,
|
||||
merchantForTransaction,
|
||||
merchantVerdict,
|
||||
type ItemOpinion,
|
||||
type OrderReview,
|
||||
type Rating,
|
||||
} from "@/lib/order-reviews";
|
||||
|
||||
/**
|
||||
* Verdicts on one delivery order, plus what was said about this merchant
|
||||
* before.
|
||||
*
|
||||
* Both halves come back together on purpose: the panel is useless without the
|
||||
* history — the whole reason to open it is to see whether this place has
|
||||
* disappointed us before. Two round trips would let it render the form first
|
||||
* and the warning second, which is the order that lets you re-order by
|
||||
* mistake.
|
||||
*
|
||||
* `reviews` is a list, not one row. A shared meal has two opinions and they
|
||||
* routinely disagree; collapsing them to one would keep whichever was saved
|
||||
* last and silently discard the other person's.
|
||||
*/
|
||||
|
||||
async function authorise(req: NextRequest, id: string) {
|
||||
const user = await getCurrentUser(req);
|
||||
if (!user) return { error: NextResponse.json({ error: "Unauthorized" }, { status: 403 }) };
|
||||
if (!(await canAccessTransactions(user.id, [Number(id)]))) {
|
||||
return { error: NextResponse.json({ error: "Forbidden" }, { status: 403 }) };
|
||||
}
|
||||
return { user };
|
||||
}
|
||||
|
||||
const SELECT_REVIEWS = `
|
||||
SELECT r.transaction_id, r.participant_id, p.name AS participant_name,
|
||||
r.rating, r.order_again, r.note, r.item_verdicts, r.updated_at
|
||||
FROM order_reviews r
|
||||
JOIN participants p ON p.id = r.participant_id
|
||||
WHERE r.transaction_id = $1
|
||||
ORDER BY r.participant_id`;
|
||||
|
||||
/**
|
||||
* Everything the order panel needs that is not the receipt itself.
|
||||
*
|
||||
* The splits come back here rather than from a separate endpoint because the
|
||||
* panel asks one question — "was this shared, and what did we think of it" —
|
||||
* and the sharing half is answered by whether a split exists. A second request
|
||||
* would let the verdict render before the share state, which is the order that
|
||||
* invites a duplicate split.
|
||||
*/
|
||||
async function panelState(transactionId: number) {
|
||||
const [reviews, splits, merchant] = await Promise.all([
|
||||
queryRaw<OrderReview>(SELECT_REVIEWS, [transactionId]),
|
||||
queryRaw<{ participant_id: number; share_percent: string }>(
|
||||
`SELECT participant_id, share_percent FROM transaction_splits
|
||||
WHERE transaction_id = $1 ORDER BY participant_id`,
|
||||
[transactionId]
|
||||
),
|
||||
merchantForTransaction(transactionId),
|
||||
]);
|
||||
return {
|
||||
reviews,
|
||||
splits,
|
||||
merchant: await merchantVerdict(merchant, transactionId),
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const auth = await authorise(req, id);
|
||||
if (auth.error) return auth.error;
|
||||
const transactionId = Number(id);
|
||||
|
||||
return NextResponse.json(await panelState(transactionId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Record or change one person's verdict.
|
||||
*
|
||||
* Upsert rather than insert: a verdict is an opinion and opinions get revised.
|
||||
* `ON CONFLICT (transaction_id, participant_id)` keeps one row per person per
|
||||
* order however many times the buttons are pressed — and, critically, lets the
|
||||
* second person's verdict land without touching the first.
|
||||
*
|
||||
* A null rating is meaningful — it clears the verdict rather than deleting the
|
||||
* row, so a note and the item opinions survive changing your mind about the
|
||||
* overall call.
|
||||
*/
|
||||
export async function PUT(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const auth = await authorise(req, id);
|
||||
if (auth.error) return auth.error;
|
||||
const transactionId = Number(id);
|
||||
|
||||
let body: {
|
||||
participant_id?: number;
|
||||
rating?: Rating | null;
|
||||
order_again?: boolean | null;
|
||||
note?: string | null;
|
||||
item_verdicts?: ItemOpinion[] | null;
|
||||
};
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json({ error: "invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Defaults to whoever is signed in, NOT to the owner: Sonu authenticates
|
||||
// through the same Traefik OAuth as participant 4, so an owner default would
|
||||
// silently file her verdict under his name. An explicit participant_id is
|
||||
// still honoured — one person entering both opinions at the table is the
|
||||
// common case in a two-person household.
|
||||
const participantId = body.participant_id ?? auth.user!.id;
|
||||
|
||||
const rating = body.rating ?? null;
|
||||
if (rating !== null && !RATINGS.includes(rating)) {
|
||||
// The DB has the same CHECK constraint; failing here gives a usable message
|
||||
// instead of a 500 carrying a Postgres constraint name.
|
||||
return NextResponse.json(
|
||||
{ error: `rating must be one of ${RATINGS.join(", ")} or null` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const note = typeof body.note === "string" ? body.note.trim() || null : null;
|
||||
|
||||
// An ABSENT item_verdicts means "leave them alone"; an empty array means
|
||||
// "clear them". Without that distinction, saving a note from a form that
|
||||
// does not carry the item state silently wipes every per-item opinion — the
|
||||
// same shape as the bug that reset `settled` on split rewrites, and just as
|
||||
// invisible on screen.
|
||||
const keepItems = body.item_verdicts === undefined;
|
||||
|
||||
// Drop anything malformed rather than reject the whole save: the rating and
|
||||
// the note are the parts the user is watching, and failing their edit over a
|
||||
// bad item entry loses the input they actually gave.
|
||||
const itemVerdicts: ItemOpinion[] = (body.item_verdicts ?? [])
|
||||
.filter(
|
||||
(v): v is ItemOpinion =>
|
||||
!!v &&
|
||||
typeof v.item === "string" &&
|
||||
v.item.trim().length > 0 &&
|
||||
ITEM_VERDICTS.includes(v.verdict)
|
||||
)
|
||||
.map((v) => ({ item: v.item.trim(), verdict: v.verdict }));
|
||||
|
||||
// `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" && rating !== "bad");
|
||||
|
||||
await queryRow(
|
||||
`INSERT INTO order_reviews (transaction_id, participant_id, rating, order_again, note, item_verdicts)
|
||||
VALUES ($1, $2, $3, $4, $5, $6::jsonb)
|
||||
ON CONFLICT (transaction_id, participant_id) DO UPDATE
|
||||
SET rating = EXCLUDED.rating,
|
||||
order_again = EXCLUDED.order_again,
|
||||
note = EXCLUDED.note,
|
||||
item_verdicts = CASE WHEN $7::boolean
|
||||
THEN order_reviews.item_verdicts
|
||||
ELSE EXCLUDED.item_verdicts END,
|
||||
updated_at = now()`,
|
||||
[
|
||||
transactionId,
|
||||
participantId,
|
||||
rating,
|
||||
orderAgain,
|
||||
note,
|
||||
JSON.stringify(itemVerdicts),
|
||||
keepItems,
|
||||
]
|
||||
);
|
||||
|
||||
return NextResponse.json(await panelState(transactionId));
|
||||
}
|
||||
@@ -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 }> }
|
||||
@@ -66,18 +90,47 @@ export async function POST(
|
||||
);
|
||||
}
|
||||
|
||||
// Carry `settled` across the rewrite.
|
||||
//
|
||||
// This replaces every split rather than editing in place, so without this the
|
||||
// recreated rows take the column default of false — silently converting a
|
||||
// discharged historical obligation into a live debt. That is not theoretical:
|
||||
// 657 pre-2026 transactions carry settled splits imported from
|
||||
// SplitMyExpenses, $37,233.28 of balance that the carryover (transaction
|
||||
// 2348) already accounts for. Editing one would double-count its share, and
|
||||
// nothing on screen would say so.
|
||||
//
|
||||
// Changing someone's percentage does not re-open the obligation — it was
|
||||
// settled outside this app and stays settled. A participant added who was not
|
||||
// there before is a genuinely new obligation and correctly starts unsettled.
|
||||
const previous = await queryRaw<{
|
||||
participant_id: number;
|
||||
settled: boolean;
|
||||
settled_at: string | null;
|
||||
}>(
|
||||
`SELECT participant_id, settled, settled_at
|
||||
FROM transaction_splits WHERE transaction_id = $1`,
|
||||
[transactionId]
|
||||
);
|
||||
const settledBefore = new Map(
|
||||
previous.map((p) => [p.participant_id, { settled: p.settled, settled_at: p.settled_at }])
|
||||
);
|
||||
|
||||
// Replace all splits for this transaction atomically
|
||||
await prisma.$transaction([
|
||||
prisma.transaction_splits.deleteMany({ where: { transaction_id: transactionId } }),
|
||||
...splits.map((s) =>
|
||||
prisma.transaction_splits.create({
|
||||
...splits.map((s) => {
|
||||
const before = settledBefore.get(s.participant_id);
|
||||
return prisma.transaction_splits.create({
|
||||
data: {
|
||||
transaction_id: transactionId,
|
||||
participant_id: s.participant_id,
|
||||
share_percent: s.share_percent,
|
||||
settled: before?.settled ?? false,
|
||||
settled_at: before?.settled_at ? new Date(before.settled_at) : null,
|
||||
},
|
||||
})
|
||||
),
|
||||
});
|
||||
}),
|
||||
]);
|
||||
|
||||
const result = await queryRaw<SplitRow>(
|
||||
|
||||
@@ -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,7 @@ 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";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const user = await getCurrentUser(req);
|
||||
@@ -13,6 +14,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 +28,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);
|
||||
@@ -79,6 +82,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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+96
-46
@@ -16,7 +16,7 @@ import {
|
||||
ReferenceLine,
|
||||
} from "recharts";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useMonthlyAnalytics, useTransactions, useUpdateTransaction } from "@/lib/hooks";
|
||||
import { useMonthlyAnalytics, useDailySpend, useTransactions, useUpdateTransaction } from "@/lib/hooks";
|
||||
import { formatCategory, CATEGORIES } from "@/lib/categories";
|
||||
import { CATEGORY_COLORS, CHART, TOOLTIP_STYLE } from "@/lib/category-colors";
|
||||
|
||||
@@ -37,6 +37,26 @@ 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)}`; }
|
||||
@@ -195,12 +215,14 @@ export default function AnalyticsPage() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [months]);
|
||||
|
||||
// Cumulative chart: fetch this month's transactions
|
||||
const smFrom = `${selectedMonth}-01`;
|
||||
const [smYear, smMonth] = selectedMonth.split("-").map(Number);
|
||||
const smNextDate = new Date(smYear, smMonth, 1);
|
||||
const smTo = `${smNextDate.getFullYear()}-${String(smNextDate.getMonth() + 1).padStart(2, "0")}-01`;
|
||||
const { data: monthTxData } = useTransactions({ from: smFrom, to: smTo, limit: 1000 });
|
||||
// Day-of-month spend, split-adjusted server-side by the same rules as the
|
||||
// headline. Also what makes the comparisons below like-for-like.
|
||||
const { data: dailyData } = useDailySpend(12);
|
||||
|
||||
// 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();
|
||||
|
||||
// Category rows for selected month
|
||||
const categoryRows = useMemo(() => {
|
||||
@@ -226,22 +248,27 @@ export default function AnalyticsPage() {
|
||||
.slice(0, 8);
|
||||
}, [analytics, months, selectedMonth]);
|
||||
|
||||
// Top movers vs previous month
|
||||
// Top movers vs previous month, compared through the same day of the month so
|
||||
// a month in progress is not measured against a complete one.
|
||||
const movers = useMemo(() => {
|
||||
if (!analytics) return [];
|
||||
const pm = prevMonth(selectedMonth);
|
||||
if (!months.includes(pm)) return [];
|
||||
return analytics.rows
|
||||
.map((r) => ({
|
||||
category: r.category,
|
||||
delta: (r.spent[selectedMonth] || 0) - (r.spent[pm] || 0),
|
||||
now: r.spent[selectedMonth] || 0,
|
||||
before: r.spent[pm] || 0,
|
||||
}))
|
||||
|
||||
const catsNow = dailyData?.byCategory?.[selectedMonth] ?? {};
|
||||
const catsBefore = dailyData?.byCategory?.[pm] ?? {};
|
||||
const categories = new Set([...Object.keys(catsNow), ...Object.keys(catsBefore)]);
|
||||
|
||||
return Array.from(categories)
|
||||
.map((category) => {
|
||||
const now = spendThrough(catsNow[category], compareDay);
|
||||
const before = spendThrough(catsBefore[category], compareDay);
|
||||
return { category, delta: now - before, now, before };
|
||||
})
|
||||
.filter((r) => Math.abs(r.delta) >= 1)
|
||||
.sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta))
|
||||
.slice(0, 6);
|
||||
}, [analytics, months, selectedMonth]);
|
||||
}, [analytics, dailyData, months, selectedMonth, compareDay]);
|
||||
|
||||
// Pareto chart data
|
||||
const paretoData = useMemo(() => {
|
||||
@@ -258,39 +285,40 @@ export default function AnalyticsPage() {
|
||||
});
|
||||
}, [categoryRows]);
|
||||
|
||||
// Cumulative spend chart data
|
||||
// Cumulative spend chart data.
|
||||
//
|
||||
// Both series now come from the same server-side spend definition as the
|
||||
// headline. The typical line is also a real averaged curve rather than the
|
||||
// month total spread evenly — spending is lumpy (rent on the 1st, a shop on
|
||||
// the weekend), so a straight line made ordinary months look erratic.
|
||||
const cumulativeData = useMemo(() => {
|
||||
const daysInMonth = new Date(smYear, smMonth, 0).getDate();
|
||||
const isCurrentMonth = selectedMonth === currentMonthStr();
|
||||
const today = new Date();
|
||||
const lastDay = isCurrentMonth ? today.getDate() : daysInMonth;
|
||||
const daysInMonth = daysInMonthOf(selectedMonth);
|
||||
const lastDay = elapsedDays(selectedMonth);
|
||||
|
||||
const daily: Record<number, number> = {};
|
||||
(monthTxData?.data ?? [])
|
||||
.filter((tx) => tx.transaction_type === "debit" &&
|
||||
!["transfers", "investment"].includes(tx.effective_category) &&
|
||||
!tx.tags?.some((t: any) => (typeof t === "string" ? t : t.name) === "family"))
|
||||
.forEach((tx) => {
|
||||
const day = new Date(tx.transaction_date).getDate();
|
||||
daily[day] = (daily[day] || 0) + Number(tx.amount_aud ?? tx.amount);
|
||||
});
|
||||
|
||||
const priorMonths = analytics?.months.filter((m) => m !== selectedMonth) ?? [];
|
||||
const priorAvg = priorMonths.length > 0
|
||||
? priorMonths.reduce((s, m) => s + (analytics?.totals[m]?.spent || 0), 0) / priorMonths.length
|
||||
: 0;
|
||||
const daily = dailyData?.daily?.[selectedMonth] ?? {};
|
||||
// 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
|
||||
);
|
||||
|
||||
let cum = 0;
|
||||
return Array.from({ length: daysInMonth }, (_, i) => {
|
||||
const day = i + 1;
|
||||
if (day <= lastDay) cum += daily[day] || 0;
|
||||
|
||||
const typical = priorMonths.length
|
||||
? priorMonths.reduce((s, m) => s + spendThrough(dailyData?.daily?.[m], day), 0) / priorMonths.length
|
||||
: 0;
|
||||
|
||||
return {
|
||||
day,
|
||||
actual: day <= lastDay ? Math.round(cum * 100) / 100 : null,
|
||||
typical: Math.round((priorAvg * day / daysInMonth) * 100) / 100,
|
||||
typical: Math.round(typical * 100) / 100,
|
||||
};
|
||||
});
|
||||
}, [monthTxData, analytics, selectedMonth, smYear, smMonth]);
|
||||
}, [dailyData, analytics, selectedMonth]);
|
||||
|
||||
if (isLoading || !analytics) {
|
||||
return (
|
||||
@@ -303,18 +331,35 @@ 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 months that have data
|
||||
const otherMonths = months.filter((m) => m !== selectedMonth && (analytics.totals[m]?.spent || 0) > 0);
|
||||
// Hero delta vs the average of the other *complete* months that have data.
|
||||
//
|
||||
// Two partial-month traps here. The month in progress never belongs in the
|
||||
// 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 comparableSpend = selectedIsPartial
|
||||
? spendThrough(dailyData?.daily?.[selectedMonth], compareDay)
|
||||
: totals.spent;
|
||||
const avgSpend = otherMonths.length
|
||||
? otherMonths.reduce((s, m) => s + (analytics.totals[m]?.spent || 0), 0) / otherMonths.length
|
||||
? otherMonths.reduce(
|
||||
(s, m) => s + (selectedIsPartial ? spendThrough(dailyData?.daily?.[m], compareDay) : analytics.totals[m]?.spent || 0),
|
||||
0
|
||||
) / otherMonths.length
|
||||
: 0;
|
||||
const avgDeltaPct = avgSpend > 0 ? Math.round(((totals.spent - avgSpend) / avgSpend) * 100) : 0;
|
||||
const avgDeltaPct = avgSpend > 0 ? Math.round(((comparableSpend - avgSpend) / avgSpend) * 100) : 0;
|
||||
const throughQualifier = selectedIsPartial ? ` through day ${compareDay}` : "";
|
||||
const heroSentence =
|
||||
avgSpend === 0 ? "" :
|
||||
Math.abs(avgDeltaPct) <= 3 ? `in line with your ${otherMonths.length}-month average` :
|
||||
`${Math.abs(avgDeltaPct)}% ${avgDeltaPct > 0 ? "above" : "below"} your ${otherMonths.length}-month average of ${fmt(avgSpend)}`;
|
||||
Math.abs(avgDeltaPct) <= 3 ? `in line with your ${otherMonths.length}-month average${throughQualifier}` :
|
||||
`${Math.abs(avgDeltaPct)}% ${avgDeltaPct > 0 ? "above" : "below"} your ${otherMonths.length}-month average of ${fmt(avgSpend)}${throughQualifier}`;
|
||||
|
||||
const pareto80idx = paretoData.findIndex((r) => r.cumulative >= 80);
|
||||
const tableMonths = analytics.months.slice(0, 6); // newest-first, last 6
|
||||
@@ -373,7 +418,10 @@ export default function AnalyticsPage() {
|
||||
{movers.length > 0 && (
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-4">
|
||||
<h3 className="text-sm font-medium mb-1">What changed</h3>
|
||||
<p className="text-xs text-zinc-500 mb-4">Biggest category moves vs {formatShortMonth(prevMonth(selectedMonth))}</p>
|
||||
<p className="text-xs text-zinc-500 mb-4">
|
||||
Biggest category moves vs {formatShortMonth(prevMonth(selectedMonth))}
|
||||
{selectedIsPartial && `, both through day ${compareDay}`}
|
||||
</p>
|
||||
<div className="grid sm:grid-cols-2 gap-x-8 gap-y-2.5">
|
||||
{movers.map((m) => (
|
||||
<div key={m.category} className="flex items-center gap-3">
|
||||
@@ -632,7 +680,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>
|
||||
);
|
||||
|
||||
@@ -10,6 +10,18 @@ import { CHART, TOOLTIP_STYLE } from "@/lib/category-colors";
|
||||
|
||||
const SPEND_TYPES = new Set(["debit", "fee", "interest"]);
|
||||
|
||||
/**
|
||||
* The API returns an exclusive upper bound (first day of the month after the
|
||||
* window). Showing that date verbatim would claim a month the totals exclude,
|
||||
* so `exclusive` steps back a day for display.
|
||||
*/
|
||||
function formatPeriodBound(iso: string | null, exclusive = false): string {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(`${iso}T00:00:00`);
|
||||
if (exclusive) d.setDate(d.getDate() - 1);
|
||||
return d.toLocaleDateString("default", { month: "short", year: "numeric" });
|
||||
}
|
||||
|
||||
function fmt(n: number) {
|
||||
return new Intl.NumberFormat("en-AU", { style: "currency", currency: "AUD", maximumFractionDigits: 0 }).format(n);
|
||||
}
|
||||
@@ -41,10 +53,13 @@ const FREQ_LABEL: Record<string, string> = {
|
||||
};
|
||||
|
||||
// ─── Section wrapper ────────────────────────────────────────────────
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
function Section({ title, aside, children }: { title: string; aside?: React.ReactNode; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<h3 className="text-base font-semibold text-zinc-200 mb-3">{title}</h3>
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 mb-3">
|
||||
<h3 className="text-base font-semibold text-zinc-200">{title}</h3>
|
||||
{aside}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -298,7 +313,8 @@ export default function InsightsPage() {
|
||||
const { data: analytics } = useMonthlyAnalytics(12);
|
||||
const { data: analytics6 } = useMonthlyAnalytics(6);
|
||||
const { data: subData } = useSubscriptions();
|
||||
const { data: feesData } = useFees();
|
||||
const [feeMonths, setFeeMonths] = useState(12);
|
||||
const { data: feesData } = useFees(feeMonths);
|
||||
|
||||
// Build regular/occasional chart data
|
||||
const chartData = useMemo(() => {
|
||||
@@ -431,11 +447,38 @@ export default function InsightsPage() {
|
||||
</Section>
|
||||
|
||||
{/* ── 4. Fees & Interest ── */}
|
||||
<Section title="Fees & interest">
|
||||
<Section
|
||||
title="Fees & interest"
|
||||
aside={
|
||||
<div className="flex items-center gap-3">
|
||||
{feesData?.period && (
|
||||
<span className="text-xs text-zinc-500 tabular-nums">
|
||||
{feesData.period.all_time
|
||||
? "All time"
|
||||
: `${formatPeriodBound(feesData.period.from)} – ${formatPeriodBound(feesData.period.to, true)}`}
|
||||
</span>
|
||||
)}
|
||||
<select
|
||||
value={feeMonths}
|
||||
onChange={(e) => setFeeMonths(Number(e.target.value))}
|
||||
className="bg-zinc-900 border border-zinc-800 rounded px-2 py-1 text-xs text-zinc-300 focus:outline-none focus:border-indigo-500 cursor-pointer"
|
||||
aria-label="Fees period"
|
||||
>
|
||||
<option value={3}>Last 3 months</option>
|
||||
<option value={6}>Last 6 months</option>
|
||||
<option value={12}>Last 12 months</option>
|
||||
<option value={24}>Last 24 months</option>
|
||||
<option value={0}>All time</option>
|
||||
</select>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{!feesData ? (
|
||||
<p className="text-zinc-500 text-sm">Loading...</p>
|
||||
) : feesData.by_bank.length === 0 && feesData.transactions.length === 0 ? (
|
||||
<p className="text-zinc-500 text-sm">No fees or interest recorded across your statements.</p>
|
||||
<p className="text-zinc-500 text-sm">
|
||||
No fees or interest recorded {feesData.period?.all_time ? "on any statement" : "in this period"}.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{feesData.by_bank.length > 0 && (
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
import Link from "next/link";
|
||||
import { useOrderDetail } from "@/lib/hooks";
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
return (
|
||||
<div className="max-w-[1180px] mx-auto">
|
||||
<Link href="/orders" className="font-mono text-[11.5px] text-indigo-400">← All orders</Link>
|
||||
<p className="mt-6 text-zinc-400 text-sm">That order could not be found.</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. */}
|
||||
{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">
|
||||
{o.canonical_name || 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>
|
||||
{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">
|
||||
{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,577 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useOrders } from "@/lib/hooks";
|
||||
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",
|
||||
};
|
||||
|
||||
/**
|
||||
* Presentational tidy only — this NEVER merges two merchants. The estate holds
|
||||
* "amazon.com.au", "Amazon.in" and "Amazon Services Australia, Inc." as three
|
||||
* distinct entities, and Amazon.in must stay separate: it is a different
|
||||
* marketplace, not a name variant. Actually unifying them is the merchant-alias
|
||||
* bridge (ticket 176). All this does is stop the same entity from looking
|
||||
* scruffy: drop the corporate suffix, and capitalise a name that arrived
|
||||
* lower-cased from a domain.
|
||||
*/
|
||||
const CORP_SUFFIX =
|
||||
/,?\s+(pty\.?\s+ltd\.?|pty\.?\s+limited|p\/l|ltd\.?|limited|inc\.?|llc|pbc|gmbh|b\.?v\.?|s\.?a\.?r\.?l\.?|oü|co\.?)$/i;
|
||||
|
||||
function tidyMerchant(name: string): string {
|
||||
let n = name.replace(/\s+/g, " ").trim();
|
||||
// Strip at most two trailing corporate suffixes ("Pty Ltd." then ",").
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const stripped = n.replace(CORP_SUFFIX, "").trim().replace(/,$/, "");
|
||||
if (stripped === n || stripped.length < 3) break;
|
||||
n = stripped;
|
||||
}
|
||||
// "amazon.com.au" reads as a machine artefact; "Amazon.com.au" reads as a name.
|
||||
if (/^[a-z]/.test(n)) n = n[0].toUpperCase() + n.slice(1);
|
||||
return n;
|
||||
}
|
||||
|
||||
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>
|
||||
)}
|
||||
{row.source_trust === "untrusted_external" && (
|
||||
<span
|
||||
title="Derived from an unverified sender — content is shown as provenance, not fact"
|
||||
className="font-mono text-[9.5px] uppercase tracking-wide text-zinc-500 border border-zinc-800 rounded-sm px-1.5"
|
||||
>unverified</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. Hiding them is a workaround for a known ingestion defect, not a fix.">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
+306
-48
@@ -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,19 +11,31 @@ 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"]);
|
||||
|
||||
function formatAmount(n: number, type?: string) {
|
||||
const formatted = `$${Number(n).toFixed(2)}`;
|
||||
function formatAmount(n: number, type?: string, currency?: string) {
|
||||
// A bare "$" on a non-AUD row was the visible half of the problem: the row
|
||||
// read as dollars while the participant balances converted to AUD, so the
|
||||
// two disagreed on screen with nothing to explain why.
|
||||
const value = Number(n).toFixed(2);
|
||||
const formatted = !currency || currency === "AUD" ? `$${value}` : `${currency} ${value}`;
|
||||
return type && !SPEND_TYPES.has(type) ? `+${formatted}` : formatted;
|
||||
}
|
||||
|
||||
@@ -143,6 +155,7 @@ function RecordPaymentModal({
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const record = useRecordPayment();
|
||||
const { data: trips = [] } = useTrips();
|
||||
const theyOweMe = currentBalance > 0;
|
||||
|
||||
// Default direction matches the debt direction
|
||||
@@ -151,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() {
|
||||
@@ -164,6 +179,7 @@ function RecordPaymentModal({
|
||||
amount: amt,
|
||||
payment_date: date,
|
||||
notes: notes || undefined,
|
||||
trip_id: tripId ? Number(tripId) : null,
|
||||
});
|
||||
onClose();
|
||||
} catch (e) {
|
||||
@@ -212,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)}
|
||||
@@ -255,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)}
|
||||
@@ -270,6 +310,125 @@ 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";
|
||||
|
||||
@@ -278,15 +437,41 @@ export default function SharedPage() {
|
||||
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"));
|
||||
@@ -298,17 +483,62 @@ export default function SharedPage() {
|
||||
return <span className="ml-0.5">{sortDir === "desc" ? "↓" : "↑"}</span>;
|
||||
}
|
||||
const { data: balances = [], isLoading: balLoading } = useParticipantBalances(realTagIds);
|
||||
const { data: allTags = [] } = useTags();
|
||||
const { data: me } = useCurrentUser();
|
||||
|
||||
// Names the tag scope when one is active. Non-empty means the cards below are
|
||||
// split totals rather than payable balances.
|
||||
const tagScopeLabel =
|
||||
realTagIds.length === 0
|
||||
? null
|
||||
: realTagIds.length === 1
|
||||
? (allTags.find((t) => String(t.id) === realTagIds[0])?.name ?? "this tag")
|
||||
: `${realTagIds.length} tags`;
|
||||
const [addingParticipant, setAddingParticipant] = useState(false);
|
||||
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);
|
||||
useEffect(() => {
|
||||
setVisibleCount(100);
|
||||
}, [search, sortCol, sortDir, tagIds, participantId]);
|
||||
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)}
|
||||
@@ -351,23 +581,37 @@ export default function SharedPage() {
|
||||
<div>
|
||||
<p className="font-medium">{b.name}</p>
|
||||
<p className="text-xs text-zinc-500">
|
||||
{settled ? "all square" : theyOweMe ? `owes you` : "you owe"}
|
||||
{/* With a tag filter on, payments are deliberately not
|
||||
subtracted — so this is a split total, not a payable
|
||||
balance, and must not claim to be one. */}
|
||||
{tagScopeLabel
|
||||
? `split total in ${tagScopeLabel}`
|
||||
: settled ? "all square" : theyOweMe ? "owes you" : "you owe"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className={`text-lg font-semibold ${settled ? "text-zinc-500" : theyOweMe ? "text-amber-400" : "text-blue-400"}`}>
|
||||
<p className={`text-lg font-semibold ${tagScopeLabel ? "text-zinc-300" : settled ? "text-zinc-500" : theyOweMe ? "text-amber-400" : "text-blue-400"}`}>
|
||||
${net.toFixed(2)}
|
||||
</p>
|
||||
{b.unconverted_count > 0 && (
|
||||
<p className="text-[11px] text-amber-500/80 mt-0.5">
|
||||
approx · {b.unconverted_count} unconverted
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{/* Settling against a tag-scoped total would record a payment
|
||||
for a figure that never was the debt. */}
|
||||
{!tagScopeLabel && (
|
||||
<button
|
||||
onClick={() => setPaymentModal({ id: b.id, name: b.name, balance: b.total_owed })}
|
||||
className="flex-1 py-1.5 text-xs font-medium bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg"
|
||||
>
|
||||
Record Payment
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowHistory(showHistory === b.id ? null : b.id)}
|
||||
className={`px-3 py-1.5 text-xs rounded-lg ${showHistory === b.id ? "bg-zinc-700 text-white" : "bg-zinc-800 text-zinc-500 hover:text-zinc-300"}`}
|
||||
@@ -387,17 +631,36 @@ 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
|
||||
@@ -413,59 +676,54 @@ export default function SharedPage() {
|
||||
Imported <SortIcon col="created_at" />
|
||||
</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" />
|
||||
</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)}
|
||||
</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 */}
|
||||
|
||||
@@ -206,6 +206,21 @@ export default function StatementsPage() {
|
||||
</td>
|
||||
<td className="px-4 py-3 text-zinc-400 whitespace-nowrap">
|
||||
{formatPeriod(s.billing_start_date, s.billing_end_date)}
|
||||
{/* An account cannot be billed twice for the same day, so
|
||||
an overlap means these transactions are in the ledger
|
||||
twice. Red rather than amber: the balance warning above
|
||||
means a statement doesn't add up, this means the data is
|
||||
double-counted everywhere it is summed. */}
|
||||
{s.overlaps?.length > 0 && (
|
||||
<div
|
||||
className="text-[10px] text-red-400 mt-0.5"
|
||||
title={`Billing period overlaps statement ${s.overlaps
|
||||
.map((o) => `#${o.id} by ${o.days} day${o.days === 1 ? "" : "s"}`)
|
||||
.join(", ")}. The overlapping transactions are likely imported twice.`}
|
||||
>
|
||||
⚠ overlaps #{s.overlaps.map((o) => o.id).join(", #")}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-zinc-400 whitespace-nowrap">
|
||||
{formatDate(s.payment_due_date ?? s.billing_end_date)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useRef, useEffect, Suspense } from "react";
|
||||
import { useState, useCallback, useRef, useEffect, Suspense, Fragment } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useTransactions, useBanks, useUpdateTransaction, useBulkAction, useTags, useStatement, useCreateRule, useParticipants, useRecordPayment, useCurrentUser, useTrips, useAssignTransactionsToTrip, useRules } from "@/lib/hooks";
|
||||
import { CATEGORIES, formatCategory } from "@/lib/categories";
|
||||
@@ -9,7 +9,8 @@ import { TagPicker } from "@/components/tag-picker";
|
||||
import { AddTransactionModal } from "@/components/add-transaction-modal";
|
||||
import { EditTransactionModal } from "@/components/edit-transaction-modal";
|
||||
import { CsvImportModal } from "@/components/csv-import-modal";
|
||||
import type { TransactionRow } from "@/lib/queries";
|
||||
import type { TransactionRow, RoutePointRow } from "@/lib/queries";
|
||||
import { OrderDetails } from "@/components/order-details";
|
||||
import type { RuleRow } from "@/lib/hooks";
|
||||
|
||||
function formatDate(d: string) {
|
||||
@@ -475,6 +476,25 @@ function MultiSelect({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* "Melbourne Airport (MEL) → Wyndham Vale VIC 3024" from the two stops on an
|
||||
* Uber *trip* receipt. Deliveries are excluded by the caller: their merchant
|
||||
* already identifies them, so the restaurant's street address would be clutter
|
||||
* on every food order.
|
||||
*
|
||||
* Keeps the first two comma-segments of each address — a truncation, not a
|
||||
* guess about geography. The venue or street comes first in Uber's format and
|
||||
* is the identifying part; the full text stays in the title attribute.
|
||||
*/
|
||||
function routeSummary(route: RoutePointRow[] | null | undefined): string | null {
|
||||
if (!route || route.length < 2) return null;
|
||||
const short = (a: string) => a.split(",").slice(0, 2).join(",").trim();
|
||||
const from = short(route[0].address);
|
||||
const to = short(route[route.length - 1].address);
|
||||
if (!from || !to) return null;
|
||||
return `${from} → ${to}`;
|
||||
}
|
||||
|
||||
export default function TransactionsPage() {
|
||||
return (
|
||||
<Suspense fallback={<p className="text-zinc-500 text-sm">Loading...</p>}>
|
||||
@@ -487,14 +507,29 @@ function TransactionsContent() {
|
||||
const searchParams = useSearchParams();
|
||||
const initialStatementId = searchParams.get("statement_id") || "";
|
||||
|
||||
// `?q=` lands the view on a specific row. The Slack order nudge links here,
|
||||
// and without it the link drops you at the top of an unfiltered ledger and
|
||||
// the merchant has to be found by hand — which is how a nudge stops getting
|
||||
// opened.
|
||||
const initialQuery = searchParams.get("q") || "";
|
||||
const initialParsed = parseQuery(initialQuery);
|
||||
|
||||
const [filters, setFilters] = useState({
|
||||
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[],
|
||||
search: "",
|
||||
search: initialParsed.text,
|
||||
statement_id: initialStatementId,
|
||||
sort_by: "transaction_date",
|
||||
sort_dir: "desc",
|
||||
@@ -505,8 +540,8 @@ function TransactionsContent() {
|
||||
has_split: "" as string,
|
||||
trip_id: "" as string,
|
||||
});
|
||||
const [queryInput, setQueryInput] = useState("");
|
||||
const [queryTokens, setQueryTokens] = useState<QueryToken[]>([]);
|
||||
const [queryInput, setQueryInput] = useState(initialQuery);
|
||||
const [queryTokens, setQueryTokens] = useState<QueryToken[]>(initialParsed.tokens);
|
||||
|
||||
function handleQueryChange(val: string) {
|
||||
setQueryInput(val);
|
||||
@@ -537,6 +572,9 @@ function TransactionsContent() {
|
||||
const [splitModal, setSplitModal] = useState<{ transactionId?: number; transactionIds?: number[]; amount?: number; description: string; merchant?: string } | null>(null);
|
||||
const [addModal, setAddModal] = useState<{ prefill?: Parameters<typeof AddTransactionModal>[0]["prefill"]; title?: string } | null>(null);
|
||||
const [editModal, setEditModal] = useState<TransactionRow | null>(null);
|
||||
// Rows expanded to show the ingested receipt. A set, not a single id: the
|
||||
// point is comparing several orders without losing your place.
|
||||
const [expanded, setExpanded] = useState<Set<number>>(new Set());
|
||||
const [showImportModal, setShowImportModal] = useState(false);
|
||||
const [paymentModal, setPaymentModal] = useState<TransactionRow | null>(null);
|
||||
const [rulePrompt, setRulePrompt] = useState<{
|
||||
@@ -686,9 +724,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}
|
||||
@@ -911,8 +979,8 @@ function TransactionsContent() {
|
||||
<tr><td colSpan={11} className="p-8 text-center text-zinc-500">No transactions found</td></tr>
|
||||
) : (
|
||||
data.data.map((t) => (
|
||||
<Fragment key={t.id}>
|
||||
<tr
|
||||
key={t.id}
|
||||
className={`border-b border-zinc-800/50 hover:bg-zinc-900/30 ${
|
||||
selected.has(t.id) ? "bg-zinc-800/40" : ""
|
||||
}`}
|
||||
@@ -928,9 +996,39 @@ function TransactionsContent() {
|
||||
<td className={`p-2 whitespace-nowrap sticky left-8 z-10 border-r border-zinc-800/80 ${selected.has(t.id) ? "bg-zinc-800" : "bg-zinc-950"}`}>{formatDate(t.transaction_date)}</td>
|
||||
<td className="p-2 whitespace-nowrap text-zinc-500 text-xs">{formatDate(t.created_at)}</td>
|
||||
<td className="p-2 max-w-xs">
|
||||
<div className="flex items-start gap-1.5">
|
||||
{t.order_platform && (
|
||||
// Only where there IS a receipt behind the row. A
|
||||
// disclosure arrow on every transaction would promise
|
||||
// detail that mostly does not exist.
|
||||
<button
|
||||
onClick={() => setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(t.id)) next.delete(t.id); else next.add(t.id);
|
||||
return next;
|
||||
})}
|
||||
className="text-zinc-600 hover:text-zinc-300 leading-none mt-0.5 shrink-0"
|
||||
title={expanded.has(t.id) ? "Hide receipt" : "Show the receipt this came from"}
|
||||
aria-expanded={expanded.has(t.id)}
|
||||
>
|
||||
{expanded.has(t.id) ? "▾" : "▸"}
|
||||
</button>
|
||||
)}
|
||||
<p className="truncate" title={t.description}>{t.description}</p>
|
||||
{t.notes && (
|
||||
</div>
|
||||
{t.notes ? (
|
||||
<p className="truncate text-xs text-zinc-500 italic mt-0.5" title={t.notes}>{t.notes}</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
|
||||
// them apart, and it was already stored. A note the user
|
||||
// wrote always wins — this only fills an empty line.
|
||||
<p
|
||||
className="truncate text-xs text-zinc-500 italic mt-0.5"
|
||||
title={t.order_route!.map((r) => `${r.label}${r.time ? ` ${r.time}` : ""}: ${r.address}`).join("\n")}
|
||||
>
|
||||
{routeSummary(t.order_route)}
|
||||
</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-2 max-w-[150px]">
|
||||
@@ -1056,6 +1154,15 @@ function TransactionsContent() {
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{expanded.has(t.id) && (
|
||||
<tr className="border-b border-zinc-800/50 bg-zinc-900/40">
|
||||
<td />
|
||||
<td colSpan={10} className="px-4 py-3">
|
||||
<OrderDetails transactionId={t.id} currency={t.currency ?? null} bare />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
|
||||
+336
-97
@@ -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,12 +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">
|
||||
<StatCard label="Total Spend" value={`$${Number(total_spend).toFixed(2)}`} sub="all transactions" 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 */}
|
||||
@@ -157,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
|
||||
@@ -176,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>
|
||||
@@ -277,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", "Share of 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>
|
||||
@@ -288,28 +431,124 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{participant_splits.map((p) => (
|
||||
{/* 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 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">${Number(p.owed).toFixed(2)}</td>
|
||||
<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"
|
||||
: net > 0
|
||||
? "still owed"
|
||||
: overpaid
|
||||
? "covered — they paid over"
|
||||
: "you owe them"}
|
||||
</span>
|
||||
{unconverted > 0 && (
|
||||
<span className="block text-[11px] text-amber-500/80 mt-0.5 font-sans">
|
||||
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>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
{/* Settled/unsettled was reported from transaction_splits.settled,
|
||||
which nothing sets — so every trip showed 100% unsettled forever,
|
||||
including ones already paid in full. Settlement is tracked across
|
||||
the whole relationship, not per trip: payments carry no trip
|
||||
attribution, so a per-trip figure cannot be computed. */}
|
||||
{/* This note used to say a per-trip figure could not be computed,
|
||||
because payments carried no trip attribution. Migration 0022 added
|
||||
split_payments.trip_id, so it can and now does — the figures above
|
||||
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">
|
||||
Settlement is tracked across all shared expenses, not per trip —
|
||||
see <Link href="/shared" className="text-zinc-400 hover:text-zinc-200 underline">Shared</Link> for
|
||||
what is actually owed.
|
||||
<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">
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
useTrips,
|
||||
} from "@/lib/hooks";
|
||||
import { SplitModal } from "./split-modal";
|
||||
import { OrderDetails } from "./order-details";
|
||||
import { CATEGORIES, formatCategory } from "@/lib/categories";
|
||||
import type { TransactionRow, TagRow } from "@/lib/queries";
|
||||
|
||||
@@ -314,6 +315,8 @@ export function EditTransactionModal({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<OrderDetails transactionId={transaction.id} currency={transaction.currency ?? null} />
|
||||
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
|
||||
@@ -0,0 +1,475 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
useOrderReceipt,
|
||||
useOrderReview,
|
||||
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",
|
||||
};
|
||||
|
||||
/**
|
||||
* Who records verdicts. A two-person household with one primary user: the
|
||||
* owner records almost everything, and the only other consumer is Sonu (user,
|
||||
* 2026-07-28). Mirrors OWNER_PARTICIPANT_ID / SECOND_CONSUMER_ID in
|
||||
* `lib/order-reviews.ts` — duplicated rather than imported because that module
|
||||
* pulls in the database client and this is a client component.
|
||||
*/
|
||||
const OWNER_PARTICIPANT_ID = 1;
|
||||
const SECOND_CONSUMER_ID = 4;
|
||||
|
||||
const RATING_LABEL: Record<OrderRating, string> = {
|
||||
loved: "Loved it",
|
||||
liked: "Liked it",
|
||||
ok: "OK",
|
||||
bad: "Bad",
|
||||
never: "Never again",
|
||||
};
|
||||
|
||||
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", "bad", "never"];
|
||||
|
||||
/**
|
||||
* The receipt behind a delivery order: what was actually bought, where it went,
|
||||
* and what we thought of it.
|
||||
*
|
||||
* The receipt half is read-only — it is what a provider sent, not something to
|
||||
* edit. The verdict half is the only part of an order that changes, and it is
|
||||
* the reason the receipts are ingested at all (ING-9): the ledger already knew
|
||||
* we had ordered from here, but not that it was bad, so orders got repeated
|
||||
* from places we disliked because nobody remembered.
|
||||
*/
|
||||
export function OrderDetails({
|
||||
transactionId,
|
||||
currency,
|
||||
bare = false,
|
||||
}: {
|
||||
transactionId: number;
|
||||
currency: string | null;
|
||||
/** Drop the top border and heading spacing when embedded in a table row. */
|
||||
bare?: boolean;
|
||||
}) {
|
||||
const { data: receipt, isLoading } = useOrderReceipt(transactionId);
|
||||
const { data: review } = useOrderReview(transactionId);
|
||||
const [reviewer, setReviewer] = useState(OWNER_PARTICIPANT_ID);
|
||||
|
||||
if (isLoading || !receipt) return null;
|
||||
|
||||
const cur = receipt.currency ?? currency ?? "AUD";
|
||||
const fmt = (n: number) => (cur === "AUD" ? `$${n.toFixed(2)}` : `${cur} ${n.toFixed(2)}`);
|
||||
const items: OrderReceipt["line_items"] = receipt.line_items ?? [];
|
||||
const route: OrderReceipt["route"] = receipt.route ?? [];
|
||||
|
||||
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>
|
||||
)}
|
||||
</p>
|
||||
{receipt.card_last4 && (
|
||||
<span className="text-xs text-zinc-600">card ••••{receipt.card_last4}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{items.length > 0 ? (
|
||||
<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 ?? 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}
|
||||
item={it.description}
|
||||
current={mine?.item_verdicts ?? []}
|
||||
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>
|
||||
) : (
|
||||
// Uber itemises groceries but not restaurant orders, and orders taken
|
||||
// before this was parsed have none either. Say which, rather than
|
||||
// showing an empty list that reads like a bug.
|
||||
<p className="text-xs text-zinc-600 italic mb-3">
|
||||
No itemised list on this receipt
|
||||
</p>
|
||||
)}
|
||||
|
||||
{route.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{route.map((pt, i) => (
|
||||
<div key={i} className="flex gap-2 text-xs">
|
||||
<span className="text-zinc-600 shrink-0 w-24">
|
||||
{pt.label}
|
||||
{pt.time && <span className="block text-zinc-700">{pt.time}</span>}
|
||||
</span>
|
||||
<span className="text-zinc-400 flex-1">{pt.address}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{receipt.order_reference && !receipt.order_reference.startsWith("msg:") && (
|
||||
<p className="mt-3 text-[11px] text-zinc-700 font-mono break-all">
|
||||
{receipt.order_reference}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{rateable && (
|
||||
<OrderVerdict
|
||||
transactionId={transactionId}
|
||||
reviewer={reviewer}
|
||||
onReviewerChange={setReviewer}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loved / never on a single line item, for the currently selected reviewer.
|
||||
*
|
||||
* Only the two poles are offered. A per-item "OK" is noise: the question at the
|
||||
* next order is "what should I get, what should I avoid", and a middling dish
|
||||
* answers neither.
|
||||
*
|
||||
* Every press sends the whole item array plus the current rating and note,
|
||||
* because the endpoint upserts a row rather than patching fields — sending a
|
||||
* partial would blank whatever it omitted.
|
||||
*/
|
||||
function ItemVerdictToggle({
|
||||
transactionId,
|
||||
reviewer,
|
||||
item,
|
||||
current,
|
||||
rating,
|
||||
note,
|
||||
}: {
|
||||
transactionId: number;
|
||||
reviewer: number;
|
||||
item: string;
|
||||
current: ItemOpinion[];
|
||||
rating: OrderRating | null;
|
||||
note: string | null;
|
||||
}) {
|
||||
const save = useSetOrderReview();
|
||||
const existing = current.find(
|
||||
(v) => v.item.trim().toLowerCase() === item.trim().toLowerCase()
|
||||
);
|
||||
|
||||
const toggle = (verdict: ItemVerdict) => {
|
||||
const rest = current.filter(
|
||||
(v) => v.item.trim().toLowerCase() !== item.trim().toLowerCase()
|
||||
);
|
||||
// Pressing the active verdict clears it — a mis-tap must be reversible, and
|
||||
// there is no other route back to "no opinion on this dish".
|
||||
const next =
|
||||
existing?.verdict === verdict ? rest : [...rest, { item, verdict }];
|
||||
save.mutate({
|
||||
transactionId,
|
||||
participantId: reviewer,
|
||||
rating,
|
||||
note,
|
||||
itemVerdicts: next,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<span className="flex gap-0.5 shrink-0">
|
||||
{(["loved", "never"] as ItemVerdict[]).map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
onClick={() => toggle(v)}
|
||||
disabled={save.isPending}
|
||||
title={v === "loved" ? "Loved this item" : "Never order this again"}
|
||||
className={`rounded px-1 leading-none transition-opacity disabled:opacity-40 ${
|
||||
existing?.verdict === v
|
||||
? "opacity-100"
|
||||
: "opacity-25 hover:opacity-60"
|
||||
}`}
|
||||
>
|
||||
{v === "loved" ? "👍" : "👎"}
|
||||
</button>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Was this order shared? One tap, and the split is the answer.
|
||||
*
|
||||
* "Shared" means shared in both senses — we both ate it and we both pay for it
|
||||
* — so this writes a real 50/50 `transaction_splits` row rather than a
|
||||
* decorative flag (user, 2026-07-28: the split was part of the original
|
||||
* requirement). There is no separate "shared" column precisely because the
|
||||
* split already IS that record, and two records of one fact drift apart.
|
||||
*
|
||||
* Unsharing clears the splits. That is safe on an order because an ingested
|
||||
* order is post-cutover by construction — the DB CHECK forbids credits orders
|
||||
* before 2026-01-09 — so no settled historical obligation can be sitting on it
|
||||
* to lose.
|
||||
*/
|
||||
function SharedToggle({
|
||||
transactionId,
|
||||
splits,
|
||||
otherName,
|
||||
}: {
|
||||
transactionId: number;
|
||||
splits: { participant_id: number; share_percent: string }[];
|
||||
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 || clearSplits.isPending}
|
||||
onClick={() =>
|
||||
// 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: [
|
||||
{ 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 ${
|
||||
shared
|
||||
? "border-sky-700 bg-sky-950 text-sky-300"
|
||||
: "border-zinc-700 text-zinc-500 hover:border-zinc-600 hover:text-zinc-300"
|
||||
}`}
|
||||
>
|
||||
{shared ? `Shared 50/50 with ${otherName}` : "Just me"}
|
||||
</button>
|
||||
{shared && (
|
||||
<span className="text-[11px] text-zinc-600">
|
||||
ask {otherName} for her verdict too
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The overall verdict, whose it is, and this merchant's track record.
|
||||
*
|
||||
* The history sits above the buttons deliberately: it is read before the next
|
||||
* order, not after, and burying it under the form is how you re-order from a
|
||||
* place you already rejected.
|
||||
*/
|
||||
function OrderVerdict({
|
||||
transactionId,
|
||||
reviewer,
|
||||
onReviewerChange,
|
||||
}: {
|
||||
transactionId: number;
|
||||
reviewer: number;
|
||||
onReviewerChange: (id: number) => void;
|
||||
}) {
|
||||
const { data, isLoading } = useOrderReview(transactionId);
|
||||
const { data: participants } = useParticipants();
|
||||
const save = useSetOrderReview();
|
||||
const [noteDraft, setNoteDraft] = useState<string | null>(null);
|
||||
|
||||
const reviewers = useMemo(
|
||||
() =>
|
||||
[OWNER_PARTICIPANT_ID, SECOND_CONSUMER_ID].map((id) => ({
|
||||
id,
|
||||
name:
|
||||
id === OWNER_PARTICIPANT_ID
|
||||
? "Me"
|
||||
: participants?.find((p) => p.id === id)?.name ?? "Them",
|
||||
})),
|
||||
[participants]
|
||||
);
|
||||
|
||||
// No merchant means no receipt behind this row — nothing to have a view on.
|
||||
if (isLoading || !data?.merchant) return null;
|
||||
|
||||
const mine = data.reviews.find((r) => r.participant_id === reviewer);
|
||||
const current = mine?.rating ?? null;
|
||||
const noteValue = noteDraft ?? mine?.note ?? "";
|
||||
const { history, warn, items } = data.merchant;
|
||||
const others = data.reviews.filter((r) => r.participant_id !== reviewer && r.rating);
|
||||
|
||||
const set = (rating: OrderRating) =>
|
||||
save.mutate({
|
||||
transactionId,
|
||||
participantId: reviewer,
|
||||
// Pressing the active rating clears it — otherwise a mis-tap is
|
||||
// permanent, and there is no other way back to "no opinion".
|
||||
rating: rating === current ? null : rating,
|
||||
note: noteValue.trim() || null,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mt-4 border-t border-zinc-800 pt-3">
|
||||
{warn && (
|
||||
<p className="mb-2 text-xs text-red-400">
|
||||
Marked “never again” here before.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{items.length > 0 && (
|
||||
<p className="mb-2 text-[11px] text-zinc-500">
|
||||
{items
|
||||
.filter((i) => i.loved > i.never)
|
||||
.slice(0, 3)
|
||||
.map((i) => `👍 ${i.item}`)
|
||||
.concat(
|
||||
items
|
||||
.filter((i) => i.never > 0)
|
||||
.slice(0, 3)
|
||||
.map((i) => `👎 ${i.item}`)
|
||||
)
|
||||
.join(" · ")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<SharedToggle
|
||||
transactionId={transactionId}
|
||||
splits={data.splits}
|
||||
otherName={reviewers[1].name}
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{reviewers.map((r) => (
|
||||
<button
|
||||
key={r.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setNoteDraft(null); // the draft belongs to the person who typed it
|
||||
onReviewerChange(r.id);
|
||||
}}
|
||||
className={`text-xs transition-colors ${
|
||||
reviewer === r.id
|
||||
? "text-zinc-200 underline underline-offset-4"
|
||||
: "text-zinc-600 hover:text-zinc-400"
|
||||
}`}
|
||||
>
|
||||
{r.name}
|
||||
{data.reviews.some((v) => v.participant_id === r.id && v.rating) && " ✓"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{RATING_ORDER.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
type="button"
|
||||
onClick={() => set(r)}
|
||||
disabled={save.isPending}
|
||||
className={`rounded border px-2 py-1 text-xs transition-colors disabled:opacity-50 ${
|
||||
current === r
|
||||
? RATING_STYLE[r]
|
||||
: "border-zinc-700 text-zinc-500 hover:border-zinc-600 hover:text-zinc-300"
|
||||
}`}
|
||||
>
|
||||
{RATING_LABEL[r]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={noteValue}
|
||||
placeholder="What was wrong (or right)?"
|
||||
onChange={(e) => setNoteDraft(e.target.value)}
|
||||
onBlur={() => {
|
||||
const next = noteValue.trim() || null;
|
||||
if (next !== (mine?.note ?? null)) {
|
||||
save.mutate({
|
||||
transactionId,
|
||||
participantId: reviewer,
|
||||
rating: current,
|
||||
note: next,
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="mt-2 w-full rounded border border-zinc-800 bg-zinc-900 px-2 py-1 text-xs text-zinc-300 placeholder:text-zinc-700 focus:border-zinc-600 focus:outline-none"
|
||||
/>
|
||||
|
||||
{others.map((o) => (
|
||||
<p key={o.participant_id} className="mt-1.5 text-[11px] text-zinc-500">
|
||||
<span className="text-zinc-400">{o.participant_name}:</span>{" "}
|
||||
{o.rating && RATING_LABEL[o.rating]}
|
||||
{o.note && <span className="italic"> — {o.note}</span>}
|
||||
</p>
|
||||
))}
|
||||
|
||||
{history.length > 0 && (
|
||||
<ul className="mt-2 space-y-1">
|
||||
{history.map((h) => (
|
||||
<li
|
||||
key={`${h.transaction_id}-${h.participant_id}`}
|
||||
className="text-[11px] text-zinc-600"
|
||||
>
|
||||
<span className="tabular-nums">{h.transaction_date}</span>
|
||||
<span className="ml-1.5 text-zinc-500">
|
||||
{h.participant_name}
|
||||
{h.rating ? ` · ${RATING_LABEL[h.rating]}` : ""}
|
||||
</span>
|
||||
{h.note && <span className="ml-1.5 italic">{h.note}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
+174
-1
@@ -1,7 +1,7 @@
|
||||
// Shared SQL fragments for analytics queries, so spend/income semantics stay
|
||||
// identical across routes.
|
||||
//
|
||||
// Two rules every analytics query must follow:
|
||||
// Three rules every analytics query must follow:
|
||||
//
|
||||
// 1. Join `statements` with LEFT JOIN and scope on COALESCE(t.owner_id, s.owner_id).
|
||||
// An INNER JOIN silently drops every manual/CSV transaction (statement_id IS
|
||||
@@ -11,6 +11,23 @@
|
||||
// bank account and again as the underlying purchases on the card statement.
|
||||
// Categorising the money movement as `transfers` and excluding it here is what
|
||||
// stops the double count. Investments are a balance-sheet move, not spend.
|
||||
// 3. Apply EXCLUDE_RECONCILED_SOURCE to every row-level query. The transaction
|
||||
// queries have always done this (`queries.ts`); analytics never did, which is
|
||||
// the other half of the same double count.
|
||||
|
||||
/**
|
||||
* `YYYY-MM-DD` for a Date, read in local time.
|
||||
*
|
||||
* `toISOString().slice(0, 10)` is the obvious thing and it is wrong here: these
|
||||
* are calendar boundaries built with `new Date(y, m, 1)`, which is local
|
||||
* midnight. In any timezone east of UTC that converts to the *previous* day, so
|
||||
* every window silently started and ended a day early — visible once the fees
|
||||
* endpoint began reporting the range it had used ("2026-04-30" for a window
|
||||
* meant to open on 1 May).
|
||||
*/
|
||||
export function toDateStr(d: Date): string {
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
/** Owner scoping that works for both statement-linked and manual transactions. */
|
||||
export const OWNER_SCOPE = `COALESCE(t.owner_id, s.owner_id)`;
|
||||
@@ -18,6 +35,76 @@ export const OWNER_SCOPE = `COALESCE(t.owner_id, s.owner_id)`;
|
||||
/** Join clause to pair with OWNER_SCOPE. */
|
||||
export const STATEMENTS_JOIN = `LEFT JOIN statements s ON s.id = t.statement_id`;
|
||||
|
||||
/**
|
||||
* Drops rows that a different row has replaced. Two distinct cases:
|
||||
*
|
||||
* **1. The manual/CSV row a statement line superseded** (`reconciled_with_id`).
|
||||
*
|
||||
* Reconciliation keeps both rows: the manual one the user entered and the
|
||||
* statement line it turned out to be. Only the statement line should count, or
|
||||
* the same purchase is spent twice. `queries.ts` has always applied this; the
|
||||
* analytics routes did not, so every reconciled row was double-counted in the
|
||||
* category totals, movers, Pareto, and merchant rankings.
|
||||
*
|
||||
* Scoped to `statement_id IS NULL` deliberately: the *source* row is the manual
|
||||
* one. A statement line pointing at something else is the survivor, not the
|
||||
* duplicate.
|
||||
*
|
||||
* Order-receipt rows (`payment_method = 'credits'`) are unaffected — they are
|
||||
* inserted with `reconciled_with_id` NULL and are held out of the reconcile
|
||||
* queue by `needsCardMatch()`, so nothing ever sets it. If one is reconciled by
|
||||
* hand against a card line, this is what stops it double-counting.
|
||||
*
|
||||
* **2. The statement row imported twice** (`superseded_by_id`, migration 0023).
|
||||
*
|
||||
* When two statements for one account bill overlapping periods, every
|
||||
* transaction in the overlap arrives twice — 31 pairs on ANZ `4085-56264`, from
|
||||
* statements 107/142/143. Case 1 cannot express this: its predicate is scoped
|
||||
* to `statement_id IS NULL` on purpose, and here BOTH rows are statement lines.
|
||||
*
|
||||
* The superseded row is excluded rather than deleted because every child of
|
||||
* `transactions` cascades on delete, and the curation is not reliably on the
|
||||
* surviving side.
|
||||
*
|
||||
* Adding it to this fragment rather than making a new one is deliberate: every
|
||||
* query that already asks "count each purchase once" now excludes both kinds
|
||||
* without being edited. Anything summing transactions without this fragment
|
||||
* still double-counts — that is the same gap that let reconciled rows into the
|
||||
* analytics routes in the first place.
|
||||
*/
|
||||
export const EXCLUDE_RECONCILED_SOURCE = `NOT (t.statement_id IS NULL AND t.reconciled_with_id IS NOT NULL)
|
||||
AND t.superseded_by_id IS NULL`;
|
||||
|
||||
/**
|
||||
* The currency `t.amount` is actually denominated in.
|
||||
*
|
||||
* Two different conventions meet here and the COALESCE order is what keeps them
|
||||
* apart:
|
||||
*
|
||||
* - A statement row is denominated in its statement's currency. If that row is
|
||||
* an overseas purchase on an AUD statement, `amount` is still AUD and
|
||||
* `foreign_currency_code` merely records what was originally charged — so
|
||||
* `s.currency` must win.
|
||||
* - An order-receipt row has no statement. There, `amount` IS the native
|
||||
* figure and `foreign_currency_code` names it (order-ingestion.ts leaves
|
||||
* `amount_aud` NULL rather than asserting an FX rate it does not have).
|
||||
*
|
||||
* Reading `s.currency` alone labels every foreign order row as AUD.
|
||||
* `s` must be the statements alias in scope.
|
||||
*/
|
||||
export const NATIVE_CURRENCY = `COALESCE(s.currency, t.foreign_currency_code, 'AUD')`;
|
||||
|
||||
/**
|
||||
* True when a row's AUD value is unknown: it is denominated in something other
|
||||
* than AUD and carries no converted figure.
|
||||
*
|
||||
* Every settlement total uses `COALESCE(t.amount_aud, t.amount)`, which for such
|
||||
* a row silently nets a foreign figure against AUD ones. Rather than drop the
|
||||
* row (which changes a balance with no trace) or convert it (with no rate),
|
||||
* count these and let the UI say the balance is incomplete.
|
||||
*/
|
||||
export const AMOUNT_UNCONVERTED = `(t.amount_aud IS NULL AND ${NATIVE_CURRENCY} <> 'AUD')`;
|
||||
|
||||
/** Transaction types that represent money going out. */
|
||||
export const SPEND_TYPES = `('debit', 'fee', 'interest')`;
|
||||
|
||||
@@ -104,3 +191,89 @@ export const SPEND_SIGNED = `CASE
|
||||
WHEN t.transaction_type IN ('refund', 'credit') THEN -(${SPEND_BASE})
|
||||
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.
|
||||
*
|
||||
* This is the line between the two questions the same table answers, and
|
||||
* conflating them is what made three different "owed" figures disagree:
|
||||
*
|
||||
* - **Owed** — what is still outstanding between two people. Must apply this.
|
||||
* - **Spend** — what a purchase cost me. Must NOT apply this.
|
||||
*
|
||||
* A settled split is still a real expense: my half of a 2025 grocery shop is my
|
||||
* spend whether or not the other half was ever repaid. Filtering settled rows
|
||||
* out of `myShare`/`mySplitOf` would re-inflate exactly the figures that
|
||||
* importing settled history exists to correct.
|
||||
*
|
||||
* `settled` marks obligations discharged OUTSIDE this app — imported
|
||||
* SplitMyExpenses history, whose repayments happened on a platform we no longer
|
||||
* run and which therefore has no `split_payments` row here.
|
||||
*
|
||||
* A live obligation is NOT settled by flipping this. It is settled by recording
|
||||
* the payment, and the balance nets to zero on its own. Doing both would
|
||||
* subtract the settlement twice — the splits leave the sum AND the payment is
|
||||
* deducted — driving the balance negative by the amount repaid. So there is
|
||||
* deliberately no "mark settled" action anywhere: settling up is recording a
|
||||
* payment, and this column is only ever written by the historical import.
|
||||
*
|
||||
* **The date is the real guard, and the flag is only a refinement of it.**
|
||||
* Nothing before SPLIT_CUTOVER can be owed, because carryover transaction 2348
|
||||
* already carries the entire pre-cutover balance as a single figure. A split on
|
||||
* an older transaction is therefore free to describe *how an expense was shared*
|
||||
* — which is what stops it inflating spend — without ever asserting a debt.
|
||||
*
|
||||
* That separation is what makes splitting history safe. Before it, the only
|
||||
* thing keeping $37,233.28 of paid debt out of the balances was a boolean that
|
||||
* any delete-and-recreate write path silently reset to false. Now losing the
|
||||
* flag on a pre-cutover row costs nothing: the date still excludes it. The flag
|
||||
* matters only for rows on or after the cutover, where it marks the handful
|
||||
* settled outside this app.
|
||||
*
|
||||
* The boundary is inclusive because transaction 2348 is itself dated
|
||||
* 2026-01-09 — an exclusive bound would drop the carryover and with it the
|
||||
* entire pre-cutover balance.
|
||||
*
|
||||
* Assumes the `transaction_splits` alias is `ts` and `transactions` is `t`.
|
||||
*/
|
||||
export const SPLIT_CUTOVER = "2026-01-09";
|
||||
|
||||
export const ACTIVE_OBLIGATION = `ts.settled = false
|
||||
AND t.transaction_date >= '${SPLIT_CUTOVER}'`;
|
||||
|
||||
/**
|
||||
* The tab a split belongs to: its transaction's trip, else the household.
|
||||
*
|
||||
* Membership already lives on `transaction_overrides.trip_id`, so this is a
|
||||
* read of existing data rather than a new grouping key. Assumes an
|
||||
* `transaction_overrides` alias `o` is joined (LEFT — a transaction with no
|
||||
* override row has no trip, which is the common case and means household).
|
||||
*/
|
||||
export const SPLIT_SCOPE = `o.trip_id`;
|
||||
|
||||
+251
-5
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { TransactionRow, StatementRow, TagRow, TripRow, TripAnalytics } from "./queries";
|
||||
import type { TransactionRow, StatementRow, TagRow, TripRow, TripAnalytics, ParticipantBalance } from "./queries";
|
||||
export type { TripRow, TripAnalytics };
|
||||
import type { CurrentUser } from "./auth";
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -216,7 +222,7 @@ export function useParticipants() {
|
||||
}
|
||||
|
||||
export function useParticipantBalances(tagIds?: string[]) {
|
||||
return useQuery<{ id: number; name: string; total_owed: number; unsettled_count: number }[]>({
|
||||
return useQuery<ParticipantBalance[]>({
|
||||
queryKey: ["participant-balances", tagIds],
|
||||
queryFn: async () => {
|
||||
const params = tagIds?.length ? `?tag_ids=${tagIds.join(",")}` : "";
|
||||
@@ -250,6 +256,132 @@ export function useTransactionSplits(transactionId: number) {
|
||||
});
|
||||
}
|
||||
|
||||
export interface OrderReceipt {
|
||||
/** '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 }[];
|
||||
subtotal: string | null;
|
||||
amount: string | null;
|
||||
currency: string | null;
|
||||
card_last4: string | null;
|
||||
flags: string[];
|
||||
source_email_subject: string | null;
|
||||
transaction_date: string | null;
|
||||
}
|
||||
|
||||
/** The receipt behind a transaction, or null when it did not come from one. */
|
||||
export function useOrderReceipt(transactionId: number) {
|
||||
return useQuery<OrderReceipt | null>({
|
||||
queryKey: ["order-receipt", transactionId],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/transactions/${transactionId}/order`);
|
||||
if (!res.ok) return null;
|
||||
return res.json();
|
||||
},
|
||||
staleTime: Infinity, // a receipt never changes
|
||||
});
|
||||
}
|
||||
|
||||
export type OrderRating = "loved" | "liked" | "ok" | "bad" | "never";
|
||||
export type ItemVerdict = "loved" | "never";
|
||||
|
||||
export interface ItemOpinion {
|
||||
item: string;
|
||||
verdict: ItemVerdict;
|
||||
}
|
||||
|
||||
export interface OrderReviewRow {
|
||||
transaction_id: number;
|
||||
participant_id: number;
|
||||
participant_name: string;
|
||||
rating: OrderRating | null;
|
||||
order_again: boolean | null;
|
||||
note: string | null;
|
||||
item_verdicts: ItemOpinion[];
|
||||
}
|
||||
|
||||
export interface OrderReviewState {
|
||||
/** One row per person who has an opinion. Empty until someone records one. */
|
||||
reviews: OrderReviewRow[];
|
||||
/** Current splits — an empty list means the order was not shared. */
|
||||
splits: { participant_id: number; share_percent: string }[];
|
||||
merchant: {
|
||||
merchant: string;
|
||||
history: {
|
||||
transaction_id: number;
|
||||
participant_id: number;
|
||||
participant_name: string;
|
||||
rating: OrderRating | null;
|
||||
note: string | null;
|
||||
transaction_date: string | null;
|
||||
}[];
|
||||
counts: Record<OrderRating, number>;
|
||||
warn: boolean;
|
||||
items: { item: string; loved: number; never: number }[];
|
||||
} | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The verdict on an order and this merchant's track record.
|
||||
*
|
||||
* No `staleTime: Infinity` here, unlike the receipt hook next to it — a receipt
|
||||
* never changes, but a verdict is the one part of an order that does.
|
||||
*/
|
||||
export function useOrderReview(transactionId: number) {
|
||||
return useQuery<OrderReviewState>({
|
||||
queryKey: ["order-review", transactionId],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/transactions/${transactionId}/review`);
|
||||
if (!res.ok) return { reviews: [], splits: [], merchant: null };
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetOrderReview() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
transactionId,
|
||||
participantId,
|
||||
rating,
|
||||
note,
|
||||
itemVerdicts,
|
||||
}: {
|
||||
transactionId: number;
|
||||
participantId: number;
|
||||
rating: OrderRating | null;
|
||||
note?: string | null;
|
||||
/** Omit to leave existing item opinions untouched. */
|
||||
itemVerdicts?: ItemOpinion[];
|
||||
}) => {
|
||||
const res = await fetch(`/api/transactions/${transactionId}/review`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
participant_id: participantId,
|
||||
rating,
|
||||
note,
|
||||
...(itemVerdicts === undefined ? {} : { item_verdicts: itemVerdicts }),
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.error || "Failed to save verdict");
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
// Every order from the same merchant now shows a different track record,
|
||||
// so invalidate the whole key rather than this one transaction.
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["order-review"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetSplits() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
@@ -275,6 +407,38 @@ export function useSetSplits() {
|
||||
qc.invalidateQueries({ queryKey: ["splits"] });
|
||||
qc.invalidateQueries({ queryKey: ["shared-transactions"] });
|
||||
qc.invalidateQueries({ queryKey: ["participant-balances"] });
|
||||
// The order panel shows share state from this same data.
|
||||
qc.invalidateQueries({ queryKey: ["order-review"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -289,6 +453,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;
|
||||
}
|
||||
|
||||
@@ -314,17 +481,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"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -748,6 +923,27 @@ export function useMonthlyAnalytics(months?: number) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sparse by day-of-month; a missing day means zero.
|
||||
* daily → { "2026-07": { 3: 42.10 } }
|
||||
* byCategory → { "2026-07": { dining: { 3: 42.10 } } }
|
||||
*/
|
||||
export interface DailySpend {
|
||||
daily: Record<string, Record<number, number>>;
|
||||
byCategory: Record<string, Record<string, Record<number, number>>>;
|
||||
}
|
||||
|
||||
export function useDailySpend(months?: number) {
|
||||
const m = months || 12;
|
||||
return useQuery<DailySpend>({
|
||||
queryKey: ["analytics", "daily", m],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/analytics/daily?months=${m}`);
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export interface SubscriptionRow {
|
||||
merchant: string;
|
||||
category: string;
|
||||
@@ -788,16 +984,25 @@ export interface FeeTxnRow {
|
||||
bank_name: string;
|
||||
}
|
||||
|
||||
export function useFees() {
|
||||
export interface FeePeriod {
|
||||
months: number;
|
||||
from: string | null;
|
||||
to: string | null;
|
||||
all_time: boolean;
|
||||
}
|
||||
|
||||
/** `months = 0` means all time. */
|
||||
export function useFees(months = 12) {
|
||||
return useQuery<{
|
||||
by_bank: FeeBankRow[];
|
||||
transactions: FeeTxnRow[];
|
||||
total_fees: number;
|
||||
total_interest: number;
|
||||
period: FeePeriod;
|
||||
}>({
|
||||
queryKey: ["analytics", "fees"],
|
||||
queryKey: ["analytics", "fees", months],
|
||||
queryFn: async () => {
|
||||
const res = await fetch("/api/analytics/fees");
|
||||
const res = await fetch(`/api/analytics/fees?months=${months}`);
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
@@ -1002,3 +1207,44 @@ 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) throw new Error("Failed to load order");
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,470 @@
|
||||
/**
|
||||
* Order spine reads for the /orders surface.
|
||||
*
|
||||
* These live here rather than in queries.ts on purpose. queries.ts is ~1,500
|
||||
* lines of ledger SQL over tables Prisma models; the order spine
|
||||
* (entity_orders, entities, extracted_facts, order_feed, order_spend) is
|
||||
* written by the ingestion-engine, is NOT in prisma/schema.prisma, and never
|
||||
* will be. Mixing it in would destroy that file's "everything here is
|
||||
* Prisma-modelled" invariant. Same database, same connection, raw SQL.
|
||||
*
|
||||
* Two rules inherited from migration 010, worth restating because breaking
|
||||
* either produces a plausible wrong number rather than an error:
|
||||
*
|
||||
* - BROWSE from order_feed. It keeps refunded/cancelled/returned rows, which
|
||||
* belong on a history page and must never be summed.
|
||||
* - SUM from order_spend, and only ever within a single currency. The spine
|
||||
* holds 20 currencies including a literal '$', XLM and MANA.
|
||||
*/
|
||||
|
||||
import { queryRaw, queryRow } from "./db";
|
||||
|
||||
/**
|
||||
* The spine is ONE mailbox (Siddharth's) and carries no owner column, so
|
||||
* /orders is gated by participant id rather than a row filter.
|
||||
*
|
||||
* This is an ALLOWLIST, and not because these three need protecting from each
|
||||
* other — everything here is on Siddharth's cards (145 of 145 statements) and
|
||||
* Sonu consumes the goods. It is because a participant is an ACCOUNTING
|
||||
* entity, and any participant row carrying an email is a login: auth.ts
|
||||
* matches x-forwarded-user against participants.email. Adding someone to split
|
||||
* a holiday must not silently hand them the purchase history. These three are
|
||||
* deliberate; a fourth has to be deliberate too.
|
||||
*
|
||||
* See DECISIONS.md ING-13 decision 5.
|
||||
*/
|
||||
export const ORDER_VIEWERS = [1, 4, 5]; // Siddharth, Sonu, Molina
|
||||
|
||||
export function canViewOrders(userId: number): boolean {
|
||||
return ORDER_VIEWERS.includes(userId);
|
||||
}
|
||||
|
||||
/** Bookings, invoices and subscriptions get their own lane: they carry no
|
||||
* goods and no delivery, so the retail columns are dead space on them. */
|
||||
export type OrderLane = "retail" | "food" | "grocery" | "digital" | "transport" | "services";
|
||||
|
||||
export interface OrderRow {
|
||||
entity_id: number;
|
||||
entity_key: string;
|
||||
canonical_name: string | null;
|
||||
merchant_name: string | null;
|
||||
platform: string | null;
|
||||
lane: OrderLane;
|
||||
status: string;
|
||||
order_reference: string | null;
|
||||
reference_source: string | null;
|
||||
source_trust: string | null;
|
||||
ordered_at: string | null;
|
||||
eta_date: string | null;
|
||||
delivered_at: string | null;
|
||||
currency: string;
|
||||
order_total: string | null;
|
||||
refunded_amount: string | null;
|
||||
line_item_count: number;
|
||||
item_preview: string[] | null;
|
||||
/** What the classifier thought the source document was. NULL on 48% of rows
|
||||
* (the Takeout backfill predates the interpretation index) — treat NULL as
|
||||
* "unknown", never as "purchase". */
|
||||
content_class: string | null;
|
||||
/** COALESCE(resolved merchant, interpretation counterparty, platform slug).
|
||||
* 95% resolve to a real name; the rest honestly show the slug rather than
|
||||
* guessing one from the sender domain — see ticket 176. */
|
||||
display_name: string;
|
||||
/** Merchant orders on a regular cadence: the subscription signal, derived
|
||||
* not extracted. NULL unless the gaps are tight relative to their mean. */
|
||||
cadence_days: number | null;
|
||||
txn_count: number;
|
||||
first_txn_id: number | null;
|
||||
}
|
||||
|
||||
export interface OrderFilters {
|
||||
lane?: string;
|
||||
platforms?: string[];
|
||||
statuses?: string[];
|
||||
from?: string;
|
||||
to?: string;
|
||||
search?: string;
|
||||
currency?: string;
|
||||
has_transaction?: string;
|
||||
hide_lifecycle_only?: boolean;
|
||||
sort_by?: string;
|
||||
sort_dir?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rows with no amount, no order reference and a single lifecycle event are not
|
||||
* purchases — they are a second entity minted from a mail describing an order
|
||||
* that already exists (Amazon "share your experience", DoorDash no-contact
|
||||
* delivery details, shipment notices). 755 of them; hiding lifts amount
|
||||
* coverage from 74% to 84%.
|
||||
*
|
||||
* This is a WORKAROUND for board 210, not a fix. The upstream defect is that a
|
||||
* lifecycle mail printing no order reference cannot join its own order, so it
|
||||
* becomes an orphan. Do not "fix" it here by deduplicating — 35 of the Amazon
|
||||
* ones are the only record their purchase happened.
|
||||
*/
|
||||
const LIFECYCLE_ONLY = `NOT (
|
||||
f.order_total IS NULL
|
||||
AND f.reference_source = 'message_id_fallback'
|
||||
AND (SELECT count(*) FROM extracted_facts ef
|
||||
WHERE ef.fact_type = 'order_event'
|
||||
AND ef.payload->>'_order_entity_key' = f.entity_key) <= 1
|
||||
)`;
|
||||
|
||||
// Never interpolate a sort column from user input.
|
||||
const SORT_COLUMNS: Record<string, string> = {
|
||||
date: "f.ordered_at",
|
||||
amount: "f.order_total",
|
||||
eta: "f.eta_date",
|
||||
platform: "f.platform",
|
||||
items: "f.line_item_count",
|
||||
};
|
||||
|
||||
function buildWhere(filters: OrderFilters) {
|
||||
const conditions: string[] = [];
|
||||
const params: unknown[] = [];
|
||||
let i = 1;
|
||||
|
||||
if (filters.lane && filters.lane !== "all") {
|
||||
conditions.push(`f.lane = $${i++}`);
|
||||
params.push(filters.lane);
|
||||
}
|
||||
if (filters.platforms?.length) {
|
||||
conditions.push(`f.platform = ANY($${i++}::text[])`);
|
||||
params.push(filters.platforms);
|
||||
}
|
||||
if (filters.statuses?.length) {
|
||||
conditions.push(`f.status = ANY($${i++}::text[])`);
|
||||
params.push(filters.statuses);
|
||||
}
|
||||
if (filters.from) {
|
||||
conditions.push(`f.ordered_at >= $${i++}::date`);
|
||||
params.push(filters.from);
|
||||
}
|
||||
if (filters.to) {
|
||||
// Half-open on the upper bound: ordered_at is a timestamptz, so
|
||||
// `<= '2026-08-12'` silently drops everything after midnight that day.
|
||||
conditions.push(`f.ordered_at < ($${i++}::date + 1)`);
|
||||
params.push(filters.to);
|
||||
}
|
||||
if (filters.currency) {
|
||||
conditions.push(`f.currency = $${i++}`);
|
||||
params.push(filters.currency);
|
||||
}
|
||||
if (filters.search) {
|
||||
// One bound term, five targets. The jsonb_typeof guard is NOT optional:
|
||||
// jsonb_array_elements raises on a non-array, and details is a
|
||||
// last-write-wins merge of extractor output whose shape is not guaranteed.
|
||||
// Measured at 6,283 rows: 19 ms, sequential scan, no index warranted.
|
||||
conditions.push(`(
|
||||
f.canonical_name ILIKE $${i}
|
||||
OR m.canonical_name ILIKE $${i}
|
||||
OR f.platform ILIKE $${i}
|
||||
OR f.order_reference ILIKE $${i}
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM jsonb_array_elements(
|
||||
CASE WHEN jsonb_typeof(f.details->'line_items') = 'array'
|
||||
THEN f.details->'line_items' ELSE '[]'::jsonb END) li
|
||||
WHERE li->>'description' ILIKE $${i})
|
||||
)`);
|
||||
params.push(`%${filters.search}%`);
|
||||
i++;
|
||||
}
|
||||
if (filters.hide_lifecycle_only !== false) conditions.push(LIFECYCLE_ONLY);
|
||||
if (filters.has_transaction === "yes") conditions.push(`link.txn_count > 0`);
|
||||
if (filters.has_transaction === "no") conditions.push(`link.txn_count = 0`);
|
||||
|
||||
return { where: conditions.length ? `WHERE ${conditions.join(" AND ")}` : "", params, next: i };
|
||||
}
|
||||
|
||||
/**
|
||||
* Ledger linkage. Phase 1 reads expense_metadata; phase 2 swaps this lateral
|
||||
* AND the detail query AND /api/transactions/[id]/order together — moving only
|
||||
* one of the three leaves the list contradicting the detail page.
|
||||
*/
|
||||
const LINK_LATERAL = `
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT count(*)::int AS txn_count,
|
||||
min(em.matched_transaction_id) AS first_txn_id
|
||||
FROM expense_metadata em
|
||||
WHERE em.source = 'order-bridge'
|
||||
AND (em.source_message_id = f.entity_key
|
||||
OR em.source_message_id LIKE f.entity_key || '#f%')
|
||||
) link ON true`;
|
||||
|
||||
const FROM_CLAUSE = `
|
||||
FROM order_feed f
|
||||
LEFT JOIN entities m ON m.id = f.merchant_entity_id
|
||||
${LINK_LATERAL}`;
|
||||
|
||||
export async function getOrderFeed(filters: OrderFilters) {
|
||||
const { where, params, next } = buildWhere(filters);
|
||||
|
||||
const countRows = await queryRaw<{ total: number }>(
|
||||
`SELECT count(*)::int AS total ${FROM_CLAUSE} ${where}`,
|
||||
params
|
||||
);
|
||||
const total = countRows[0]?.total ?? 0;
|
||||
|
||||
const sortCol = SORT_COLUMNS[filters.sort_by ?? "date"] ?? "f.ordered_at";
|
||||
const sortDir = filters.sort_dir === "asc" ? "ASC" : "DESC";
|
||||
const limit = Math.min(filters.limit ?? 50, 200);
|
||||
const offset = filters.offset ?? 0;
|
||||
|
||||
const data = await queryRaw<OrderRow>(
|
||||
`SELECT f.entity_id::int AS entity_id,
|
||||
f.entity_key, f.canonical_name, f.platform, f.lane, f.status,
|
||||
f.order_reference, f.reference_source, f.source_trust,
|
||||
f.ordered_at, f.eta_date, f.delivered_at,
|
||||
f.currency, f.order_total, f.line_item_count,
|
||||
f.content_class, f.display_name, f.cadence_days,
|
||||
m.canonical_name AS merchant_name,
|
||||
link.txn_count, link.first_txn_id,
|
||||
(SELECT sp.refunded_amount FROM order_spend sp
|
||||
WHERE sp.entity_id = f.entity_id) AS refunded_amount,
|
||||
(SELECT jsonb_agg(t.li->>'description' ORDER BY t.n)
|
||||
FROM jsonb_array_elements(
|
||||
CASE WHEN jsonb_typeof(f.details->'line_items') = 'array'
|
||||
THEN f.details->'line_items' ELSE '[]'::jsonb END)
|
||||
WITH ORDINALITY t(li, n)
|
||||
-- 12, not 3: the row shows the first few and the expansion shows
|
||||
-- the rest without a second round trip. Beyond 12 the detail page
|
||||
-- is the right surface.
|
||||
WHERE t.n <= 12) AS item_preview
|
||||
${FROM_CLAUSE}
|
||||
${where}
|
||||
-- NULLS LAST always: eta_date is NULL on ~65% of retail and ~95% of food,
|
||||
-- and Postgres sorts NULLs FIRST under DESC — an unguarded sort leads with
|
||||
-- thousands of blanks and reads as an empty page.
|
||||
ORDER BY ${sortCol} ${sortDir} NULLS LAST, f.entity_id DESC
|
||||
LIMIT $${next} OFFSET $${next + 1}`,
|
||||
[...params, limit, offset]
|
||||
);
|
||||
|
||||
return { data, total, limit, offset };
|
||||
}
|
||||
|
||||
export interface OrderFacets {
|
||||
/** Orders per calendar year across the WHOLE feed — deliberately not scoped
|
||||
* to the active date range, because the strip's job is to show where the
|
||||
* current range sits in the twenty-one years available. */
|
||||
years: { year: number; n: number }[];
|
||||
/** Corpus totals for the masthead, so a filtered count is never mistaken
|
||||
* for the whole archive. */
|
||||
all_time: number;
|
||||
with_amount: number;
|
||||
lanes: { lane: string; n: number }[];
|
||||
platforms: { platform: string; n: number }[];
|
||||
statuses: { status: string; n: number }[];
|
||||
currencies: { currency: string; n: number }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Facets are computed under the active date range but NOT under the active
|
||||
* lane — a lane tab showing "0" is information, a lane tab that vanished is a
|
||||
* bug report waiting to happen.
|
||||
*/
|
||||
export async function getOrderFacets(filters: OrderFilters): Promise<OrderFacets> {
|
||||
const base: OrderFilters = {
|
||||
from: filters.from,
|
||||
to: filters.to,
|
||||
hide_lifecycle_only: filters.hide_lifecycle_only,
|
||||
};
|
||||
const { where, params } = buildWhere(base);
|
||||
const laneScoped = buildWhere({ ...base, lane: filters.lane });
|
||||
|
||||
// The year strip and the all-time totals ignore the date range on purpose.
|
||||
const spanFilters: OrderFilters = { hide_lifecycle_only: filters.hide_lifecycle_only };
|
||||
const span = buildWhere(spanFilters);
|
||||
|
||||
const [years, totals, lanes, platforms, statuses, currencies] = await Promise.all([
|
||||
queryRaw<{ year: number; n: number }>(
|
||||
`SELECT extract(year from f.ordered_at)::int AS year, count(*)::int AS n
|
||||
${FROM_CLAUSE} ${span.where}${span.where ? " AND" : " WHERE"} f.ordered_at IS NOT NULL
|
||||
GROUP BY 1 ORDER BY 1`, span.params),
|
||||
queryRaw<{ all_time: number; with_amount: number }>(
|
||||
`SELECT count(*)::int AS all_time,
|
||||
count(*) FILTER (WHERE f.order_total IS NOT NULL AND f.order_total > 0)::int AS with_amount
|
||||
${FROM_CLAUSE} ${span.where}`, span.params),
|
||||
queryRaw<{ lane: string; n: number }>(
|
||||
`SELECT f.lane, count(*)::int AS n ${FROM_CLAUSE} ${where} GROUP BY 1 ORDER BY 2 DESC`, params),
|
||||
queryRaw<{ platform: string; n: number }>(
|
||||
`SELECT f.platform, count(*)::int AS n ${FROM_CLAUSE} ${laneScoped.where}
|
||||
GROUP BY 1 ORDER BY 2 DESC LIMIT 40`, laneScoped.params),
|
||||
queryRaw<{ status: string; n: number }>(
|
||||
`SELECT f.status, count(*)::int AS n ${FROM_CLAUSE} ${laneScoped.where}
|
||||
GROUP BY 1 ORDER BY 2 DESC`, laneScoped.params),
|
||||
queryRaw<{ currency: string; n: number }>(
|
||||
`SELECT f.currency, count(*)::int AS n ${FROM_CLAUSE} ${laneScoped.where}
|
||||
GROUP BY 1 ORDER BY 2 DESC`, laneScoped.params),
|
||||
]);
|
||||
return {
|
||||
years,
|
||||
all_time: totals[0]?.all_time ?? 0,
|
||||
with_amount: totals[0]?.with_amount ?? 0,
|
||||
lanes, platforms, statuses, currencies,
|
||||
};
|
||||
}
|
||||
|
||||
export interface OrderLifecycleEvent {
|
||||
fact_id: string;
|
||||
event_kind: string | null;
|
||||
effective_at: string | null;
|
||||
amount: string | null;
|
||||
currency: string | null;
|
||||
document_title: string | null;
|
||||
source_system: string | null;
|
||||
}
|
||||
|
||||
export interface OrderSibling {
|
||||
entity_key: string;
|
||||
canonical_name: string | null;
|
||||
platform: string | null;
|
||||
order_total: string | null;
|
||||
currency: string;
|
||||
ordered_at: string | null;
|
||||
relation: "settled_by" | "settles";
|
||||
}
|
||||
|
||||
export interface OrderLinkedTxn {
|
||||
transaction_id: number;
|
||||
transaction_date: string;
|
||||
description: string;
|
||||
amount: string;
|
||||
source_message_id: string;
|
||||
}
|
||||
|
||||
export interface OrderDetail {
|
||||
entity_key: string;
|
||||
canonical_name: string | null;
|
||||
merchant_name: string | null;
|
||||
platform: string | null;
|
||||
lane: OrderLane;
|
||||
status: string;
|
||||
direction: string;
|
||||
order_reference: string | null;
|
||||
reference_source: string | null;
|
||||
source_trust: string | null;
|
||||
ordered_at: string | null;
|
||||
eta_date: string | null;
|
||||
delivered_at: string | null;
|
||||
currency: string;
|
||||
order_total: string | null;
|
||||
gross_total: string | null;
|
||||
refunded_amount: string | null;
|
||||
tracking_url: string | null;
|
||||
tracking_carrier: string | null;
|
||||
line_items: { description?: string; quantity?: number; amount?: number }[];
|
||||
is_settled_duplicate: boolean;
|
||||
content_class: string | null;
|
||||
display_name: string;
|
||||
cadence_days: number | null;
|
||||
order_count: number | null;
|
||||
events: OrderLifecycleEvent[];
|
||||
siblings: OrderSibling[];
|
||||
transactions: OrderLinkedTxn[];
|
||||
}
|
||||
|
||||
export async function getOrderDetail(entityKey: string): Promise<OrderDetail | null> {
|
||||
/**
|
||||
* Read entity_orders DIRECTLY here — the one sanctioned exception to
|
||||
* "browse from order_feed". A settled rail order is excluded from the feed
|
||||
* by design, but must still be openable from the sibling link on the shop
|
||||
* order it settles. Without this, following that link 404s.
|
||||
*/
|
||||
const head = await queryRow<Omit<OrderDetail, "events" | "siblings" | "transactions" | "line_items"> & {
|
||||
line_items: unknown;
|
||||
}>(
|
||||
`SELECT e.entity_key, e.canonical_name, e.source_trust,
|
||||
o.platform,
|
||||
order_lane(o.platform, p.category, e.canonical_name) AS lane,
|
||||
o.status, o.direction, o.order_reference, o.reference_source,
|
||||
o.ordered_at, o.eta_date, o.delivered_at, o.currency,
|
||||
o.order_total, o.tracking_url, o.tracking_carrier,
|
||||
m.canonical_name AS merchant_name,
|
||||
(o.settles_entity_id IS NOT NULL) AS is_settled_duplicate,
|
||||
COALESCE(o.details->'line_items', '[]'::jsonb) AS line_items,
|
||||
sp.order_total AS net_total,
|
||||
sp.gross_total,
|
||||
sp.refunded_amount,
|
||||
ctx.content_class,
|
||||
COALESCE(me2.canonical_name, ctx.counterparty, o.platform) AS display_name,
|
||||
rec.cadence_days,
|
||||
rec.order_count
|
||||
FROM entities e
|
||||
JOIN entity_orders o ON o.entity_id = e.id
|
||||
LEFT JOIN entities m ON m.id = o.merchant_entity_id
|
||||
LEFT JOIN entities me2 ON me2.id = o.merchant_entity_id
|
||||
LEFT JOIN order_platforms p ON p.slug = o.platform
|
||||
LEFT JOIN order_spend sp ON sp.entity_id = o.entity_id
|
||||
LEFT JOIN order_merchant_cadence rec ON rec.merchant_entity_id = o.merchant_entity_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT di.content_class, di.counterparty
|
||||
FROM extracted_facts ef
|
||||
JOIN document_interpretations di ON di.source_document_id = ef.source_document_id
|
||||
WHERE ef.fact_type = 'order_event'
|
||||
AND ef.payload->>'_order_entity_key' = e.entity_key
|
||||
ORDER BY (di.counterparty IS NULL), ef.effective_at LIMIT 1
|
||||
) ctx ON true
|
||||
WHERE e.entity_key = $1`,
|
||||
[entityKey]
|
||||
);
|
||||
if (!head) return null;
|
||||
|
||||
const events = await queryRaw<OrderLifecycleEvent>(
|
||||
// Uses the expression index on (payload->>'_order_entity_key') added in 016.
|
||||
`SELECT ef.id::text AS fact_id,
|
||||
ef.payload->>'event_kind' AS event_kind,
|
||||
ef.effective_at, ef.amount, ef.currency,
|
||||
sd.title AS document_title, sd.source_system
|
||||
FROM extracted_facts ef
|
||||
LEFT JOIN source_documents sd ON sd.id = ef.source_document_id
|
||||
WHERE ef.fact_type = 'order_event'
|
||||
AND ef.status = 'active'
|
||||
AND ef.payload->>'_order_entity_key' = $1
|
||||
ORDER BY ef.effective_at NULLS LAST, ef.id`,
|
||||
[entityKey]
|
||||
);
|
||||
|
||||
const siblings = await queryRaw<OrderSibling>(
|
||||
`SELECT e2.entity_key, e2.canonical_name, o2.platform, o2.order_total,
|
||||
o2.currency, o2.ordered_at, 'settled_by'::text AS relation
|
||||
FROM entity_orders o2
|
||||
JOIN entities e2 ON e2.id = o2.entity_id
|
||||
WHERE o2.settles_entity_id = (SELECT id FROM entities WHERE entity_key = $1)
|
||||
UNION ALL
|
||||
SELECT e3.entity_key, e3.canonical_name, o3.platform, o3.order_total,
|
||||
o3.currency, o3.ordered_at, 'settles'::text AS relation
|
||||
FROM entity_orders o1
|
||||
JOIN entity_orders o3 ON o3.entity_id = o1.settles_entity_id
|
||||
JOIN entities e3 ON e3.id = o3.entity_id
|
||||
WHERE o1.entity_id = (SELECT id FROM entities WHERE entity_key = $1)`,
|
||||
[entityKey]
|
||||
);
|
||||
|
||||
const transactions = await queryRaw<OrderLinkedTxn>(
|
||||
`SELECT t.id::int AS transaction_id, t.transaction_date, t.description,
|
||||
t.amount, em.source_message_id
|
||||
FROM expense_metadata em
|
||||
JOIN transactions t
|
||||
ON t.id = COALESCE(em.matched_transaction_id, em.transaction_id)
|
||||
WHERE em.source = 'order-bridge'
|
||||
AND (em.source_message_id = $1 OR em.source_message_id LIKE $1 || '#f%')
|
||||
ORDER BY t.transaction_date`,
|
||||
[entityKey]
|
||||
);
|
||||
|
||||
const raw = head as unknown as Record<string, unknown>;
|
||||
return {
|
||||
...(head as unknown as OrderDetail),
|
||||
line_items:
|
||||
typeof raw.line_items === "string"
|
||||
? JSON.parse(raw.line_items as string)
|
||||
: ((raw.line_items as OrderDetail["line_items"]) ?? []),
|
||||
events,
|
||||
siblings,
|
||||
transactions,
|
||||
};
|
||||
}
|
||||
+82
-14
@@ -5,6 +5,39 @@ export * from "./order-parse";
|
||||
|
||||
export const CUTOVER_DATE = "2026-01-09";
|
||||
|
||||
/**
|
||||
* Owner for ingested orders. Analytics scope on COALESCE(t.owner_id,
|
||||
* s.owner_id); an ingested order has no statement, so leaving owner_id NULL
|
||||
* hides it from every view in the app while it sits in the table.
|
||||
*/
|
||||
export const DEFAULT_OWNER_ID = 1;
|
||||
|
||||
/** Human labels for the platform a receipt came from. */
|
||||
export const PLATFORM_LABEL: Record<ParsedOrder["platform"], string> = {
|
||||
doordash: "DoorDash",
|
||||
ubereats: "Uber Eats",
|
||||
uber: "Uber",
|
||||
};
|
||||
|
||||
/**
|
||||
* Transaction description.
|
||||
*
|
||||
* The merchant alone ("Order - Burger Corner") does not say where to go and
|
||||
* look for the detail, and there are restaurants on both platforms. The
|
||||
* platform is the one thing the parser always knows and used to discard.
|
||||
*/
|
||||
export function orderDescription(_platform: ParsedOrder["platform"], merchant: string): string {
|
||||
// The platform is deliberately NOT in the headline. What identifies the
|
||||
// transaction is the restaurant; which courier delivered it is provenance,
|
||||
// and it already has a home — the expandable Order details panel renders
|
||||
// `expense_metadata.platform` next to its heading (user, 2026-07-28).
|
||||
//
|
||||
// Keeping it here also fragmented the merchant: the same restaurant reads
|
||||
// "Mad Mex (DoorDash)" and "Mad Mex (Uber Eats)" depending on who carried
|
||||
// the bag, which is not a distinction anyone rating the food cares about.
|
||||
return `Order - ${merchant}`;
|
||||
}
|
||||
|
||||
export interface IngestResult {
|
||||
transactionId: number | null;
|
||||
metadataId: number | null;
|
||||
@@ -86,7 +119,13 @@ async function ensureTag(name: string): Promise<number> {
|
||||
*/
|
||||
export async function processOrderIngestion(
|
||||
order: ParsedOrder,
|
||||
options: { messageId?: string; backfillMode?: boolean } = {}
|
||||
options: {
|
||||
messageId?: string;
|
||||
backfillMode?: boolean;
|
||||
ownerId?: number;
|
||||
subject?: string;
|
||||
sender?: string;
|
||||
} = {}
|
||||
): Promise<IngestResult> {
|
||||
const flags = [...order.flags];
|
||||
const day = order.order_datetime.slice(0, 10);
|
||||
@@ -108,7 +147,6 @@ export async function processOrderIngestion(
|
||||
|
||||
// ---- resolve the credits portion ----------------------------------------
|
||||
let creditsAmount: number | null = null;
|
||||
let cardAmount: number | null = order.payment.card_amount;
|
||||
|
||||
if (order.payment.ambiguous) {
|
||||
const { cardAmount: reconciled } = await reconcileCardLeg(order);
|
||||
@@ -122,9 +160,7 @@ export async function processOrderIngestion(
|
||||
// resolves it once the statement lands. Backfill hits the same path and
|
||||
// resolves immediately, because those statements are already imported.
|
||||
flags.push("awaiting_card_statement");
|
||||
cardAmount = null;
|
||||
} else {
|
||||
cardAmount = reconciled;
|
||||
const remainder = Number((order.totals.total_charged - reconciled).toFixed(2));
|
||||
if (remainder > 0.02) {
|
||||
creditsAmount = remainder;
|
||||
@@ -135,9 +171,29 @@ export async function processOrderIngestion(
|
||||
creditsAmount = order.payment.credits_amount;
|
||||
}
|
||||
|
||||
// ---- I1: cutover --------------------------------------------------------
|
||||
// ---- I1: cutover (retired 2026-07-28) -----------------------------------
|
||||
//
|
||||
// This used to return early for any credits order dated before the cutover,
|
||||
// storing NOTHING — no transaction and no metadata, so the receipt was
|
||||
// discarded entirely.
|
||||
//
|
||||
// Its reason was splits, not spend: before 2026-01-09 shared expenses lived
|
||||
// in SplitMyExpenses, and re-importing them would double-charge against
|
||||
// carryover transaction 2348. That reason expired with 788219b, where
|
||||
// ACTIVE_OBLIGATION became `settled = false AND transaction_date >=
|
||||
// '2026-01-09'` — a pre-cutover split can no longer assert a debt, so a
|
||||
// pre-cutover order cannot move a balance however it is recorded.
|
||||
//
|
||||
// What it was left doing was hiding ordinary history: 275 orders, $9,799.96
|
||||
// of meals and rides across 2020-2025, invisible purely because they were
|
||||
// paid from a gift-card balance instead of a card. Ingestion still writes no
|
||||
// splits at all, so nothing here touches what anyone owes.
|
||||
//
|
||||
// The known imprecision is on the funding side and is accepted rather than
|
||||
// guessed at — see migration 0026 for why the ShopBack loads are left as
|
||||
// they are.
|
||||
if (creditsAmount !== null && day < CUTOVER_DATE) {
|
||||
return { transactionId: null, metadataId: null, flags, skipped: "pre_cutover" };
|
||||
flags.push("pre_cutover_credits_order");
|
||||
}
|
||||
|
||||
// ---- I6 / I5 ------------------------------------------------------------
|
||||
@@ -151,11 +207,11 @@ export async function processOrderIngestion(
|
||||
transaction_date, description, amount, amount_aud, category, payment_method,
|
||||
merchant_name, merchant_normalized, transaction_type,
|
||||
foreign_currency_amount, foreign_currency_code, owner_id
|
||||
) VALUES ($1,$2,$3,$4,$5,'credits',$6,$6,'debit',$7,$8,NULL)
|
||||
) VALUES ($1,$2,$3,$4,$5,'credits',$6,$6,'debit',$7,$8,$9)
|
||||
RETURNING id`,
|
||||
[
|
||||
day,
|
||||
`Order - ${order.merchant_name}`,
|
||||
orderDescription(order.platform, order.merchant_name),
|
||||
creditsAmount,
|
||||
// No FX rate is available at ingest, so amount_aud is left NULL for
|
||||
// foreign orders rather than asserting a conversion we cannot make.
|
||||
@@ -164,6 +220,11 @@ export async function processOrderIngestion(
|
||||
order.merchant_name,
|
||||
isAud ? null : creditsAmount,
|
||||
isAud ? null : order.currency,
|
||||
// Owner scoping is COALESCE(t.owner_id, s.owner_id). These rows carry
|
||||
// no statement, so a NULL owner_id makes them invisible in every view
|
||||
// in the app — present in the table, absent from the UI. The backfill
|
||||
// inserted 85 rows nobody could see.
|
||||
options.ownerId ?? DEFAULT_OWNER_ID,
|
||||
]
|
||||
);
|
||||
transactionId = txn!.id;
|
||||
@@ -185,8 +246,9 @@ export async function processOrderIngestion(
|
||||
`INSERT INTO expense_metadata (
|
||||
transaction_id, source, source_message_id, order_reference, line_items,
|
||||
subtotal, amount, merchant_normalized, transaction_date,
|
||||
card_last4, currency, flags, reconciled_at
|
||||
) VALUES ($1,'email',$2,$3,$4::jsonb,$5,$6,$7,$8,$9,$10,$11::jsonb,$12)
|
||||
card_last4, currency, flags, reconciled_at,
|
||||
platform, source_email_subject, source_email_from, route
|
||||
) VALUES ($1,'email',$2,$3,$4::jsonb,$5,$6,$7,$8,$9,$10,$11::jsonb,$12,$13,$14,$15,$16::jsonb)
|
||||
RETURNING id`,
|
||||
[
|
||||
transactionId,
|
||||
@@ -201,6 +263,10 @@ export async function processOrderIngestion(
|
||||
order.currency,
|
||||
JSON.stringify(flags),
|
||||
pending ? null : new Date().toISOString(),
|
||||
order.platform,
|
||||
options.subject ?? null,
|
||||
options.sender ?? null,
|
||||
JSON.stringify(order.route ?? []),
|
||||
]
|
||||
);
|
||||
|
||||
@@ -230,9 +296,10 @@ export async function reconcilePendingOrders(): Promise<{
|
||||
merchant_normalized: string;
|
||||
card_last4: string | null;
|
||||
currency: string | null;
|
||||
platform: ParsedOrder["platform"] | null;
|
||||
}>(
|
||||
`SELECT id, order_reference, amount::text, transaction_date::text,
|
||||
merchant_normalized, card_last4, currency
|
||||
merchant_normalized, card_last4, currency, platform
|
||||
FROM expense_metadata
|
||||
WHERE transaction_id IS NULL
|
||||
AND reconciled_at IS NULL
|
||||
@@ -246,7 +313,7 @@ export async function reconcilePendingOrders(): Promise<{
|
||||
const total = Number(row.amount);
|
||||
const probe: ParsedOrder = {
|
||||
order_reference: row.order_reference,
|
||||
platform: "doordash",
|
||||
platform: row.platform ?? "doordash",
|
||||
merchant_name: row.merchant_normalized,
|
||||
order_datetime: `${row.transaction_date}T00:00:00Z`,
|
||||
currency: row.currency || "AUD",
|
||||
@@ -256,6 +323,7 @@ export async function reconcilePendingOrders(): Promise<{
|
||||
service_fee: null, tip: null, discounts: null, total_charged: total,
|
||||
},
|
||||
line_items: [],
|
||||
route: [],
|
||||
is_family: false,
|
||||
flags: [],
|
||||
};
|
||||
@@ -280,9 +348,9 @@ export async function reconcilePendingOrders(): Promise<{
|
||||
`INSERT INTO transactions (
|
||||
transaction_date, description, amount, amount_aud, category,
|
||||
payment_method, merchant_name, merchant_normalized, transaction_type, owner_id
|
||||
) VALUES ($1,$2,$3,$3,$5,'credits',$4,$4,'debit',NULL)
|
||||
) VALUES ($1,$2,$3,$3,$5,'credits',$4,$4,'debit',$6)
|
||||
RETURNING id`,
|
||||
[row.transaction_date, `Order - ${row.merchant_normalized}`, remainder, row.merchant_normalized, category]
|
||||
[row.transaction_date, orderDescription(row.platform ?? "doordash", row.merchant_normalized), remainder, row.merchant_normalized, category, DEFAULT_OWNER_ID]
|
||||
);
|
||||
txnId = txn!.id;
|
||||
created++;
|
||||
|
||||
+310
-39
@@ -20,6 +20,18 @@ export interface LineItem {
|
||||
options?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A stop on the receipt's map: pick-up, delivery, or (for a trip) the ride's
|
||||
* start and end. Uber prints these for every order under `Order details`.
|
||||
*/
|
||||
export interface RoutePoint {
|
||||
/** "Pick-up" / "Delivery" — whatever the receipt itself calls it. */
|
||||
label: string;
|
||||
/** Local time as printed, e.g. "1:20 pm". No date; the receipt gives none. */
|
||||
time: string | null;
|
||||
address: string;
|
||||
}
|
||||
|
||||
export interface PaymentBreakdown {
|
||||
credits_amount: number | null;
|
||||
card_amount: number | null;
|
||||
@@ -47,6 +59,8 @@ export interface ParsedOrder {
|
||||
payment: PaymentBreakdown;
|
||||
totals: OrderTotals;
|
||||
line_items: LineItem[];
|
||||
/** Uber only. Empty for DoorDash, whose receipts carry no addresses. */
|
||||
route: RoutePoint[];
|
||||
is_family: boolean;
|
||||
flags: string[];
|
||||
}
|
||||
@@ -87,26 +101,38 @@ export class OrderParseError extends Error {
|
||||
|
||||
const stripTags = (s: string) => s.replace(/<[^>]+>/g, " ");
|
||||
|
||||
/**
|
||||
* Entity decoding, ordered so `&` resolves last.
|
||||
*
|
||||
* `•` was missing, and it is not a cosmetic omission: DoorDash separates
|
||||
* an item's name from its options with a bullet, and parseDoorDashLineItems
|
||||
* splits on the literal "•". Left encoded, the split never happens and the
|
||||
* whole line collapses into the description — "Fire Extinguisher (Chicken
|
||||
* Burgers) • Regular" instead of a name plus one option. So the entity
|
||||
* showed up on screen AND the structure behind it was lost.
|
||||
*
|
||||
* Numeric entities are decoded generically rather than one at a time, which is
|
||||
* how $ came to be listed individually while its neighbours were not.
|
||||
*
|
||||
* `&` goes last because decoding it first turns a literal "&bull;" —
|
||||
* text that should stay as written — into a bullet.
|
||||
*/
|
||||
const decodeEntities = (s: string) =>
|
||||
s
|
||||
.replace(/ /gi, " ")
|
||||
.replace(/&/gi, "&")
|
||||
.replace(/'|'/gi, "'")
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/$/g, "$")
|
||||
.replace(/…/gi, "…");
|
||||
.replace(/…/gi, "…")
|
||||
.replace(/•/gi, "•")
|
||||
.replace(/·/gi, "·")
|
||||
.replace(/–/gi, "–")
|
||||
.replace(/—/gi, "—")
|
||||
.replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCodePoint(parseInt(h, 16)))
|
||||
.replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(parseInt(d, 10)))
|
||||
.replace(/&/gi, "&");
|
||||
|
||||
const collapse = (s: string) => s.replace(/\s+/g, " ").trim();
|
||||
|
||||
/** URL-decodes without throwing on malformed percent-escapes. */
|
||||
function safeDecode(s: string): string {
|
||||
try {
|
||||
return decodeURIComponent(s.replace(/%(?![0-9a-f]{2})/gi, "%25"));
|
||||
} catch {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
const money = (raw: string): number => Math.abs(parseFloat(raw.replace(/[$,]/g, "")));
|
||||
|
||||
/**
|
||||
@@ -125,6 +151,103 @@ function tdPairValue(html: string, label: string): number | null {
|
||||
return m ? money(m[1]) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uber itemises only *grocery* orders — a restaurant receipt states a total and
|
||||
* nothing else, which is why 67 of the 101 backfilled orders have no items.
|
||||
* When it does itemise, the markup is far better than DoorDash's: every cell
|
||||
* carries a `data-testid` naming its role and the item's own uuid, so quantity,
|
||||
* title and amount can be bound to each other by id rather than by position.
|
||||
*/
|
||||
function parseUberLineItems(html: string): LineItem[] {
|
||||
const items: LineItem[] = [];
|
||||
const titleRe =
|
||||
/data-testid="shoppingCart_item_title_([0-9a-f-]+)"[^>]*>([\s\S]*?)<\/td>/gi;
|
||||
|
||||
for (const m of html.matchAll(titleRe)) {
|
||||
const [, id, rawTitle] = m;
|
||||
const description = collapse(decodeEntities(stripTags(rawTitle)));
|
||||
if (!description) continue;
|
||||
|
||||
const qtyM = html.match(
|
||||
new RegExp(`data-testid="shoppingCart_item_quantity_${id}"[^>]*>\\s*(\\d+)\\s*<`, "i")
|
||||
);
|
||||
const amtM = html.match(
|
||||
new RegExp(
|
||||
`data-testid="shoppingCart_item_amount_${id}"[^>]*>([\\s\\S]*?)<\\/td>`,
|
||||
"i"
|
||||
)
|
||||
);
|
||||
const amtText = amtM ? collapse(decodeEntities(stripTags(amtM[1]))) : "";
|
||||
const amtNum = amtText.match(/(-?[\d,]+\.\d{2})/);
|
||||
|
||||
items.push({
|
||||
qty: qtyM ? parseInt(qtyM[1], 10) : 1,
|
||||
description,
|
||||
// A sold-out item prints 0.00 and is genuinely part of the order — it
|
||||
// explains a total that does not match what was asked for. Keep it.
|
||||
amount: amtNum ? money(amtNum[1]) : 0,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Uber's `Order details` block, anchored on `data-testid="address_point_N_*"`.
|
||||
*
|
||||
* The template repeats the whole block twice (once hidden for narrow screens),
|
||||
* so the same stop appears more than once and has to be de-duplicated. This is
|
||||
* the same markup a *trip* receipt uses for its start and destination — rides
|
||||
* are not ingested today, but the reader will not need changing when they are.
|
||||
*/
|
||||
function parseUberRoute(html: string): RoutePoint[] {
|
||||
const seen = new Set<string>();
|
||||
const points: RoutePoint[] = [];
|
||||
|
||||
const labelRe = /data-testid="address_point_(\d+)_time"[^>]*>([\s\S]*?)<\/td>/gi;
|
||||
for (const m of html.matchAll(labelRe)) {
|
||||
const [, idx, rawLabel] = m;
|
||||
const addrM = html.match(
|
||||
new RegExp(`data-testid="address_point_${idx}_address"[^>]*>([\\s\\S]*?)<\\/td>`, "i")
|
||||
);
|
||||
if (!addrM) continue;
|
||||
|
||||
const address = collapse(decodeEntities(stripTags(addrM[1])));
|
||||
// Delivery receipts share one cell between time and label — "1:20 pm -
|
||||
// Pick-up". Trip receipts print the time alone, with no label at all, so
|
||||
// the naive split put the time in `label` and left `time` null. Position
|
||||
// carries the meaning there: first stop is where the ride began.
|
||||
const combined = collapse(decodeEntities(stripTags(rawLabel)));
|
||||
const split = combined.match(/^(.*?)\s+-\s+(.*)$/);
|
||||
let time: string | null;
|
||||
let label: string;
|
||||
if (split) {
|
||||
time = split[1];
|
||||
label = split[2];
|
||||
} else if (/^\d{1,2}:\d{2}\s*(am|pm)?$/i.test(combined)) {
|
||||
time = combined;
|
||||
label = ""; // filled in positionally below — the receipt gives none
|
||||
} else {
|
||||
time = null;
|
||||
label = combined;
|
||||
}
|
||||
|
||||
const key = `${label}|${time}|${address}`;
|
||||
if (!address || seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
points.push({ label, time, address });
|
||||
}
|
||||
|
||||
// A trip receipt labels neither end. Position is the only thing that says
|
||||
// which is which, and for a two-stop trip it says it unambiguously. Only
|
||||
// filled where the receipt itself was silent, so a future template that does
|
||||
// label its stops keeps its own wording.
|
||||
if (points.length === 2 && points.every((p) => !p.label)) {
|
||||
points[0].label = "Pick-up";
|
||||
points[1].label = "Drop-off";
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
function parseDoorDashLineItems(html: string): LineItem[] {
|
||||
// <td width="10%">1x</td><td width="75%"><b>Name</b> (Cat)<br><font>• Opt</font>…</td><td width="15%">$22.10</td>
|
||||
const re =
|
||||
@@ -180,8 +303,35 @@ function parseMerchant(platform: string, meta: MessageMeta, text: string): strin
|
||||
throw new OrderParseError(`cannot determine merchant`, meta.messageId);
|
||||
}
|
||||
|
||||
function parsePayment(platform: string, html: string, text: string): PaymentBreakdown {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
/**
|
||||
* Currency notations Uber actually uses in receipt totals.
|
||||
*
|
||||
* Deliberately narrow: only prefixes that are unambiguous. "R$" (BRL) and "$"
|
||||
* alone are excluded — a bare dollar sign is used by a dozen currencies and
|
||||
* resolving it here would overrule the body-wide scan that reads the receipt's
|
||||
* own stated code.
|
||||
*/
|
||||
const SYMBOL_PREFIX_CURRENCY: Record<string, string> = {
|
||||
A: "AUD",
|
||||
NZ: "NZD",
|
||||
US: "USD",
|
||||
S: "SGD",
|
||||
HK: "HKD",
|
||||
C: "CAD",
|
||||
};
|
||||
|
||||
const SYMBOL_CURRENCY: Record<string, string> = {
|
||||
"₹": "INR",
|
||||
"€": "EUR",
|
||||
"£": "GBP",
|
||||
};
|
||||
|
||||
function parsePayment(
|
||||
platform: string,
|
||||
html: string,
|
||||
text: string,
|
||||
statedTotal: number | null = null
|
||||
): PaymentBreakdown {
|
||||
const out: PaymentBreakdown = {
|
||||
credits_amount: null,
|
||||
card_amount: null,
|
||||
@@ -224,16 +374,69 @@ function parsePayment(platform: string, html: string, text: string): PaymentBrea
|
||||
" "
|
||||
);
|
||||
|
||||
const cash = text.match(/Uber Cash\s*(?:[A-Z]{3})?\s*\$?([\d,]+\.\d{2})/i);
|
||||
if (cash) out.credits_amount = money(cash[1]);
|
||||
const card = text.match(
|
||||
/(?:Visa|MasterCard|American Express|Amex)[^\d]*(\d{4})[^\d]*(?:[A-Z]{3})?\s*\$?([\d,]+\.\d{2})/i
|
||||
// "Payments Uber Cash $25.33" — but newer receipts put a timestamp between
|
||||
// the label and the amount, and write the currency as a prefix:
|
||||
// "Payments Uber Cash 10/17/25 8:50 PM A$54.87"
|
||||
// Both defeated the old pattern, and the failure was silent and expensive:
|
||||
// Uber Cash IS credits, so an unreadable payment line left credits_amount
|
||||
// null and the order was filed as card-settled. It then went looking for a
|
||||
// card leg that does not exist, found nothing, and became an orphan with no
|
||||
// transaction and no card to match on. 118 of the captured messages sit in
|
||||
// that state. The date is allowed for explicitly rather than by widening the
|
||||
// gap, so a distant unrelated amount still cannot be captured.
|
||||
const cash = text.match(
|
||||
/Uber Cash\s*(?:\d{1,2}\/\d{1,2}\/\d{2,4}\s*)?(?:\d{1,2}:\d{2}\s*(?:AM|PM)?\s*)?(?:([A-Z]{3})\s*)?(?:[A-Z]{1,2})?[$₹€£]?\s*([\d,]+\.\d{2})/i
|
||||
);
|
||||
if (card) {
|
||||
out.card_last4 = card[1];
|
||||
out.card_amount = money(card[2]);
|
||||
if (cash) out.credits_amount = money(cash[2]);
|
||||
// Anchor on the masking, not on a list of card brands. Uber labels the card
|
||||
// leg with whatever the issuer is called — "Westpac ••••8032 $15.33",
|
||||
// "Mastercard ••••3893 (CBA Ultimate) CHF 51.23" — so a brand allowlist
|
||||
// silently drops the card half of a mixed payment. Found in the backfill
|
||||
// dry-run: Uber Cash $1.17 + Westpac ••••8032 $15.33 against a $16.50 total,
|
||||
// which validateOrderTotals correctly refused rather than under-recording.
|
||||
// The gap was `[^\d]{0,40}` — no digits — which the newer layout breaks by
|
||||
// printing a timestamp there: "Westpac ••••8032 2/14/25 1:59 PM A$8.68".
|
||||
// That silently dropped the card half of every mixed payment in the new
|
||||
// format, and stayed invisible only while Uber Cash was also unreadable:
|
||||
// both legs missing meant the order looked card-settled and the whole total
|
||||
// was booked to a card. Reading credits without fixing this reads half an
|
||||
// order, and validateOrderTotals rightly refuses it.
|
||||
//
|
||||
// Every leg is summed rather than just the first. One order can be charged
|
||||
// in several instalments to the SAME card — a Dubai trip billed as
|
||||
// "Citi Prestige ••••0253 7/2/25 AED 17.67" and again the next day, totalling
|
||||
// 577.83 — and an instrument can carry no mask at all ("PayPal - <email>").
|
||||
// Taking one match under-reads both, and the order is then refused for a
|
||||
// shortfall the receipt does not actually have.
|
||||
const legs = [
|
||||
...text.matchAll(
|
||||
/(?:(?:••••|\*{4}|\u2022{4})\s*(\d{4})|PayPal)[^$₹€£]{0,60}?(?:([A-Z]{3})\s+|(?:[A-Z]{1,2})?[$₹€£]\s?)([\d,]+\.\d{2})/g
|
||||
),
|
||||
];
|
||||
//
|
||||
// Summing is right for instalments but wrong for a re-auth. A Dubai trip
|
||||
// prints "Citi Prestige ••••0253 AED 17.67" (the authorisation) and then
|
||||
// "Citi Prestige ••••0253 AED 577.83" (the settled charge) against a stated
|
||||
// total of 577.83 — the first leg is superseded, not additive, and adding it
|
||||
// overstates the trip by the held amount. So a leg that already equals the
|
||||
// stated total IS the payment; only when none does are the legs instalments
|
||||
// that must be added. A mixed credits+card order is unaffected: neither leg
|
||||
// equals the total there, which is exactly why it needs summing.
|
||||
const exact = legs.find(
|
||||
(l) => statedTotal !== null && Math.abs(money(l[3]) - statedTotal) < 0.02
|
||||
);
|
||||
if (exact && out.credits_amount === null) {
|
||||
out.card_last4 = exact[1] ?? null;
|
||||
out.card_amount = money(exact[3]);
|
||||
} else {
|
||||
let cardTotal = 0;
|
||||
for (const leg of legs) {
|
||||
cardTotal += money(leg[3]);
|
||||
if (leg[1] && !out.card_last4) out.card_last4 = leg[1];
|
||||
}
|
||||
if (!cash && !card && /Payments\s+\S+\s+(?:[A-Z]{3}\s|\$)/.test(text)) {
|
||||
if (legs.length > 0) out.card_amount = Number(cardTotal.toFixed(2));
|
||||
}
|
||||
if (!cash && legs.length === 0 && /Payments\s+\S+\s+(?:[A-Z]{3}\s|\$)/.test(text)) {
|
||||
out.ambiguous = true;
|
||||
}
|
||||
return out;
|
||||
@@ -272,6 +475,20 @@ export function parseOrderHTML(html: string, meta: MessageMeta): ParsedOrder {
|
||||
);
|
||||
}
|
||||
|
||||
// Uber sends TWO mails per trip with the same subject and the same total: a
|
||||
// "charge summary" when the trip ends, then the real receipt once payment
|
||||
// settles. The summary says so itself — "This is not a payment receipt ...
|
||||
// You will receive a trip receipt when the payment is processed with payment
|
||||
// information" — and it carries no tripReference, so order_reference would
|
||||
// fall back to `msg:<message-id>` and I7 could not dedupe it against the
|
||||
// receipt that follows. Every trip would be recorded twice.
|
||||
if (/This is not a payment receipt|This is your charge summary/i.test(text)) {
|
||||
throw new NotAReceiptError(
|
||||
"charge summary, not a payment receipt — the real receipt follows",
|
||||
meta.messageId
|
||||
);
|
||||
}
|
||||
|
||||
const platform = detectPlatform(meta, text);
|
||||
const merchant_name = parseMerchant(platform, meta, text);
|
||||
|
||||
@@ -346,8 +563,9 @@ export function parseOrderHTML(html: string, meta: MessageMeta): ParsedOrder {
|
||||
if (total === null) {
|
||||
throw new OrderParseError("no total stated anywhere in receipt", meta.messageId);
|
||||
}
|
||||
const subtotal = tdPairValue(clean, "Subtotal");
|
||||
totals = {
|
||||
subtotal: tdPairValue(clean, "Subtotal"),
|
||||
subtotal,
|
||||
taxes: tdPairValue(clean, "Taxes"),
|
||||
delivery_fee: tdPairValue(clean, "Delivery Fee"),
|
||||
service_fee: tdPairValue(clean, "Service Fee"),
|
||||
@@ -355,13 +573,43 @@ export function parseOrderHTML(html: string, meta: MessageMeta): ParsedOrder {
|
||||
discounts: tdPairValue(clean, "Discounts"),
|
||||
total_charged: total,
|
||||
};
|
||||
|
||||
// An order paid entirely from DoorDash credits states "Total Charged
|
||||
// $0.00" — truthfully, because nothing was charged to a card — while the
|
||||
// items above it add up to a real amount. Read literally that is a $0
|
||||
// order, and `validateOrderTotals` rejected 88 of them as "non-positive
|
||||
// total 0", which is the single largest cause of parse failures in the
|
||||
// captured mail and discards exactly the credit-funded spend this pipeline
|
||||
// exists to make visible.
|
||||
//
|
||||
// The order's value is its subtotal. Recording that keeps a credits meal
|
||||
// countable in budgets; recording zero would show the order and hide what
|
||||
// it cost. Guarded on the receipt actually saying credits, so a genuinely
|
||||
// zero-value mail still fails rather than inheriting a stray subtotal.
|
||||
if (total === 0 && subtotal !== null && subtotal > 0 && /Paid with[\s\S]{0,60}?credits/i.test(text)) {
|
||||
totals.total_charged = subtotal;
|
||||
flags.push("credits_funded_zero_charge");
|
||||
}
|
||||
} else {
|
||||
// Uber Eats states a Total, optionally in a foreign currency:
|
||||
// "Total $25.33" | "Total LKR 3,783.20"
|
||||
// Uber Eats states a Total, optionally in a foreign currency. Uber writes
|
||||
// the currency in three different notations and all three occur in real
|
||||
// mail:
|
||||
// "Total $25.33" bare — the home currency
|
||||
// "Total LKR 3,783.20" ISO code, space-separated
|
||||
// "Total A$54.87" symbol-prefixed: A$, NZ$, US$, S$, HK$, C$
|
||||
// "Total ₹1,240.00" a bare symbol
|
||||
// Only the first two were handled. The prefixed form is not exotic: it is
|
||||
// what Uber sends for ordinary Australian orders, so 176 of 550 captured
|
||||
// messages — most of them 2024-2025, i.e. current mail rather than legacy
|
||||
// templates — failed with "no Total found" while the amount sat in plain
|
||||
// sight in the body. `A$` misses `[A-Z]{3}` by a character.
|
||||
//
|
||||
// The [Family] orders are placed for family in Sri Lanka and are priced in
|
||||
// LKR — reading those as dollars would inflate them ~200x, which is a large
|
||||
// part of why they must not reach a budget untagged.
|
||||
const m = text.match(/(?:New Total|Total)\s*(?:([A-Z]{3})\s*)?\$?\s*([\d,]+\.\d{2})/);
|
||||
const m = text.match(
|
||||
/(?:New Total|Total)\s*(?:([A-Z]{3})\s*)?(?:([A-Z]{1,2})?\$|([₹€£]))?\s*([\d,]+\.\d{2})/
|
||||
);
|
||||
if (!m) throw new OrderParseError("no Total found", meta.messageId);
|
||||
totals = {
|
||||
subtotal: extractLabelled(text, "Item subtotal"),
|
||||
@@ -370,23 +618,32 @@ export function parseOrderHTML(html: string, meta: MessageMeta): ParsedOrder {
|
||||
service_fee: extractLabelled(text, "Service Fee"),
|
||||
tip: null,
|
||||
discounts: null,
|
||||
total_charged: money(m[2]),
|
||||
total_charged: money(m[4]),
|
||||
};
|
||||
if (m[1]) explicitCurrency = m[1].toUpperCase().replace(/\$$/, "");
|
||||
// A symbol is only evidence of currency when it is qualified. A bare "$"
|
||||
// stays unset so the body-wide scan below still gets its say — the receipt
|
||||
// often names the currency elsewhere, and guessing AUD here would overrule
|
||||
// it.
|
||||
explicitCurrency =
|
||||
(m[1] && m[1].toUpperCase()) ||
|
||||
(m[2] && SYMBOL_PREFIX_CURRENCY[m[2].toUpperCase()]) ||
|
||||
(m[3] && SYMBOL_CURRENCY[m[3]]) ||
|
||||
explicitCurrency;
|
||||
}
|
||||
|
||||
// ---- payment -------------------------------------------------------------
|
||||
const payment = parsePayment(platform, clean, text);
|
||||
const payment = parsePayment(platform, clean, text, totals.total_charged);
|
||||
if (payment.ambiguous && is_family) {
|
||||
// [Family] receipts name the payer, not an instrument ("Payments Siddharth
|
||||
// LKR 3,783.20"), so no split is recoverable and there is no card leg to
|
||||
// reconcile against — parking them would mean never importing them, which
|
||||
// fails the actual requirement (import, tag, exclude from budgets).
|
||||
// Treated as credits so the order is recorded and tagged. Safe because the
|
||||
// family tag removes it from every budget regardless of instrument.
|
||||
payment.ambiguous = false;
|
||||
payment.credits_amount = totals.total_charged;
|
||||
flags.push("family_payment_assumed_credits");
|
||||
// LKR 3,783.20"). An earlier version read that as credits-funded. It is
|
||||
// not: the card statement carries all four of them (CBA ...3893, exact
|
||||
// foreign_currency_amount matches), so creating a transaction duplicated
|
||||
// spend that was already recorded — precisely the double-count I5 exists to
|
||||
// prevent.
|
||||
//
|
||||
// Treated as card-settled: provenance only, no transaction. The statement
|
||||
// line IS the transaction, and it is what should carry the `family` tag.
|
||||
flags.push("family_card_settled_no_transaction");
|
||||
} else if (payment.ambiguous) {
|
||||
// Split not stated and resolvable from the card statement — left for the
|
||||
// ingestion runner to reconcile, not guessed here.
|
||||
@@ -400,11 +657,15 @@ export function parseOrderHTML(html: string, meta: MessageMeta): ParsedOrder {
|
||||
// ---- line items ----------------------------------------------------------
|
||||
// Uber Eats receipts carry no itemisation (verified across 29 real mails).
|
||||
const line_items =
|
||||
platform === "doordash" ? parseDoorDashLineItems(clean) : [];
|
||||
platform === "doordash" ? parseDoorDashLineItems(clean) : parseUberLineItems(clean);
|
||||
if (platform === "doordash" && line_items.length === 0) {
|
||||
flags.push("no_line_items_parsed");
|
||||
}
|
||||
|
||||
// Uber prints addresses on every receipt; DoorDash prints none at all, so an
|
||||
// empty route there is expected rather than a parse failure.
|
||||
const route = platform === "doordash" ? [] : parseUberRoute(clean);
|
||||
|
||||
const currency =
|
||||
explicitCurrency ||
|
||||
(/\b(NZD|USD|LKR|CHF|EUR|GBP|SGD|INR)\b/.test(text)
|
||||
@@ -420,6 +681,7 @@ export function parseOrderHTML(html: string, meta: MessageMeta): ParsedOrder {
|
||||
payment,
|
||||
totals,
|
||||
line_items,
|
||||
route,
|
||||
is_family,
|
||||
flags,
|
||||
};
|
||||
@@ -457,7 +719,16 @@ export function validateOrderTotals(
|
||||
}
|
||||
|
||||
// Cross-check the header total against the table total where both exist.
|
||||
if (html && order.platform === "doordash") {
|
||||
//
|
||||
// Skipped for a credits-funded order: both stated totals are $0.00 there and
|
||||
// agree with each other, but the recorded amount is deliberately the
|
||||
// subtotal, so the check would reject every one of them for "disagreeing"
|
||||
// with a figure the parser overrode on purpose.
|
||||
if (
|
||||
html &&
|
||||
order.platform === "doordash" &&
|
||||
!order.flags.includes("credits_funded_zero_charge")
|
||||
) {
|
||||
const header = collapse(decodeEntities(stripTags(html))).match(
|
||||
/Total:\s*\$?([\d,]+\.\d{2})/i
|
||||
);
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import { queryRaw, queryRow } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* Verdicts on delivery orders — the "don't order from here again" memory.
|
||||
*
|
||||
* The problem this exists for is not accounting. Orders were placed twice from
|
||||
* places we disliked because nobody remembered by the time the next order went
|
||||
* in (user, 2026-07-28). The ledger already knew we had been there; it just had
|
||||
* nowhere to record what we thought of it.
|
||||
*
|
||||
* **A verdict is recorded per order per person, but read per merchant.**
|
||||
* `order_reviews` keys on `(transaction_id, participant_id)`, because what you
|
||||
* are judging is one delivery — this Thai place was bad *that night*, with
|
||||
* those items — and because a shared meal produces two opinions that routinely
|
||||
* disagree. That disagreement is the useful part; one row per transaction
|
||||
* cannot hold it.
|
||||
*
|
||||
* The signal you need later is about the merchant, so it is derived by
|
||||
* aggregating a merchant's orders rather than stored on one. Storing it per
|
||||
* merchant instead would mean the second verdict silently overwrites the first
|
||||
* and you lose the fact that it was fine twice and awful once.
|
||||
*
|
||||
* The join key is `expense_metadata.merchant_normalized`, not
|
||||
* `transactions.merchant_name`: the latter is a bank descriptor and reads
|
||||
* differently for the same restaurant on different nights.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Five levels. Three collapsed the distinction that decides a re-order —
|
||||
* "loved" and "liked" are both "would order again" but only one is worth going
|
||||
* out of your way for, and "ok" is not a recommendation at all. "bad" was added
|
||||
* because the jump from "ok" to "never again" is too big and most
|
||||
* disappointments live in the gap (user, 2026-07-28).
|
||||
*
|
||||
* Only "never" raises the warning on a future order. A blacklist that fires for
|
||||
* every mediocre meal stops being read, so "bad" records the disappointment
|
||||
* without triggering the alarm.
|
||||
*/
|
||||
export type Rating = "loved" | "liked" | "ok" | "bad" | "never";
|
||||
|
||||
export const RATINGS: Rating[] = ["loved", "liked", "ok", "bad", "never"];
|
||||
|
||||
/**
|
||||
* Per-item opinions, keyed by the line item's description.
|
||||
*
|
||||
* Only the poles are offered. A per-item "ok" is noise: the useful question at
|
||||
* the next order is "what should I get / what should I avoid here", and a
|
||||
* middling dish answers neither.
|
||||
*/
|
||||
export type ItemVerdict = "loved" | "never";
|
||||
|
||||
export const ITEM_VERDICTS: ItemVerdict[] = ["loved", "never"];
|
||||
|
||||
export interface ItemOpinion {
|
||||
item: string;
|
||||
verdict: ItemVerdict;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whose verdict this is by default, and who the other one is.
|
||||
*
|
||||
* A two-person household with one primary user: the owner records almost every
|
||||
* verdict, and the only other consumer is Sonu (user, 2026-07-28). Named rather
|
||||
* than inlined so the Slack nudge, the split it creates and the second verdict
|
||||
* it asks for cannot drift apart.
|
||||
*/
|
||||
export const OWNER_PARTICIPANT_ID = 1;
|
||||
export const SECOND_CONSUMER_ID = 4;
|
||||
|
||||
export interface OrderReview {
|
||||
transaction_id: number;
|
||||
participant_id: number;
|
||||
participant_name?: string;
|
||||
rating: Rating | null;
|
||||
order_again: boolean | null;
|
||||
note: string | null;
|
||||
item_verdicts: ItemOpinion[];
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface MerchantVerdict {
|
||||
merchant: string;
|
||||
/** Verdicts on OTHER orders from this merchant, newest first. */
|
||||
history: {
|
||||
transaction_id: number;
|
||||
participant_id: number;
|
||||
participant_name: string;
|
||||
rating: Rating | null;
|
||||
note: string | null;
|
||||
transaction_date: string | null;
|
||||
}[];
|
||||
counts: Record<Rating, number>;
|
||||
/**
|
||||
* True when this merchant has ever been marked `never` — and ONLY `never`.
|
||||
* "bad" is deliberately excluded: the warning is a blacklist, and one that
|
||||
* fires for every mediocre meal is one nobody reads.
|
||||
*/
|
||||
warn: boolean;
|
||||
/**
|
||||
* What to get and what to avoid here, pooled across every order from this
|
||||
* merchant. This is the payoff for recording items at all — the order-level
|
||||
* rating tells you whether to come back, this tells you what to order when
|
||||
* you do.
|
||||
*/
|
||||
items: { item: string; loved: number; never: number }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* `merchant_normalized` for a transaction, resolving both directions.
|
||||
*
|
||||
* A card-settled order has no transaction of its own — the statement line is
|
||||
* the transaction and the receipt points at it through
|
||||
* `matched_transaction_id`. Looking only at `transaction_id` misses exactly the
|
||||
* orders that were paid by card, which is most of them.
|
||||
*/
|
||||
export async function merchantForTransaction(
|
||||
transactionId: number
|
||||
): Promise<string | null> {
|
||||
const row = await queryRow<{ merchant_normalized: string | null }>(
|
||||
`SELECT merchant_normalized FROM expense_metadata
|
||||
WHERE transaction_id = $1 OR matched_transaction_id = $1
|
||||
LIMIT 1`,
|
||||
[transactionId]
|
||||
);
|
||||
return row?.merchant_normalized ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What we have previously said about a merchant.
|
||||
*
|
||||
* `exclude` drops the order being looked at, so the panel shows "what you said
|
||||
* the other times" rather than echoing the verdict you are currently editing.
|
||||
* Pass null when there is no current order — the ingest path, where the whole
|
||||
* point is that nothing has been said about this one yet.
|
||||
*/
|
||||
export async function merchantVerdict(
|
||||
merchant: string | null,
|
||||
exclude: number | null = null
|
||||
): Promise<MerchantVerdict | null> {
|
||||
if (!merchant) return null;
|
||||
|
||||
const rows = await queryRaw<{
|
||||
transaction_id: number;
|
||||
participant_id: number;
|
||||
participant_name: string;
|
||||
rating: Rating | null;
|
||||
note: string | null;
|
||||
transaction_date: string | null;
|
||||
item_verdicts: ItemOpinion[] | null;
|
||||
}>(
|
||||
// `rating IS NOT NULL` is deliberately NOT in the WHERE clause: a review
|
||||
// can carry item verdicts and no overall rating, and dropping those would
|
||||
// lose exactly the "the noodles here are great" signal this exists for.
|
||||
`SELECT r.transaction_id, r.participant_id, p.name AS participant_name,
|
||||
r.rating, r.note, r.item_verdicts,
|
||||
to_char(t.transaction_date, 'YYYY-MM-DD') AS transaction_date
|
||||
FROM order_reviews r
|
||||
JOIN transactions t ON t.id = r.transaction_id
|
||||
JOIN participants p ON p.id = r.participant_id
|
||||
JOIN expense_metadata em
|
||||
ON em.transaction_id = r.transaction_id
|
||||
OR em.matched_transaction_id = r.transaction_id
|
||||
-- Case-folded: the platforms capitalise the same restaurant differently
|
||||
-- ("TEG Kebabs & Biryani" on Uber Eats, "TEG KEBABS & BIRYANI" on
|
||||
-- DoorDash). An exact match split one restaurant's history in two, so a
|
||||
-- "never again" recorded through one app never warned you in the other —
|
||||
-- silently defeating the whole point of the memory.
|
||||
WHERE lower(em.merchant_normalized) = lower($1)
|
||||
AND ($2::int IS NULL OR r.transaction_id <> $2)
|
||||
ORDER BY t.transaction_date DESC, r.participant_id
|
||||
LIMIT 50`,
|
||||
[merchant, exclude]
|
||||
);
|
||||
|
||||
const counts: Record<Rating, number> = { loved: 0, liked: 0, ok: 0, bad: 0, never: 0 };
|
||||
for (const r of rows) if (r.rating) counts[r.rating] += 1;
|
||||
|
||||
// Pool item opinions across orders. Case-folded because the same dish comes
|
||||
// back with inconsistent capitalisation between receipts; the first spelling
|
||||
// seen is kept for display.
|
||||
const pool = new Map<string, { item: string; loved: number; never: number }>();
|
||||
for (const r of rows) {
|
||||
for (const v of r.item_verdicts ?? []) {
|
||||
if (!v?.item) continue;
|
||||
const key = v.item.trim().toLowerCase();
|
||||
const entry = pool.get(key) ?? { item: v.item.trim(), loved: 0, never: 0 };
|
||||
if (v.verdict === "loved") entry.loved += 1;
|
||||
else if (v.verdict === "never") entry.never += 1;
|
||||
pool.set(key, entry);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
merchant,
|
||||
history: rows
|
||||
.filter((r) => r.rating !== null || r.note)
|
||||
.map(({ item_verdicts: _drop, ...h }) => h),
|
||||
counts,
|
||||
warn: counts.never > 0,
|
||||
items: [...pool.values()].sort(
|
||||
(a, b) => b.loved + b.never - (a.loved + a.never)
|
||||
),
|
||||
};
|
||||
}
|
||||
+625
-118
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,221 @@
|
||||
import { queryRaw, queryRow } from "@/lib/db";
|
||||
import { DEFAULT_OWNER_ID } from "@/lib/order-ingestion";
|
||||
|
||||
/**
|
||||
* Grocery receipts scanned in pantry-app, arriving as candidate spend.
|
||||
*
|
||||
* The shape deliberately mirrors the order lane rather than inventing a second mechanism,
|
||||
* but it makes one decision differently and the difference is the point: **nothing is
|
||||
* parked.** An order can wait for its statement because it is already visible as an email;
|
||||
* a gift-card grocery shop is visible nowhere at all, so a scan that does not produce a
|
||||
* transaction produces nothing a person can see. Every leg becomes a manual transaction
|
||||
* (`statement_id IS NULL`) immediately, and finance's existing pending-reconciliation queue
|
||||
* resolves the ones that have a card leg coming.
|
||||
*
|
||||
* Why one transaction per tender leg rather than one per receipt: a shop settled $40.75 on
|
||||
* a gift card and $73.82 on a Mastercard has a statement line for $73.82 and nothing for
|
||||
* the rest. A single $114.57 row marked `credits` is excluded from the queue and lets the
|
||||
* statement line double-count; marked `card` it is searched for at ±1% of $114.57 and never
|
||||
* matches, so it parks forever while the statement line still counts. Either way the shop
|
||||
* books $188.39. Per-leg rows make each amount exactly the settled amount, so the existing
|
||||
* matcher works untouched.
|
||||
*/
|
||||
|
||||
export type TenderLeg = {
|
||||
leg_index: number;
|
||||
amount: number;
|
||||
card_last4?: string | null;
|
||||
card_product?: string | null;
|
||||
/** pantry's vocabulary; mapped to finance's payment_method below. */
|
||||
class?: "card" | "gift_card" | "cash" | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Same keys the order lane uses, because the panel that renders an itemised receipt reads
|
||||
* `qty`/`description`/`amount` and a grocery shop is a receipt like any other. `unit` and
|
||||
* `category` are the two things a grocery line has and a delivery line does not; nothing
|
||||
* renders them yet, and the intra-transaction category composition will.
|
||||
*/
|
||||
export type ReceiptLineItem = {
|
||||
description: string;
|
||||
qty?: number | null;
|
||||
amount?: number | null;
|
||||
unit?: string | null;
|
||||
category?: string | null;
|
||||
};
|
||||
|
||||
export type ParsedReceipt = {
|
||||
receipt_uid?: string | null;
|
||||
capture_event_id: number;
|
||||
image_sha256?: string | null;
|
||||
merchant_name: string;
|
||||
store_detail?: string | null;
|
||||
transaction_date: string;
|
||||
total: number;
|
||||
tax_amount?: number | null;
|
||||
tender_raw?: string | null;
|
||||
loyalty_card_number?: string | null;
|
||||
tender_legs: TenderLeg[];
|
||||
line_items: ReceiptLineItem[];
|
||||
};
|
||||
|
||||
export type ReceiptIngestResult = {
|
||||
group: string;
|
||||
legs: { leg_index: number; transactionId: number; metadataId: number; paymentMethod: string | null; skipped?: string }[];
|
||||
flags: string[];
|
||||
};
|
||||
|
||||
export class ReceiptValidationError extends Error {}
|
||||
|
||||
/**
|
||||
* `credits` rather than a new `gift_card`: prepaid value with no card leg is a concept
|
||||
* finance already has, and reusing it inherits both the `needsCardMatch()` exclusion that
|
||||
* keeps such rows out of the reconciliation queue and the "Gift Card" label `bankLabel()`
|
||||
* renders for them. A second word for one idea would have needed both taught again.
|
||||
*
|
||||
* An unknown class maps to NULL, which `needsCardMatch()` treats as reconcilable — so a leg
|
||||
* nobody could classify lands in the queue visibly unresolved instead of being silently
|
||||
* decided either way.
|
||||
*/
|
||||
function paymentMethodFor(legClass: TenderLeg["class"]): string | null {
|
||||
if (legClass === "gift_card") return "credits";
|
||||
if (legClass === "cash") return "cash";
|
||||
if (legClass === "card") return "card";
|
||||
return null;
|
||||
}
|
||||
|
||||
const round2 = (value: number) => Number(value.toFixed(2));
|
||||
|
||||
/**
|
||||
* The whole shop is `groceries`. What it was actually made of — food versus household —
|
||||
* stays derived from `line_items` at display time rather than stored, because a mixed shop
|
||||
* is one payment and splitting the transaction to describe it would make the amount that
|
||||
* reconciles against the statement line stop matching it.
|
||||
*/
|
||||
const RECEIPT_CATEGORY = "groceries";
|
||||
|
||||
export function validateReceipt(receipt: ParsedReceipt): string[] {
|
||||
const flags: string[] = [];
|
||||
if (!receipt.tender_legs?.length) throw new ReceiptValidationError("at least one tender leg is required");
|
||||
if (!Number.isFinite(receipt.total) || receipt.total <= 0) throw new ReceiptValidationError("total must be a positive number");
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(receipt.transaction_date)) throw new ReceiptValidationError("transaction_date must be YYYY-MM-DD");
|
||||
|
||||
// The check that detects a split at all, and the one that proves the payment side was
|
||||
// read whole. A leg missed here becomes spend that never appears.
|
||||
const legSum = round2(receipt.tender_legs.reduce((total, leg) => total + Number(leg.amount || 0), 0));
|
||||
if (Math.abs(legSum - round2(receipt.total)) > 0.02) {
|
||||
throw new ReceiptValidationError(`tender legs sum to ${legSum.toFixed(2)} but the receipt total is ${receipt.total.toFixed(2)}`);
|
||||
}
|
||||
|
||||
// Lines are allowed to disagree: promotional rows are deliberately skipped during
|
||||
// extraction, so this flags rather than rejects. The money is the tender, not the lines.
|
||||
const lineSum = round2((receipt.line_items ?? []).reduce((total, line) => total + Number(line.amount || 0), 0));
|
||||
if (receipt.line_items?.length && Math.abs(lineSum - round2(receipt.total)) > 0.02) flags.push(`line_items_sum_${lineSum.toFixed(2)}`);
|
||||
if (receipt.tender_legs.length > 1) flags.push("split_tender");
|
||||
if (receipt.tender_legs.some((leg) => !leg.class)) flags.push("unclassified_tender");
|
||||
return flags;
|
||||
}
|
||||
|
||||
/**
|
||||
* The receipt's own identity, not the capture's. A photo and the store's e-receipt PDF of
|
||||
* one purchase are different files with different hashes, so keying on the capture would
|
||||
* let the same shop arrive twice as two unrelated sets of transactions. Falls back to the
|
||||
* capture id when the receipt did not print enough to identify itself — that risks a
|
||||
* duplicate, which is visible and removable, rather than a merge, which silently hides a
|
||||
* real shop.
|
||||
*/
|
||||
export function receiptGroup(receipt: ParsedReceipt): string {
|
||||
return `pantry:${receipt.receipt_uid || `capture:${receipt.capture_event_id}`}`;
|
||||
}
|
||||
|
||||
export async function processReceiptIngestion(receipt: ParsedReceipt): Promise<ReceiptIngestResult> {
|
||||
const flags = validateReceipt(receipt);
|
||||
const group = receiptGroup(receipt);
|
||||
// Line items describe the whole shop but can only attach to one row —
|
||||
// expense_metadata.transaction_id is UNIQUE, and duplicating them would double any
|
||||
// composition derived from them. They go on the card leg because that is the row which
|
||||
// reconciles onto the statement line, which is where an unreadable `COLES 0556` descriptor
|
||||
// actually gets its contents. With no card leg, the largest leg carries them.
|
||||
const cardLeg = receipt.tender_legs.find((leg) => leg.class === "card" || !leg.class)
|
||||
?? [...receipt.tender_legs].sort((a, b) => Number(b.amount) - Number(a.amount))[0];
|
||||
|
||||
const legs: ReceiptIngestResult["legs"] = [];
|
||||
for (const leg of receipt.tender_legs) {
|
||||
const reference = `${group}#${leg.leg_index}`;
|
||||
const existing = await queryRow<{ id: number; transaction_id: number | null }>(
|
||||
`SELECT id, transaction_id FROM expense_metadata WHERE source = 'pantry' AND order_reference = $1`,
|
||||
[reference]
|
||||
);
|
||||
if (existing) {
|
||||
legs.push({ leg_index: leg.leg_index, transactionId: existing.transaction_id ?? 0, metadataId: existing.id, paymentMethod: null, skipped: "already_ingested" });
|
||||
continue;
|
||||
}
|
||||
|
||||
const paymentMethod = paymentMethodFor(leg.class);
|
||||
const amount = round2(Number(leg.amount));
|
||||
const description = receipt.store_detail ? `${receipt.merchant_name} ${receipt.store_detail}` : receipt.merchant_name;
|
||||
const txn = await queryRow<{ id: number }>(
|
||||
`INSERT INTO transactions (
|
||||
transaction_date, description, amount, amount_aud, category, payment_method,
|
||||
merchant_name, merchant_normalized, transaction_type, owner_id
|
||||
) VALUES ($1,$2,$3,$3,$4,$5,$6,$6,'debit',$7) RETURNING id`,
|
||||
[receipt.transaction_date, description, amount, RECEIPT_CATEGORY, paymentMethod, receipt.merchant_name, DEFAULT_OWNER_ID]
|
||||
);
|
||||
|
||||
const carriesLines = leg.leg_index === cardLeg?.leg_index;
|
||||
const meta = await queryRow<{ id: number }>(
|
||||
`INSERT INTO expense_metadata (
|
||||
transaction_id, source, order_reference, line_items, subtotal, amount,
|
||||
merchant_normalized, transaction_date, card_last4, currency, flags,
|
||||
reconciled_at, payment_method, payment_method_detail, tender_raw,
|
||||
receipt_sha256, receipt_group, extraction_model
|
||||
) VALUES ($1,'pantry',$2,$3::jsonb,$4,$5,$6,$7,$8,'AUD',$9::jsonb,NULL,$10,$11,$12,$13,$14,'cloud-budget')
|
||||
RETURNING id`,
|
||||
[
|
||||
txn!.id,
|
||||
reference,
|
||||
JSON.stringify(carriesLines ? receipt.line_items ?? [] : []),
|
||||
receipt.tax_amount ?? null,
|
||||
amount,
|
||||
receipt.merchant_name,
|
||||
receipt.transaction_date,
|
||||
leg.card_last4 ?? null,
|
||||
JSON.stringify(carriesLines ? flags : [...flags, "line_items_on_card_leg"]),
|
||||
paymentMethod,
|
||||
leg.card_product ?? null,
|
||||
receipt.tender_raw ?? null,
|
||||
receipt.image_sha256 ?? null,
|
||||
group,
|
||||
]
|
||||
);
|
||||
legs.push({ leg_index: leg.leg_index, transactionId: txn!.id, metadataId: meta!.id, paymentMethod });
|
||||
}
|
||||
return { group, legs, flags };
|
||||
}
|
||||
|
||||
/**
|
||||
* Statement lines that look like a pantry row already marked as needing no card leg.
|
||||
*
|
||||
* Such rows never enter the reconciliation queue — that is the whole point of classifying
|
||||
* them — so a mis-learned card would otherwise double-count in silence. This only flags: the
|
||||
* same uncertainty that makes the classification fallible makes the match a suggestion, and
|
||||
* auto-reconciling on it would trade a visible error for an invisible one. A confirmed
|
||||
* conflict is a reason to correct the card's stored class, which fixes every later receipt
|
||||
* from it at once.
|
||||
*/
|
||||
export async function pantryTenderConflicts(): Promise<{ transactionId: number; statementTransactionId: number; amount: string; merchant: string | null; date: string }[]> {
|
||||
return queryRaw(
|
||||
`SELECT p.id AS "transactionId", s.id AS "statementTransactionId", p.amount::text AS amount,
|
||||
p.merchant_normalized AS merchant, p.transaction_date::text AS date
|
||||
FROM transactions p
|
||||
JOIN expense_metadata em ON em.transaction_id = p.id AND em.source = 'pantry'
|
||||
JOIN transactions s ON s.statement_id IS NOT NULL
|
||||
AND s.transaction_date BETWEEN p.transaction_date - 3 AND p.transaction_date + 3
|
||||
AND s.amount BETWEEN p.amount * 0.99 AND p.amount * 1.01
|
||||
AND upper(coalesce(s.description, '')) LIKE '%' || upper(p.merchant_name) || '%'
|
||||
WHERE p.statement_id IS NULL
|
||||
AND p.reconciled_with_id IS NULL
|
||||
AND p.payment_method IN ('cash', 'credits')
|
||||
ORDER BY p.transaction_date DESC`
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
*/
|
||||
|
||||
import { queryRaw } from "@/lib/db";
|
||||
import { completeSplit } from "@/lib/splits";
|
||||
import type { Actions } from "@/lib/rules";
|
||||
|
||||
export interface SnapshotEntry {
|
||||
@@ -68,6 +69,10 @@ export async function applyRuleActions(
|
||||
[transactionId, s.participant_id, s.share_percent]
|
||||
);
|
||||
}
|
||||
// A rule may name only the other person — "split Woolworths with Sonu 50%"
|
||||
// is a complete thought, and ten of the original rules are written that way.
|
||||
// The payer's half is implied by it, so write the implication down.
|
||||
await completeSplit(transactionId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
import { RATINGS, type Rating } from "@/lib/order-reviews";
|
||||
|
||||
/**
|
||||
* The order nudge as Block Kit, so the answer happens IN Slack.
|
||||
*
|
||||
* A link to the app was the first version and it was the wrong shape: being
|
||||
* sent to a web app to answer "was this shared?" is enough friction that the
|
||||
* question stops getting answered, which is the whole failure the nudge exists
|
||||
* to prevent (user, 2026-07-28).
|
||||
*
|
||||
* Buttons carry `<transactionId>:<verb>` in `value`. The transaction id has to
|
||||
* travel in the payload because Slack gives the handler nothing else to
|
||||
* identify the row — the message text is not a key.
|
||||
*/
|
||||
|
||||
export interface NudgeState {
|
||||
transactionId: number;
|
||||
merchant: string;
|
||||
currency: string;
|
||||
total: number;
|
||||
isFamily?: boolean;
|
||||
shared: boolean;
|
||||
/** Ratings already recorded, as participant name → rating. */
|
||||
ratings?: { name: string; rating: Rating }[];
|
||||
warn?: boolean;
|
||||
warnNote?: string | null;
|
||||
}
|
||||
|
||||
const RATING_LABEL: Record<Rating, string> = {
|
||||
loved: "Loved it",
|
||||
liked: "Liked it",
|
||||
ok: "OK",
|
||||
bad: "Bad",
|
||||
never: "Never again",
|
||||
};
|
||||
|
||||
export function nudgeBlocks(s: NudgeState) {
|
||||
const lines = [
|
||||
`:receipt: *${s.merchant}* — ${s.currency} ${s.total.toFixed(2)}` +
|
||||
(s.isFamily ? " · [Family]" : ""),
|
||||
];
|
||||
if (s.warn) {
|
||||
lines.push(
|
||||
`:warning: You marked this merchant *never again* before` +
|
||||
(s.warnNote ? ` — _${s.warnNote}_` : "")
|
||||
);
|
||||
}
|
||||
if (s.ratings?.length) {
|
||||
lines.push(s.ratings.map((r) => `${r.name}: *${RATING_LABEL[r.rating]}*`).join(" · "));
|
||||
}
|
||||
|
||||
return [
|
||||
{ type: "section", text: { type: "mrkdwn", text: lines.join("\n") } },
|
||||
// A select, not four buttons. Slack's mobile client gives every button in
|
||||
// an actions block its own full-width row, so four ratings became four
|
||||
// stacked bars and the card filled the screen. One select is one row and
|
||||
// still one decision; the extra tap to open it is the price of a nudge you
|
||||
// can read at a glance.
|
||||
{
|
||||
type: "actions",
|
||||
block_id: "rate",
|
||||
elements: [
|
||||
{
|
||||
type: "static_select",
|
||||
action_id: "rate",
|
||||
placeholder: { type: "plain_text", text: "How was it?" },
|
||||
options: RATINGS.map((r) => ({
|
||||
text: { type: "plain_text", text: RATING_LABEL[r] },
|
||||
value: `${s.transactionId}:rate:${r}`,
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "actions",
|
||||
block_id: "details",
|
||||
elements: [
|
||||
{
|
||||
type: "button",
|
||||
action_id: "open_details",
|
||||
text: { type: "plain_text", text: "Add details" },
|
||||
value: `${s.transactionId}:details`,
|
||||
},
|
||||
],
|
||||
},
|
||||
// Sharing comes LAST. You judge the food, then decide who pays for it —
|
||||
// asking "was this shared?" before "was it any good?" inverts the order a
|
||||
// person actually thinks in (user, 2026-07-28). The controls are
|
||||
// independent and each writes immediately, so this is presentation only.
|
||||
{
|
||||
type: "actions",
|
||||
block_id: "share",
|
||||
elements: [
|
||||
{
|
||||
type: "button",
|
||||
action_id: "share_toggle",
|
||||
// The label states what pressing it will DO, not the current state.
|
||||
// A button labelled with its own state reads as already-pressed and
|
||||
// gets tapped to "fix" it, toggling the thing it was reporting.
|
||||
text: {
|
||||
type: "plain_text",
|
||||
text: s.shared ? "Make it just me" : "Shared 50/50",
|
||||
},
|
||||
style: s.shared ? undefined : "primary",
|
||||
value: `${s.transactionId}:share`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "context",
|
||||
elements: [
|
||||
{
|
||||
type: "mrkdwn",
|
||||
text: s.shared
|
||||
? ":busts_in_silhouette: Split 50/50 — both verdicts welcome"
|
||||
: ":bust_in_silhouette: Not shared",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-item verdicts and the free-text note, as a Slack modal.
|
||||
*
|
||||
* A message cannot collect free text and an actions block caps at 25 elements,
|
||||
* so this is the only Slack-native way to ask "which dish, and why". It stays
|
||||
* inside Slack — no browser, no app — which is the entire reason it exists
|
||||
* rather than a link.
|
||||
*
|
||||
* The overall rating deliberately stays on the card: it is the thing you do
|
||||
* every time and it should cost one tap. This is for the times something was
|
||||
* notably good or bad.
|
||||
*
|
||||
* `private_metadata` carries the ids because a view_submission arrives as a
|
||||
* fresh request with no reference to the message it came from.
|
||||
*/
|
||||
export function detailsModal(
|
||||
transactionId: number,
|
||||
participantId: number,
|
||||
merchant: string,
|
||||
items: string[],
|
||||
existing: { note?: string | null; itemVerdicts?: { item: string; verdict: string }[] }
|
||||
) {
|
||||
const verdictOf = (item: string) =>
|
||||
existing.itemVerdicts?.find(
|
||||
(v) => v.item.trim().toLowerCase() === item.trim().toLowerCase()
|
||||
)?.verdict ?? null;
|
||||
|
||||
const opt = (text: string, value: string) => ({
|
||||
text: { type: "plain_text", text },
|
||||
value,
|
||||
});
|
||||
|
||||
const itemBlocks = items
|
||||
// Slack allows 100 blocks per view; a grocery order can be long, and past
|
||||
// ~20 rows nobody is scrolling a modal to rate a tin of tomatoes anyway.
|
||||
.slice(0, 20)
|
||||
.map((item, i) => {
|
||||
const current = verdictOf(item);
|
||||
const options = [opt("👍 Great", "loved"), opt("👎 Never again", "never")];
|
||||
return {
|
||||
type: "input",
|
||||
block_id: `item_${i}`,
|
||||
optional: true,
|
||||
// The label carries the item name; Slack truncates at 150 chars.
|
||||
label: { type: "plain_text", text: item.slice(0, 150) },
|
||||
element: {
|
||||
type: "radio_buttons",
|
||||
action_id: "verdict",
|
||||
options,
|
||||
...(current
|
||||
? { initial_option: options.find((o) => o.value === current) }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
type: "modal",
|
||||
callback_id: "order_details",
|
||||
private_metadata: JSON.stringify({
|
||||
t: transactionId,
|
||||
p: participantId,
|
||||
// The item text is not recoverable from the submission — Slack returns
|
||||
// block ids and values, not labels — so it travels with the view.
|
||||
i: items.slice(0, 20),
|
||||
}),
|
||||
title: { type: "plain_text", text: "Order details" },
|
||||
submit: { type: "plain_text", text: "Save" },
|
||||
close: { type: "plain_text", text: "Cancel" },
|
||||
blocks: [
|
||||
{ type: "section", text: { type: "mrkdwn", text: `*${merchant}*` } },
|
||||
{
|
||||
type: "input",
|
||||
block_id: "note",
|
||||
optional: true,
|
||||
label: { type: "plain_text", text: "Anything worth remembering?" },
|
||||
element: {
|
||||
type: "plain_text_input",
|
||||
action_id: "value",
|
||||
multiline: true,
|
||||
initial_value: existing.note ?? undefined,
|
||||
placeholder: {
|
||||
type: "plain_text",
|
||||
text: "e.g. the biryani was decent, sides were cold",
|
||||
},
|
||||
},
|
||||
},
|
||||
...(itemBlocks.length
|
||||
? [{ type: "divider" }, ...itemBlocks]
|
||||
: [
|
||||
{
|
||||
type: "context",
|
||||
elements: [
|
||||
{
|
||||
type: "mrkdwn",
|
||||
// Uber itemises groceries but not restaurant orders, so an
|
||||
// empty list is the receipt, not a failure.
|
||||
text: "_This receipt has no itemised list._",
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The card DMed to the other person when an order is shared with them.
|
||||
*
|
||||
* Sharing splits the money; this is the other half of the same requirement —
|
||||
* "if shared then get feedback from other user as well" (user, 2026-07-28).
|
||||
* Without it that half was aspirational: she is not in #smarthome, so the
|
||||
* channel card she was invited to answer was one she could not see.
|
||||
*
|
||||
* A DM rather than adding her to the channel, so her surface stays "orders that
|
||||
* concern me" instead of the whole house's ops feed.
|
||||
*
|
||||
* No share button here on purpose. She is being told it was shared, not asked
|
||||
* to decide — and a second person toggling the split from a stale copy of the
|
||||
* card is a race with no upside.
|
||||
*/
|
||||
export function partnerNudgeBlocks(s: NudgeState, sharerName: string) {
|
||||
const lines = [
|
||||
`:receipt: *${s.merchant}* — ${s.currency} ${s.total.toFixed(2)}`,
|
||||
`${sharerName} shared this with you, 50/50. How was it?`,
|
||||
];
|
||||
if (s.warn) {
|
||||
lines.push(
|
||||
`:warning: This merchant was marked *never again* before` +
|
||||
(s.warnNote ? ` — _${s.warnNote}_` : "")
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
{ type: "section", text: { type: "mrkdwn", text: lines.join("\n") } },
|
||||
{
|
||||
type: "actions",
|
||||
block_id: "rate",
|
||||
elements: [
|
||||
{
|
||||
type: "static_select",
|
||||
action_id: "rate",
|
||||
placeholder: { type: "plain_text", text: "How was it?" },
|
||||
options: RATINGS.map((r) => ({
|
||||
text: { type: "plain_text", text: RATING_LABEL[r] },
|
||||
value: `${s.transactionId}:rate:${r}`,
|
||||
})),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "actions",
|
||||
block_id: "details",
|
||||
elements: [
|
||||
{
|
||||
type: "button",
|
||||
action_id: "open_details",
|
||||
text: { type: "plain_text", text: "Add details" },
|
||||
value: `${s.transactionId}:details`,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { createHmac, timingSafeEqual } from "node:crypto";
|
||||
|
||||
/**
|
||||
* Slack request signature verification.
|
||||
*
|
||||
* This endpoint is the one route in the app that is NOT behind Traefik's OAuth
|
||||
* chain — it has to be, because Slack posts to it as a machine with no browser
|
||||
* session. The signature IS the authentication, so it is not optional and it
|
||||
* fails closed: an unset signing secret rejects everything rather than waving
|
||||
* requests through, which is the failure mode that would quietly expose split
|
||||
* writes to the open internet.
|
||||
*
|
||||
* The v0 scheme signs `v0:<timestamp>:<raw body>`. It must be the RAW body —
|
||||
* re-serialising the parsed form changes the bytes and every signature fails.
|
||||
*/
|
||||
export function verifySlackSignature(
|
||||
rawBody: string,
|
||||
timestamp: string | null,
|
||||
signature: string | null
|
||||
): boolean {
|
||||
const secret = process.env.SLACK_SIGNING_SECRET;
|
||||
if (!secret || !timestamp || !signature) return false;
|
||||
|
||||
// Replay window. Slack recommends 5 minutes; a captured request is otherwise
|
||||
// valid forever, and these actions move money between people.
|
||||
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
|
||||
if (!Number.isFinite(age) || age > 300) return false;
|
||||
|
||||
const expected =
|
||||
"v0=" +
|
||||
createHmac("sha256", secret)
|
||||
.update(`v0:${timestamp}:${rawBody}`)
|
||||
.digest("hex");
|
||||
|
||||
const a = Buffer.from(expected);
|
||||
const b = Buffer.from(signature);
|
||||
// timingSafeEqual throws on length mismatch, which is itself a leak of one
|
||||
// bit; check length first and return the same false either way.
|
||||
return a.length === b.length && timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
/**
|
||||
* Which participant pressed the button.
|
||||
*
|
||||
* SLACK_USER_MAP is `<slack user id>:<participant id>` pairs, comma separated.
|
||||
* An unknown Slack user is rejected rather than defaulted to the owner: in a
|
||||
* two-person household a wrong attribution is not a rounding error, it is the
|
||||
* other person's opinion recorded under your name.
|
||||
*/
|
||||
/** The reverse: which Slack user is this participant, for DMing them. */
|
||||
export function slackUserForParticipant(participantId: number): string | null {
|
||||
const map = process.env.SLACK_USER_MAP ?? "";
|
||||
for (const pair of map.split(",")) {
|
||||
const [slack, participant] = pair.split(":").map((s) => s.trim());
|
||||
if (slack && Number(participant) === participantId) return slack;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function participantForSlackUser(slackUserId: string): number | null {
|
||||
const map = process.env.SLACK_USER_MAP ?? "";
|
||||
for (const pair of map.split(",")) {
|
||||
const [slack, participant] = pair.split(":").map((s) => s.trim());
|
||||
if (slack && slack === slackUserId && participant) {
|
||||
const id = Number(participant);
|
||||
if (Number.isInteger(id)) return id;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { queryRaw, queryRow } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* A split has to add up to 100%.
|
||||
*
|
||||
* The database was happy to hold a transaction whose only split row said
|
||||
* "Sonu 50%", because everything that *reads* a split treats the payer's share
|
||||
* as whatever is left over — see `myShare` in analytics-sql.ts, which falls back
|
||||
* to `100 - SUM(everyone else)`. The arithmetic was never wrong.
|
||||
*
|
||||
* It was still a bug, because a ledger is read as well as computed. On screen
|
||||
* that row is a 50% share and a blank, which looks like half the money is
|
||||
* unallocated, and there is no way to tell it apart from a split someone left
|
||||
* half-finished. It also leaks: the participant filter on the Shared view
|
||||
* (`getSharedTransactions`) selects transactions that have an explicit row for
|
||||
* that participant, so filtering by the payer silently drops every transaction
|
||||
* where their share was only ever implied.
|
||||
*
|
||||
* So the remainder gets written down. `completeSplit` is the single place that
|
||||
* does it, and every write path ends in it.
|
||||
*
|
||||
* **This is deliberately balance-neutral.** The owner's row on their own
|
||||
* transaction is excluded from both halves of the balance query in
|
||||
* `getParticipantBalances` — "they owe me" reads `ts.participant_id != $1` on
|
||||
* transactions I own, "I owe them" reads transactions I do not own. So adding a
|
||||
* row for the transaction's own owner cannot create, enlarge or discharge a
|
||||
* debt; it makes explicit the number every reader was already inferring. That
|
||||
* is why the remainder is assigned to the *owner* rather than to "me": a row
|
||||
* for me on someone else's transaction is a real obligation, and this helper
|
||||
* must never invent one of those.
|
||||
*/
|
||||
|
||||
/** The owner whose share is implied: the transaction's, falling back to its statement's. */
|
||||
async function effectiveOwner(transactionId: number): Promise<number | null> {
|
||||
const row = await queryRow<{ owner_id: number | null }>(
|
||||
`SELECT COALESCE(t.owner_id, s.owner_id) AS owner_id
|
||||
FROM transactions t
|
||||
LEFT JOIN statements s ON s.id = t.statement_id
|
||||
WHERE t.id = $1`,
|
||||
[transactionId]
|
||||
);
|
||||
return row?.owner_id ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialise the owner's share so the split totals 100%.
|
||||
*
|
||||
* Does nothing to a transaction with no splits — an unshared transaction is not
|
||||
* a 100% split of itself, and writing one would put every row in the Shared
|
||||
* view. Does nothing when the other shares already total 100% (they owe all of
|
||||
* it, and the owner's share is a genuine zero), beyond removing a stale owner
|
||||
* row if one is left behind.
|
||||
*
|
||||
* Over-allocated splits (>100%) are left exactly as they are. That is a
|
||||
* mistake the caller should have rejected, and quietly deleting someone's share
|
||||
* to force the total down would destroy the evidence of it.
|
||||
*/
|
||||
export async function completeSplit(transactionId: number): Promise<void> {
|
||||
const owner = await effectiveOwner(transactionId);
|
||||
if (owner === null) return;
|
||||
|
||||
const rows = await queryRaw<{ participant_id: number; share_percent: string }>(
|
||||
`SELECT participant_id, share_percent FROM transaction_splits WHERE transaction_id = $1`,
|
||||
[transactionId]
|
||||
);
|
||||
if (rows.length === 0) return;
|
||||
|
||||
const others = rows
|
||||
.filter((r) => r.participant_id !== owner)
|
||||
.reduce((sum, r) => sum + Number(r.share_percent), 0);
|
||||
const remainder = Number((100 - others).toFixed(2));
|
||||
|
||||
if (remainder > 0.01) {
|
||||
await queryRaw(
|
||||
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (transaction_id, participant_id)
|
||||
DO UPDATE SET share_percent = EXCLUDED.share_percent`,
|
||||
[transactionId, owner, remainder]
|
||||
);
|
||||
} else if (remainder > -0.01) {
|
||||
// Exactly nothing left for the owner. Drop their row rather than storing a
|
||||
// 0% share, so "they owe all of it" keeps looking like one row, not two.
|
||||
await queryRaw(
|
||||
`DELETE FROM transaction_splits WHERE transaction_id = $1 AND participant_id = $2`,
|
||||
[transactionId, owner]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** `completeSplit` over many transactions, for bulk and rule-run paths. */
|
||||
export async function completeSplits(transactionIds: number[]): Promise<void> {
|
||||
for (const id of transactionIds) await completeSplit(id);
|
||||
}
|
||||
Reference in New Issue
Block a user