58 Commits
Author SHA1 Message Date
siddharthd 22c2349a47 docs: one CSV import path, and the statement-coverage rule that replaced the Frollo importer
ci / lint-test (push) Successful in 43s
2026-08-13 15:09:11 +10:00
siddharthd 3edcc27781 csv import: map a currency column, or foreign rows land as AUD
ci / lint-test (push) Successful in 41s
Deleting the Frollo importer dropped its currency handling and nothing replaced
it: ColumnMapping had no currency column, so applyMapping could never produce
foreign_currency_code and batchInsertCSVTransactions' support for it was
unreachable from the UI. The USD 10,782 salary imported as A$10,782 — about a
third under, sitting in a column of AUD figures looking entirely normal, which
is the same defect the owner spotted in the first place.

An optional Currency column now sets foreign_currency_code and
foreign_currency_amount when the cell is a three-letter code other than AUD, and
leaves amount_aud NULL rather than inventing a rate. That is the shape order
ingestion already uses and what AMOUNT_UNCONVERTED looks for, so the row renders
as its native figure with "no AUD rate" and the statement supplies the real
number when it arrives.

Caught by reading the imported row rather than the import summary: the summary
said 171 inserted and was right about everything it reported.
2026-08-13 15:08:50 +10:00
siddharthd 461c021e7a csv import: exclude what the statements already cover, and delete the Frollo importer
ci / lint-test (push) Successful in 43s
"This is becoming too complex... Frollo should be done through that [the manual
CSV import]" (owner). It was right: a bespoke importer, an API route, a CLI, two
scheduled n8n workflows and a shared secret existed to do what the CSV import
modal already did, minus one rule.

That rule is statement coverage, and it turns out to be the whole thing. Applying
each account's newest billing_end_date as a watermark takes the real 2,607-row
Frollo export down to 171 rows — with no account allowlist, no credit-card
exclusion and no Frollo-specific scoping at all. Cards drop out on their own
because their statements are current; the 46 card rows that survive are genuinely
post-statement. Every bit of the bespoke apparatus was doing by hand what one
query does generically.

Deleted: src/lib/frollo-csv.ts, src/lib/frollo-ingest.ts, scripts/import-frollo.mts,
src/app/api/frollo/, both test files, the FROLLO_INGEST_TOKEN wiring, and the n8n
Frollo Import + Frollo Freshness Check workflows.

Added to the shared CSV path, so every import benefits:

  - getStatementCoverage() + /api/import/statement-coverage. The review step
    leaves out rows an account's statements already cover and says how many, with
    the rows one click away. Only applies when an account column is mapped and
    that account has statements — a row is never dropped on a guess.
  - Optional Account and Row ID columns in the mapper. Account drives the
    watermark and is stored as source_account; Row ID becomes source_ref.
  - An in-file duplicate warning. A CDR re-consent re-exports history under
    fresh ids, so source_ref cannot see it — 385 twins in one 2,563-row export
    doubled every salary payment, and that is not visible by eye in a review
    table.

Two pre-existing bugs in that path, both of which this plan depends on:

  - The category chosen in the review step was accepted by
    batchInsertCSVTransactions and then left out of the INSERT column list, so it
    was silently discarded and the trigger wrote 'other'. Not cosmetic: an
    uncategorised credit is admitted by NET_SPEND_ROWS and negated by
    SPEND_SIGNED, so 62 imported transfers cancelled $74,338 of spend while
    counting as no income.
  - The path had no idempotency whatsoever. row_index is assigned MAX+1 on every
    run, which makes uq_transaction_identity structurally unable to fire, so a
    second import of the same file duplicated all of it. Now writes source +
    source_ref with ON CONFLICT DO NOTHING.

awaitsStatementLine()'s removal note is kept but rewritten: it no longer points
at a deleted file, and the lesson stands — the queue jump from 8 to 558 was the
measurement, not the noise.
2026-08-13 12:58:06 +10:00
siddharthd b82c4570bd frollo: don't import what the statements already cover
ci / lint-test (push) Successful in 45s
"We should not be importing from Frollo what we already have from statements"
(owner). The amount+direction guard could not deliver that, because the two
sources decompose the same event differently: Frollo bundles the Wise fee into
the transfer (10001.13) where the statement itemises it (10000.00 + 1.13). So 38
Wise USD rows passed the amount guard as new while being the same money, and were
the reason the transactions view showed a USD figure where every neighbouring row
showed AUD.

A statement's billing_end_date is a hard watermark: everything on that account up
to that date is already in the ledger, itemised and converted. Guard 1 now drops
any feed row at or before its account's newest statement. Guard 2 (amount +
direction) stays as the net for accounts that have no statement at all.

Note this is the coverage test the first import needed and got wrong. That one
asked whether a row's date fell inside a statement's min-max window, which for
periods spanning 182 to 460 days swallows a year and answers nothing. The
watermark asks a question that has an answer: up to what date is this account
complete?

Matching is on last4, verified against the live statement set — the eight
in-scope accounts with statements each map to one bank, no cross-bank collision.
The watermarks are printed by the CLI so a wrong boundary is visible rather than
inferred from a row count.

Re-imported from empty: 425 covered by statement, 15 amount twins, 110 inserted.
Exactly one row now carries a foreign currency with no AUD figure — the
2026-08-12 HDR salary, which is the genuinely-new pre-statement row this feed
exists for. Was 39.
2026-08-13 12:36:08 +10:00
siddharthd afd75d3f09 transactions: never print a foreign amount as AUD
ci / lint-test (push) Successful in 48s
The amount cell rendered formatAmount(amount_aud ?? amount) with no currency
argument, so a row with no AUD figure fell back to its native amount and was
stamped with a dollar sign. A USD 10,782.00 Wise credit showed as "+$10,782.00"
in a column of AUD figures — the same payment from the statement, one row above,
correctly showed $15,518.53 over USD 10,782.00. So the feed row read as $10,782
AUD when the real value is about $15,500, and the sub-line just repeated the
same number correctly labelled.

amount_unconverted is already selected by the transactions query (queries.ts:298)
and was simply unused here. When set, the native figure becomes the headline and
the absence of a rate is stated rather than papered over.

Pre-existing rather than Frollo's: any row with amount_aud NULL and a foreign
currency hits it, including foreign order-receipt rows. Frollo made it visible
at scale — 39 rows.
2026-08-13 12:32:17 +10:00
siddharthd 2738213a23 docs: record the ledger-duplicate guard and why awaitsStatementLine is gone
ci / lint-test (push) Successful in 40s
2026-08-13 12:24:59 +10:00
siddharthd bb8a009e02 frollo: don't re-import what the ledger already has, and stop hiding the queue
ci / lint-test (push) Successful in 44s
The first import wrote 550 rows on 2026-08-13. 422 of them (77%) were second
copies of transactions the ledger already held from statements — $1,023,824.63
of movement counted twice. The owner found it by opening the transactions view
and seeing one HDR Global salary listed twice, once as A$15,518.53 from the
statement and once as US$10,782.00 from the feed.

The currency was never the defect. toLedgerRow already left amount_aud NULL and
named the currency in foreign_currency_code, which is the documented contract for
a row whose AUD value is unknown, and the transactions page labels it. What made
it look wrong was the duplicate sitting beside it.

Two changes.

Upstream, ingestFrolloCsv now drops rows the ledger already holds, matching on
amount + direction within LEDGER_MATCH_DAYS (3). Three things had to be right and
the first two were not, each caught only by rehearsing against real data rather
than fixtures:

  - pg returns a DATE as a JS Date while Prisma and the CSV give strings.
    String(date).slice(0,10) is "Wed Mar 10", which parses to NaN, so the first
    dry run reported 550 to insert and zero duplicates. dayMs() takes both.
  - Direction has to be in the key. This ledger is full of internal transfers
    between the owner's own accounts and the feed carries both legs: 2026-05-18
    has +3076.04 into ANZ and -3076.04 out of AMP. Matching on amount alone let
    the credit leg consume the ledger's debit row, so the real duplicate was
    written — 20 rows got in that way.
  - 'refund' is money in. The feed calls a reversed account fee a credit and the
    statement importer types it 'refund'; classifying it as an outflow left every
    ANZ servicing-fee reversal behind.

Downstream, awaitsStatementLine() is removed. Its premise — feed rows never await
a statement line — was asserted, never tested, and false for almost every
account. Worse is how it got there: the reconcile queue jumped 8 -> 558 when the
feed landed, that jump was read as noise and filtered away, and filtering it
removed the only mechanism that would ever have collapsed the duplicates. The
queue was right. A feed row IS a row awaiting its statement line.

Re-imported: 375 dropped as already-on-ledger, 175 inserted. Residual duplicates
4 rows / $15.01, all sub-$5 account fees where several identical amounts fall in
overlapping windows and greedy consumption picks the wrong one; not chased
further at this scale.

The CLI prints the already-on-ledger count even when zero — a number you have to
go looking for is a number nobody looks at.
2026-08-13 12:23:01 +10:00
siddharthd 3e826a317b CLAUDE.md: document the Frollo account feed and the new reconcile exclusion
ci / lint-test (push) Successful in 39s
Two things a future reader would otherwise have to reverse-engineer.

The reconcile queue now excludes account-feed rows for a reason unrelated to
payment method: a feed row is the account's own ledger entry, not a receipt
awaiting a statement line, because the importer only covers accounts whose
statements are deliberately not imported. Measured, the queue went from 8 to
558 without it.

And the de-duplication rule needs its history attached, because the obvious
version is wrong in a way that passes tests: keeping close-id rows as genuine
repeats survived 29 tests and a clean dry run while still doubling three
salary payments. In-scope duplicate pairs have id gaps from 58 to 260
million; real repeats sat at 1-4.
2026-08-13 11:29:08 +10:00
siddharthd 21e9e765a3 Add /api/frollo/ingest so the import can run unattended
ci / lint-test (push) Successful in 40s
Shares one module with the CLI rather than reimplementing the insert:
frollo-ingest.ts holds parsing, scoping, de-duplication and the write, and
both callers pass in their own SQL executor (Prisma in the route, a pg
client in the script). The alternative is two implementations of the same
insert, which is how the pantry healthcheck came to be fixed in one repo
and left broken in the other.

The route refuses rather than guesses. findAnomalies() returns every reason
an unattended run should stop - a configured account contributing no rows,
an unrecognised account, a near-consecutive-id collapse that might be a
real repeat, a batch over ~200 rows, or an export taken with pending
included - and the route answers 409 having written nothing.

Two defects the wiring surfaced. Deliberately excluded credit cards were
reported as unknown accounts, which would have raised the new-account
anomaly on every single run and left the automatic path permanently
refusing; EXCLUDED_ACCOUNTS now distinguishes excluded from unknown. And
pending was tested after account scope, so pending rows on cards - which is
all of them so far - classified as out-of-scope and the wrong-export-option
signal could never fire; pending is now tested first.
2026-08-13 11:19:27 +10:00
siddharthd 493ff6f631 Import Frollo account feeds for the accounts statements don't cover
ci / lint-test (push) Successful in 47s
Credit cards keep arriving as monthly statements and stay the source of
truth for them. This covers the other fourteen accounts, whose statements
arrive every 182 to 460 days — AMP including the loan, ANZ Access, Wise
including the income account, Up, ING, and the small transaction accounts.
About 50 rows a month, where the alternative is downloading each statement
by hand.

The file needs three defences, all found by diffing three real exports
(smarthome DECISIONS.md ING-11):

A CDR re-consent makes Frollo re-ingest an account's whole history under
fresh transaction ids while the originals survive, and consents expire
annually. On this export 112 rows were such twins, and every HDR salary
payment appeared twice — importing blind doubles reported income. dedupe()
collapses each natural-key group to its lowest id, lowest because old ids
were a strict subset of new across two exports, so source_ref stays stable
and a re-import inserts nothing.

An earlier version of that rule kept close-id rows on a 10,000 threshold,
reasoning that genuine same-day repeats have consecutive ids. Verifying it
against the income rows killed it: in-scope duplicate pairs have id gaps
from 58 to 260 million, so no threshold separates them from the gaps of 1-4
that real repeats showed. It now collapses unconditionally and flags
anything within 10 for review — the errors are asymmetric, and nothing in
scope has ever tripped the flag.

A lapsed consent removes an account from the export silently, with no error
and no marker; the row count just drops. So the import asserts the account
roster and refuses to run when a configured account contributes nothing.

Also holds these rows out of the pending-reconciliation queue. A feed row is
the account's own ledger entry, not a receipt awaiting a statement line —
these accounts' statements are deliberately not imported — so without the
exclusion 550 rows a year would bury the receipts that need a decision. The
queue stays at 8 instead of 558.

Foreign rows follow order-ingestion's existing shape: amount is the native
figure, foreign_currency_code names it, amount_aud stays NULL rather than
asserting a rate, and AMOUNT_UNCONVERTED already reports the balance as
incomplete.

Dry run by default. Verified against the real export before applying:
550 rows inserted, 14 accounts, re-run inserts 0.
2026-08-13 10:49:35 +10:00
siddharthd 2c666236b2 orders: share the merchant tidy, and fix two bugs the corpus run found (board 212)
ci / lint-test (push) Successful in 41s
The tidy existed but lived inside the list page, so the DETAIL page never had
it: following a row through showed "Apple" becoming "Apple Pty Ltd." Moved to
src/lib/merchant-label.ts and applied on both surfaces.

Running it over all 1,451 distinct merchant names — rather than over examples —
turned up two real defects:

  Coburger & Co                    -> "Coburger &"
  eBay Commerce Australia Pty Ltd. -> "EBay Commerce Australia"

The first strips a suffix that is part of the brand and leaves a dangling
connector. The second is the capitalisation rule firing on "starts with a
lower-case letter" when what it means is "is a bare domain" — mangling a brand
that is deliberately lower-cased. Both are now guarded, and re-running over the
full corpus reports 170 names tidied and 0 defects.

PRESENTATION ONLY. It merges nothing and must never decide two rows are the
same merchant. Amazon.in stays Amazon.in — a different marketplace with
different currency and geography, not a name variant — and there is a test
asserting it can never equal amazon.com.au. Kogan.com, GOG.com, AliExpress.com
and Catch.com.au are real brand names containing a TLD and are left alone.
Real unification is the merchant-alias bridge, ticket 176, which has already
merged what can be merged safely.

The five names that read alike after tidying (ebay/eBay/eBay Inc.,
menulog/Menulog Pty Ltd, deliveroo/Deliveroo, grab/Grab, paypal/PayPal) are
genuinely one merchant each, so reading alike is correct. Lower-case platform
SLUGS are deliberately not capitalised: "ebay" -> "Ebay" is wrong, and getting
it right is the registry's job, not the stylesheet's.
2026-08-12 21:07:50 +10:00
siddharthd 215303161b orders: bring the lifecycle-only toggle's numbers up to date (board 210)
ci / lint-test (push) Successful in 42s
The count moved three times on 2026-08-12 and the history is the point:

  755  when the predicate was written
  904  after board 219 turned unstated zeros into NULLs — those 149 rows had
       been failing the "no amount" test on a technicality
  699  after 210a retired 205 Amazon "share your experience" mails, which were
       never lifecycle events at all
  620  after 210b joined 79 phantoms to their real parent, placebo-verified at
       a 1.9% error rate

What is left is the corpus coverage ceiling rather than a defect — a mail whose
parent is not in the corpus has nothing to join to — and the tooltip now says
that instead of describing the whole class as an open ingestion defect.
2026-08-12 20:38:43 +10:00
siddharthd 3b5e495ca4 orders phase 2: order_transaction_links, and move every reader to it (board 205)
ci / lint-test (push) Successful in 41s
Phase 1 read linkage out of expense_metadata, which is shaped as ONE ROW PER
TRANSACTION — transaction_id UNIQUE, matched_transaction_id partial-unique — so
every multiplicity it expresses is smuggled through a string key (0029 keys
split shipments <entity_key>#f<fact_id>). A BNPL plan needs four rows against
one order and has no such trick available.

Migration 0030 adds order_transaction_links (many-to-many, keyed on entity_key
rather than entities.id: finance-app does not model the spine and must not hold
an FK across a boundary a re-extraction can decompile) and backfills all 63
existing bridge rows.

THE UNIQUENESS RULE WAS WRONG FIRST TIME, in the most on-brand way available.
It read (entity_key, leg_kind, COALESCE(leg_index, 0)), so two shipment legs of
the same order — both leg_index NULL — collapsed to one key and ON CONFLICT DO
NOTHING dropped one SILENTLY. Caught only because the backfill reported 62
against 63 candidates. The row it ate was order_amazon_249-4859367-0690246's
$130.00 second shipment, the same order named in migration 0029's comment as
the reason split shipments need distinct keys at all. What identifies a leg
depends on its kind: an instalment by its INDEX, a shipment by its FACT, a
whole-order charge by neither.

ALL FOUR READERS MOVE TOGETHER, links first with expense_metadata as fallback:

  - LINK_LATERAL (list txn_count/first_txn_id, and the has_transaction facets
    that read it)
  - the detail page's transactions query
  - /api/transactions/[id]/order
  - the order_ctx lateral in queries.ts

Moving fewer is not a smaller change, it is an inconsistent one: the matcher's
four links for order_ebay_14-11714-95953 come with no expense_metadata row, so
a half-move would show four instalments on the detail page while the list said
txn_count = 0 and put the order on the wrong side of BOTH has_transaction
filters. Verified after: detail 4 legs, list txn_count 4, has_transaction=yes
includes it, =no excludes it.

UNION not UNION ALL on transaction_id — after the backfill the same charge is
legitimately in both stores and counting it twice would show "2 charges" on a
single-payment order.

order_platform is deliberately NOT coalesced with the link's platform. It gates
the receipt disclosure arrow on /transactions, and a BNPL leg has no receipt
behind it — filling it in put an arrow on four Afterpay rows that expand to
nothing, which is the exact promise the arrow exists to avoid making. Separate
leg fields carry the sub-line instead ("1 of 4 - DJI Air 3 Fly More Combo").
order-details.tsx now also requires a real receipt (platform present) before
rendering, because the endpoint can answer with a link alone.

Two invariant views, both empty and expected to stay so: order_link_orphans
(spine re-keying silently orphans a TEXT key — bridge links are rebuildable,
`manual` ones are lost curation) and order_link_drift (the two stores
disagreeing). The plan's suggested fix for drift — widening the bridge's NOT
EXISTS guard to "neither store has it" — was NOT taken: the expense_metadata
INSERT has no ON CONFLICT, so re-attempting an order that already has a receipt
row would duplicate it. Detecting is cheap; a non-idempotent re-write is not.

Unchanged: order_feed 6,265, order_spend AUD 4,895 / $442,651.80. Links 75.
2026-08-12 20:29:42 +10:00
siddharthd 6b9b5fe518 orders: badge the annual summary receipts (board 211)
ci / lint-test (push) Successful in 40s
Migration 033 adds entity_orders.summarises_period; order_spend drops those
rows and order_feed keeps them. Unbadged, a $2,376 annual tax receipt
restating twelve monthly donations renders as the largest single purchase of
the year, so both surfaces say what it is:

- /orders  — a "year summary" chip beside the auth badges
- /orders/<key> — a banner in the same shape as the settled-rail one, saying
  the monthly debits are in the ledger under their own dates

The amount is deliberately still shown. It is the year's giving total and the
reason the row is worth keeping; it just belongs to no total on the page. The
detail page already falls back to o.order_total when order_spend has no row,
which is exactly the case now — verified: gross_total null, order_total 2125.5.
2026-08-12 20:12:36 +10:00
siddharthd 231ef3411a orders: record why line items may not exempt a row from the phantom filter
ci / lint-test (push) Successful in 40s
Board 219 nulled 263 unstated zero totals in the spine, which moved 149 rows
into the lifecycle-only hide predicate — they had been failing its "no amount"
test on a technicality, an unstated 0 they should never have carried, while
matching the phantom signature on every other count.

Tried exempting rows that carry line items, on the theory that naming what was
bought proves a purchase. It surfaced 442 rows and the first window checked
showed why it is wrong: the Amazon "Ordered:" mail that mints a msg- phantom
carries the item list too, so Xelsluthe and YIWENTEC each rendered TWICE again
— the exact complaint this page was fixed for today. Reverted, and the reason
recorded in place so it is not re-attempted.

Items do not separate an orphan purchase from an item-carrying echo. Only
deduplication does, and that is board 210.

No behaviour change from the previous commit; comments and counts only.
2026-08-12 18:26:24 +10:00
siddharthd 699e4a2ddd orders: cast cadence order_count to int — BigInt broke every recurring merchant
ci / lint-test (push) Successful in 43s
order_merchant_cadence.order_count is a count(*), so BIGINT, and JSON.stringify
throws on it: 'Do not know how to serialize a BigInt', a 500 carrying no SQL.
It fires only where a cadence EXISTS, so the failure hid behind the
merchant_entity_id fault and reappeared the moment that was fixed — St. Ali
(40 orders, 14-day cadence) still 500'd while Amazon opened fine.
2026-08-12 17:12:14 +10:00
siddharthd 79940202f1 orders: fix the 42703 that broke EVERY order detail page
getOrderDetail joined order_merchant_cadence on merchant_entity_id. Migration
021 rekeyed that view to merchant_key (merchant_entity_id is NULL on ~9% of the
feed, and duplicate merchant entities split one shop's history); 027 and 029
moved order_feed and order_spend across, and this query was missed. Every
/orders/<key> request has since failed with

  column rec.merchant_entity_id does not exist

The reason nobody saw a 500 is the second half of this commit: the page
collapsed every failure into 'That order could not be found.' A server fault
wearing the costume of a data condition reads as an empty spine and gets
investigated in the wrong repo. The hook now carries the status and only a
genuine 404 says the order is missing.
2026-08-12 17:10:13 +10:00
siddharthd 9790b64e1d orders: badge sender authentication, not source_trust
source_trust is 'untrusted_external' on 100% of rows — every order came from
email — so the badge marked every row and discriminated nothing. auth_verdict
(migration 029) does: 93% pass, and the 7% that do not are the ones worth
seeing. Adds the injection-scanner flag beside it.
2026-08-12 17:10:04 +10:00
siddharthd 1b34cd65d8 orders: restore the design the prototype had, and stop repeating the row
ci / lint-test (push) Successful in 49s
Rebuilding the prototype in React quietly dropped most of what made it read
well. Side by side, the live page had lost the year strip entirely — which was
both the hero and the context for the date range — along with the masthead
totals, the A$ prefix on amounts, and the Fraunces wordmark. The trust marker
rendered as a tofu box. Restored, with the strip clickable so a bar scopes to
that year.

Also three content fixes, all visible the moment anyone looked at a real page.

The title and the manifest were printing the same text twice on 1,964 of 4,649
itemised rows — 42% — because on a single-item order the order title IS the line
item. The title now earns its line only when it says something the items do not.

Food line items carry the full customisation: one Subway order runs to 450
characters naming every topping, and it swamped everything around it. Rows show
a short form, cut at the first bracket where there is one, since "Footlong
(Italian Herb…" is the product and the bracket is the customisation.

Rows now expand, following the disclosure pattern the transactions page already
uses, and the query fetches twelve items rather than three so opening one costs
no round trip.

Merchant names get a presentational tidy — corporate suffixes dropped, a
lower-cased domain capitalised. This never merges two merchants: the estate
holds amazon.com.au, Amazon.in and Amazon Services Australia as three distinct
entities, and Amazon.in must stay separate because it is a different marketplace
rather than a name variant. Actually unifying them is the merchant-alias bridge.
2026-08-12 13:28:06 +10:00
siddharthd c05c4b5a35 orders: show the context, not just the row
ci / lint-test (push) Successful in 44s
The first cut rendered entity_orders and nothing else, and it read like a table
dump: a courier tracking notice and a subscription payment both presented as
retail purchases, merchants shown as sender-domain slugs, and no indication that
the same coffee roaster had been billing fortnightly for two years.

Rows now carry what the ingestion layer already knew. A kind badge from
content_class distinguishes a delivery notice, an invoice, a booking and a
subscription from an actual purchase — NULL stays unbadged rather than being
labelled a purchase, because 48% of rows predate the interpretation index and
"unknown" is not "order". display_name shows the resolved merchant (98% of rows)
instead of the slug. A recurrence badge marks merchants billing on a cadence,
which is derived from the gaps between orders rather than stated anywhere in the
mail. And the order's own title now appears when it differs from the merchant,
so the manifest is not the only detail on the row.

Adds the services lane for bookings, invoices and subscriptions — things with no
goods and no delivery, where the retail columns are dead space.
2026-08-12 12:50:03 +10:00
siddharthd b26d526e83 orders: a browse surface for the purchase history the ledger cannot show
ci / lint-test (push) Successful in 59s
The spine holds ~6,300 purchase orders back to 2006, ~4,600 of them itemised,
and 61 reach a transaction. Everything else has been visible only through SQL.
This adds /orders and /orders/[entityKey] over it.

The point of the page is the manifest. /transactions can only ever say "AMAZON
AU MARKETPLACE SYDNEY"; a row here says what was in the box, which is the one
thing the ledger structurally cannot carry.

Five lanes, because the shapes genuinely differ — retail ends refunded or
returned 14.1% of the time against food's 5.1%, food has no meaningful ETA where
grocery has one on 54.8% of orders, digital never ships at all. The lane comes
from order_lane() in migration 018 rather than a column, because slug 'uber'
carries 394 taxi rides and 485 Eats orders.

Defaults to this year: 449 orders rather than 6,283. Twenty-one years is the
archive, not the working set.

Three things the data forced. Unknown amounts render "not stated", never $0.00,
because 1,648 orders have no amount and a zero would be false. A full reversal
strikes the figure through; a partial refund does not, since striking $191.40
when $13.33 came back is a lie — the charge stays primary and the credit sits
under it with the net. And rows with no amount, no reference and one lifecycle
event are hidden by default, which lifts amount coverage from 74% to 84%; the
toggle says on its face that it is a workaround for board 210 rather than a fix.

Reads are raw SQL in lib/order-feed.ts rather than queries.ts: the spine is
written by the ingestion-engine, is not in prisma/schema.prisma and never will
be, and mixing it into a file where everything is Prisma-modelled would destroy
that invariant.

/orders is gated by an explicit viewer allowlist. Not because the three people
listed need protecting from each other — everything here is on one person's
cards — but because a participant is an accounting entity and any participant
row with an email is a login. Adding someone to split a holiday must not
silently hand them the purchase history.

Verified live: gate returns 403 for non-participants and for a missing identity
header; search "drone" finds the DJI order through its line items; the detail
page renders its three lifecycle events and its Afterpay settlement sibling; and
a bridged Amazon order shows both split-shipment charges.
2026-08-12 12:15:16 +10:00
siddharthd 872da3b12c shared page: window the rendered rows — DOM no longer grows with history
ci / lint-test (push) Successful in 1m4s
The table rendered every split row (1,279 today, ~600/yr growth), so page
weight was unbounded even after the re-render fix. Render the first 100 of
the filtered/sorted set with Show more (+100) / Show all; any filter or
sort change resets the window. Data stays fully client-side on purpose —
search still counts matches across all history — only the DOM is capped.
Headless-measured expand stall: 165ms -> 35ms.
2026-08-10 19:46:58 +10:00
siddharthd b4f28010df shared page: fix expand freeze — cache date formatter, memoize rows
ci / lint-test (push) Successful in 1m0s
Expanding a receipt on /shared froze the browser: this table renders every
split row at once (1,279 today), and one state change re-rendered all of
them. 520ms of each pass was formatDate constructing a fresh
Intl.DateTimeFormat per call (2,558 calls per render); the rest was
rebuilding 1,279 rows to change one. Headless-measured click stall:
802ms -> 165ms on the server; slower machines multiply the former.

- module-level DATE_FMT, reused
- rows extracted into memoized SharedTxRow with stable callbacks
- filter+sort wrapped in useMemo so unrelated renders skip it
2026-08-10 19:39:19 +10:00
siddharthd 0e5422919e shared page: expandable receipt details on split transactions
ci / lint-test (push) Successful in 57s
The item list existed only on the owner's transactions page — the person
a meal or order was split WITH could see the amount but never what was in
it. getSharedTransactions now carries the same order_platform LATERAL the
transactions page uses, and rows with a receipt get the same disclosure
arrow + OrderDetails expansion. The shared viewer is a split participant,
so canAccessTransactions already authorises the order API for them; meal
rows keep the verdict UI (the partner records their own), bridge rows
stay review-free.
2026-08-10 18:27:16 +10:00
siddharthd 7f8e363b5b order details: hide the review surface for spine-bridged retail rows
ci / lint-test (push) Successful in 58s
Verdicts are the meal lane's feedback loop (loved/never steers the next
restaurant order and the nudge card warns on re-order). Bridge rows
(source='order-bridge': Amazon, eBay, Officeworks...) have no such loop,
so ItemVerdictToggle and OrderVerdict stay hidden there. The order API
now returns 'source'; OrderReceipt.platform widened to string — the
bridge writes real platform slugs beyond the meal trio.
2026-08-10 18:02:51 +10:00
siddharthd 502e3f563c 0029: rescope uq_expense_source_order for per-shipment bridge rows
ci / lint-test (push) Successful in 1m1s
Split-shipment orders are charged per shipment; the spine bridge now
writes one row per shipment sharing (source, order_reference). Meal-lane
idempotency semantics unchanged (index scope excludes only
source='order-bridge'); bridge rows get their own unique index on
(source, source_message_id). Applied 2026-08-10.
2026-08-10 17:42:45 +10:00
siddharthd 7819a88af2 order-details: null item amounts render nothing, not $0.00; qty defaults to 1
ci / lint-test (push) Successful in 1m4s
Spine-bridged receipts (Amazon shipment notices, eBay) name the goods
without per-item prices — null means 'the mail didn't say', and printing
$0.00 would assert it did.
2026-08-10 13:37:39 +10:00
siddharthd a1e776e9be docs: quote the net trip phase figures, not the raw sums
ci / lint-test (push) Successful in 47s
The phase queries apply NET_SPEND_ROWS and EXCLUDE_RECONCILED_SOURCE like every
other analytic, so committed is $21,229.56 rather than the $22,050.51 a raw sum
gives. The $820.95 gap is a partial refund on the Luxury Escapes booking, and it
is netted out on purpose.
2026-08-02 22:22:46 +10:00
siddharthd 9e4b518f57 Split a trip into its two economies, so travel stops being a 60% slab
ci / lint-test (push) Successful in 48s
travel dominated every trip page and said nothing. The tempting fix is a finer
travel taxonomy, which needs a hand-maintained merchant list — the trap #19
already describes — and it is also the wrong diagnosis.

travel is the only category that spans both phases of a trip. Every other one is
100% on-the-ground: on Europe 2026, dining, transport, entertainment, groceries
and shopping are all exactly $0.00 before departure. The chart was not bad, it
was two economies stacked into one, and travel was the only thing visible in the
union.

So split on start_date and use the axis that carries information in each phase.
Booked ahead ($22,050.51, 57%) is all flights and stays, so merchant is the axis
— Agoda $4,490, Air India $3,454, Luxury Escapes $3,284. On the ground
($16,946.94) travel falls to $8,241 among dining $4,452 and transport $2,938, and
category is finally worth charting.

The hero is the ratio, not a lone total, with the on-ground daily rate beside it
— the only figure comparable between trips, since totals are not: Europe
$677.88/day against Auckland $83.39. A trip with near-zero committed spend says
so, because Sonu + Sunny's $184.84 is a filing artefact (both legs' bookings sit
on the first trip), not a cheap trip.

Two dataviz rules this page was breaking. Category bars now use one copper hue
with the name as a direct label: the per-bar rainbow double-encoded identity the
label already carries, and the trip subset fails CVD validation on this surface
(other vs shopping at delta-E 5.0 protan, below the floor of 6). And the hero
figure drops the serif and tabular-nums, which read as decoration at that size.
The phase bar is two ordinal steps of one hue, validated with --ordinal against
the card surface, with a 2px gap so the boundary is an edge.

278 passing, build clean. Data verified against the database directly; I could
not render the page in a browser to eyeball the layout.
2026-08-02 22:20:55 +10:00
siddharthd 2d341e24a0 A negative trip balance is over-coverage, not a bill
ci / lint-test (push) Successful in 49s
You were right and my label was wrong. A payment is allocated to a trip 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, where she still owes $5,313.38 — nothing is owed
to her, and "you owe them" said the opposite.

The arithmetic never changed; only what the page claims it means. paid_to_me is
the discriminator: negative with a payment into the scope is over-coverage,
negative with no payment is genuinely owed because the viewer's share of the
other person's spending exceeds theirs. Both cases now tested. All three of
today's negatives are the first kind.

The trip table gains an Overall balance column from the unscoped participant
balances, because a single trip's figure cannot tell you whether to pay anyone,
and the footnote now says settlement is always against the overall figure.

278 passing, build clean.
2026-08-02 21:52:51 +10:00
siddharthd 6db2345c49 Net the trip debt to one figure, and show payer, category and search on Shared
ci / lint-test (push) Successful in 48s
Shared view: the query already returned owner_name and effective_category, the
table just never rendered them. Paid by sits next to Splits because together
they are whose money went out and whose share it was. Search is client-side —
this endpoint returns all 1,267 split rows in one request with no pagination, so
there is nothing for a round-trip to narrow, and the sort was already
client-side. It matches description, merchant, notes, category and payer, but not
participant names: the dropdown does that, and "sonu" matching every row she is
split on would read as broken.

Trip owed collapses to one settle-up figure per person, with the breakdown
beside it so the net is auditable rather than asserted.

I argued against netting a few hours ago and was wrong. The claim was that the
grouped-payment allocation cleared each trip against the one-directional gross,
so netting would redefine that debt after the fact. The rows say otherwise:
Europe's $802.75 is 56 transactions Sonu actually paid across Rome, Venice, the
Dolomites, Bellagio, Lucerne and Paris on which I hold 25%, and paid_by_me is
$0.00 on every row of every trip because nothing has ever been recorded going
from me to her. Her side looked settled only because the allocation derived her
payment split from her gross, so it lands on zero by construction. The
one-directional view was hiding a live obligation, not protecting an allocation.

Nets now: Auckland Sonu +$1,077.25, Europe Sonu -$802.75, Sonu + Sunny -$936.34,
Europe Molina -$816.16.

Also correcting an error in my own reporting: I said Auckland's mirror was
$0.00. It is $428.39 — 17 Auckland rows Sonu paid that I hold a split on. Two
ad-hoc verification queries mis-joined on a nullable scope column and
under-reported the mirror side. The app code was never affected and the owed
column is still byte-identical.

The footnote now states the trap the netting exposes: a debt settled by a payment
left on the household tab still reads as outstanding on the trip. Payment 5
(Molina to Sonu, $1,605.49) is exactly that case and is left alone as a data
decision.

277 passing, build clean.
2026-08-02 21:19:40 +10:00
siddharthd cb7665ded1 Let everyone on a trip see it, and give payments their scope back
ci / lint-test (push) Successful in 52s
Trips were scoped to trips.owner_id, so Sonu saw no trips at all — despite
having paid for 104 of the tagged rows herself. Her own spending was invisible
on the only page organised around it.

A participant is now anyone with a split on, who paid for, or whose payment is
scoped to, a transaction tagged to the trip. Derived, not stored. A
trip_participants table was designed and rejected: the expenses already carry
the fact, and two records of one fact drift apart. Deriving it also excludes
Singapore + Bangkok 2026 from Sonu for free, which a table would have to be kept
in sync to do. Siddharth 4 trips, Sonu 3, Molina 1.

Everything about a trip is shared except delete. Both trip foreign keys are
ON DELETE SET NULL, so deleting Europe 2026 untags 210 transactions and NULLs
the trip scope on 6 payments — where the hand-derived Europe-first allocation
lives, which nothing recomputes. That stays with the owner.

Trip owed now returns both directions and nets neither. An obligation lives on a
row someone else paid for, so a viewer-as-payer figure can never hold it, and
Sonu's Europe read "you are owed $2,408.24" while omitting the $8,004.04 she
owed. Collapsing the two into a signed net is the tempting next step and would
have corrupted the scope allocation: the grouped-payment allocation cleared each
trip against the one-directional gross, so redefining the debt afterwards turns
$8,004.04 already allocated into an $802.75 over-allocation with household
understated by the same amount. Verified byte-identical — Auckland $1,505.64,
Europe Molina -$816.16, Europe Sonu $0.00, Sonu + Sunny $0.00.

getTransactions gained trip_all_rows so a participant sees the whole trip. It is
opt-in and not implied by trip_id, because the same endpoint backs the main
transactions list and its trip filter must keep owner scoping. Participation is
re-checked in SQL, so passing the flag for someone else's trip returns nothing.

Payments can finally say what they settle. trip_id has existed since migration
0022 but POST never read it and GET never returned it, so every payment made in
the app landed on household and the 9 trip-scoped rows were hand-written SQL.
"Both" needs no new shape — one row per scope sharing a linked_transaction_id.

Three write paths had no authorisation at all and were reachable by any
participant: assignTransactionsToTrip checked nothing, DELETE on a payment
deleted by bare id, and POST accepted any from/to pair. All three now check.

Also fixes the test suite, which was pointing at postgres-pantry: container IPs
move on recreation and 172.22.0.47 stopped being postgres-personal. It only
failed safe because the credentials did not match — resetDB now refuses to
truncate anything not named personal_test.

22 new tests, 276 passing, build clean.
2026-08-02 19:11:19 +10:00
siddharthd 6e179d3a0a docs: put the UI review's priorities 1-4 on the board, and correct two claims
ci / lint-test (push) Successful in 53s
The review was written 2026-07-26 and last touched 2026-07-29, one day before
the board replaced ACTIONS.md, so it was never migrated. Priorities 1-4 existed
only in this file. They are now epic #154 with #155-#159 under it.

Two claims in the doc were wrong. It said Priority 0 was guarded by
analytics-sql.test.ts; that file covers the three SQL fragments and nothing
else, so items 2, 3 and 6 -- pace/headline parity, the fees date range, and
partial-month comparison -- have no test at all. Those are the three where a
regression is silent, which is why the gap is worth a ticket rather than a note.

And it predated two changes in the same metric-integrity family: the signed
investments line (c70d2b1) and transfers hidden by default (f6c500b). The first
matters to the doc directly -- the cashflow strip it describes now carries a
signed invested figure, so a negative month is data rather than a bug.
2026-08-02 16:44:04 +10:00
siddharthd dbc6fd1352 Add source identity to transactions so a feed can be re-imported safely
ci / lint-test (push) Successful in 51s
The CSV importer has no idempotency and structurally cannot have any:
batchInsertCSVTransactions assigns row_index = MAX(row_index) + 1, and
row_index is the fifth column of uq_transaction_identity, so the constraint
is guaranteed a fresh value on every run. The same file imported twice
produces two full sets of rows and nothing objects.

Tolerable for the hand-driven bank CSV this was built for. Not tolerable for
any recurring feed, whose windows overlap by design — and not cleanable
afterwards, since every child of transactions is ON DELETE CASCADE, so a
duplicate must be superseded rather than deleted (0023). ~$42k of re-imported
statement rows already show how that goes.

source_ref carries the provider's own key verbatim, enforced by a partial
unique index rather than an application-side ON CONFLICT that a refactor can
quietly drop.

Found while evaluating Frollo (DECISIONS.md ING-11 in the smarthome repo).
That feed is not being ingested — 88% of it duplicates existing statement
coverage — but this gap is real independently of it.
2026-08-01 22:13:07 +10:00
siddharthd c70d2b1fac Sign the investments line so withdrawals net against contributions
ci / lint-test (push) Successful in 50s
A withdrawal from a fund is a disinvestment, not income: units convert
back to cash and net worth is 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 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 March, $3,000
in May 2025, $53,000 across the window. Since net = income - spent -
investments, March's net of -$55,918.73 should read -$5,918.73.

Filing withdrawals as income is the other tempting answer and is worse:
it books an asset disposal as earnings and feeds the same figure into net
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,
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.

The budget page gates the Invested card on `!== 0` rather than `> 0` — a
net-disinvesting month is real data, not an empty one — and renders
negative months in amber so the sign is not hidden by matching digits.
2026-07-31 00:05:05 +10:00
siddharthd f6c500b27a Hide transfers in the transactions view by default
ci / lint-test (push) Successful in 51s
Transfers move money between your own accounts; at 433 of 3,996 rows
(~11%) they crowd out the rows that represent actual spending.

getTransactions gains `exclude_categories`, opt-in per caller and
deliberately not defaulted in queries.ts: the rules preview and the bulk
rule-apply path both read candidate rows through getTransactions, and a
default exclusion there would silently shrink what a rule can see and
reach — invisibly, since a rule that matches nothing looks the same as a
rule with nothing to do.

Two behaviours the filter needs, both tested:

- An explicit category pick beats the exclusion. Selecting "Transfers"
  while the default is on subtracts it from the hidden list instead of
  returning zero rows and reading as "you have no transfers".
- COALESCE the effective category to '' before `<> ALL`. NULL <> ALL(...)
  is NULL, not true, so an uncategorised row would disappear from a
  filter that never named its category — the trap EXCLUDE_NON_SPEND
  already documents.

The default is 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 the row you went there to check.
2026-07-30 23:34:31 +10:00
siddharthd 549abd8cca docs: point work tracking at the board, not ACTIONS.md
ci / lint-test (push) Successful in 41s
Status for this app now lives on the Vikunja board (saved filter
finance-app), which replaced the smarthome repo's ACTIONS.md on 2026-07-30.

Notes the thing a single-label filter hides: a ticket can carry several
system labels — the receipt→pantry work is finance-app, pantry-app and
email-ingestion at once — so the finance filter is a view, not the boundary
of what will touch this codebase.

Also records the one dated item here: postgres-personal runs PostgreSQL 14,
EOL 2026-11-12, and it holds statements, transactions, orders and
expense_metadata.
2026-07-30 23:12:50 +10:00
siddharthd a465b147a3 Read receipt lines under the keys the receipt panel renders
ci / lint-test (push) Successful in 48s
The order-details panel reads qty/description/amount from
expense_metadata.line_items. A grocery shop is a receipt like any other, so it
stores the same keys rather than name/quantity/line_total — otherwise the rows
arrive complete and display blank, which is exactly the failure ING-8 names.
unit and category are the two fields a grocery line has and a delivery line does
not; nothing renders them yet and the category composition will.
2026-07-30 13:51:33 +10:00
siddharthd 17028c79ff Accept grocery receipts scanned in pantry as candidate spend
ci / lint-test (push) Successful in 48s
Adds /api/receipts/ingest as a sibling to the order lane, sharing its shape but
making one decision differently: 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 produces no transaction produces nothing
anyone can see. Every payment becomes a manual row immediately and the existing
pending-reconciliation queue resolves the ones with a card leg coming.

One transaction per tender leg. A $114.57 shop settled $40.75 gift card +
$73.82 Mastercard has a statement line for $73.82 only. A single row marked
credits is excluded from the queue while that line double-counts; marked card it
is searched for at 1% of $114.57 and never matches. Either way the shop books
$188.39. Per-leg rows make each amount the settled amount, so the matcher works
untouched.

Reconciliation now carries expense_metadata across. It already moved overrides,
tags and splits from the manual row to the statement row and left metadata
behind, which did not matter while metadata only came from an email that made
its own transaction. It matters now that it carries a shop's line items:
unmoved, the contents vanish at exactly the moment the statement line appears,
and COLES 0556 MANOR LAKES stays as unreadable as before anything was scanned.
transaction_id is UNIQUE, so a statement row that already has metadata keeps it
and the pantry row is flagged rather than raising a constraint violation.

Also regenerates the Prisma model. card_last4, currency, flags, reconciled_at,
matched_transaction_id, platform and route have been in the database since
migrations 0019/0020 and were absent from schema.prisma — regenerating the
client from it would have dropped columns the order lane writes on every ingest.

23 integration tests against the real schema, built from the three receipts that
drove the design. Existing suites unchanged: 104 unit, 144 integration.
2026-07-30 13:40:32 +10:00
siddharthd 69b3ed8ea9 docs: catch CLAUDE.md and the UI review up with the shared-expenses rebuild
ci / lint-test (push) Successful in 45s
The redesign is live (2026-07-28); only the loan model remains a proposal.
The UI review's 'settled is dead data' guidance described the pre-rebuild
state and is superseded — settled now gates ACTIVE_OBLIGATION.
2026-07-29 22:22:42 +10:00
siddharthd a56e5e2de5 docs: splits total 100%, and why the remainder is the owner's
ci / lint-test (push) Successful in 45s
2026-07-29 10:26:44 +10:00
siddharthd 22e4a1ead0 fix(splits): make every split account for 100%
ci / lint-test (push) Successful in 1m39s
A 50/50 arrangement was stored as a single row saying "Sonu 50%". The
arithmetic was never wrong — `myShare` resolves the payer's share as
`100 - SUM(everyone else)`, so balances and per-user spend were correct
throughout. It was still a bug, because a ledger is read as well as
computed: on screen that row is a 50% share against a blank, which looks
like half the money is unallocated and is indistinguishable from a split
somebody abandoned half-finished.

It also leaked. `getSharedTransactions` filters by participant with an
EXISTS on an explicit split row, so filtering the Shared view by the
payer silently dropped every transaction where their share was only ever
implied.

Four write paths could produce it, three of them unguarded:

  - the Slack nudge's share button, which inserted one row
  - `POST /api/transactions`, where the add form shows an amber total
    under 100 but saves anyway — this is how Lawn Mowing and Hedge
    Pruning were stored
  - `applyRuleActions`, where ten of the fourteen live split rules name
    only the other person

`completeSplit` is now the single place that writes the remainder, and
every one of those paths ends in it. The remainder goes to the
transaction's *owner*, never to "me": the owner's row on their own
transaction is excluded from both halves of the balance query, so it
cannot create, enlarge or discharge a debt, whereas a row for me on
someone else's transaction is a real obligation. That distinction is
what makes this safe to apply to existing data.

Also fixes the order panel's "Shared 50/50" toggle, which was inert in
both directions: it posted a lone 50% row to share (rejected — must
total 100%) and an empty array to un-share (rejected — array required),
because no way to clear a split existed. DELETE on the splits route is
that way.

Backfill: 7 rows, verified against a row-level dump diff — 2549 -> 2556
rows, none removed, none modified — and participant balances byte
identical before and after (Molina 19556.07, Sonu 20913.35). Every split
in the database now totals 100%.

Not done: a database-level constraint. Enforcing the sum needs a
deferred constraint trigger, and the rule path commits its DELETE and
INSERT as separate statements, so the trigger would reject the
intermediate state. Making it work means wrapping every write path in a
transaction, which is a larger change than the defect warrants.
2026-07-29 10:23:06 +10:00
siddharthd dd0462a5f9 fix(orders): decode &bull; so item options separate again
ci / lint-test (push) Successful in 53s
`&bull;` was missing from the entity table, and that was not cosmetic.
DoorDash separates an item's name from its options with a bullet and
parseDoorDashLineItems splits on the literal "•" — so left encoded, the
split never happened and the line collapsed into the description:
"Bucket and Side Pack (Meal Deals) &bull; Hot Bucket &bull; Chips" with
options []. The entity showed on screen and the structure behind it was
gone.

Numeric entities are now decoded generically rather than one at a time,
which is how &#36; came to be listed individually while its neighbours
were not, and &amp; resolves last so a literal "&amp;bull;" stays as
written instead of turning into a bullet.

Repaired the 47 stored rows by re-parsing the captured email behind each
one rather than string-replacing the entity, since a replacement would
have fixed the display and left options [] underneath. Rehearsed first:
all 47 re-parsed, all 47 gained options, 0 line items lost, 0 amounts
changed. Old values kept in dump/rollback-line-items-20260728-223737.json.

Pre-existing — 22 rows predate today — but the pre-cutover backfill more
than doubled the affected rows, which is what surfaced it. Verified in
the Order details panel, not in SQL.
2026-07-28 22:38:38 +10:00
siddharthd 3144cf3176 feat(orders): record credits orders from before the cutover
ci / lint-test (push) Successful in 58s
I1 refused any credits-funded order dated before 2026-01-09, storing
nothing at all — no transaction and no metadata, so the receipt was
discarded rather than kept as history.

Its reason was splits, not spend: before the cutover 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 — and
ingestion writes no splits at any date, which now has a test of its own.

What the guard was still doing was hiding ordinary history: 275 orders,
$9,799.96 of meals and rides across 2020-2025, invisible only because the
money came from a gift-card balance instead of a card.

The funding side stays as it is, deliberately. Some of those orders were
paid from ShopBack gift cards that are themselves booked as expenses, so
that portion is counted twice. The exposure is bounded at $3,411.16 over
14 loads and is probably smaller: 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 — and six are categorised `gifts`, which 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 shown.
Only the ShopBack purchase emails can settle it, joined on total paid.
2026-07-28 22:20:03 +10:00
siddharthd fe104a9618 fix(orders): read both legs of an Uber payment
ci / lint-test (push) Successful in 53s
Uber Cash IS credits, and the payment line had become unreadable in the
newer layout: "Payments Uber Cash 10/17/25 8:50 PM A$54.87" carries a
timestamp between the label and the amount, and writes the currency as a
prefix. Both defeated the pattern, so credits_amount stayed null and the
order was filed as card-settled — sent looking for a card leg that does
not exist, found nothing, and left as an orphan with no transaction and
no card tail to match on. 118 captured messages sit in that state, and
every one of them is pre-cutover, so reading them correctly means I1
skips them rather than storing enrichment that points at nothing.

The card leg had the same blind spot, hidden behind the first: the gap
between the mask and the amount was [^\d]{0,40}, which a timestamp
breaks. While BOTH legs were unreadable a mixed payment still looked
consistent — the order read as card-settled for the full total. Fixing
only credits reads half an order, which validateOrderTotals correctly
refuses. Found exactly that way: four messages that validated before
began failing "payments sum to 34.92 but receipt states 43.60".

Legs are summed rather than taken first, because one order can be charged
in instalments and an instrument can carry no mask at all (PayPal). But a
leg that already equals the stated total IS the payment, not an
instalment: a Dubai trip prints an AED 17.67 authorisation and then the
AED 577.83 settled charge, and adding the hold overstates the trip. A
mixed credits+card order is unaffected — neither leg equals the total
there, which is why it needs summing.

A/B over all 776 captures: 468 parsed by both, zero change to any amount,
currency or existing card tail, 0 lost, 118 credits figures newly read.
Mutation-tested: dropping the exact-leg rule fails 2 tests, dropping the
Uber Cash read fails 7.
2026-07-28 21:59:35 +10:00
siddharthd 63aaf1eb21 fix(orders): read A$ totals and credits-funded receipts
ci / lint-test (push) Successful in 51s
Two parse bugs that between them made 218 of 776 captured messages
unreadable. Neither was the "old template" they were filed as.

Uber writes the currency three ways and only two were handled. "Total
A$54.87" is what it sends for ordinary Australian orders — A$ misses
[A-Z]{3} by one character — so 98 of 275 Uber Eats mails and 78 of 275
trip mails failed with "no Total found" while the amount sat in plain
sight. Most were 2024-2025, i.e. current mail. NZ$, US$, S$, HK$, C$ and
a bare rupee/euro/pound symbol are handled the same way. A bare "$" is
still left unresolved on purpose: a dozen currencies use it, and the
body-wide scan that reads the receipt's own stated code should win.

A DoorDash order paid from credits states "Total Charged $0.00"
truthfully, above a real subtotal. Read literally that is a $0 order, and
validateOrderTotals rejected 88 of them as non-positive — discarding
exactly the credit-funded spend this pipeline exists to surface. The
order's value is its subtotal; recording zero would show the order and
hide what it cost. Guarded on the receipt actually saying credits, so an
empty mail still fails rather than inheriting a stray subtotal, and the
header cross-check stands down for these or it rejects the figure the
parser deliberately overrode.

Measured A/B over all 776 captures: 254 parsed by both parsers with zero
change to any amount or currency, 0 lost, 214 newly readable. Both fixes
mutation-tested — reverting the regex fails 4 tests, removing the credits
branch fails 3.

Fixtures are real captured receipts, per the 2026-07-26 rewrite: the
earlier synthetic suite passed while the parser could not read a real
email.

No history replay — I7 idempotency refuses re-reads and that needs an
explicit update mode. This fixes ingest from here on.
2026-07-28 20:32:02 +10:00
siddharthd 7cf247951a docs: the verdict scale, and the two Slack rules that cost real data
ci / lint-test (push) Successful in 43s
2026-07-28 19:17:25 +10:00
siddharthd d458228625 feat(orders): a fifth verdict, 'bad', between ok and never again
ci / lint-test (push) Successful in 47s
The jump from "OK" to "Never again" is too big and most
disappointments live in the gap (user, 2026-07-28) — so a merely poor
meal either flattered itself as OK or got blacklisted.

Only 'never' raises the warning on a future order. A blacklist that fires
for every mediocre delivery is one nobody reads, so 'bad' records the
disappointment without triggering the alarm. Both set order_again = false
— you would not choose either again — and that split between "would I
order it" and "warn me about it" is the point of the extra level.

Migration widens the CHECK; nothing is removed, so no existing row needs
mapping.
2026-07-28 19:12:26 +10:00
siddharthd 82f751cbf4 fix(slack): update the card via response_url, not the HTTP response
ci / lint-test (push) Successful in 43s
Every press wrote correctly and then left the card showing stale state,
so a working button looked dead — and a button that looks dead gets
pressed again, which toggled the split back. That is how two orders got
unshared while looking like nothing had happened.

The cause was a wrong assumption in the original design: Block Kit
interactivity ignores the HTTP response body. Replacing a message from
the response is legacy attachment-style behaviour. The update has to go
to payload.response_url, which needs no bot token — so it stays in the
app rather than becoming another n8n node.

Two things that were also invisible now speak up. The share guard used to
return silently when it refused a three-way or uneven split, which is
indistinguishable from a broken button; it now says which it was, as an
ephemeral only the presser sees. Same for an unmapped Slack user.

The response still echoes the rendered blocks. Slack ignores them, but it
lets a card be rendered server-side without pressing anything — which is
what stops the replay tooling from hand-writing a card with a guessed
share state, the mistake that cost a real split earlier today.
2026-07-28 18:54:53 +10:00
siddharthd 9f0f38449b feat(slack): ask the other person too, and put rating before sharing
ci / lint-test (push) Successful in 43s
Two halves of the same requirement, one of which was quietly missing.

Sharing split the money but never reached her: she is not in #smarthome,
so the card whose caption said "both verdicts welcome" was one she could
not see. Now a share DMs her a card of her own. 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. She was already in SLACK_USER_MAP,
so her press files under participant 4.

Only on the press that turns sharing ON, and only when someone else did
the sharing. Re-notifying on every later rating press would turn one
shared meal into a stream of DMs, which is how a nudge gets muted.

Her card carries no share button: she is being told it was shared, not
asked to decide, and two people toggling one split from separate copies
of a card is a race with no upside.

The app still holds no Slack bot token — it returns a notify instruction
and n8n sends it, the same shape as the modal open. If SLACK_USER_MAP has
no id for her the DM is skipped silently: the split is correct and
complete either way, and failing the press over an unaddressable nudge
would be the worse trade.

Card reordered to rate -> details -> share. You judge the food, then
decide who pays for it; asking "was this shared?" first inverts the
order a person thinks in. The status caption moved under the share button
it describes rather than sitting orphaned mid-card.
2026-07-28 18:33:25 +10:00
siddharthd 0595d49d5c fix(orders): the restaurant is the merchant, not the courier
ci / lint-test (push) Successful in 43s
Reverses a change made on request. The platform in the headline
fragmented the merchant: the same restaurant read as two, depending on
who carried the bag, and that is not a distinction anyone rating the food
cares about. It also already has a home — the expandable Order details
panel renders expense_metadata.platform next to its heading, which is
where the user asked for it.

The fragmentation was worse than cosmetic. merchantVerdict joined on an
exact merchant_normalized, and the platforms capitalise differently
("TEG Kebabs & Biryani" on Uber Eats, "TEG KEBABS & BIRYANI" on
DoorDash), so one restaurant kept two separate histories and a "never
again" recorded through one app never warned in the other — silently
defeating the point of the memory. Now case-folded; verified on real
data, where the DoorDash order sees 1 prior verdict against 0 before.

81 existing descriptions backfilled in one transaction, dry-run first and
dumped beforehand. The regex is anchored to the end so suburb parens
survive: "Order - Coles (Wyndham Vale) (Uber Eats)" becomes
"Order - Coles (Wyndham Vale)", not "Order - Coles".
2026-07-28 17:51:36 +10:00
siddharthd 50c5b7c430 fix(slack): don't let one tap flatten a hand-made split
ci / lint-test (push) Successful in 43s
Splits on orders are made by hand, so a third participant or an uneven
share is a deliberate decision — and "Make it just me" deleted every
split row regardless. A one-tap button silently destroying an
arrangement made with more care than the tap that undid it is the same
failure shape as the rewrite that dropped `settled`.

Now it refuses when a participant other than the two consumers is
present, or when the share is not 50. Verified against the running stack:
a three-way split and a 70/30 both survive a press; a plain 50/50 still
toggles off and back on.

Also: the nudge reads share state instead of assuming false. Today a
freshly ingested order has no splits — the 140 that do were split by hand
after the backfill, not by a rule — but the label drives a destructive
button, so a wrong assumption there costs data rather than a cosmetic
error. One query is cheaper.
2026-07-28 16:40:53 +10:00
siddharthd 14d6b40578 feat(slack): per-item verdicts and a note, in a Slack modal
ci / lint-test (push) Successful in 42s
The card can rate an order but cannot ask which dish or why: a message
cannot collect free text, and an actions block caps at 25 elements while
item counts vary per receipt. A modal is the only Slack-native answer,
and it stays inside Slack — no browser, no app, which is the whole reason
it exists rather than a link.

The overall rating deliberately stays on the card. That is the thing done
every time and it should cost one tap; this is for when something was
notably good or bad.

finance-app holds no Slack bot token by design, so it returns the view
and n8n — which already has the credential — calls views.open. One copy
of the token, no new secret, no compose change.

Item text travels in private_metadata because a submission returns block
ids and values, never labels, so there is otherwise no way back to which
dish a radio button referred to. Capped at 20 rows: a grocery order runs
long and nobody scrolls a modal to rate a tin of tomatoes.

The modal does NOT write the rating. A form that silently reset a
decision the user did not revisit is the same class of bug as the split
rewrite that dropped `settled`.
2026-07-28 16:33:10 +10:00
siddharthd 8fbcbc5f83 fix(slack): one select instead of four rating buttons
ci / lint-test (push) Successful in 47s
Slack's mobile client gives every button in an actions block its own
full-width row, so the four ratings rendered as four stacked bars and the
nudge filled the screen (user, with a screenshot). A select is one row
and still one decision.

The interactive route now resolves both shapes — a button carries
`value`, a select carries it on `selected_option`. Reading only the
former would have left sharing working while rating silently did
nothing.
2026-07-28 16:22:49 +10:00
siddharthd aaa36dd75e feat(slack): answer the order nudge in Slack, without opening the app
ci / lint-test (push) Successful in 40s
Being sent to a web app to answer "was this shared?" is enough friction
that the question stops getting answered — which is the exact failure the
nudge exists to prevent. So the buttons now act in place: pressing
"Shared 50/50" writes the transaction_splits row and edits the message,
and the app is never opened.

Slack does NOT reach this route directly. It posts to an n8n webhook that
forwards the raw body and signature headers here (user's suggestion).
That is the better shape: n8n already terminates public webhooks, so the
app keeps its blanket OAuth chain and gains no internet-facing
unauthenticated route, and no Traefik change is needed. n8n cannot verify
the signature itself — its Code sandbox has no `require`, so no `crypto`.

Two independent gates, both failing closed: the shared x-ingest-token
(came from n8n) and Slack's v0 signature over the raw body (came from
Slack, not replayed within 5 minutes). An unset signing secret rejects
everything rather than waving it through, because the alternative turns a
misconfigured deploy into an open write endpoint.

An unmapped Slack user is refused 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.

Block Kit is built in the app (ingest returns slack_blocks) rather than
in n8n expressions: a template string is untestable, and this shape has
to stay in step with what the interactive endpoint re-renders after each
press. Null when there is no transaction yet — a card-settled order is
parked until its statement arrives, so there is nothing to split or rate.

Also: /transactions now honours ?q=, so the link lands on the row instead
of the top of an unfiltered ledger.
2026-07-28 15:50:06 +10:00
siddharthd 66a6a51fb8 docs: order verdicts are built; record the four load-bearing shape decisions
ci / lint-test (push) Successful in 41s
2026-07-28 15:43:20 +10:00
siddharthd b8919a4775 feat(transactions): honour ?q= so a link can land on one row
ci / lint-test (push) Successful in 42s
The Slack order nudge links here. 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 being opened.
2026-07-28 15:34:24 +10:00
siddharthd 0cb46a087b feat(orders): record what we thought of an order, per person
The ledger already knew we had ordered from a place; it did not know the
food was bad. Orders got repeated from places we disliked because nobody
remembered by the time the next one went in. That is what the receipt
ingestion was for (ING-9) and the last piece was missing: order_reviews
existed as a table with no API, no UI and no writes.

Four levels, not three. "Loved" and "liked" are both "would order again"
but only one is worth a detour, and "ok" is not a recommendation.

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 the old UNIQUE on transaction_id alone could not hold
it. Now UNIQUE (transaction_id, participant_id), and the default is the
signed-in user rather than the owner: Sonu authenticates through the same
Traefik OAuth as participant 4, so an owner default would have filed her
verdict under his name.

Per-item opinions key on the item DESCRIPTION, not its index. An index is
meaningless across orders; "the Pad Thai here is good" is the signal that
has to survive into the next order from the same merchant. Only the two
poles are offered — a per-item "ok" answers neither of the questions you
ask at order time.

Sharing is recorded as a real 50/50 split, not a decorative flag. The
split already IS the record that an order was shared, and two records of
one fact drift apart.

An ABSENT item_verdicts means "leave them alone"; an empty array clears
them. Without that distinction a note-only save silently 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: making
keepItems a no-op fails exactly one test.

mockDbWithPool gained queryRow. Omitting an export from the mock makes it
undefined at the call site, which fails as "not a function" and reads
like a code bug rather than a test-harness gap.
2026-07-28 15:31:22 +10:00
76 changed files with 9830 additions and 337 deletions
+2
View File
@@ -6,6 +6,8 @@ Application code lives in `src/`. Next.js App Router pages and API route handler
Keep data flow consistent: API routes call query functions in `src/lib/queries.ts`, which use `queryRaw()` from `src/lib/db.ts`; client components access APIs through TanStack Query hooks in `src/lib/hooks.ts`.
**Task tracking is the Vikunja board at `https://tasks.bosecamp.com`** (project *Work*, saved filter **finance-app**), which replaced the smarthome repo's `ACTIONS.md` on 2026-07-30. Update the ticket in the same change as the code. See `CLAUDE.md` → "Work tracking".
## Build, Test, and Development Commands
- `npm ci` installs the locked dependency set (Node 22 is used in CI).
+461 -11
View File
@@ -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
@@ -81,7 +131,7 @@ docker exec postgres-personal psql -U personal -d personal < prisma/migrations/<
### Key Tables
- `statements` — one row per billing period per bank account
- `transactions` — line items; `statement_id` is nullable (NULL = manual entry); `reconciled_with_id` links a manual tx to its matched statement tx; `payment_method` (migration 0016) is `card | cash | bank_transfer | other`, NULL = unknown
- `transactions` — line items; `statement_id` is nullable (NULL = manual entry); `reconciled_with_id` links a manual tx to its matched statement tx; `payment_method` (migration 0016) is `card | cash | bank_transfer | other`, NULL = unknown; `source` / `source_ref` / `source_account` (migration 0028) identify a row that came from an account feed rather than a statement or a hand entry — `source_ref` is the provider's own id and carries a partial unique index, which is the only thing making a re-import idempotent
### Cash and reconciliation
@@ -92,9 +142,28 @@ within 3 days and 1% on amount — and accepting one is silently destructive:
reconciled manual rows are filtered out of every query, so the cash spend
disappears while the card transaction it matched claims to be that same spend.
Only cash is excluded. Bank transfers *do* appear on a statement now that
transaction accounts are imported, and NULL means unknown — both stay
candidates, preserving the behaviour of every pre-existing row.
Only cash is excluded on payment method. Bank transfers *do* appear on a
statement now that transaction accounts are imported, and NULL means unknown —
both stay candidates, preserving the behaviour of every pre-existing row.
**Account-feed rows are NOT excluded, and the removed exclusion is worth
knowing about.** There was an `awaitsStatementLine()` predicate here, added with
the Frollo importer on the premise that a feed row is the account's own ledger
entry with no statement line coming. That premise was asserted and never tested.
It was false for almost every account: 422 of the first 550 imported rows already
had a statement twin.
What matters is how it got in. The queue jumped from 8 to 558 the moment the feed
landed, and that jump was read as noise and filtered out. **The queue was right**
those rows genuinely were provisional entries awaiting statement lines — and
filtering it removed the only mechanism that would ever have collapsed them, so
the duplicates became permanent instead of transient and stayed invisible until a
human saw one salary payment listed twice in two currencies.
A feed row belongs in the queue. Queue volume is solved **upstream**, by not
importing rows the ledger already holds (`LEDGER_MATCH_DAYS`), never downstream
by hiding the ones that are there. If a genuinely statement-less feed is ever
added, give it its own predicate and prove the premise with a query first.
ATM withdrawals stay categorised as spend rather than `transfers`. Treating them
as transfers only works if every cash purchase is logged; with partial logging
@@ -133,6 +202,37 @@ filter on `settled` — half a 2025 grocery shop was your expense whether or not
the other half was repaid. Filtering it out re-inflates exactly the figures the
historical import exists to correct.
**Every split totals 100%, and the payer's row is written down.** `myShare`
resolves the payer's share as `100 - SUM(everyone else)`, so a 50/50 stored as
a lone "Sonu 50%" row still computed correctly — and still read on screen as a
50% share against a blank. `completeSplit` (`src/lib/splits.ts`) is the single
place that materialises the remainder, and every write path ends in it:
`applyRuleActions`, `POST /api/transactions`, the Slack nudge's share button,
and the rule-revert restore. `POST /api/transactions/[id]/splits` needs no call
— it already rejects anything not summing to 100.
The remainder always goes to the transaction's **owner**, never to "me". The
owner's row on their own transaction is excluded from both halves of
`getParticipantBalances` (`ts.participant_id != $1` on transactions I own; the
converse on ones I do not), so writing it cannot create, enlarge or discharge a
debt. A row for *me* on someone else's transaction is a real obligation — never
synthesise one. This is what made the 7-row backfill in `22e4a1e` safe;
balances were byte-identical across it.
There is no database-level constraint on the sum. Enforcing it needs a deferred
constraint trigger, and the rule path commits its DELETE and INSERT as separate
autocommitted statements, so the trigger would reject the intermediate state.
`share_percent` has a CHECK of `> 0 AND <= 100`, so a 0% row cannot be stored —
when the others grow to cover the whole amount, the owner's row is deleted
rather than zeroed.
**Un-sharing needs DELETE, not an empty POST.** The splits route rejects an
empty array ("splits array required"), so `DELETE /api/transactions/[id]/splits`
is the only way to clear. The order panel's "Shared 50/50" toggle was inert in
both directions until `22e4a1e` because it posted a lone 50% row to share and
`[]` to un-share, and the endpoint rejected both.
**Any split write path that deletes-and-recreates must carry `settled` across.**
`POST /api/transactions/[id]/splits` did not, and silently converted discharged
obligations into live debt — $37,233.28 was exposed. Fixed in `6add958`.
@@ -168,7 +268,213 @@ a *cancelled* booking has both legs untagged from the trip by hand, because a
trip never incurred a cost it cancelled.
**Trips:** Europe 2026 (id 1, 19 Mar12 Apr), Auckland 2026 (id 2),
Europe — Sonu + Sunny (id 3, 1228 Apr, created 2026-07-28 from tag 5).
Europe — Sonu + Sunny (id 3, 1228 Apr, created 2026-07-28 from tag 5),
Singapore + Bangkok 2026 (id 4).
### Why `travel` looked useless on a trip page, and the fix (2026-08-02)
`travel` was ~60% of every trip and told you nothing. The tempting fix — a finer
travel taxonomy (flights / stays / getting around) — needs a hand-maintained
merchant list, which is the trap ticket #19 already describes. It is also the
wrong diagnosis.
**Measured on Europe 2026, `travel` is the only category that spans both phases
of a trip. Every other category is 100% on-the-ground — dining, transport,
entertainment, groceries and shopping are all exactly $0.00 before departure.**
So the category chart was not a bad chart; it was two different economies stacked
into one, and travel was the only thing visible in the union.
The fix is to split on `trips.start_date` and use the axis that carries
information in each phase:
- **Booked ahead** (before `start_date`, **$21,229.56 / 56%** on Europe, 30
bookings) — everything is a flight, a stay or a rail ticket, so category is a
constant and **merchant** is the axis: Agoda $4,491, Air India $3,454, Azwebin
$2,696, Luxury Escapes $2,463.
- **On the ground** (on/after `start_date`, **$16,946.77**, 180 charges) — travel
falls to $8,241 among dining $4,452, transport $2,938, entertainment $698,
groceries $589, shopping $29. **Category is finally worth charting.**
These are the **net** figures the page shows, and they are lower than a raw
`SUM(amount)` by design: the phase queries apply `NET_SPEND_ROWS` / `SPEND_SIGNED`
and `EXCLUDE_RECONCILED_SOURCE`, the same fragments as every other analytic. Raw
sums give $22,050.51 committed and Luxury Escapes at $3,283.80 — the $820.95 gap
is a partial refund on that booking. Do not "fix" the difference; a partly
refunded booking must not read at full price.
`getTripAnalytics` returns `phases`, `committed_merchants`, `on_ground_categories`
and `on_ground_daily`. A trip with a NULL `start_date` has no knowable departure,
so the SQL folds everything into on-ground rather than reporting it as committed.
**The daily rate is the only figure comparable between trips**, because totals are
not — trips differ in length. Europe $677.88/day, Sonu + Sunny $573.57, Singapore
+ Bangkok $309.76, Auckland $83.39. The page ranks the current trip against the
others from the already-loaded `useTrips()` list.
**A trip with near-zero committed spend is a filing artefact, not a cheap trip.**
Europe — Sonu + Sunny shows $184.84 committed against Europe 2026's $22,050.51
because both legs' flights and stays were filed on the first trip. The page says
so rather than letting the ratio read as missing data.
Two chart rules this page now follows, both from the `dataviz` skill and both
previously broken here:
- **One series → one colour.** The category bars use a single copper hue with the
category as a direct label. `CATEGORY_COLORS` was a per-bar rainbow, which
double-encodes identity the label already carries — and the trip subset **fails**
CVD validation on this surface (`other``shopping` ΔE 5.0 protan, below the
floor of 6). Do not reintroduce per-category colour on a labelled bar chart.
- **No serif and no `tabular-nums` on the hero figure.** Fraunces is for section
headings; a display face on a large number reads as decoration, and equal-width
digits make it look loose.
The phase split bar is two ordinal steps of one hue (`#7c4820``#d28a47`),
validated with `--ordinal` against the `#171410` card surface, with a 2px gap so
the boundary is an edge rather than a colour change. Both segments are
direct-labelled, so it needs no legend.
### Trip participation is derived, and a trip is shared
Rebuilt 2026-08-02. Trips were scoped to `trips.owner_id`, so Sonu saw **no
trips at all** despite paying for 104 of the tagged rows herself — her own
spending was invisible on the only page organised around it.
**A participant is anyone with a split on, who paid for, or whose payment is
scoped to, a transaction tagged to the trip.** Derived (`TRIP_PARTICIPANT` in
`queries.ts`), never stored. A membership table was designed and rejected: it
would be a second record of a fact the expenses already carry, and two records
of one fact drift — the same reason sharing is a real split rather than a flag.
The derivation also gets the exclusions right for free, which a table has to be
kept in sync to do: Singapore + Bangkok 2026 has no Sonu split and no Sonu
payment, so she is not a participant and never sees it. Live result is
Siddharth 4 trips, Sonu 3, Molina 1.
**Everything is shared except delete.** Read, edit and assign are open to any
participant. `deleteTrip` stays `owner_id`-only because both trip foreign keys
are `ON DELETE SET NULL`, so deleting Europe 2026 untags 210 transactions *and*
NULLs the trip scope on 6 payments — which is where the hand-derived
Europe-first allocation lives, and nothing recomputes it. The route returns 403
with the reason rather than a 404 that pretends the trip is missing.
**`getTransactions` gained `trip_all_rows`, and it is opt-in for a reason.** A
participant sees every row on a trip, not only their own — the trip total
already counts every payer. It must NOT be implied by `trip_id` being present:
`GET /api/transactions` is also the main transactions list, and its trip filter
has to keep owner scoping or filtering your own ledger by "Europe 2026" would
quietly fill it with someone else's rows. Participation is re-checked in SQL, so
passing the flag for a trip you are not on returns nothing rather than
everything. Only `trips/[id]/page.tsx` sets it.
**Trip owed is pairwise and returns BOTH directions, never netted.** `owed` is
unchanged — their share of rows *the viewer* paid. `i_owe` is the mirror: the
viewer's share of rows *that participant* paid. Rendering the pair from the
viewer's side is the whole fix; an obligation lives on a row someone else paid
for, so a viewer-as-payer figure can never contain it, and Sonu's Europe 2026
read "you are owed $2,408.24" while omitting the $8,004.04 she owed.
**The API returns both halves whole; the trip page nets them for display.** One
figure per person, with the breakdown beside it, because a net nobody can
decompose is how a wrong figure survives.
**A NEGATIVE trip net is not a bill — this is the trap, and it was got wrong
twice.** A payment is allocated to a scope as a lump sum, and the grouped-payment
allocation gave each trip enough to clear the payer's **gross** share. So netting
the other side off leaves a fully-paid trip negative by exactly what the payment
over-covered: Europe reads **$802.75** because Sonu paid $8,004.04 against a net
share of $7,201.30. That surplus is already carried in the overall balance —
**she still owes $5,313.38 overall** — so labelling it "you owe them" was flatly
wrong. Scope nets sum to the overall figure; a negative simply means this scope
was over-covered and the excess sits in another.
The discriminator is `paid_to_me`:
- negative **with** a payment into the scope → over-covered, nothing to pay
- negative **with no** payment → genuinely owed, because the viewer's share of
the other person's spending exceeds theirs
All three of today's negatives are the first kind (Europe Sonu $802.75,
Sonu + Sunny $936.34, Europe Molina $816.16). Both cases are tested. The trip
table therefore carries an **Overall balance** column from the unscoped
`getParticipantBalances` — the trip figure alone cannot tell you whether to pay
anyone, and **settlement is always against the overall figure, never one trip.**
**On whether to net — the reasoning reversed once, and the second answer is the
right one.** The first objection was that the grouped-payment allocation (memory
case `allocate_grouped_payments`) cleared each trip against the *one-directional*
gross, Europe first with the remainder to household, so netting would redefine
that debt after the fact. Checking the underlying rows overturned it: Europe's
$802.75 is **56 real transactions Sonu paid** across Rome, Venice, the Dolomites,
Bellagio, Lucerne and Paris on which Siddharth holds 25% — and `paid_by_me` is
**$0.00 on every row of every trip**, because nothing has ever been recorded
going from him to her. The one-directional view was concealing a live obligation,
not protecting an allocation. Her side was paid in full and looked settled only
because the allocation derived her payment split *from* her gross, so it lands on
zero by construction.
Current nets: Auckland Sonu **+$1,077.25** (1,505.64 428.39), Europe Sonu
**$802.75**, Sonu + Sunny **$936.34**, Europe Molina **$816.16** — the three
negatives all being over-coverage, per the rule above. The `owed` column itself
was verified byte-identical when the mirror was added — $1,505.64, $816.16,
$0.00, $0.00.
**A payment left on the household tab makes a settled trip debt read as
outstanding.** Payment 5 (Molina → Sonu, $1,605.49) discharged the Europe debt
between those two but carries `trip_id IS NULL`, so a trip-scoped net cannot see
it and Europe still shows it owing. That is the cost of scope being optional, and
the reason the Record Payment modal now asks. Fixable per row with
`UPDATE split_payments SET trip_id = 1 WHERE id = 5` — not done, it is a data
decision.
### The Shared view shows payer and category, and is searchable (2026-08-02)
`getSharedTransactions` already returned `owner_name` and `effective_category`;
the table simply never rendered them. **Paid by** sits next to **Splits**
deliberately — together they are the two halves of the question the page exists
to answer, whose money went out and whose share it was. It shows the *effective*
owner (`COALESCE(t.owner_id, s.owner_id)`), which is the account the spend left,
and the same figure every balance on the page is computed from. Category uses the
override-first COALESCE, so a correction made anywhere shows here.
Search is **client-side**, unlike the transactions page. This endpoint returns
every split row in one request (1,267 today) with no pagination, so there is
nothing for a server round-trip to narrow, and the sort was already client-side.
It matches description, merchant, notes, category and payer — deliberately **not**
participant names, because the participant dropdown already does that and typing
"sonu" matching every row she is split on would read as broken.
### Payment scope reaches the API (2026-08-02)
`split_payments.trip_id` has existed since migration 0022, but `POST
/api/split-payments` never read it and `GET` never returned it — so **every
payment recorded through the app landed on the household tab**, and the 9
trip-scoped rows had to be written by hand in SQL. A $11k Europe settlement was
silently reducing the ongoing household balance.
Both fixed. The modal has a "Settles" selector (Household or a trip) and history
shows each payment's scope as a chip. **"Both" needs no new shape:** one
transfer becomes one row per scope sharing a `linked_transaction_id`, which is
why there is deliberately no unique constraint on it — tx 4121's $4,794.06 sits
as $1,145.52 against Europe — Sonu + Sunny and $3,648.54 against household, and
tx 4111's $3,779.33 spans two trips. All six linked transfers reconcile to the
cent.
### Three write paths that had no authorisation
All closed 2026-08-02. Each was reachable by any authenticated participant:
- **`assignTransactionsToTrip`** took no caller and checked nothing, so
`PATCH /api/trips/[id]/transactions` and `POST /api/transactions/bulk`
(`assign_trip`) let anyone move any transaction id into any trip id. Not being
able to *see* a trip was no obstacle, because the write path never read one.
Now: only rows the caller can already see move, and a non-null destination must
be a trip they participate in — enforced in the query, not the route, so
neither caller can bypass it. Returns the count actually moved.
- **`DELETE /api/split-payments?id=`** deleted by id with no check at all. Erasing
a settlement silently resurrects a discharged debt — the same class of damage
as the split rewrite that reset `settled`. Now limited to the two people the
payment is between.
- **`POST /api/split-payments`** accepted any `from`/`to` pair. Now the payment
must involve the caller, and a trip scope must be a trip they are on.
**Partial split coverage inside a category is usually correct, not a gap.** Only
*shared* items are split. `utilities` sits at 69% yours because Globird, OVO, GWW
@@ -183,8 +489,75 @@ category-level ratio that "looks wrong" usually is not.
offset account, 39 rows, $37,980.24) are categorised `transfers`, indistinguishable
from ordinary internal transfers. The loan model below is unbuilt.
**`order_reviews` is a table wired to nothing** — 0 rows, no API, no UI, no
writes. The "never order from here again" capability does not exist.
### Order verdicts — "never order from here again"
Built 2026-07-28 (migrations 0024, 0025). `order_reviews` was previously a table
wired to nothing; it now backs `GET`/`PUT /api/transactions/[id]/review`, the UI
in `components/order-details.tsx`, and the Slack card in
`lib/slack-blocks.ts` + `app/api/slack/interactive/route.ts`. Logic in
`lib/order-reviews.ts`.
Five things about the shape, each load-bearing:
- **Per person, not per order.** `UNIQUE (transaction_id, participant_id)`. A
shared meal produces two opinions that routinely disagree, and the
disagreement is the useful part. `PUT` defaults to the **signed-in user**,
not the owner — Sonu authenticates through the same Traefik OAuth as
participant 4, so an owner default would file her verdict under his name.
- **Five levels** — `loved`, `liked`, `ok`, `bad`, `never`. Three collapsed the
distinction that decides a re-order; `bad` was added because the jump from
`ok` to `never again` is too big and most disappointments live in the gap.
- **Only `never` sets `warn`.** A blacklist that fires for every mediocre meal
is one nobody reads. `bad` and `never` both set `order_again = false` — you
would not choose either — but only `never` raises the alarm on a future
order. "Would I order it" and "warn me about it" are different questions.
- **Item verdicts key on the item DESCRIPTION**, not its index — an index is
meaningless across orders, and "the Pad Thai here is good" has to survive
into the next order from the same merchant. Pooled case-folded across the
merchant's orders. Only `loved`/`never`: a per-item "ok" answers neither
question you ask at order time.
- **An ABSENT `item_verdicts` means "leave them alone"; `[]` clears them.**
Without that distinction a note-only save wipes every per-item opinion — the
same shape as the bug that reset `settled` on split rewrites, and just as
invisible on screen. Mutation-tested.
**The merchant is the restaurant, not the courier.** `orderDescription` does not
append the platform — that was added on request and reversed on 2026-07-28,
because it fragmented the merchant and the platform already renders in the Order
details panel. 81 descriptions were backfilled. `merchantVerdict` joins
**case-folded**: the platforms capitalise differently (`TEG Kebabs & Biryani` vs
`TEG KEBABS & BIRYANI`) and an exact match kept two separate histories, so a
"never again" through one app never warned in the other.
The merchant signal is *derived* by aggregating on
`expense_metadata.merchant_normalized` — never `transactions.merchant_name`,
which is a bank descriptor.
**Sharing is a real 50/50 split, not a flag** — the split already IS the record,
and two records of one fact drift apart. The toggle **refuses** when a
participant outside {1, 4} is present or the second consumer's share is not 50:
splits are made by hand here, so a third party or an uneven share is deliberate
and one tap must not flatten it. It says which case it refused on.
`/api/orders/ingest` returns `prior_verdict` (so the nudge can warn inline) and
`slack_blocks` (so Block Kit stays in tested code rather than n8n expressions).
### Slack cards — two rules that cost real data
1. **Update via `response_url`, never the HTTP response body.** Block Kit
interactivity ignores the response body; replacing a message that way is
legacy attachment-style behaviour. Getting this wrong meant every press wrote
correctly and left the card stale, so a working button looked dead, got
pressed again, and toggled itself back — three splits were lost before
`conversations.history` showing `edited: false` settled it. `response_url`
needs no bot token, so the app posts it directly.
2. **Never hand-write a card.** A card built with a guessed `shared: false`
mislabels an already-shared order and the button then deletes the split.
Render by calling the interactive endpoint with a no-op verb (`<id>:noop`):
it writes nothing and returns blocks built from live state.
Slack reaches this route through an n8n webhook, not directly — see the
smarthome repo's CLAUDE.md and the `slack-interactive-via-n8n` memory.
### The shared loan
@@ -263,6 +636,55 @@ deleted, and deleting a rule must not cascade away the audit trail.
changed by something else since the run are flagged, because reverting restores
the pre-run value and discards the later edit.
### Importing an aggregator/bank CSV (`/api/import/csv`)
There is **one** import path — the CSV import modal — and Frollo goes through it
like any other file. A bespoke Frollo importer, API route, CLI and two scheduled
n8n workflows existed for a day and were deleted on 2026-08-13: net 1,152 lines.
**The rule that made the rest unnecessary is statement coverage.** Each account's
newest `billing_end_date` is a watermark; a row on or before it is already in the
ledger. On the real 2,607-row export that leaves **171 rows** — with no account
allowlist and no credit-card exclusion, because cards have current statements and
drop out on their own. Every bit of the deleted apparatus was doing by hand what
`getStatementCoverage()` does generically.
Note what this replaced. The first attempt asked whether a row's date fell inside
a statement's minmax *window*, which for accounts whose statements span 182 to
460 days swallows a year and answers nothing — it let 422 duplicates into 550
rows. Amount-matching cannot substitute either: the two sources decompose the
same event differently, bundling a Wise transfer fee into the transfer (10001.13)
where the statement itemises it (10000.00 + 1.13).
**Map these optional columns or lose something silently:**
| Column | Without it |
|---|---|
| **Account** | nothing is excluded — you review the whole file |
| **Currency** | a foreign row is stored as if AUD (USD 10,782 → A$10,782) |
| **Row ID** | a re-import duplicates instead of no-opping |
Leave **Category** unmapped for an aggregator: its spend categories are not
trusted, and you set them per row in the review step.
**Two bugs fixed the day this shipped, both of which the flow depends on:**
`batchInsertCSVTransactions` accepted a `category` and then omitted it from the
INSERT column list, so every category chosen in review was discarded and the
trigger wrote `'other'` — not cosmetic, because an uncategorised credit is
admitted by `NET_SPEND_ROWS` and negated by `SPEND_SIGNED`, so 62 imported
transfers cancelled **$74,338** of spend while counting as no income. And the
path had no idempotency at all: `row_index` is assigned MAX+1 every run, making
`uq_transaction_identity` structurally unable to fire. It now writes
`source`/`source_ref` with `ON CONFLICT DO NOTHING`.
**The in-file duplicate warning is not redundant with `source_ref`.** A CDR
re-consent re-exports an account's whole history under fresh ids while the
originals survive — 385 twins in one 2,563-row export, doubling every salary
payment. Fresh ids mean `source_ref` sees new rows, and 385 is not visible by eye
in a review table. Consents expire annually, so expect it.
Undo an import: `DELETE FROM transactions WHERE source = '<source>'`.
### Trusting extracted statement data
**Balance assertions are the check that works.** `getStatements` computes
@@ -381,6 +803,33 @@ Loan interest uses the `loan_interest` category; principal repayments use
`investment` (excluded from spend, surfaced on the investments line in monthly
analytics).
### The investments line is signed
A withdrawal from a fund is a **disinvestment**, not income. Units convert back
to cash; net worth is unchanged. `INVESTMENT_SIGNED` (`analytics-sql.ts`) makes
credits and refunds negative so they net against contributions, and
`/api/analytics/monthly` is the only consumer.
Summed unsigned, a withdrawal read as *more* money invested: March 2026 showed
$38,615.34 of investing in a month that was net **$11,384.66**, because a
$25,000 Raiz withdrawal was added to an $8,563.80 IBKR deposit instead of
cancelling it. **Each credit costs twice** — once for being added, once for not
being subtracted — so the error is double the credit, $50,000 in that month.
Filing withdrawals as `income` is the other tempting answer and is worse: it
books an asset disposal as earnings and feeds `net = income spent
investments` with a flattering sign. Same reason the Up item sales in Known Gaps
do not belong on the income line.
**What this cannot resolve:** part of a withdrawal genuinely *is* income — the
capital gain. The bank descriptor is one gross figure with no cost base
(`TRANSFER FROM RAIZ WITHDRAWAL 7D5262D8A839248A12`), so it cannot be decomposed
from statement data. Netting tracks cash committed against cash returned and
leaves the gain for holdings data to surface; it does not assert the gain is zero.
Consequence for the UI: a net-disinvesting month is real data, so the budget page
gates on `!== 0`, not `> 0`, and renders negatives in amber.
### Prisma
The schema at `prisma/schema.prisma` covers all tables. The generated client (gitignored) must be regenerated after schema changes:
@@ -408,11 +857,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 14 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,
+37 -10
View File
@@ -2,7 +2,10 @@
**Date:** 2026-07-26
**Status:** Priority 0 implemented 2026-07-27 (see below). Priorities 14 remain
proposals.
proposals, tracked on the board under epic **Analytics & Insights information
architecture** (#154) — verified 2026-08-02, nothing in 1, 2 or 4 has been
started, and 3 is partially covered by the shared-expenses rebuild rather than by
this review.
## Implementation status — Priority 0 (2026-07-27)
@@ -44,7 +47,30 @@ Also fixed while in here, both found by checking rather than by proposal:
category-day, so the pace chart ended the month a few cents off its own
headline. Both now carry 4dp and round once, at display.
Guarded by `src/__tests__/integration/analytics-sql.test.ts`.
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
@@ -530,9 +556,10 @@ The unfiltered balance cards correctly implement a running ledger:
> splits minus payments
This is coherent and should not be changed to exclude splits marked `settled`.
The `transaction_splits.settled` field is dead data and must not be used for new
UI claims.
*(Superseded 2026-07-28: the shared-expenses rebuild made `settled`
load-bearing — it gates `ACTIVE_OBLIGATION` and survives split rewrites. See
`docs/shared-expenses-design.md` for the live model; the paragraph above
described the pre-rebuild state.)*
### What the current model cannot answer
@@ -654,7 +681,7 @@ inferred from actual contributions.
## Recommended implementation order
### Priority 0 — metric integrity
### Priority 0 — metric integrity *(done 2026-07-27; test gap #155)*
1. Exclude reconciled source rows from monthly analytics.
2. Make spend pace use the same spend semantics as the headline.
@@ -663,14 +690,14 @@ inferred from actual contributions.
5. Fix Shared foreign-currency presentation.
6. Avoid partial-month versus full-month comparisons.
### Priority 1 — product hierarchy
### Priority 1 — product hierarchy *(#156)*
1. Add Overview and make it the default route.
2. Group sidebar navigation by user intent.
3. Rename `/budget` to `/analytics`.
4. Add consistent methodology and coverage indicators.
### Priority 2 — Analytics and Insights
### Priority 2 — Analytics and Insights *(#157)*
1. Simplify Analytics around period, comparison, change explanation, trend, and
drill-down.
@@ -679,7 +706,7 @@ inferred from actual contributions.
4. Add flexible-spending levers, anomalies, and a data-quality work queue.
5. Rework recurring patterns and fees into decision-oriented summaries.
### Priority 3 — Shared
### Priority 3 — Shared *(#158; items 2 and 5 landed with the shared-expenses rebuild)*
1. Add settlement contexts.
2. Link payments to real transactions.
@@ -688,7 +715,7 @@ inferred from actual contributions.
5. Backfill historical closed-context splits so long-range personal analytics
become comparable.
### Priority 4 — polish
### Priority 4 — polish *(#159)*
1. Improve mobile layouts.
2. Add accessibility semantics.
@@ -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';
@@ -0,0 +1,127 @@
-- 0030 — order ↔ transaction, many-to-many (board 205, orders phase 2)
--
-- Phase 1 read linkage out of `expense_metadata`, which is shaped as ONE ROW
-- PER TRANSACTION: `transaction_id` is UNIQUE and `matched_transaction_id`
-- partial-unique. Every multiplicity it expresses today is smuggled through a
-- string key — migration 0029 keys split shipments `<entity_key>#f<fact_id>` —
-- and a BNPL plan needs four rows for one order with no such trick available.
--
-- The flagship case: order_ebay_14-11714-95953, a A$1,599 DJI drone paid in
-- four A$399.75 Afterpay legs (txns 2318, 2333, 1652, 1664). Phase 1 shows it
-- with zero linked transactions, which is correct and useless.
--
-- KEYED ON entity_key, NOT entities.id. finance-app does not model the spine
-- and must not carry an FK across an ownership boundary that a spine
-- re-extraction can decompile. The real cascade risk is NOT transaction
-- deletion (that is handled below) but spine RE-KEYING: supersede_stale()
-- decompiles an entity when a re-extraction yields a different entity_key, and
-- a TEXT key with no FK silently orphans. Bridge-sourced links are rebuildable;
-- `manual` ones are curation and are not. Hence the orphan view at the end.
CREATE TABLE IF NOT EXISTS order_transaction_links (
id SERIAL PRIMARY KEY,
entity_key TEXT NOT NULL,
transaction_id INTEGER NOT NULL REFERENCES transactions(id) ON DELETE CASCADE,
leg_kind TEXT NOT NULL CHECK (leg_kind IN ('charge','shipment','instalment','refund','fee')),
-- HUMAN position only: "2 of 4". Never an id.
--
-- The first draft stored a fact id here. Live shipment keys already carry
-- fact ids 22664 and 22711, max(extracted_facts.id) is 23,781 today, and
-- SMALLINT tops out at 32,767 — that ceiling arrives on ordinary corpus
-- growth and the insert dies with `smallint out of range`. Fact ids go in
-- source_fact_id, which is BIGINT.
leg_index SMALLINT,
leg_count SMALLINT,
source_fact_id BIGINT,
-- The LEG's own amount, signed: positive is money out, negative is a credit
-- coming back. Storing the order total on every leg would make four rows sum
-- to four times the purchase.
amount NUMERIC(12,2) NOT NULL,
currency TEXT NOT NULL DEFAULT 'AUD',
source TEXT NOT NULL CHECK (source IN ('order-bridge','instalment-matcher','manual')),
confidence TEXT NOT NULL DEFAULT 'exact' CHECK (confidence IN ('exact','derived','manual')),
evidence JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Every index needs IF NOT EXISTS, not just the table: a run that creates the
-- table and then fails during the backfill would otherwise abort on re-run
-- with "relation already exists" and need manual surgery.
--
-- uq_otl_transaction is load-bearing. Without it two matchers can each claim
-- txn 2318 and the order reads as paid twice. It is deliberately stricter than
-- reality for one case — a single card charge covering two orders — which then
-- surfaces as a NAMED REJECTED INSERT rather than as silent duplication.
CREATE UNIQUE INDEX IF NOT EXISTS uq_otl_transaction ON order_transaction_links (transaction_id);
-- WHAT IDENTIFIES A LEG DEPENDS ON ITS KIND, and the first version of this
-- index got it wrong in the most on-brand way available. It read
-- (entity_key, leg_kind, COALESCE(leg_index, 0)), so two shipment legs of the
-- same order — both with a NULL leg_index — collapsed to the same key and
-- ON CONFLICT DO NOTHING dropped one SILENTLY. Caught only because the
-- backfill reported 62 rows against 63 candidates.
--
-- The row it ate was order_amazon_249-4859367-0690246's $130.00 second
-- shipment (txn 3171) — the same order named in migration 0029's comment as
-- the reason split shipments need distinct keys at all, because Amazon mails
-- and CHARGES per shipment. A uniqueness rule that cannot hold two shipments
-- is the exact defect 0029 was written to fix.
--
-- So: an instalment leg is identified by its INDEX (1 of 4), a shipment leg by
-- its FACT (each shipment is its own extracted fact), and a whole-order charge
-- by neither — one per order, which the zeros express.
DROP INDEX IF EXISTS uq_otl_leg;
CREATE UNIQUE INDEX IF NOT EXISTS uq_otl_leg ON order_transaction_links
(entity_key, leg_kind, COALESCE(leg_index, 0), COALESCE(source_fact_id, 0));
CREATE INDEX IF NOT EXISTS idx_otl_entity ON order_transaction_links (entity_key);
-- Backfill the existing bridge rows so the table is not empty on day one and
-- the readers can switch over in the same change. `#f<fact_id>` splits into a
-- shipment leg carrying its fact id; everything else is a whole-order charge.
INSERT INTO order_transaction_links
(entity_key, transaction_id, leg_kind, leg_index, source_fact_id, amount, currency, source, confidence, evidence)
SELECT split_part(em.source_message_id, '#', 1) AS entity_key,
COALESCE(em.matched_transaction_id, em.transaction_id) AS transaction_id,
CASE WHEN em.source_message_id LIKE '%#f%' THEN 'shipment' ELSE 'charge' END,
NULL::smallint,
NULLIF(substring(em.source_message_id FROM '#f([0-9]+)$'), '')::bigint,
COALESCE(em.amount, 0),
COALESCE(em.currency, 'AUD'),
'order-bridge',
'exact',
jsonb_build_object('backfilled_from', 'expense_metadata',
'source_message_id', em.source_message_id)
FROM expense_metadata em
WHERE em.source = 'order-bridge'
AND COALESCE(em.matched_transaction_id, em.transaction_id) IS NOT NULL
ON CONFLICT DO NOTHING;
-- Spine re-keying orphans links silently. Bridge rows can be rebuilt by
-- re-running the bridge; a `manual` orphan is lost curation and is the row
-- that actually needs a human. Same idea as order_settlement_violations.
CREATE OR REPLACE VIEW order_link_orphans AS
SELECT l.id, l.entity_key, l.transaction_id, l.leg_kind, l.leg_index,
l.amount, l.currency, l.source, l.confidence, l.created_at
FROM order_transaction_links l
WHERE NOT EXISTS (SELECT 1 FROM entities e WHERE e.entity_key = l.entity_key);
-- Two stores that can disagree will disagree. The bridge now writes both in
-- ONE transaction, so a half-written pair should be impossible — this view
-- exists to prove that rather than to assume it, and it must stay empty.
--
-- The plan's suggested guard (widen the bridge's `NOT EXISTS expense_metadata`
-- to "neither store has it") was NOT taken: the expense_metadata INSERT has no
-- ON CONFLICT, so re-attempting an order that already has a receipt row would
-- duplicate it. Detecting drift is cheap; re-attempting a non-idempotent write
-- to fix a state that cannot occur is not.
CREATE OR REPLACE VIEW order_link_drift AS
SELECT em.source_message_id,
COALESCE(em.matched_transaction_id, em.transaction_id) AS transaction_id,
em.amount, em.currency, em.reconciled_at
FROM expense_metadata em
WHERE em.source = 'order-bridge'
AND COALESCE(em.matched_transaction_id, em.transaction_id) IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM order_transaction_links l
WHERE l.transaction_id = COALESCE(em.matched_transaction_id, em.transaction_id)
);
+24 -2
View File
@@ -42,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 {
@@ -186,13 +187,16 @@ model transactions {
superseded_by_id Int?
principal_amount Decimal? @db.Decimal(12, 2)
interest_amount Decimal? @db.Decimal(12, 2)
source String? // feed this row came from, e.g. "frollo"; NULL = statement/manual (migration 0028)
source_ref String? // the provider's own transaction id — idempotency key for re-imports
source_account String? // account label as the source names it
statement statements? @relation(fields: [statement_id], references: [id], onDelete: Cascade)
reconciled_with transactions? @relation("reconciled", fields: [reconciled_with_id], references: [id], onDelete: SetNull)
reconciled_by transactions[] @relation("reconciled")
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 {
@@ -214,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")
@@ -221,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?
@@ -229,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,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&nbsp;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 restaurants pick-up&nbsp;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&nbsp;receipt to claim your&nbsp;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&nbsp;confirm youve received all&nbsp;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.&nbsp; 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
@@ -4,6 +4,7 @@ import {
EXCLUDE_RECONCILED_SOURCE,
NATIVE_CURRENCY,
AMOUNT_UNCONVERTED,
INVESTMENT_SIGNED,
} from "../../lib/analytics-sql";
/**
@@ -124,3 +125,61 @@ describe("NATIVE_CURRENCY", () => {
expect((await currencyOf(id)).ccy).toBe("AUD");
});
});
describe("INVESTMENT_SIGNED", () => {
/** The signed value the investments line would attribute to this row. */
async function signedValue(id: number): Promise<number> {
const row = await queryRow<{ v: string }>(
`SELECT (${INVESTMENT_SIGNED})::text AS v FROM transactions t WHERE t.id = $1`,
[id]
);
return Number(row!.v);
}
it("counts a contribution positive", async () => {
const id = await scratchTxn(
"transaction_date, description, amount, transaction_type, category",
"'2026-03-01','Investment fixture — deposit', 5000.00, 'debit', 'investment'"
);
expect(await signedValue(id)).toBe(5000);
});
it("counts a withdrawal negative so it nets against contributions", async () => {
// The $25,000 Raiz withdrawal that made March 2026 read as a $38,615.34
// investing month when it was net -$11,384.66.
const id = await scratchTxn(
"transaction_date, description, amount, transaction_type, category",
"'2026-03-19','Investment fixture — withdrawal', 25000.00, 'credit', 'investment'"
);
expect(await signedValue(id)).toBe(-25000);
});
it("a deposit and an equal withdrawal net to zero", async () => {
const inId = await scratchTxn(
"transaction_date, description, amount, transaction_type, category",
"'2026-03-01','Investment fixture — net in', 1000.00, 'debit', 'investment'"
);
const outId = await scratchTxn(
"transaction_date, description, amount, transaction_type, category",
"'2026-03-02','Investment fixture — net out', 1000.00, 'credit', 'investment'"
);
expect(await signedValue(inId) + await signedValue(outId)).toBe(0);
});
it("prefers amount_aud over the native amount", async () => {
// The IBKR rows are USD; the line is denominated in AUD.
const id = await scratchTxn(
"transaction_date, description, amount, amount_aud, transaction_type, category",
"'2026-07-25','Investment fixture — foreign', 10000.00, 14310.00, 'debit', 'investment'"
);
expect(await signedValue(id)).toBe(14310);
});
it("treats a refund like a withdrawal", async () => {
const id = await scratchTxn(
"transaction_date, description, amount, transaction_type, category",
"'2026-03-01','Investment fixture — reversal', 1500.00, 'refund', 'investment'"
);
expect(await signedValue(id)).toBe(-1500);
});
});
+35
View File
@@ -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,
@@ -177,21 +177,43 @@ describe("Order ingestion — invariants", () => {
expect(b.skipped).toBe("already_ingested");
expect(b.metadataId).toBe(a.metadataId);
const n = await queryRow<{ c: string }>(
`SELECT count(*)::text c FROM transactions WHERE description = 'Order - Mad Mex (DoorDash)'`
`SELECT count(*)::text c FROM transactions WHERE description = 'Order - Mad Mex'`
);
expect(Number(n!.c)).toBe(1);
});
it("I1: a credits order before the cutover is refused", async () => {
it("I1 retired: a credits order before the cutover is recorded, not refused", async () => {
// I1 used to store nothing at all for these — no transaction and no
// metadata — so the receipt was discarded. Its reason was splits (shared
// expenses lived in SplitMyExpenses before 2026-01-09), and that expired
// when ACTIVE_OBLIGATION gained its date bound: a pre-cutover split can no
// longer assert a debt, so a pre-cutover order cannot move a balance
// however it is recorded. All it was still doing was hiding history.
const p = parseOrderHTML(html("dd-01"), meta({ receivedAt: "2025-11-15T12:00:00Z" }));
const res = await processOrderIngestion(p);
expect(res.skipped).toBe("pre_cutover");
expect(res.transactionId).toBeNull();
await expect(
queryRaw(
`INSERT INTO transactions (transaction_date, amount, payment_method) VALUES ('2025-11-15', 20.00, 'credits')`
)
).rejects.toThrow();
expect(res.skipped).toBeUndefined();
expect(res.transactionId).not.toBeNull();
expect(res.flags).toContain("pre_cutover_credits_order");
const txn = await queryRow<{ transaction_date: string; payment_method: string; amount: string }>(
`SELECT transaction_date::text, payment_method, amount::text
FROM transactions WHERE id = $1`,
[res.transactionId]
);
expect(txn!.transaction_date).toBe("2025-11-15");
expect(txn!.payment_method).toBe("credits");
});
it("a pre-cutover order still creates no split, so no balance moves", async () => {
// The whole safety argument for retiring I1. Ingestion writes no splits at
// any date; if that ever changes, this fails before a balance does.
const p = parseOrderHTML(html("dd-01"), meta({ receivedAt: "2025-11-15T12:00:00Z", messageId: "pre-cut-2" }));
const res = await processOrderIngestion(p);
const n = await queryRow<{ c: string }>(
`SELECT count(*)::text c FROM transaction_splits WHERE transaction_id = $1`,
[res.transactionId]
);
expect(Number(n!.c)).toBe(0);
});
it("I11: a [Family] order records provenance and creates no transaction", async () => {
@@ -389,15 +411,17 @@ describe("owner scoping", () => {
});
describe("how an ingested order presents in the app", () => {
it("names the platform in the description", async () => {
// "Order - Burger Corner" gives no way to know where to look for the
// detail, and the same restaurant can be on both platforms.
it("names the restaurant, not the courier", async () => {
// Reversed 2026-07-28. The platform was in the headline on request, but it
// fragmented the merchant — the same restaurant read differently depending
// on who delivered — and it already has a home: the Order details panel
// renders expense_metadata.platform next to its heading.
const p = parseOrderHTML(html("dd-01"), meta({ messageId: `desc-${Date.now()}` }));
const res = await processOrderIngestion(p);
const row = await queryRow<{ description: string }>(
`SELECT description FROM transactions WHERE id = $1`, [res.transactionId]
);
expect(row!.description).toMatch(/\(DoorDash\)$/);
expect(row!.description).toBe('Order - Mad Mex');
});
it("reads as 'Gift Card', not 'Manual', and stays out of the reconcile queue", async () => {
@@ -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);
});
});
+493 -1
View File
@@ -8,7 +8,10 @@ mockDbWithPool(pool);
// Dynamic import AFTER the mock ensures getTransactions / getParticipantBalances
// use the test pool rather than Prisma's singleton.
const { getTransactions, getParticipantBalances, getTripAnalytics, getTripById, getStatements } = await import("@/lib/queries");
const {
getTransactions, getParticipantBalances, getTripAnalytics, getTripById, getStatements,
getTrips, isTripParticipant, assignTransactionsToTrip, deleteTrip,
} = await import("@/lib/queries");
beforeEach(async () => {
await resetDB(pool);
@@ -105,6 +108,63 @@ describe("getTransactions — category filter", () => {
});
});
describe("getTransactions — exclude_categories", () => {
it("hides the excluded category", async () => {
const { ownerId } = await seedParticipants(pool);
await insertTransaction(pool, ownerId, { description: "Grocery run", category: "groceries" });
await insertTransaction(pool, ownerId, { description: "Card payment", category: "transfers" });
const { data, total } = await getTransactions(ownerId, {
exclude_categories: ["transfers"], limit: 50, offset: 0,
});
expect(data).toHaveLength(1);
expect(total).toBe(1);
expect(data[0].description).toBe("Grocery run");
});
it("keeps uncategorised rows visible", async () => {
// NULL <> ALL(...) is NULL, not true. Without the COALESCE an uncategorised
// row would vanish from a filter that never named its category.
const { ownerId } = await seedParticipants(pool);
// insertTransaction defaults category to 'other', so insert directly.
await pool.query(
`INSERT INTO transactions (owner_id, statement_id, transaction_date, description, amount, transaction_type, category, row_index)
VALUES ($1, NULL, '2026-06-15', 'Unknown thing', 100, 'debit', NULL, 0)`,
[ownerId]
);
const { data } = await getTransactions(ownerId, {
exclude_categories: ["transfers"], limit: 50, offset: 0,
});
expect(data).toHaveLength(1);
expect(data[0].description).toBe("Unknown thing");
});
it("an explicit category pick beats the exclusion", async () => {
const { ownerId } = await seedParticipants(pool);
await insertTransaction(pool, ownerId, { description: "Card payment", category: "transfers" });
const { data } = await getTransactions(ownerId, {
categories: ["transfers"], exclude_categories: ["transfers"], limit: 50, offset: 0,
});
expect(data).toHaveLength(1);
});
it("respects the category override, not the raw category", async () => {
const { ownerId } = await seedParticipants(pool);
const txId = await insertTransaction(pool, ownerId, { description: "Was a transfer", category: "transfers" });
await pool.query(
`INSERT INTO transaction_overrides (transaction_id, category_override) VALUES ($1, 'investment')`,
[txId]
);
const { data } = await getTransactions(ownerId, {
exclude_categories: ["transfers"], limit: 50, offset: 0,
});
expect(data).toHaveLength(1);
});
});
describe("getTransactions — search filter", () => {
it("searches description case-insensitively", async () => {
const { ownerId } = await seedParticipants(pool);
@@ -721,3 +781,435 @@ describe("the split cutover gates every balance", () => {
expect(Number(bob?.total_owed ?? 0)).toBeCloseTo(0);
});
});
// ── Trip participation ────────────────────────────────────────────────────────
//
// Trips were scoped to `trips.owner_id`, so a co-traveller saw nothing: Sonu
// could not open a single trip despite paying for 104 of the tagged rows
// herself. Participation is DERIVED from the expenses rather than stored as a
// membership list, because a trip is all the expenses on one trip — and two
// records of one fact drift apart.
describe("trip participation — visibility", () => {
/** A trip owned by `ownerId` with one row `ownerId` paid for. */
async function tripWithOwnerRow(ownerId: number, name = "Owned Trip") {
const trip = await pool.query(
`INSERT INTO trips (owner_id, name) VALUES ($1, $2) RETURNING id`,
[ownerId, name]
);
const tripId = trip.rows[0].id as number;
const txId = await insertTransaction(pool, ownerId, { amount: 200, category: "travel" });
await pool.query(
`INSERT INTO transaction_overrides (transaction_id, trip_id) VALUES ($1, $2)`,
[txId, tripId]
);
return { tripId, txId };
}
it("shows a trip to its owner", async () => {
const { ownerId } = await seedParticipants(pool);
const { tripId } = await tripWithOwnerRow(ownerId);
const trips = await getTrips(ownerId);
expect(trips.map((t) => t.id)).toContain(tripId);
});
it("shows a trip to someone holding a split on one of its rows", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const { tripId, txId } = await tripWithOwnerRow(ownerId);
await pool.query(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
VALUES ($1, $2, 50)`,
[txId, otherId]
);
const trips = await getTrips(otherId);
expect(trips.map((t) => t.id)).toContain(tripId);
expect(await isTripParticipant(tripId, otherId)).toBe(true);
});
it("shows a trip to someone who paid for one of its rows but holds no split", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const { tripId } = await tripWithOwnerRow(ownerId);
const theirTx = await insertTransaction(pool, otherId, { amount: 80, category: "travel" });
await pool.query(
`INSERT INTO transaction_overrides (transaction_id, trip_id) VALUES ($1, $2)`,
[theirTx, tripId]
);
expect((await getTrips(otherId)).map((t) => t.id)).toContain(tripId);
});
it("shows a trip to someone whose payment is scoped to it", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const { tripId } = await tripWithOwnerRow(ownerId);
await pool.query(
`INSERT INTO split_payments (from_participant_id, to_participant_id, amount, payment_date, trip_id)
VALUES ($1, $2, 50, '2026-06-20', $3)`,
[otherId, ownerId, tripId]
);
expect((await getTrips(otherId)).map((t) => t.id)).toContain(tripId);
});
// The Singapore + Bangkok 2026 case. A trip nobody else took must not appear
// just because trips became shareable — this is the whole reason
// participation is derived from the expenses rather than granted.
it("HIDES a trip from someone with no split, no row and no payment on it", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const { tripId } = await tripWithOwnerRow(ownerId, "Solo Trip");
expect((await getTrips(otherId)).map((t) => t.id)).not.toContain(tripId);
expect(await isTripParticipant(tripId, otherId)).toBe(false);
expect(await getTripById(tripId, otherId)).toBeNull();
});
});
describe("trip owed — both directions, never netted", () => {
/** `payerId` paid a $200 travel row on the trip; `splitId` holds 50% of it. */
async function seed(payerId: number, splitId: number, ownerId: number) {
const trip = await pool.query(
`INSERT INTO trips (owner_id, name) VALUES ($1, 'Pair Trip') RETURNING id`,
[ownerId]
);
const tripId = trip.rows[0].id as number;
const txId = await insertTransaction(pool, payerId, { amount: 200, category: "travel" });
await pool.query(
`INSERT INTO transaction_overrides (transaction_id, trip_id) VALUES ($1, $2)`,
[txId, tripId]
);
await pool.query(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
VALUES ($1, $2, 50)`,
[txId, splitId]
);
return tripId;
}
it("the payer sees it as owed to them, with nothing on the mirror", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const tripId = await seed(ownerId, otherId, ownerId);
const { participant_splits } = await getTripAnalytics(tripId, ownerId);
const bob = participant_splits.find((r) => r.participant_id === otherId)!;
expect(Number(bob.owed)).toBeCloseTo(100);
expect(Number(bob.i_owe)).toBeCloseTo(0);
expect(Number(bob.i_owe_gross)).toBeCloseTo(0);
});
// The figure that could not exist before. An obligation lives on a row someone
// ELSE paid for, so a viewer-as-payer query can never contain it — which is
// why Sonu's Europe page read "you are owed $2,408.24" while omitting the
// $8,004.04 she owed.
it("the split holder sees the same figure as owed BY them", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const tripId = await seed(ownerId, otherId, ownerId);
const { participant_splits } = await getTripAnalytics(tripId, otherId);
const alice = participant_splits.find((r) => r.participant_id === ownerId)!;
expect(Number(alice.i_owe)).toBeCloseTo(100);
expect(Number(alice.owed)).toBeCloseTo(0);
});
// Europe 2026: paid in full, so the net is zero but the gross is not — the UI
// needs both to say "settled" rather than a bare "0.00".
it("keeps gross and paid alongside the net so a paid-up trip reads as settled", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const tripId = await seed(ownerId, otherId, ownerId);
await pool.query(
`INSERT INTO split_payments (from_participant_id, to_participant_id, amount, payment_date, trip_id)
VALUES ($1, $2, 100, '2026-06-20', $3)`,
[otherId, ownerId, tripId]
);
const asPayer = await getTripAnalytics(tripId, ownerId);
const bob = asPayer.participant_splits.find((r) => r.participant_id === otherId)!;
expect(Number(bob.owed)).toBeCloseTo(0);
expect(Number(bob.owed_gross)).toBeCloseTo(100);
expect(Number(bob.paid_to_me)).toBeCloseTo(100);
const asDebtor = await getTripAnalytics(tripId, otherId);
const alice = asDebtor.participant_splits.find((r) => r.participant_id === ownerId)!;
expect(Number(alice.i_owe)).toBeCloseTo(0);
expect(Number(alice.i_owe_gross)).toBeCloseTo(100);
expect(Number(alice.paid_by_me)).toBeCloseTo(100);
});
// The API returns both halves whole; the trip page nets them for display. The
// halves must stay separately available so that net is decomposable — a net
// nobody can audit is how a wrong figure survives, and it is what let Europe
// read "settled" while concealing 56 rows Sonu had paid.
it("returns each direction whole rather than pre-netted", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const tripId = await seed(ownerId, otherId, ownerId);
// A second row, paid the other way, so both directions are live at once.
const theirTx = await insertTransaction(pool, otherId, { amount: 60, category: "travel" });
await pool.query(
`INSERT INTO transaction_overrides (transaction_id, trip_id) VALUES ($1, $2)`,
[theirTx, tripId]
);
await pool.query(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
VALUES ($1, $2, 50)`,
[theirTx, ownerId]
);
const { participant_splits } = await getTripAnalytics(tripId, ownerId);
const bob = participant_splits.find((r) => r.participant_id === otherId)!;
expect(Number(bob.owed)).toBeCloseTo(100);
expect(Number(bob.i_owe)).toBeCloseTo(30);
// What the page displays as the single settle-up figure.
expect(Number(bob.owed) - Number(bob.i_owe)).toBeCloseTo(70);
});
// Europe 2026's shape exactly, and the case the UI must NOT call a debt.
//
// A payment is allocated to a trip as a lump sum covering the payer's GROSS
// share, so netting the other side off leaves the trip negative by whatever
// the payment over-covered. That surplus is carried in the overall balance,
// not owed to them — which is why the page distinguishes a negative net WITH a
// payment into the scope (over-covered) from one WITHOUT (genuinely owed).
// `paid_to_me` is what makes the two separable, so it must stay non-zero here.
it("goes negative by the over-covered amount when a payment clears the gross", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const tripId = await seed(ownerId, otherId, ownerId);
// Bob pays his $100 share in full, scoped to the trip.
await pool.query(
`INSERT INTO split_payments (from_participant_id, to_participant_id, amount, payment_date, trip_id)
VALUES ($1, $2, 100, '2026-06-20', $3)`,
[otherId, ownerId, tripId]
);
// But Alice holds a share of something Bob paid for, and never settled it.
const bobsTx = await insertTransaction(pool, otherId, { amount: 40, category: "travel" });
await pool.query(
`INSERT INTO transaction_overrides (transaction_id, trip_id) VALUES ($1, $2)`,
[bobsTx, tripId]
);
await pool.query(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
VALUES ($1, $2, 50)`,
[bobsTx, ownerId]
);
const { participant_splits } = await getTripAnalytics(tripId, ownerId);
const bob = participant_splits.find((r) => r.participant_id === otherId)!;
expect(Number(bob.owed)).toBeCloseTo(0); // his gross, fully paid
expect(Number(bob.i_owe)).toBeCloseTo(20); // her share of his spending
expect(Number(bob.owed) - Number(bob.i_owe)).toBeCloseTo(-20);
// The discriminator: he paid into this scope, so the -20 is over-coverage
// rather than a bill. Without paid_to_me the page cannot tell the two apart.
expect(Number(bob.paid_to_me)).toBeGreaterThan(0);
});
it("goes negative with no payment when the viewer's share simply exceeds theirs", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const trip = await pool.query(
`INSERT INTO trips (owner_id, name) VALUES ($1, 'Unpaid Trip') RETURNING id`,
[ownerId]
);
const tripId = trip.rows[0].id as number;
// Only one row, paid by Bob, with Alice holding half. Nobody has paid anyone.
const bobsTx = await insertTransaction(pool, otherId, { amount: 90, category: "travel" });
await pool.query(
`INSERT INTO transaction_overrides (transaction_id, trip_id) VALUES ($1, $2)`,
[bobsTx, tripId]
);
await pool.query(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
VALUES ($1, $2, 50)`,
[bobsTx, ownerId]
);
const { participant_splits } = await getTripAnalytics(tripId, ownerId);
const bob = participant_splits.find((r) => r.participant_id === otherId)!;
expect(Number(bob.owed) - Number(bob.i_owe)).toBeCloseTo(-45);
// No payment into the scope, so this negative IS Alice's to settle.
expect(Number(bob.paid_to_me)).toBeCloseTo(0);
});
it("reports whether the viewer owns the trip", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const tripId = await seed(ownerId, otherId, ownerId);
expect((await getTripAnalytics(tripId, ownerId)).viewer_is_owner).toBe(true);
expect((await getTripAnalytics(tripId, otherId)).viewer_is_owner).toBe(false);
});
});
describe("getTransactions — trip_all_rows", () => {
async function seedSharedTrip() {
const { ownerId, otherId } = await seedParticipants(pool);
const trip = await pool.query(
`INSERT INTO trips (owner_id, name) VALUES ($1, 'Shared Trip') RETURNING id`,
[ownerId]
);
const tripId = trip.rows[0].id as number;
// One row Bob holds a split on — this is what makes him a participant.
const shared = await insertTransaction(pool, ownerId, { description: "Shared hotel", category: "travel" });
// One row Bob has no stake in whatsoever.
const solo = await insertTransaction(pool, ownerId, { description: "Alice solo museum", category: "travel" });
await pool.query(
`INSERT INTO transaction_overrides (transaction_id, trip_id) VALUES ($1, $2), ($3, $2)`,
[shared, tripId, solo]
);
await pool.query(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
VALUES ($1, $2, 50)`,
[shared, otherId]
);
return { ownerId, otherId, tripId };
}
it("gives a participant every row on the trip", async () => {
const { otherId, tripId } = await seedSharedTrip();
const { data } = await getTransactions(otherId, {
trip_id: String(tripId), trip_all_rows: true, limit: 50, offset: 0,
});
expect(data.map((r) => r.description).sort()).toEqual(["Alice solo museum", "Shared hotel"]);
});
// The main transactions page filters by trip through this same endpoint. If
// the widening were implied by trip_id, filtering your own ledger by a trip
// would silently fill it with someone else's rows and skew its totals.
it("keeps owner scoping when the flag is absent", async () => {
const { otherId, tripId } = await seedSharedTrip();
const { data } = await getTransactions(otherId, {
trip_id: String(tripId), limit: 50, offset: 0,
});
expect(data.map((r) => r.description)).toEqual(["Shared hotel"]);
});
it("returns nothing to a non-participant who passes the flag", async () => {
const { ownerId } = await seedSharedTrip();
const stranger = await pool.query(
`INSERT INTO participants (name) VALUES ('Carol') RETURNING id`
);
const carolId = stranger.rows[0].id as number;
const trip = await pool.query(`SELECT id FROM trips LIMIT 1`);
const { data } = await getTransactions(carolId, {
trip_id: String(trip.rows[0].id), trip_all_rows: true, limit: 50, offset: 0,
});
expect(data).toHaveLength(0);
expect(ownerId).toBeGreaterThan(0);
});
it("does not widen anything when trip_id is 'unassigned'", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
await insertTransaction(pool, ownerId, { description: "Alice untripped" });
await insertTransaction(pool, otherId, { description: "Bob untripped" });
const { data } = await getTransactions(otherId, {
trip_id: "unassigned", trip_all_rows: true, limit: 50, offset: 0,
});
expect(data.map((r) => r.description)).toEqual(["Bob untripped"]);
});
});
describe("assignTransactionsToTrip — authorisation", () => {
it("refuses a trip the caller does not participate in", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const trip = await pool.query(
`INSERT INTO trips (owner_id, name) VALUES ($1, 'Private Trip') RETURNING id`,
[ownerId]
);
const tripId = trip.rows[0].id as number;
const bobsTx = await insertTransaction(pool, otherId, { description: "Bob lunch" });
await expect(assignTransactionsToTrip(tripId, [bobsTx], otherId)).rejects.toThrow(/participant/i);
});
// The hole this closed: the function took no caller at all, so any
// authenticated participant could move any transaction id into any trip.
it("silently skips transactions the caller cannot see", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const trip = await pool.query(
`INSERT INTO trips (owner_id, name) VALUES ($1, 'Alice Trip') RETURNING id`,
[ownerId]
);
const tripId = trip.rows[0].id as number;
const mine = await insertTransaction(pool, ownerId, { description: "Alice flight" });
const theirs = await insertTransaction(pool, otherId, { description: "Bob private" });
const moved = await assignTransactionsToTrip(tripId, [mine, theirs], ownerId);
expect(moved).toBe(1);
const rows = await pool.query(
`SELECT transaction_id FROM transaction_overrides WHERE trip_id = $1`, [tripId]
);
expect(rows.rows.map((r) => r.transaction_id)).toEqual([mine]);
});
it("lets a participant assign their own transaction to the trip", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const trip = await pool.query(
`INSERT INTO trips (owner_id, name) VALUES ($1, 'Joint Trip') RETURNING id`,
[ownerId]
);
const tripId = trip.rows[0].id as number;
// Make Bob a participant first.
const seedTx = await insertTransaction(pool, ownerId, { category: "travel" });
await pool.query(
`INSERT INTO transaction_overrides (transaction_id, trip_id) VALUES ($1, $2)`,
[seedTx, tripId]
);
await pool.query(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
VALUES ($1, $2, 50)`,
[seedTx, otherId]
);
const bobsTx = await insertTransaction(pool, otherId, { description: "Bob taxi" });
expect(await assignTransactionsToTrip(tripId, [bobsTx], otherId)).toBe(1);
});
});
// Delete is the one thing that stayed owner-only. Both trip foreign keys are
// ON DELETE SET NULL, so deleting a trip untags every transaction on it and
// drops the trip scope from its payments — including a hand-derived allocation
// that nothing recomputes.
describe("deleteTrip — owner only", () => {
it("does not delete when a non-owner participant asks", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const trip = await pool.query(
`INSERT INTO trips (owner_id, name) VALUES ($1, 'Precious Trip') RETURNING id`,
[ownerId]
);
const tripId = trip.rows[0].id as number;
const txId = await insertTransaction(pool, ownerId, { category: "travel" });
await pool.query(
`INSERT INTO transaction_overrides (transaction_id, trip_id) VALUES ($1, $2)`,
[txId, tripId]
);
await pool.query(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
VALUES ($1, $2, 50)`,
[txId, otherId]
);
// Bob can see it...
expect(await getTripById(tripId, otherId)).not.toBeNull();
await deleteTrip(tripId, otherId);
// ...and still cannot remove it, nor untag its transaction.
expect(await getTripById(tripId, ownerId)).not.toBeNull();
const still = await pool.query(
`SELECT trip_id FROM transaction_overrides WHERE transaction_id = $1`, [txId]
);
expect(still.rows[0].trip_id).toBe(tripId);
});
it("deletes when the owner asks", async () => {
const { ownerId } = await seedParticipants(pool);
const trip = await pool.query(
`INSERT INTO trips (owner_id, name) VALUES ($1, 'Doomed Trip') RETURNING id`,
[ownerId]
);
const tripId = trip.rows[0].id as number;
await deleteTrip(tripId, ownerId);
expect(await getTripById(tripId, ownerId)).toBeNull();
});
});
@@ -0,0 +1,231 @@
import { describe, it, expect, beforeAll, beforeEach } from "vitest";
import { Pool } from "pg";
import { createPool, resetDB, seedParticipants } from "./helpers";
/**
* The receipt lane, exercised against the real schema.
*
* Every fixture here is a receipt that actually exists: a Coles split-tender e-receipt
* (7777 Werribee, 11/07/2026, $114.57 settled $40.75 gift card + $73.82 Mastercard), a
* Woolworths e-receipt (3345 Wyndham Vale, 29/06/2026, $40.75, single gift card) and a
* Coles photo (556 Manor Lakes, 29/07/2026, $23.18). The split one is the reason the lane
* writes one transaction per tender leg rather than one per receipt, so it is the case
* these tests are built around.
*/
const TOKEN = "test-receipt-token";
let pool: Pool;
let POST: typeof import("../../app/api/receipts/ingest/route").POST;
const req = (body: unknown, token: string | null = TOKEN) =>
({
headers: { get: (h: string) => (h === "x-ingest-token" ? token : null) },
json: async () => body,
}) as never;
const colesSplit = () => ({
receipt_uid: "coles:7777:114:2153:2026-07-11",
capture_event_id: 412,
image_sha256: "sha-coles-split",
merchant_name: "Coles",
store_detail: "7777",
transaction_date: "2026-07-11",
total: 114.57,
tender_raw: "EFT $40.75\nEFT $73.82\n***** 0443 MASTERCARD\nCREDIT ACCOUNT STORE CARD",
tender_legs: [
{ leg_index: 1, amount: 40.75, card_last4: "0443", card_product: "STORE CARD", class: "gift_card" as const },
{ leg_index: 2, amount: 73.82, card_last4: "3302", card_product: "MASTERCARD", class: "card" as const },
],
line_items: [
{ description: "Pork Loin Roast", qty: 1, unit: "ea", amount: 15.86, category: "meat" },
{ description: "Tomatoes", qty: 1, unit: "ea", amount: 98.71, category: "produce" },
],
});
const woolworthsGiftCard = () => ({
receipt_uid: "woolworths:3345:62:148:2026-06-29",
capture_event_id: 500,
merchant_name: "Woolworths",
store_detail: "3345",
transaction_date: "2026-06-29",
total: 40.75,
tender_legs: [{ leg_index: 1, amount: 40.75, card_last4: "0443", card_product: "STORE CARD", class: "gift_card" as const }],
line_items: [{ description: "Oat Milk", qty: 2, unit: "ea", amount: 40.75, category: "dairy" }],
});
beforeAll(async () => {
process.env.RECEIPT_INGEST_TOKEN = TOKEN;
pool = createPool();
({ POST } = await import("../../app/api/receipts/ingest/route"));
});
beforeEach(async () => {
await resetDB(pool);
await pool.query("DELETE FROM expense_metadata");
// transactions.owner_id references participants, and resetDB truncates it with RESTART
// IDENTITY — so DEFAULT_OWNER_ID (1) has to be re-seeded or every insert here fails the
// foreign key. Same dependency the order lane has; it just never had to say so.
await seedParticipants(pool);
});
const legsOf = async (group: string) =>
(await pool.query(
`SELECT t.id, t.amount::text, t.payment_method, t.statement_id, em.order_reference, em.line_items, em.flags
FROM expense_metadata em JOIN transactions t ON t.id = em.transaction_id
WHERE em.receipt_group = $1 ORDER BY em.order_reference`,
[group]
)).rows;
describe("auth", () => {
it("rejects a missing or wrong token", async () => {
expect((await POST(req(colesSplit(), null))).status).toBe(401);
expect((await POST(req(colesSplit(), "nope"))).status).toBe(401);
});
it("rejects a body missing what it needs to book money", async () => {
expect((await POST(req({ merchant_name: "Coles" }))).status).toBe(400);
});
});
describe("a split-tender receipt", () => {
it("books one transaction per leg, not one for the total", async () => {
const res = await POST(req(colesSplit()));
expect(res.status).toBe(200);
const body = await res.json();
expect(body.legs).toHaveLength(2);
const rows = await legsOf("pantry:coles:7777:114:2153:2026-07-11");
expect(rows.map((r) => r.amount)).toEqual(["40.75", "73.82"]);
// Not one $114.57 row: marked credits it would let the $73.82 statement line
// double-count; marked card it would be searched for at ±1% of $114.57 and never match.
expect(rows.some((r) => r.amount === "114.57")).toBe(false);
});
it("sums the legs back to the printed total", async () => {
await POST(req(colesSplit()));
const rows = await legsOf("pantry:coles:7777:114:2153:2026-07-11");
const total = rows.reduce((sum, r) => sum + Number(r.amount), 0);
expect(Number(total.toFixed(2))).toBe(114.57);
});
it("marks the gift-card leg credits and the card leg card", async () => {
await POST(req(colesSplit()));
const rows = await legsOf("pantry:coles:7777:114:2153:2026-07-11");
expect(rows.find((r) => r.amount === "40.75")!.payment_method).toBe("credits");
expect(rows.find((r) => r.amount === "73.82")!.payment_method).toBe("card");
});
it("leaves every leg as a manual row for the reconciliation queue", async () => {
await POST(req(colesSplit()));
const rows = await legsOf("pantry:coles:7777:114:2153:2026-07-11");
expect(rows.every((r) => r.statement_id === null)).toBe(true);
});
it("puts the line items on the card leg only", async () => {
// They describe the whole shop but transaction_id is UNIQUE on expense_metadata, so
// they can attach to one row — and the card leg is the one that reconciles onto the
// statement line, which is where an unreadable descriptor gets its contents.
await POST(req(colesSplit()));
const rows = await legsOf("pantry:coles:7777:114:2153:2026-07-11");
expect(rows.find((r) => r.amount === "73.82")!.line_items).toHaveLength(2);
expect(rows.find((r) => r.amount === "40.75")!.line_items).toHaveLength(0);
});
it("flags the split so it can be shown as one purchase", async () => {
await POST(req(colesSplit()));
const rows = await legsOf("pantry:coles:7777:114:2153:2026-07-11");
expect(rows[0].flags).toContain("split_tender");
});
});
describe("a gift-card receipt", () => {
it("becomes a visible transaction on the day it is scanned", async () => {
const res = await POST(req(woolworthsGiftCard()));
expect(res.status).toBe(200);
const rows = await legsOf("pantry:woolworths:3345:62:148:2026-06-29");
expect(rows).toHaveLength(1);
expect(rows[0].amount).toBe("40.75");
expect(rows[0].payment_method).toBe("credits");
});
it("is owned, so it is not invisible in every view", async () => {
// Owner scoping is COALESCE(t.owner_id, s.owner_id) and these rows carry no statement;
// a NULL owner is how 85 backfilled order rows ended up in the table and on no screen.
await POST(req(woolworthsGiftCard()));
const { rows } = await pool.query("SELECT owner_id FROM transactions WHERE merchant_name = 'Woolworths'");
expect(rows[0].owner_id).not.toBeNull();
});
});
describe("idempotency", () => {
it("keys on the receipt, so the same shop from two files lands once", async () => {
// A photo and the store's e-receipt PDF are different files with different hashes.
// Keying on the capture would let one purchase arrive twice.
await POST(req(colesSplit()));
await POST(req({ ...colesSplit(), capture_event_id: 999, image_sha256: "sha-different-file" }));
const rows = await legsOf("pantry:coles:7777:114:2153:2026-07-11");
expect(rows).toHaveLength(2); // still just the two legs
});
it("reports the replay rather than silently doing nothing", async () => {
await POST(req(colesSplit()));
const body = await (await POST(req(colesSplit()))).json();
expect(body.legs.every((l: { skipped?: string }) => l.skipped === "already_ingested")).toBe(true);
});
it("falls back to the capture when the receipt did not identify itself", async () => {
// A crumpled photo can lose the header entirely. That receipt still becomes spend; it
// just cannot be recognised if the same shop is scanned again from another file.
await POST(req({ ...colesSplit(), receipt_uid: null }));
expect(await legsOf("pantry:capture:412")).toHaveLength(2);
});
});
describe("validation — what becomes money", () => {
it("refuses a receipt whose tender does not add up to its total", async () => {
// A missed leg is spend that never appears; booking the rest would put a number in the
// ledger nobody can stand behind.
const broken = { ...colesSplit(), tender_legs: [colesSplit().tender_legs[0]] };
const res = await POST(req(broken));
expect(res.status).toBe(422);
expect((await res.json()).reason).toContain("tender legs sum to 40.75");
expect(await legsOf("pantry:coles:7777:114:2153:2026-07-11")).toHaveLength(0);
});
it("refuses a receipt with no tender at all", async () => {
expect((await POST(req({ ...colesSplit(), tender_legs: [] }))).status).toBe(422);
});
it("flags but accepts lines that do not sum, because promo rows are skipped by design", async () => {
const res = await POST(req({ ...colesSplit(), line_items: [{ description: "One line", amount: 10 }] }));
expect(res.status).toBe(200);
expect((await res.json()).flags.some((f: string) => f.startsWith("line_items_sum_"))).toBe(true);
});
it("writes nothing at all when validation fails", async () => {
await POST(req({ ...colesSplit(), tender_legs: [] }));
const { rows } = await pool.query("SELECT count(*)::int AS n FROM transactions");
expect(rows[0].n).toBe(0);
});
it("marks a leg nobody could classify as reconcilable rather than deciding for them", async () => {
const unknown = { ...colesSplit(), tender_legs: [{ leg_index: 1, amount: 114.57, card_last4: "9999", card_product: null, class: null }] };
const res = await POST(req(unknown));
expect(res.status).toBe(200);
const rows = await legsOf("pantry:coles:7777:114:2153:2026-07-11");
// NULL is what needsCardMatch() treats as still needing a card match, so it surfaces in
// the queue instead of being silently excluded from it.
expect(rows[0].payment_method).toBeNull();
expect(rows[0].flags).toContain("unclassified_tender");
});
});
describe("dry run", () => {
it("validates without writing", async () => {
const res = await POST(req({ ...colesSplit(), dryRun: true }));
expect(res.status).toBe(200);
expect((await res.json()).dryRun).toBe(true);
const { rows } = await pool.query("SELECT count(*)::int AS n FROM transactions");
expect(rows[0].n).toBe(0);
});
});
@@ -0,0 +1,166 @@
import { describe, it, expect, beforeAll, beforeEach, vi } from "vitest";
import { Pool } from "pg";
import { createPool, resetDB, seedParticipants } from "./helpers";
/**
* What happens to a scanned receipt when its statement finally arrives.
*
* This is the case the first design of this lane got wrong, so it is tested first-class.
* Reconciliation moves a manual row's overrides, tags and splits onto the statement row and
* then hides the manual row from every figure. `expense_metadata` was the one child it left
* behind — which did not matter while metadata only ever came from an email that had made
* its own transaction, and matters completely now that it carries a shop's line items. Left
* unmoved, the contents of the shop vanish at exactly the moment the statement line shows
* up, and `COLES 0556 MANOR LAKES` stays as unreadable as it was before anything was
* scanned.
*/
const TOKEN = "test-receipt-token";
let pool: Pool;
let ownerId: number;
let ingest: typeof import("../../app/api/receipts/ingest/route").POST;
let reconcile: typeof import("../../app/api/transactions/reconcile/route").POST;
const ingestReq = (body: unknown) =>
({ headers: { get: (h: string) => (h === "x-ingest-token" ? TOKEN : null) }, json: async () => body }) as never;
// The reconcile route authenticates a browser session rather than a shared secret.
const userReq = (body: unknown) => ({ json: async () => body }) as never;
const colesSplit = {
receipt_uid: "coles:7777:114:2153:2026-07-11",
capture_event_id: 412,
merchant_name: "Coles",
transaction_date: "2026-07-11",
total: 114.57,
tender_legs: [
{ leg_index: 1, amount: 40.75, card_last4: "0443", card_product: "STORE CARD", class: "gift_card" },
{ leg_index: 2, amount: 73.82, card_last4: "3302", card_product: "MASTERCARD", class: "card" },
],
line_items: [
{ description: "Pork Loin Roast", qty: 1, unit: "ea", amount: 15.86, category: "meat" },
{ description: "Jasmine Rice 1kg", qty: 1, unit: "ea", amount: 98.71, category: "pantry_dry" },
],
};
async function statementLine(amount: number, date: string, description: string) {
const statement = await pool.query(
`INSERT INTO statements (filename, account_number, bank_name, billing_start_date, billing_end_date, owner_id)
VALUES ('sept.pdf', '1234-5678-9012-3302', 'Test Bank', $1::date - 20, $1::date + 10, $2) RETURNING id`,
[date, ownerId]
);
const txn = await pool.query(
`INSERT INTO transactions (statement_id, transaction_date, description, amount, transaction_type, owner_id)
VALUES ($1, $2, $3, $4, 'debit', $5) RETURNING id`,
[statement.rows[0].id, date, description, amount, ownerId]
);
return txn.rows[0].id as number;
}
beforeAll(async () => {
process.env.RECEIPT_INGEST_TOKEN = TOKEN;
pool = createPool();
// The reconcile route scopes every query to the signed-in user. Mocked before the import
// (doMock is not hoisted, so it can close over `ownerId`, which resetDB reassigns on each
// run) because an ES module binding cannot be reassigned afterwards.
vi.doMock("@/lib/auth", () => ({
getCurrentUser: async () => ({ id: ownerId, name: "Alice", email: "alice@example.com" }),
}));
({ POST: ingest } = await import("../../app/api/receipts/ingest/route"));
({ POST: reconcile } = await import("../../app/api/transactions/reconcile/route"));
});
beforeEach(async () => {
await resetDB(pool);
await pool.query("DELETE FROM expense_metadata");
({ ownerId } = await seedParticipants(pool));
});
const reconcileAs = (manualId: number, statementId: number) =>
reconcile(userReq({ matches: [{ manual_id: manualId, statement_tx_id: statementId }] }));
describe("a card leg meeting its statement line", () => {
it("carries the shop's line items onto the statement row", async () => {
await ingest(ingestReq(colesSplit));
const manual = await pool.query(
`SELECT t.id FROM transactions t JOIN expense_metadata em ON em.transaction_id = t.id
WHERE em.order_reference = 'pantry:coles:7777:114:2153:2026-07-11#2'`
);
const manualId = manual.rows[0].id as number;
const statementId = await statementLine(73.82, "2026-07-12", "COLES 7777 WERRIBEE");
const res = await reconcileAs(manualId, statementId);
expect(res.status ?? 200).toBe(200);
const moved = await pool.query(`SELECT transaction_id, line_items FROM expense_metadata WHERE order_reference = $1`, [
"pantry:coles:7777:114:2153:2026-07-11#2",
]);
// The whole point: the items are now on the row that survives, not the one that got hidden.
expect(moved.rows[0].transaction_id).toBe(statementId);
expect(moved.rows[0].line_items).toHaveLength(2);
});
it("counts the shop once, not twice", async () => {
await ingest(ingestReq(colesSplit));
const manual = await pool.query(
`SELECT t.id FROM transactions t JOIN expense_metadata em ON em.transaction_id = t.id
WHERE em.order_reference = 'pantry:coles:7777:114:2153:2026-07-11#2'`
);
const statementId = await statementLine(73.82, "2026-07-12", "COLES 7777 WERRIBEE");
await reconcileAs(manual.rows[0].id, statementId);
// Reconciled manual rows are excluded from figures by reconciled_with_id; what remains
// live is the gift-card leg plus the statement line = the $114.57 that was actually spent.
const { rows } = await pool.query(
`SELECT coalesce(sum(amount), 0)::text AS total FROM transactions
WHERE reconciled_with_id IS NULL AND superseded_by_id IS NULL`
);
expect(Number(rows[0].total)).toBeCloseTo(114.57, 2);
});
it("leaves the gift-card leg alone", async () => {
await ingest(ingestReq(colesSplit));
const manual = await pool.query(
`SELECT t.id FROM transactions t JOIN expense_metadata em ON em.transaction_id = t.id
WHERE em.order_reference = 'pantry:coles:7777:114:2153:2026-07-11#2'`
);
const statementId = await statementLine(73.82, "2026-07-12", "COLES 7777 WERRIBEE");
await reconcileAs(manual.rows[0].id, statementId);
const gift = await pool.query(
`SELECT t.reconciled_with_id, t.payment_method FROM transactions t
JOIN expense_metadata em ON em.transaction_id = t.id
WHERE em.order_reference = 'pantry:coles:7777:114:2153:2026-07-11#1'`
);
expect(gift.rows[0].reconciled_with_id).toBeNull();
expect(gift.rows[0].payment_method).toBe("credits");
});
it("keeps the other source's metadata when the statement row already has some", async () => {
// An emailed or Paperless copy of the same purchase may have got there first.
// transaction_id is UNIQUE, so one of them has to lose — and it must lose visibly
// rather than by constraint violation at 11pm.
await ingest(ingestReq(colesSplit));
const manual = await pool.query(
`SELECT t.id FROM transactions t JOIN expense_metadata em ON em.transaction_id = t.id
WHERE em.order_reference = 'pantry:coles:7777:114:2153:2026-07-11#2'`
);
const statementId = await statementLine(73.82, "2026-07-12", "COLES 7777 WERRIBEE");
await pool.query(
`INSERT INTO expense_metadata (transaction_id, source, order_reference, line_items)
VALUES ($1, 'email', 'email:already-here', '[]'::jsonb)`,
[statementId]
);
const res = await reconcileAs(manual.rows[0].id, statementId);
expect(res.status ?? 200).toBe(200);
const incumbent = await pool.query(`SELECT source FROM expense_metadata WHERE transaction_id = $1`, [statementId]);
expect(incumbent.rows.map((r) => r.source)).toEqual(["email"]);
const pantryRow = await pool.query(`SELECT flags FROM expense_metadata WHERE order_reference = $1`, [
"pantry:coles:7777:114:2153:2026-07-11#2",
]);
expect(JSON.stringify(pantryRow.rows[0].flags)).toContain("metadata_collision_on_reconcile");
});
});
+304
View File
@@ -0,0 +1,304 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
import type { Pool } from "pg";
import {
createPool,
mockDbWithPool,
resetDB,
seedParticipants,
insertTransaction,
} from "./helpers";
/**
* Every split adds up to 100%.
*
* The failure this guards against is not an arithmetic one — `myShare` has
* always treated the payer's share as the remainder, so the numbers were right.
* It is that the remainder was never written down, so a 50/50 arrangement was
* stored as a single row reading "Sonu 50%" and displayed as half a split.
*/
describe("completeSplit", () => {
let pool: Pool;
let completeSplit: (id: number) => Promise<void>;
beforeAll(async () => {
pool = createPool();
mockDbWithPool(pool);
({ completeSplit } = await import("@/lib/splits"));
});
afterAll(async () => {
await pool.end();
});
beforeEach(async () => {
await resetDB(pool);
});
const sharesOf = async (txId: number) => {
const r = await pool.query(
`SELECT participant_id, share_percent::float FROM transaction_splits
WHERE transaction_id = $1 ORDER BY participant_id`,
[txId]
);
return r.rows as { participant_id: number; share_percent: number }[];
};
it("writes the payer's half of a 50/50 recorded as one row", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const txId = await insertTransaction(pool, ownerId);
await pool.query(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
VALUES ($1, $2, 50)`,
[txId, otherId]
);
await completeSplit(txId);
expect(await sharesOf(txId)).toEqual([
{ participant_id: ownerId, share_percent: 50 },
{ participant_id: otherId, share_percent: 50 },
]);
});
it("leaves an unsplit transaction unsplit", async () => {
// A transaction nobody shares is not a 100% split of itself. Writing one
// would put every row in the Shared view.
const { ownerId } = await seedParticipants(pool);
const txId = await insertTransaction(pool, ownerId);
await completeSplit(txId);
expect(await sharesOf(txId)).toEqual([]);
});
it("adds nothing when the other party owes all of it", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const txId = await insertTransaction(pool, ownerId);
await pool.query(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
VALUES ($1, $2, 100)`,
[txId, otherId]
);
await completeSplit(txId);
expect(await sharesOf(txId)).toEqual([
{ participant_id: otherId, share_percent: 100 },
]);
});
it("removes the owner's row when the others grow to cover the whole amount", async () => {
// A 50/50 revised to "they owe all of it". The owner's share becomes zero,
// and a 0% row cannot be stored anyway — `share_percent > 0` is a CHECK
// constraint — so the row has to go rather than be zeroed.
const { ownerId, otherId } = await seedParticipants(pool);
const txId = await insertTransaction(pool, ownerId);
await pool.query(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
VALUES ($1, $2, 100), ($1, $3, 50)`,
[txId, otherId, ownerId]
);
await completeSplit(txId);
expect(await sharesOf(txId)).toEqual([
{ participant_id: otherId, share_percent: 100 },
]);
});
it("fills the remainder for a three-way split, not a half", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const third = (
await pool.query(`INSERT INTO participants (name) VALUES ('Carol') RETURNING id`)
).rows[0].id as number;
const txId = await insertTransaction(pool, ownerId);
await pool.query(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
VALUES ($1, $2, 50), ($1, $3, 25)`,
[txId, otherId, third]
);
await completeSplit(txId);
expect(await sharesOf(txId)).toEqual([
{ participant_id: ownerId, share_percent: 25 },
{ participant_id: otherId, share_percent: 50 },
{ participant_id: third, share_percent: 25 },
]);
});
it("leaves an over-allocated split alone instead of trimming someone's share", async () => {
// >100% is a caller's mistake. Silently deleting a share to force the total
// down would destroy the evidence of it.
// No single row may exceed 100 (CHECK constraint), but two can add up past
// it — 60 + 60 is how an over-allocated split actually arrives.
const { ownerId, otherId } = await seedParticipants(pool);
const third = (
await pool.query(`INSERT INTO participants (name) VALUES ('Dave') RETURNING id`)
).rows[0].id as number;
const txId = await insertTransaction(pool, ownerId);
await pool.query(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
VALUES ($1, $2, 60), ($1, $3, 60)`,
[txId, otherId, third]
);
await completeSplit(txId);
expect(await sharesOf(txId)).toEqual([
{ participant_id: otherId, share_percent: 60 },
{ participant_id: third, share_percent: 60 },
]);
});
it("gives the remainder to the statement's owner when the row has none", async () => {
// Statement rows carry no owner_id of their own; it comes from the
// statement. Transaction 3828 was one of these.
const { ownerId, otherId } = await seedParticipants(pool);
const stmt = await pool.query(
`INSERT INTO statements (owner_id, filename, bank_name, account_number, billing_start_date, billing_end_date)
VALUES ($1, 'test.pdf', 'Test Bank', '0001', '2026-06-01', '2026-06-30') RETURNING id`,
[ownerId]
);
const tx = await pool.query(
`INSERT INTO transactions (owner_id, statement_id, transaction_date, description, amount, transaction_type, row_index)
VALUES (NULL, $1, '2026-06-15', 'Statement row', 29.17, 'debit', 0) RETURNING id`,
[stmt.rows[0].id]
);
const txId = tx.rows[0].id as number;
await pool.query(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
VALUES ($1, $2, 50)`,
[txId, otherId]
);
await completeSplit(txId);
expect(await sharesOf(txId)).toEqual([
{ participant_id: ownerId, share_percent: 50 },
{ participant_id: otherId, share_percent: 50 },
]);
});
it("never puts my share on someone else's transaction", async () => {
// The remainder goes to the transaction's owner, never to "me". A row for
// me on a transaction I do not own is a debt I owe, and this helper must
// not invent one.
const { ownerId, otherId } = await seedParticipants(pool);
const txId = await insertTransaction(pool, otherId); // they paid
await pool.query(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
VALUES ($1, $2, 50)`,
[txId, ownerId]
);
await completeSplit(txId);
const shares = await sharesOf(txId);
expect(shares).toEqual([
{ participant_id: ownerId, share_percent: 50 },
{ participant_id: otherId, share_percent: 50 },
]);
// My share is unchanged — the new row belongs to the payer.
expect(shares.find((s) => s.participant_id === ownerId)?.share_percent).toBe(50);
});
it("does not disturb a settled split", async () => {
// Adding the payer's row must not touch anyone else's `settled` flag —
// that is how $37k of discharged debt gets resurrected.
const { ownerId, otherId } = await seedParticipants(pool);
const txId = await insertTransaction(pool, ownerId);
await pool.query(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent, settled)
VALUES ($1, $2, 50, true)`,
[txId, otherId]
);
await completeSplit(txId);
const r = await pool.query(
`SELECT settled FROM transaction_splits
WHERE transaction_id = $1 AND participant_id = $2`,
[txId, otherId]
);
expect(r.rows[0].settled).toBe(true);
});
it("is idempotent", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const txId = await insertTransaction(pool, ownerId);
await pool.query(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
VALUES ($1, $2, 50)`,
[txId, otherId]
);
await completeSplit(txId);
const once = await sharesOf(txId);
await completeSplit(txId);
expect(await sharesOf(txId)).toEqual(once);
});
});
/**
* The rule path, which is where ten of the live rules write a single share.
*/
describe("applyRuleActions completes the split", () => {
let pool: Pool;
let applyRuleActions: (
id: number,
actions: { apply_split?: { participant_id: number; share_percent: number }[] }
) => Promise<void>;
beforeAll(async () => {
pool = createPool();
mockDbWithPool(pool);
({ applyRuleActions } = await import("@/lib/rule-actions"));
});
afterAll(async () => {
await pool.end();
});
beforeEach(async () => {
await resetDB(pool);
});
it("writes the payer's half for a rule that names only the other person", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const txId = await insertTransaction(pool, ownerId, { description: "Woolworths" });
await applyRuleActions(txId, {
apply_split: [{ participant_id: otherId, share_percent: 50 }],
});
const r = await pool.query(
`SELECT participant_id, share_percent::float FROM transaction_splits
WHERE transaction_id = $1 ORDER BY participant_id`,
[txId]
);
expect(r.rows).toEqual([
{ participant_id: ownerId, share_percent: 50 },
{ participant_id: otherId, share_percent: 50 },
]);
});
it("leaves a rule that already names both alone", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const txId = await insertTransaction(pool, ownerId);
await applyRuleActions(txId, {
apply_split: [
{ participant_id: ownerId, share_percent: 50 },
{ participant_id: otherId, share_percent: 50 },
],
});
const r = await pool.query(
`SELECT sum(share_percent)::float AS total, count(*)::int AS n
FROM transaction_splits WHERE transaction_id = $1`,
[txId]
);
expect(r.rows[0]).toEqual({ total: 100, n: 2 });
});
});
@@ -0,0 +1,216 @@
import { describe, it, expect } from "vitest";
import {
applyMapping,
inFileDuplicates,
last4,
splitByCoverage,
type ColumnMapping,
type ParsedTransaction,
} from "@/lib/csv-parser";
/**
* The statement-coverage rule, which replaced a whole Frollo-specific importer.
*
* The first aggregator import wrote 422 duplicate rows out of 550 — $1,023,824
* of movement counted twice — because "these accounts issue no statements" was
* asserted rather than queried. Amount-matching could not fix it either: the two
* sources decompose the same event differently, bundling a transfer fee where
* the statement itemises it.
*
* A statement's end date answers the question that actually has an answer: up
* to what date is this account complete? On the real 2026-08-13 export it takes
* 2,607 rows down to 171, with no account allowlist and no card exclusions.
*/
const tx = (o: Partial<ParsedTransaction> & { date: string }): ParsedTransaction => ({
description: "SOMETHING",
amount: 10,
transaction_type: "debit",
...o,
});
describe("last4", () => {
it("reads the same account written four different ways", () => {
// Every one of these forms appears in the real data.
expect(last4("xxxxxxxxxxxx2176")).toBe("2176");
expect(last4("235242176")).toBe("2176");
expect(last4("xxx-xxx xx4878")).toBe("4878");
expect(last4("4085-56264")).toBe("6264");
});
it("returns empty for an unusable identifier rather than guessing", () => {
expect(last4("N/A")).toBe("");
expect(last4("12")).toBe("");
expect(last4("")).toBe("");
});
});
describe("splitByCoverage", () => {
const coverage = [{ last4: "2176", coveredTo: "2026-07-26" }];
it("excludes a row on or before the account's newest statement", () => {
const { keep, covered } = splitByCoverage(
[tx({ date: "2026-07-25", account: "xxxxxxxxxxxx2176" })],
coverage
);
expect(covered).toHaveLength(1);
expect(keep).toHaveLength(0);
});
it("treats the watermark date itself as covered", () => {
const { covered } = splitByCoverage(
[tx({ date: "2026-07-26", account: "xxxxxxxxxxxx2176" })],
coverage
);
expect(covered).toHaveLength(1);
});
it("keeps a row after the watermark — the whole point of the feed", () => {
// The 2026-08-12 salary: the statement has not arrived, so this row is the
// only record of it and must survive.
const { keep } = splitByCoverage(
[tx({ date: "2026-08-12", account: "xxxxxxxxxxxx2176", amount: 10782 })],
coverage
);
expect(keep).toHaveLength(1);
});
it("keeps everything for an account with no statements", () => {
const { keep, covered } = splitByCoverage(
[tx({ date: "2020-01-01", account: "xxx-xxx xx4878" })],
coverage
);
expect(keep).toHaveLength(1);
expect(covered).toHaveLength(0);
});
it("keeps everything when no account column was mapped", () => {
// A single-account bank export has no account column, and a row must never
// be dropped on a guess about which account it belongs to.
const { keep, covered } = splitByCoverage([tx({ date: "2020-01-01" })], coverage);
expect(keep).toHaveLength(1);
expect(covered).toHaveLength(0);
});
it("applies each account's own watermark, not a global one", () => {
// The real export spans watermarks from 2026-03-31 to 2026-07-26. A single
// date would either import duplicates or discard real rows.
const { keep, covered } = splitByCoverage(
[
tx({ date: "2026-05-01", account: "xxxx0887" }), // covered to 2026-03-31 → keep
tx({ date: "2026-05-01", account: "xxxx2176" }), // covered to 2026-07-26 → drop
],
[
{ last4: "0887", coveredTo: "2026-03-31" },
{ last4: "2176", coveredTo: "2026-07-26" },
]
);
expect(keep).toHaveLength(1);
expect(keep[0].account).toBe("xxxx0887");
expect(covered).toHaveLength(1);
});
});
describe("inFileDuplicates", () => {
it("counts twins that a row id cannot catch", () => {
// A CDR re-consent re-exports an account's whole history under fresh ids
// while the originals survive, so source_ref sees two distinct rows. 385 of
// these were in one 2,563-row export and doubled every salary payment.
const rows = [
tx({ date: "2026-07-07", amount: 10782, description: "HDR", account: "4830" }),
tx({ date: "2026-07-07", amount: 10782, description: "HDR", account: "4830" }),
];
expect(inFileDuplicates(rows)).toBe(1);
});
it("does not flag the same amount on different days", () => {
expect(
inFileDuplicates([
tx({ date: "2026-07-07", amount: 5, description: "COFFEE" }),
tx({ date: "2026-07-08", amount: 5, description: "COFFEE" }),
])
).toBe(0);
});
it("does not flag the same amount on different accounts", () => {
// Both legs of an internal transfer: same day, same amount, different
// accounts, and both are real.
expect(
inFileDuplicates([
tx({ date: "2026-05-18", amount: 3076.04, description: "Transfer", account: "6264" }),
tx({ date: "2026-05-18", amount: 3076.04, description: "Transfer", account: "9940" }),
])
).toBe(0);
});
});
describe("applyMapping with account and row-id columns", () => {
const labels = ["transaction_id", "description", "amount", "transaction_date", "account_number"];
const mapping: ColumnMapping = {
dateCol: "transaction_date",
descriptionCol: "description",
amountMode: "single",
amountCol: "amount",
accountCol: "account_number",
sourceRefCol: "transaction_id",
};
it("carries the account and row id through", () => {
const out = applyMapping(
[["1326131276", "HDR Global Services", "10782.00", "2026-08-12", "xxxxxxxxxxxx4830"]],
labels,
mapping,
"YYYY-MM-DD"
);
expect(out).toHaveLength(1);
expect(out[0].account).toBe("xxxxxxxxxxxx4830");
expect(out[0].source_ref).toBe("1326131276");
// A positive single-column amount is money in.
expect(out[0].transaction_type).toBe("credit");
});
it("marks a non-AUD row as native and unconverted", () => {
// The 2026-08-12 salary is USD. Stored without this it reads as A$10,782 —
// about a third under — and sits in a column of AUD figures looking normal.
const out = applyMapping(
[["1", "HDR Global Services", "10782.00", "2026-08-12", "xxxx4830", "USD"]],
[...labels, "currency"],
{ ...mapping, currencyCol: "currency" },
"YYYY-MM-DD"
);
expect(out[0].foreign_currency_code).toBe("USD");
expect(out[0].foreign_currency_amount).toBe(10782);
});
it("leaves an AUD row unmarked", () => {
const out = applyMapping(
[["1", "SOMETHING", "-42.50", "2026-08-12", "xxxx4830", "AUD"]],
[...labels, "currency"],
{ ...mapping, currencyCol: "currency" },
"YYYY-MM-DD"
);
expect(out[0].foreign_currency_code).toBeUndefined();
});
it("ignores a currency cell that is not a code", () => {
const out = applyMapping(
[["1", "SOMETHING", "-42.50", "2026-08-12", "xxxx4830", ""]],
[...labels, "currency"],
{ ...mapping, currencyCol: "currency" },
"YYYY-MM-DD"
);
expect(out[0].foreign_currency_code).toBeUndefined();
});
it("leaves both undefined when the columns are not mapped", () => {
const out = applyMapping(
[["1", "SOMETHING", "-42.50", "2026-08-12", "xxxx4830"]],
labels,
{ dateCol: "transaction_date", descriptionCol: "description", amountMode: "single", amountCol: "amount" },
"YYYY-MM-DD"
);
expect(out[0].account).toBeUndefined();
expect(out[0].source_ref).toBeUndefined();
expect(out[0].transaction_type).toBe("debit");
});
});
+72
View File
@@ -0,0 +1,72 @@
import { describe, expect, it } from "vitest";
import { tidyMerchant } from "@/lib/merchant-label";
/**
* Board 212. Every fixture below is a real `display_name` from order_feed.
* The cases that matter most are the ones that must NOT change: this is a
* presentational tidy and merging anything here would be a data error wearing
* a stylesheet.
*/
describe("tidyMerchant", () => {
it("capitalises a bare domain used as a name", () => {
expect(tidyMerchant("amazon.com.au")).toBe("Amazon.com.au");
expect(tidyMerchant("cdkeys.com")).toBe("Cdkeys.com");
});
it("NEVER merges the country marketplaces", () => {
// The single most important assertion in this file. Amazon.in is a
// different marketplace with different currency and geography, and 289
// orders sit behind it.
expect(tidyMerchant("Amazon.in")).toBe("Amazon.in");
expect(tidyMerchant("amazon.com.au")).not.toBe(tidyMerchant("Amazon.in"));
});
it("leaves real brand names that contain a TLD alone", () => {
for (const brand of ["Kogan.com", "GOG.com", "AliExpress.com", "Catch.com.au", "GeekBuying.com"]) {
expect(tidyMerchant(brand)).toBe(brand);
}
});
it("does not uppercase a deliberately lower-cased brand", () => {
// The bug the corpus run found: gating on "starts lower-case" rather than
// on "is a bare domain" turned this into "EBay Commerce Australia".
expect(tidyMerchant("eBay Commerce Australia Pty Ltd.")).toBe("eBay Commerce Australia");
expect(tidyMerchant("iRobot Australia Pty Ltd")).toBe("iRobot Australia");
});
it("drops trailing corporate suffixes", () => {
expect(tidyMerchant("Apple Pty Ltd.")).toBe("Apple");
expect(tidyMerchant("Microsoft Pty. Limited")).toBe("Microsoft");
expect(tidyMerchant("Meta Platforms, Inc.")).toBe("Meta Platforms");
expect(tidyMerchant("Domino's Pizza Enterprises Limited")).toBe("Domino's Pizza Enterprises");
expect(tidyMerchant("Blinks Labs GmbH")).toBe("Blinks Labs");
expect(tidyMerchant("Rasier New Zealand Limited")).toBe("Rasier New Zealand");
});
it("strips two suffixes when a name carries two", () => {
expect(tidyMerchant("Samsung Electronics Co. Ltd.")).toBe("Samsung Electronics");
expect(tidyMerchant("DiDi Mobility Information Technology Pte. Ltd."))
.toBe("DiDi Mobility Information Technology");
});
it("does not leave a dangling connector", () => {
// 'Co' is part of this brand. Stripping it produced "Coburger &".
expect(tidyMerchant("Coburger & Co")).toBe("Coburger & Co");
});
it("keeps a name that would be reduced to almost nothing", () => {
// "UT" is too short to stand on its own; better scruffy than cryptic.
expect(tidyMerchant("UT LLC")).toBe("UT LLC");
});
it("normalises whitespace and tolerates junk", () => {
expect(tidyMerchant(" Yaffa Media Pty Ltd ")).toBe("Yaffa Media");
expect(tidyMerchant("")).toBe("");
});
it("is idempotent", () => {
for (const n of ["amazon.com.au", "Apple Pty Ltd.", "Coburger & Co", "Amazon.in"]) {
expect(tidyMerchant(tidyMerchant(n))).toBe(tidyMerchant(n));
}
});
});
+208 -7
View File
@@ -306,17 +306,218 @@ describe("Uber trips", () => {
});
describe("orderDescription", () => {
it("names the platform", () => {
expect(orderDescription("doordash", "Mad Mex")).toBe("Order - Mad Mex (DoorDash)");
it("names the restaurant, not the courier", () => {
// The platform is provenance and lives in the Order details panel, which
// already renders expense_metadata.platform. Putting it here fragmented the
// merchant — the same restaurant read differently depending on who carried
// the bag, which nobody rating the food cares about.
expect(orderDescription("doordash", "Mad Mex")).toBe("Order - Mad Mex");
expect(orderDescription("ubereats", "Coles (Wyndham Vale)")).toBe(
"Order - Coles (Wyndham Vale) (Uber Eats)"
"Order - Coles (Wyndham Vale)"
);
});
it("does not restate a platform the merchant already names", () => {
// A trip's merchant is literally "Uber Trip"; "(Uber)" after it says
// nothing. What identifies a trip is its addresses, and those live in the
// Order details panel.
it("leaves a merchant that already names the platform alone", () => {
// A trip's merchant is literally "Uber Trip". What identifies a trip is its
// addresses, and those live in the Order details panel.
expect(orderDescription("uber", "Uber Trip")).toBe("Order - Uber Trip");
});
it("gives the same description whichever platform delivered it", () => {
// The regression this whole change exists to prevent.
expect(orderDescription("doordash", "TEG Kebabs & Biryani")).toBe(
orderDescription("ubereats", "TEG Kebabs & Biryani")
);
});
});
/**
* Two parse failures that between them accounted for 218 of the 287 unreadable
* messages in the 776-message capture set. Neither was an "old template": the
* Uber one fails on current 2024-2025 mail, and the DoorDash one fails on every
* order paid from credits, in every year.
*/
describe("currency notations Uber actually sends", () => {
const uber = (f: string, subject: string) =>
parseOrderHTML(html(f), meta({ subject, sender: "Uber Receipts <noreply@uber.com>" }));
it("reads a symbol-prefixed Australian total", () => {
// "Total A$54.87". The old pattern allowed a 3-letter ISO code or a bare
// "$", so A$ — which is what Uber sends for ordinary domestic orders —
// matched neither and 98 of 275 Uber Eats mails were unreadable.
const o = uber("ue-aud-prefix", "Your Friday evening order with Uber Eats");
expect(o.totals.total_charged).toBe(54.87);
expect(o.currency).toBe("AUD");
});
it("reads NZ$ as New Zealand dollars, not Australian", () => {
// The prefix is the only thing distinguishing them, and getting it wrong
// books a Queenstown dinner at the wrong rate rather than failing loudly.
const o = uber("ue-nzd-prefix", "Your Saturday evening order with Uber Eats");
expect(o.totals.total_charged).toBe(22.83);
expect(o.currency).toBe("NZD");
});
it("reads a bare rupee symbol on a trip", () => {
const o = uber("ut-inr-symbol", "Your Friday evening trip with Uber");
expect(o.totals.total_charged).toBe(622.74);
expect(o.currency).toBe("INR");
});
it("still reads the space-separated ISO form", () => {
// The [Family] LKR receipts depend on this and must not regress.
const o = uber("ut-nzd-prefix", "Your Sunday afternoon trip with Uber");
expect(o.totals.total_charged).toBe(10.83);
expect(o.currency).toBe("NZD");
});
});
describe("credits-funded orders are orders", () => {
const credits = () =>
parseOrderHTML(
html("dd-credits-zero"),
meta({ subject: "Order Confirmation for Siddharth from Chilli India" })
);
it("records the subtotal when the card was charged nothing", () => {
// The receipt says "Subtotal $71.86 ... Total Charged $0.00" — truthfully,
// because credits covered it. Reading that as a $0 order threw away the
// credit-funded spend this pipeline exists to surface.
const o = credits();
expect(o.totals.total_charged).toBe(71.86);
expect(o.totals.subtotal).toBe(71.86);
expect(o.flags).toContain("credits_funded_zero_charge");
});
it("books the amount as credits, not as a card charge", () => {
const o = credits();
expect(o.payment.credits_amount).toBe(71.86);
expect(o.payment.card_amount).toBeNull();
expect(o.payment.card_last4).toBeNull();
});
it("passes validation instead of being rejected as non-positive", () => {
// Both stated totals are $0.00 and agree, so the header cross-check has to
// stand down here or it rejects the very figure the parser overrode.
const o = credits();
expect(validateOrderTotals(o, html("dd-credits-zero"))).toEqual({ ok: true });
});
it("does not invent a total when the receipt never says credits", () => {
// The guard that keeps this from becoming "any zero total borrows the
// subtotal" — a genuinely empty receipt must still fail.
const notCredits = html("dd-credits-zero").replace(/Paid with/gi, "Charged to");
const o = parseOrderHTML(notCredits, meta({ subject: "Order Confirmation for Siddharth from Chilli India" }));
expect(o.totals.total_charged).toBe(0);
expect(validateOrderTotals(o, notCredits).ok).toBe(false);
});
});
describe("Uber Cash is credits, not a card", () => {
it("reads a payment line carrying a timestamp and a prefixed currency", () => {
// "Payments Uber Cash 10/17/25 8:50 PM A$54.87". The old pattern allowed
// neither the timestamp nor the A$ prefix, so credits_amount stayed null
// and the order was filed as card-settled — sent looking for a card leg
// that does not exist, and left as an orphan with nothing to match on.
const o = parseOrderHTML(
html("ue-aud-prefix"),
meta({
subject: "Your Friday evening order with Uber Eats",
sender: "Uber Receipts <noreply@uber.com>",
})
);
expect(o.payment.credits_amount).toBe(54.87);
expect(o.payment.card_last4).toBeNull();
expect(o.payment.ambiguous).toBe(false);
});
it("still reads the plain form, where the timestamp follows the amount", () => {
// "Uber Cash $25.33 22/7/26 1:41 pm" — the older layout the widened
// pattern must not break.
const o = parseOrderHTML(
html("ue-00"),
meta({
subject: "Your order with Uber Eats",
sender: "Uber Receipts <noreply@uber.com>",
})
);
expect(o.payment.credits_amount).toBe(25.33);
});
it("still reads the card leg of a mixed payment", () => {
// "Uber Cash $1.17 ... Westpac ••••8032 $15.33" — the credits half must
// not swallow the card half.
const o = parseOrderHTML(
html("ue-mixed"),
meta({ subject: "Your order with Uber Eats", sender: "Uber Receipts <noreply@uber.com>" })
);
expect(o.payment.credits_amount).toBe(1.17);
expect(o.payment.card_last4).toBe("8032");
});
});
describe("payment legs", () => {
const trip = (f: string) =>
parseOrderHTML(
html(f),
meta({
subject: "Your Wednesday afternoon trip with Uber",
sender: "Uber Receipts <noreply@uber.com>",
})
);
it("does not add a superseded authorisation to the settled charge", () => {
// "Citi Prestige ••••0253 AED 17.67" then the same card "AED 577.83",
// against a stated total of 577.83. The first is a hold, not a part
// payment; adding it overstates the trip by the held amount.
const o = trip("ut-reauth");
expect(o.totals.total_charged).toBe(577.83);
expect(o.payment.card_amount).toBe(577.83);
expect(o.payment.card_last4).toBe("0253");
expect(validateOrderTotals(o, html("ut-reauth")).ok).toBe(true);
});
it("adds the legs of a genuinely split payment", () => {
// "PayPal - <email> A$78.41" + "Uber Cash A$6.85" = 85.26. Neither leg
// equals the total, so both are real and both must be counted — and the
// PayPal leg carries no card mask to anchor on.
const o = trip("ut-paypal");
expect(o.totals.total_charged).toBe(85.26);
expect(o.payment.credits_amount).toBe(6.85);
expect(o.payment.card_amount).toBe(78.41);
expect(validateOrderTotals(o, html("ut-paypal")).ok).toBe(true);
});
});
describe("HTML entities in line items", () => {
it("splits options on an encoded bullet instead of swallowing it", () => {
// "<b>Sweet &amp; Sour Crunch</b> (…)<br><font>&bull; Sweet &amp; Sour
// Crunch 12 Pieces</font>". &bull; 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("&bull;");
expect(all).not.toContain("&amp;");
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 &amp;bull; alone rather than turning it into a bullet", () => {
// Why &amp; resolves last: decoding it first would rewrite text that was
// deliberately escaped.
const o = parseOrderHTML(
html("dd-01").replace("Mad Mex", "A&amp;amp;bull;B"),
meta()
);
expect(JSON.stringify(o)).not.toContain("A&bull;B".replace("&bull;", "•"));
});
});
+99
View File
@@ -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();
});
});
+4 -2
View File
@@ -9,6 +9,7 @@ import {
EXCLUDE_RECONCILED_SOURCE,
NET_SPEND_ROWS,
SPEND_SIGNED,
INVESTMENT_SIGNED,
mySplitOf,
toDateStr,
} from "@/lib/analytics-sql";
@@ -80,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;
@@ -88,7 +90,7 @@ export async function GET(req: NextRequest) {
}>(
`SELECT
TO_CHAR(DATE_TRUNC('month', t.transaction_date::date), 'YYYY-MM') as month,
SUM(COALESCE(t.amount_aud, t.amount))::numeric(12,2) as total_invested,
SUM(${INVESTMENT_SIGNED})::numeric(12,2) as total_invested,
COUNT(*)::int as transaction_count
FROM transactions t
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
+9 -1
View File
@@ -8,6 +8,12 @@ export async function POST(req: NextRequest) {
const body = await req.json() as {
bank_name: string;
/**
* Optional provenance label, stored on every row as `source`. With a
* `source_ref` per row it makes a re-import a no-op; without it this path
* has no idempotency at all.
*/
source?: string;
transactions: {
date: string;
description: string;
@@ -17,6 +23,8 @@ export async function POST(req: NextRequest) {
foreign_currency_amount?: number;
foreign_currency_code?: string;
category?: string;
account?: string;
source_ref?: string;
}[];
};
@@ -25,7 +33,7 @@ export async function POST(req: NextRequest) {
}
const tagId = await ensureTag("csv-import", "#8b5cf6");
const inserted = await batchInsertCSVTransactions(user.id, body.transactions, tagId);
const inserted = await batchInsertCSVTransactions(user.id, body.transactions, tagId, body.source);
return NextResponse.json({ inserted }, { status: 201 });
}
@@ -0,0 +1,17 @@
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { getStatementCoverage } from "@/lib/queries";
/**
* How far each account's statements already reach.
*
* Read by the CSV import modal so it can drop rows the ledger already holds.
* The first Frollo import went in without this and wrote 422 duplicates out of
* 550 — the same payments the statements already carried, itemised and
* converted. On the 2026-08-13 export the rule takes 2,607 rows down to 171.
*/
export async function GET(req: NextRequest) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
return NextResponse.json({ coverage: await getStatementCoverage() });
}
+29
View File
@@ -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);
}
+52
View File
@@ -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 });
@@ -77,6 +91,14 @@ export async function POST(req: NextRequest) {
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,
@@ -85,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
+49
View File
@@ -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 });
}
+52
View File
@@ -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(
+398
View File
@@ -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,
};
}
+40 -2
View File
@@ -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 });
}
+25 -2
View File
@@ -26,7 +26,7 @@ export async function GET(
}
const row = await queryRow(
`SELECT platform, order_reference, line_items, route, subtotal, amount,
`SELECT source, platform, order_reference, line_items, route, subtotal, amount,
currency, card_last4, flags, source_email_subject, transaction_date
FROM expense_metadata
-- A card-settled order creates no transaction of its own (I5): the
@@ -38,7 +38,30 @@ export async function GET(
[Number(id)]
);
// Phase 2 (board 205). A BNPL leg has NO expense_metadata row of its own —
// the plan is four transactions against one order — so without this the four
// Afterpay debits behind the A$1,599 DJI drone stay four bare "Afterpay
// $399.75" rows naming nothing. That is the surface where this was noticed.
const link = await queryRow<{
entity_key: string; leg_kind: string; leg_index: number | null;
leg_count: number | null; canonical_name: string | null;
platform: string | null; order_total: string | null; currency: string | null;
}>(
`SELECT l.entity_key, l.leg_kind, l.leg_index::int, l.leg_count::int,
e.canonical_name, o.platform, o.order_total, o.currency
FROM order_transaction_links l
LEFT JOIN entities e ON e.entity_key = l.entity_key
LEFT JOIN entity_orders o ON o.entity_id = e.id
WHERE l.transaction_id = $1
LIMIT 1`,
[Number(id)]
);
// Not an order — most transactions aren't. Null, not 404: the caller is
// asking "is there a receipt behind this?", and "no" is a normal answer.
return NextResponse.json(row ?? null);
if (!row && !link) return NextResponse.json(null);
// expense_metadata carries the itemised receipt and stays primary; the link
// adds what it cannot express — which order this is a leg OF, and which leg.
return NextResponse.json({ ...(row ?? {}), order_link: link ?? null });
}
@@ -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 }> }
+11 -2
View File
@@ -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`,
+8
View File
@@ -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 });
+13
View File
@@ -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 });
}
+9 -2
View File
@@ -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 }
);
}
}
+7 -2
View File
@@ -331,7 +331,10 @@ export default function AnalyticsPage() {
const totals = analytics.totals[selectedMonth] ?? { spent: 0, income: 0, investments: 0, net: 0 };
const hasIncome = months.some((m) => (analytics.totals[m]?.income || 0) > 0);
const hasInvestments = months.some((m) => (analytics.totals[m]?.investments || 0) > 0);
// `!== 0`, not `> 0`: the investments line is signed, so a net-disinvesting
// month is real data, not an empty one. A window where every month nets
// negative would otherwise render as "—".
const hasInvestments = months.some((m) => (analytics.totals[m]?.investments || 0) !== 0);
// Hero delta vs the average of the other *complete* months that have data.
//
@@ -677,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>
);
+314
View File
@@ -0,0 +1,314 @@
"use client";
import { use } from "react";
import Link from "next/link";
import { useOrderDetail } from "@/lib/hooks";
import { tidyMerchant } from "@/lib/merchant-label";
/**
* One order: what it was, what happened to it, and what paid for it.
*
* The lifecycle timeline IS the tracking UI. tracking_url is NULL on all 7,608
* spine rows and on all 11,092 order_event payloads, because the shared HTML
* renderer strips anchor hrefs before extraction ever sees them (board 208).
* Do not build a tracking widget against a field nothing populates.
*/
/** What the classifier thought the source document was — see the list page. */
const KIND_LABEL: Record<string, string> = {
courier_tracking: "delivery notice",
invoice_receipt: "invoice",
subscription: "subscription",
booking: "booking",
account_notice: "account notice",
};
const EVENT_LABEL: Record<string, string> = {
placed: "Order placed",
shipped: "Dispatched",
out_for_delivery: "Out for delivery",
delivered: "Delivered",
cancelled: "Cancelled",
returned: "Returned",
refunded: "Refunded",
};
const dateFmt = new Intl.DateTimeFormat("en-AU", { day: "numeric", month: "short", year: "numeric" });
function fmtMoney(amount: string | null | undefined, currency: string) {
if (amount === null || amount === undefined) return null;
const n = Number(amount);
if (!Number.isFinite(n)) return null;
try {
return new Intl.NumberFormat("en-AU", { style: "currency", currency, minimumFractionDigits: 2 }).format(n);
} catch {
return `${n.toFixed(2)} ${currency}`;
}
}
function Panel({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section>
<h3 className="font-mono text-[10.5px] uppercase tracking-widest text-zinc-500 font-normal pb-2 mb-3 border-b border-zinc-800">
{title}
</h3>
{children}
</section>
);
}
export default function OrderDetailPage({ params }: { params: Promise<{ entityKey: string }> }) {
const { entityKey } = use(params);
const key = decodeURIComponent(entityKey);
const { data: o, isLoading, error } = useOrderDetail(key);
if (isLoading) return <div className="p-6 text-zinc-500 text-sm">Loading order</div>;
if (error || !o) {
// A missing order and a broken query are different problems and must not
// share a sentence. Only a 404 means "no such order"; anything else is this
// page failing, and saying so is what sends the next person to the server
// log instead of to the spine.
const status = (error as (Error & { status?: number }) | null)?.status;
return (
<div className="max-w-[1180px] mx-auto">
<Link href="/orders" className="font-mono text-[11.5px] text-indigo-400"> All orders</Link>
{status === 404 ? (
<p className="mt-6 text-zinc-400 text-sm">That order could not be found.</p>
) : (
<p className="mt-6 text-zinc-400 text-sm">
This order could not be loaded{status ? ` (server error ${status})` : ""}. The order exists
something in this page failed. Check the finance-app log.
</p>
)}
</div>
);
}
const gross = Number(o.gross_total ?? o.order_total ?? NaN);
const refund = Number(o.refunded_amount ?? NaN);
const partial = Number.isFinite(gross) && Number.isFinite(refund) && refund > 0 && refund < gross;
const linkedTotal = o.transactions.reduce((a, t) => a + Math.abs(Number(t.amount) || 0), 0);
return (
<div className="max-w-[1180px] mx-auto">
<Link href="/orders" className="font-mono text-[11.5px] text-indigo-400 hover:text-indigo-300"> All orders</Link>
{/* A settled rail row must never look like an ordinary order — that is
how a human counts the same purchase twice. */}
{/* Board 211 — same shape as the settled-rail banner above: a real
document with a real figure that is not a purchase. Saying which
payments it restates is the point; the total alone reads as a
single large order. */}
{o.summarises_period && (
<div className="mt-4 bg-zinc-900 border-l-2 border-amber-600 px-4 py-3">
<span className="block font-mono text-[10px] uppercase tracking-widest text-amber-500 mb-1">
Annual summary, not a purchase
</span>
<span className="text-[12.5px] text-zinc-300">
This receipt restates payments already made across the year the
monthly debits are in the ledger under their own dates. It is kept
for reference and excluded from spend totals.
</span>
</div>
)}
{o.is_settled_duplicate && (
<div className="mt-4 bg-zinc-900 border-l-2 border-indigo-600 px-4 py-3">
<span className="block font-mono text-[10px] uppercase tracking-widest text-indigo-500 mb-1">
Payment record, not a separate purchase
</span>
<span className="text-[12.5px] text-zinc-300">
This restates another order and is deliberately excluded from spend totals.
</span>
</div>
)}
<header className="border-b border-zinc-800 pb-5 mt-4 mb-6">
<div className="font-mono text-[11.5px] text-zinc-400 tabular-nums">
{o.platform}
{o.order_reference && !o.order_reference.startsWith("msg-") && <> · order {o.order_reference}</>}
{o.ordered_at && <> · {dateFmt.format(new Date(o.ordered_at))}</>}
</div>
<h2 className="font-display text-[27px] leading-tight text-zinc-50 my-3 max-w-[26ch] text-balance">
{/* canonical_name is the ORDER title ("DJI Air 3 Fly More Combo") and
is left alone; only the display_name fallback is a merchant name
and wants tidying. */}
{o.canonical_name || tidyMerchant(o.display_name ?? "") || "Order"}
</h2>
<div className="flex gap-2 flex-wrap mb-3">
{o.content_class && KIND_LABEL[o.content_class] && (
<span className="font-mono text-[10px] uppercase tracking-wider text-zinc-400 border border-zinc-700 rounded-sm px-2 py-0.5">
{KIND_LABEL[o.content_class]}
</span>
)}
{o.cadence_days && (
<span className="font-mono text-[10px] uppercase tracking-wider text-indigo-400 border border-indigo-800 rounded-sm px-2 py-0.5">
recurring · every ~{o.cadence_days} days
{o.order_count ? ` · ${o.order_count} orders` : ""}
</span>
)}
</div>
<div className="flex gap-6 flex-wrap font-mono text-[11.5px] text-zinc-400 tabular-nums">
<div>
<span className="block text-[10px] uppercase tracking-widest text-zinc-500 mb-0.5">
{partial ? "Charged" : "Total"}
</span>
{fmtMoney(o.gross_total ?? o.order_total, o.currency) ?? <span className="italic text-zinc-500">not stated</span>}
</div>
{partial && (
<div>
<span className="block text-[10px] uppercase tracking-widest text-zinc-500 mb-0.5">Refunded</span>
<span className="text-indigo-400">{fmtMoney(o.refunded_amount, o.currency)}</span>
</div>
)}
<div>
<span className="block text-[10px] uppercase tracking-widest text-zinc-500 mb-0.5">Status</span>
{o.status.replace(/_/g, " ")}
</div>
{o.merchant_name && (
<div>
<span className="block text-[10px] uppercase tracking-widest text-zinc-500 mb-0.5">Merchant</span>
{/* Board 212: the list has tidied this since it shipped and the
detail page did not, so following a row through showed
"Apple" becoming "Apple Pty Ltd." Presentational only —
merges nothing, and Amazon.in stays Amazon.in. */}
{tidyMerchant(o.merchant_name)}
</div>
)}
</div>
</header>
<div className="grid gap-7 lg:grid-cols-[1.15fr_1fr]">
<div className="flex flex-col gap-7">
<Panel title="Lifecycle">
{/* Ordinal markers are legitimate HERE — the events genuinely are a
sequence and order carries meaning. They are absent from the
list, where they would only decorate. */}
<ol className="list-none m-0 p-0">
{o.events.map((e, i) => (
<li key={e.fact_id} className="grid grid-cols-[26px_92px_1fr_auto] gap-3 items-baseline py-2 border-b border-zinc-800/60">
<span className="font-mono text-[10.5px] text-indigo-600 tabular-nums">{String(i + 1).padStart(2, "0")}</span>
<span className="font-mono text-[11.5px] text-zinc-400 tabular-nums">
{e.effective_at ? dateFmt.format(new Date(e.effective_at)) : "—"}
</span>
<span className="text-[13px] text-zinc-100">
{EVENT_LABEL[e.event_kind ?? ""] ?? e.event_kind ?? "Event"}
</span>
<span className="font-mono text-[11.5px] text-zinc-500 tabular-nums">
{fmtMoney(e.amount, e.currency ?? o.currency) ?? "—"}
</span>
</li>
))}
{!o.events.length && <li className="py-2 text-[13px] text-zinc-500">No lifecycle events recorded.</li>}
</ol>
</Panel>
<Panel title={`Contents${o.line_items.length ? `${o.line_items.length}` : ""}`}>
{o.line_items.length ? (
<ul className="list-none m-0 p-0">
{o.line_items.map((li, i) => (
<li key={i} className="flex justify-between gap-4 py-2 border-b border-zinc-800/60 text-[13px]">
<span className="text-zinc-100">
{li.description}
{li.quantity ? <span className="font-mono text-[11px] text-zinc-500 ml-2">×{li.quantity}</span> : null}
</span>
<span className="font-mono text-[12.5px] text-zinc-300 tabular-nums whitespace-nowrap">
{fmtMoney(li.amount != null ? String(li.amount) : null, o.currency) ?? ""}
</span>
</li>
))}
</ul>
) : (
<p className="text-[13px] text-zinc-500 italic m-0">No itemised list on this receipt.</p>
)}
</Panel>
</div>
<div className="flex flex-col gap-7">
<Panel title={o.transactions.length ? `Payments — ${o.transactions.length} charge${o.transactions.length > 1 ? "s" : ""}` : "Payments"}>
{o.transactions.length ? (
<>
<ul className="list-none m-0 p-0">
{o.transactions.map((t) => (
<li key={t.transaction_id} className="grid grid-cols-[1fr_auto] gap-3 items-baseline py-2 border-b border-zinc-800/60">
<span className="font-mono text-[12px] text-zinc-300">
{t.description}
<small className="block text-[10.5px] text-zinc-500">
{/* Board 205. Four identical "Afterpay" rows are
indistinguishable without their position, and
indistinguishable rows are what made this order
look like four purchases. */}
{t.leg_kind === "instalment" && t.leg_index && t.leg_count
? `Instalment ${t.leg_index} of ${t.leg_count} · `
: t.leg_kind === "shipment"
? "Shipment · "
: ""}
{dateFmt.format(new Date(t.transaction_date))} · txn {t.transaction_id}
</small>
</span>
<span className="font-mono text-[13px] text-zinc-50 tabular-nums">
{fmtMoney(String(Math.abs(Number(t.amount))), o.currency)}
</span>
</li>
))}
</ul>
<div className="flex justify-between items-baseline pt-3 font-mono text-xs">
<span className="uppercase tracking-widest text-[10px] text-zinc-500">Accounted for</span>
<span className="text-indigo-300 text-sm tabular-nums">
{fmtMoney(String(linkedTotal), o.currency)}
{o.gross_total && <span className="text-zinc-500"> of {fmtMoney(o.gross_total, o.currency)}</span>}
</span>
</div>
</>
) : (
<p className="text-[13px] text-zinc-500 m-0 leading-relaxed">
No transaction linked yet. The ledger is statement-fed, so a recent purchase has
nothing to match against until its card statement is imported often a few weeks.
Charges only; refunds and fees are not linked in this phase.
</p>
)}
</Panel>
{o.siblings.length > 0 && (
<Panel title="Also recorded as">
<ul className="list-none m-0 p-0">
{o.siblings.map((s) => (
<li key={s.entity_key} className="py-2 border-b border-zinc-800/60">
<Link href={`/orders/${encodeURIComponent(s.entity_key)}`}
className="text-[13px] text-zinc-100 hover:text-indigo-300">
{s.canonical_name || s.entity_key}
</Link>
<span className="block font-mono text-[11px] text-zinc-500 tabular-nums">
{s.relation === "settled_by"
? "paid via this record"
: "this record pays for that order"}
{" · "}{s.platform}
{s.order_total && <> · {fmtMoney(s.order_total, s.currency)}</>}
</span>
</li>
))}
</ul>
</Panel>
)}
<Panel title="Provenance">
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 font-mono text-[11.5px] m-0">
<dt className="text-zinc-500">Key</dt>
<dd className="text-zinc-300 m-0 break-all">{o.entity_key}</dd>
<dt className="text-zinc-500">Reference</dt>
<dd className="text-zinc-300 m-0">
{o.reference_source === "message_id_fallback"
? <span className="text-indigo-500">none printed cannot merge with siblings</span>
: (o.order_reference ?? "—")}
</dd>
<dt className="text-zinc-500">Lane</dt>
<dd className="text-zinc-300 m-0">{o.lane}</dd>
<dt className="text-zinc-500">Trust</dt>
<dd className="text-zinc-300 m-0">{o.source_trust ?? "—"}</dd>
</dl>
</Panel>
</div>
</div>
</div>
);
}
+586
View File
@@ -0,0 +1,586 @@
"use client";
import { Suspense, useMemo, useState } from "react";
import Link from "next/link";
import { useOrders } from "@/lib/hooks";
import { tidyMerchant } from "@/lib/merchant-label";
import type { OrderRow } from "@/lib/order-feed";
/**
* Orders — browse the purchase history the ledger cannot show.
*
* The spine holds ~6,300 purchase orders back to 2006, ~4,600 of them
* itemised, of which a few dozen reach a transaction. Everything else is
* visible only here. The point of the page is the MANIFEST: /transactions can
* only ever say "AMAZON AU SYDNEY"; this says what was in the box.
*/
const LANES = [
{ id: "retail", label: "Retail" },
{ id: "food", label: "Food" },
{ id: "transport", label: "Transport" },
{ id: "digital", label: "Digital" },
{ id: "services", label: "Services" },
{ id: "grocery", label: "Grocery" },
] as const;
// Twenty-one years is the archive, not the working set. Default to this year.
type RangeKey = "m0" | "m1" | "m3" | "y0" | "y1" | "all" | string;
const RANGES: { id: RangeKey; label: string }[] = [
{ id: "m0", label: "This month" },
{ id: "m1", label: "Last month" },
{ id: "m3", label: "Last 3 months" },
{ id: "y0", label: "This year" },
{ id: "y1", label: "Last year" },
{ id: "all", label: "All 21 years" },
];
function iso(d: Date) {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
function rangeFor(key: RangeKey): { from?: string; to?: string; label: string } {
const now = new Date();
const y = now.getFullYear();
const m = now.getMonth();
switch (key) {
case "m0": return { from: iso(new Date(y, m, 1)), to: iso(now), label: "this month" };
case "m1": return { from: iso(new Date(y, m - 1, 1)), to: iso(new Date(y, m, 0)), label: "last month" };
case "m3": return { from: iso(new Date(y, m - 2, 1)), to: iso(now), label: "the last 3 months" };
case "y0": return { from: `${y}-01-01`, to: iso(now), label: String(y) };
case "y1": return { from: `${y - 1}-01-01`, to: `${y - 1}-12-31`, label: String(y - 1) };
case "all": return { label: "2006present" };
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: "2006present" };
}
}
}
/**
* What the source document actually was. The classifier already knew — 188
* orders came from courier_tracking documents and 147 from invoice_receipt —
* and the first cut of this page showed all of them as retail purchases.
* NULL means the interpretation index never saw it (48% of rows, the Takeout
* backfill), which is "unknown", not "purchase" — so it gets no badge at all
* rather than a confident wrong one.
*/
const KIND_LABEL: Record<string, string> = {
courier_tracking: "delivery",
invoice_receipt: "invoice",
subscription: "subscription",
booking: "booking",
account_notice: "notice",
};
const IN_FLIGHT = new Set(["ordered", "shipped", "out_for_delivery"]);
const REVERSED = new Set(["refunded", "returned", "cancelled"]);
const dateFmt = new Intl.DateTimeFormat("en-AU", { day: "numeric", month: "short" });
const yearFmt = new Intl.DateTimeFormat("en-AU", { year: "numeric" });
function fmtMoney(amount: string | null, currency: string) {
if (amount === null || amount === undefined) return null;
const n = Number(amount);
if (!Number.isFinite(n)) return null;
try {
// narrowSymbol so AUD reads "A$" rather than a bare "$" — the spine holds
// 20 currencies and an unqualified dollar sign is ambiguous across them.
return new Intl.NumberFormat("en-AU", {
style: "currency", currency, currencyDisplay: "narrowSymbol",
minimumFractionDigits: 2,
}).format(n).replace(/^\$/, "A$");
} catch {
// 20 currencies live in the spine, including a literal '$', XLM and MANA.
// Intl throws on those; show the number and the raw code rather than
// blowing up the row.
return `${n.toFixed(2)} ${currency}`;
}
}
function StatusPill({ status }: { status: string }) {
const live = IN_FLIGHT.has(status);
const bad = REVERSED.has(status);
const label = status.replace(/_/g, " ");
const cls = live
? "bg-indigo-600 text-zinc-950"
: bad
? "border border-zinc-500 text-zinc-400"
: "border border-zinc-700 text-zinc-400";
return (
<span className={`inline-block px-2 py-0.5 rounded-sm text-[10px] font-mono uppercase tracking-wider whitespace-nowrap ${cls}`}>
{label}
</span>
);
}
/**
* Food line items carry the full customisation — one Subway order runs to 450
* characters listing every topping — and a raw dump swamps the row. Truncate
* per item; the detail page shows them whole.
*/
const ITEM_MAX = 58;
function shorten(d: string) {
const clean = d.replace(/\s+/g, " ").trim();
if (clean.length <= ITEM_MAX) return clean;
// Cut at the first bracket if there is one — "Footlong (Italian Herb…" is
// the product; everything inside the bracket is the customisation.
const brk = clean.indexOf("(");
if (brk > 12 && brk <= ITEM_MAX) return clean.slice(0, brk).trim() + "…";
return clean.slice(0, ITEM_MAX).trimEnd() + "…";
}
function Manifest({ row }: { row: OrderRow }) {
const all = row.item_preview ?? [];
const items = all.slice(0, 3);
if (!items.length) {
return <span className="text-xs text-zinc-500 italic">No itemised list on this receipt</span>;
}
const extra = row.line_item_count - items.length;
return (
<span className="text-[13px] text-zinc-300 leading-snug">
{items.map((d, i) => (
<span key={i} title={d}>
{i > 0 && <span className="text-indigo-800 mx-1.5">·</span>}
{shorten(d)}
</span>
))}
{extra > 0 && <span className="font-mono text-xs text-indigo-500 ml-1.5">+{extra}</span>}
</span>
);
}
/**
* The title earns its own line only when it says something the manifest does
* not. On 1,964 of 4,649 itemised rows (42%) the order title IS the single
* line item — printing both rendered the same text twice.
*/
function showTitle(row: OrderRow): boolean {
const t = row.canonical_name?.replace(/\s+/g, " ").trim();
if (!t) return false;
if (t === row.display_name) return false;
const first = (row.item_preview ?? [])[0];
if (!first) return true;
const norm = (x: string) => x.toLowerCase().replace(/[^a-z0-9]/g, "").slice(0, 28);
return norm(t) !== norm(first);
}
function YearStrip({
years, activeFrom, activeTo, onPick,
}: {
years: { year: number; n: number }[];
activeFrom?: string;
activeTo?: string;
onPick: (y: number) => void;
}) {
if (!years.length) return <div className="h-[54px]" />;
const max = Math.max(...years.map((y) => y.n));
const lo = activeFrom ? Number(activeFrom.slice(0, 4)) : -Infinity;
const hi = activeTo ? Number(activeTo.slice(0, 4)) : Infinity;
return (
<div className="flex items-end gap-[3px]">
{years.map(({ year, n }) => {
const on = year >= lo && year <= hi;
return (
<button
key={year}
onClick={() => onPick(year)}
title={`${year}${n.toLocaleString()} orders`}
aria-label={`${year}, ${n} orders`}
aria-pressed={on}
className="flex-1 min-w-0 flex flex-col items-center gap-1.5 group"
>
<span
style={{ height: `${Math.max(2, Math.round((44 * n) / max))}px` }}
className={`w-full rounded-[1px] transition-colors ${
on ? "bg-indigo-400" : "bg-indigo-800 group-hover:bg-indigo-500"
}`}
/>
<span className={`font-mono text-[9.5px] tabular-nums ${on ? "text-indigo-300" : "text-zinc-500 group-hover:text-zinc-300"}`}>
{String(year).slice(2)}
</span>
</button>
);
})}
</div>
);
}
function Row({ row, expanded, onToggle }: { row: OrderRow; expanded: boolean; onToggle: () => void }) {
const reversed = REVERSED.has(row.status);
const gross = Number(row.order_total ?? NaN);
const refund = Number(row.refunded_amount ?? NaN);
const partial =
Number.isFinite(gross) && Number.isFinite(refund) && refund > 0 && refund < gross;
// A FULL reversal strikes the figure — the money all came back. A PARTIAL
// refund must not: striking $191.40 when $13.33 came back is a lie. The
// charge stays primary (it is what hit the card), the credit sits under it.
const struck = reversed && !partial;
const money = fmtMoney(row.order_total, row.currency);
return (
<tr className={`border-b border-zinc-800/60 hover:bg-zinc-900 ${struck ? "opacity-60" : ""}`}>
<td className="p-3 align-top font-mono text-[11px] text-zinc-400 tabular-nums whitespace-nowrap">
{row.ordered_at ? (
<>
{dateFmt.format(new Date(row.ordered_at))}
<span className="block text-[10px] text-zinc-500">{yearFmt.format(new Date(row.ordered_at))}</span>
</>
) : <span className="text-zinc-600"></span>}
</td>
<td className="p-3 align-top">
<div className="flex items-center gap-2 mb-1">
{/* Disclosure only where there is something behind it. Food items run
to 450 characters of customisation, so the row shows a short form
and the expansion carries the whole receipt. */}
{row.line_item_count > 0 && (
<button
onClick={onToggle}
aria-expanded={expanded}
aria-label={expanded ? "Hide items" : `Show all ${row.line_item_count} items`}
className="text-zinc-500 hover:text-indigo-400 font-mono text-[10px] w-3 shrink-0"
>{expanded ? "▾" : "▸"}</button>
)}
<Link
href={`/orders/${encodeURIComponent(row.entity_key)}`}
className="text-[13.5px] text-zinc-50 hover:text-indigo-300"
>
{tidyMerchant(row.display_name)}
</Link>
{row.content_class && KIND_LABEL[row.content_class] && (
<span className="font-mono text-[9.5px] uppercase tracking-wide text-zinc-400 border border-zinc-700 rounded-sm px-1.5">
{KIND_LABEL[row.content_class]}
</span>
)}
{row.cadence_days && (
<span
title={`Orders from this merchant arrive on a regular cadence — derived from the gaps between them, not stated anywhere in the mail.`}
className="font-mono text-[9.5px] uppercase tracking-wide text-indigo-400 border border-indigo-800 rounded-sm px-1.5"
>
every ~{row.cadence_days}d
</span>
)}
{row.reference_source === "message_id_fallback" && (
<span
title="No order reference in this mail — it cannot merge with its lifecycle siblings, so the same purchase may appear twice."
className="font-mono text-[9.5px] uppercase tracking-wide text-indigo-600 border border-indigo-800 rounded-sm px-1.5"
>no ref</span>
)}
{/* NOT source_trust — that is 'untrusted_external' on 100% of rows,
because every order here came from email, so badging it marked
every row and told you nothing. Sender authentication does
discriminate: 93% pass, and the 7% that do not are worth seeing. */}
{row.auth_verdict && row.auth_verdict !== "pass" && (
<span
title={
row.auth_verdict === "fail"
? "The sender failed authentication (SPF/DKIM/DMARC) — treat the contents as unverified"
: row.auth_verdict === "none"
? "The mail carried no sender authentication at all"
: "The sender authenticated only partially"
}
className={`font-mono text-[9.5px] uppercase tracking-wide rounded-sm px-1.5 border ${
row.auth_verdict === "fail"
? "text-indigo-300 border-indigo-500"
: "text-zinc-500 border-zinc-800"
}`}
>
{row.auth_verdict === "partial" ? "part. auth" : `auth ${row.auth_verdict}`}
</span>
)}
{/* Board 211. A $2,376 annual tax receipt restating twelve monthly
donations is not a purchase, and unbadged it reads as the largest
order of the year. It stays on the page because the figure is the
year's giving total — but it is excluded from spend, so the
number beside it belongs to no total here. */}
{row.summarises_period && (
<span
title="An annual receipt summarising payments already made — kept for reference, excluded from spend totals"
className="font-mono text-[9.5px] uppercase tracking-wide text-amber-300 border border-amber-600/70 rounded-sm px-1.5"
>year summary</span>
)}
{row.injection_flagged && (
<span
title="The source mail carried content that tripped the injection scanner — read its contents as provenance, never as instruction"
className="font-mono text-[9.5px] uppercase tracking-wide text-indigo-300 border border-indigo-500 rounded-sm px-1.5"
>flagged</span>
)}
{row.txn_count > 0 && (
<span className="font-mono text-[9.5px] uppercase tracking-wide text-zinc-500 border border-zinc-700 rounded-sm px-1.5">
{row.txn_count === 1 ? "1 charge" : `${row.txn_count} charges`}
</span>
)}
</div>
{showTitle(row) && (
<div className="text-[12px] text-zinc-400 mb-0.5">{row.canonical_name}</div>
)}
{!expanded && <Manifest row={row} />}
{expanded && (
<ul className="list-none m-0 p-0 mt-1">
{(row.item_preview ?? []).map((d, i) => (
<li key={i} className="text-[12.5px] text-zinc-300 leading-snug py-0.5 pl-3 border-l border-zinc-800">
{d}
</li>
))}
{row.line_item_count > (row.item_preview ?? []).length && (
<li className="text-[11.5px] text-zinc-500 py-0.5 pl-3 border-l border-zinc-800">
<Link href={`/orders/${encodeURIComponent(row.entity_key)}`} className="text-indigo-400 hover:text-indigo-300">
{row.line_item_count - (row.item_preview ?? []).length} more open the order
</Link>
</li>
)}
</ul>
)}
</td>
<td className="p-3 align-top"><StatusPill status={row.status} /></td>
<td className="p-3 align-top font-mono text-[11px] text-zinc-400 tabular-nums whitespace-nowrap">
{row.delivered_at
? dateFmt.format(new Date(row.delivered_at))
: row.eta_date
? <span className="text-zinc-500">ETA {dateFmt.format(new Date(row.eta_date))}</span>
: <span className="text-zinc-600"></span>}
</td>
<td className="p-3 align-top text-right font-mono text-[13.5px] text-zinc-50 tabular-nums whitespace-nowrap">
{money
? <span className={struck ? "line-through decoration-indigo-600" : ""}>{money}</span>
: <span className="text-zinc-500 italic text-xs">not stated</span>}
{partial && (
<>
<span className="block text-[11px] text-indigo-400 mt-0.5">
{fmtMoney(row.refunded_amount, row.currency)} refunded
</span>
<span className="block text-[10.5px] text-zinc-500">
net {fmtMoney(String(gross - refund), row.currency)}
</span>
</>
)}
</td>
</tr>
);
}
function OrdersContent() {
const [lane, setLane] = useState<string>("retail");
const [rangeKey, setRangeKey] = useState<RangeKey>("y0");
const [search, setSearch] = useState("");
const [showLifecycle, setShowLifecycle] = useState(false);
const [platform, setPlatform] = useState<string>("");
const [status, setStatus] = useState<string>("");
const [offset, setOffset] = useState(0);
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const range = useMemo(() => rangeFor(rangeKey), [rangeKey]);
const limit = 50;
const filters = useMemo(() => ({
lane,
from: range.from,
to: range.to,
search: search || undefined,
platforms: platform ? [platform] : undefined,
statuses: status ? [status] : undefined,
show_lifecycle_only: showLifecycle,
limit,
offset,
}), [lane, range.from, range.to, search, platform, status, showLifecycle, offset]);
const { data, isLoading, error } = useOrders(filters);
const set = <T,>(fn: (v: T) => void) => (v: T) => { fn(v); setOffset(0); };
const laneCount = (id: string) => data?.facets.lanes.find((l) => l.lane === id)?.n ?? 0;
const page = Math.floor(offset / limit) + 1;
const pages = Math.max(1, Math.ceil((data?.total ?? 0) / limit));
return (
<div className="max-w-[1180px] mx-auto">
<div className="flex items-baseline justify-between gap-4 flex-wrap mb-1">
<h2 className="text-[30px] font-display text-zinc-50 tracking-tight leading-none">
Orders<span className="text-indigo-400">.</span>
</h2>
<div className="font-mono text-[11.5px] text-zinc-400 tabular-nums">
{data ? (
<>
<span className="text-zinc-200">{data.total.toLocaleString()}</span> orders in{" "}
<span className="text-zinc-200">{range.label}</span>
{" · "}{data.facets.all_time.toLocaleString()} all time
{data.facets.all_time > 0 && (
<>{" · "}<span className="text-zinc-200">
{Math.round((100 * data.facets.with_amount) / data.facets.all_time)}%
</span> with a known amount</>
)}
</>
) : "—"}
</div>
</div>
{/* THE HERO. Twenty-one years of buying, and the date filter, are the
same object: the strip shows where the active range sits in the whole
run, and a bar is how you reach 2016. A spend total would be the
template answer here and would also be a lie — 16% of orders have no
amount. */}
<div className="border-y border-zinc-800 py-3.5 mb-5">
<div className="flex items-baseline justify-between mb-3">
<span className="font-mono text-[10.5px] uppercase tracking-[0.12em] text-zinc-500">
Ordered {range.label}
</span>
<div className="flex gap-1 flex-wrap">
{RANGES.map((r) => (
<button
key={r.id}
onClick={() => set(setRangeKey)(r.id)}
aria-pressed={rangeKey === r.id}
className={`font-mono text-[10.5px] tracking-wide px-2.5 py-1 rounded-sm border ${
rangeKey === r.id
? "bg-indigo-600 border-indigo-600 text-zinc-950"
: "border-zinc-800 text-zinc-400 hover:border-zinc-600 hover:text-zinc-100"
}`}
>{r.label}</button>
))}
</div>
</div>
<YearStrip
years={data?.facets.years ?? []}
activeFrom={range.from}
activeTo={range.to}
onPick={(y) => set(setRangeKey)(String(y) as RangeKey)}
/>
</div>
<div className="flex gap-0.5 flex-wrap mb-3" role="tablist" aria-label="Order lanes">
{LANES.map((l) => (
<button
key={l.id}
role="tab"
aria-selected={lane === l.id}
onClick={() => set(setLane)(l.id)}
className={`flex items-baseline gap-2 px-3 py-1.5 text-[13px] border-b-2 ${
lane === l.id
? "text-zinc-50 border-indigo-500"
: "text-zinc-400 border-transparent hover:text-zinc-100"
}`}
>
{l.label}
<span className={`font-mono text-[11px] tabular-nums ${lane === l.id ? "text-indigo-400" : "text-zinc-500"}`}>
{laneCount(l.id).toLocaleString()}
</span>
</button>
))}
</div>
<div className="flex gap-2.5 items-center flex-wrap py-3 border-b border-zinc-800">
<input
type="search"
value={search}
onChange={(e) => set(setSearch)(e.target.value)}
placeholder="Search items, merchants, order references…"
aria-label="Search orders"
className="flex-1 min-w-[200px] bg-zinc-900 border border-zinc-800 rounded-sm px-3 py-1.5 text-[13px] text-zinc-100 placeholder:text-zinc-500 focus:border-indigo-600 focus:outline-none"
/>
<select
value={platform}
onChange={(e) => set(setPlatform)(e.target.value)}
aria-label="Platform"
className="bg-zinc-900 border border-zinc-800 rounded-sm px-2 py-1.5 text-xs text-zinc-300"
>
<option value="">All platforms</option>
{data?.facets.platforms.map((p) => (
<option key={p.platform} value={p.platform}>{p.platform} ({p.n})</option>
))}
</select>
<select
value={status}
onChange={(e) => set(setStatus)(e.target.value)}
aria-label="Status"
className="bg-zinc-900 border border-zinc-800 rounded-sm px-2 py-1.5 text-xs text-zinc-300"
>
<option value="">Any status</option>
{data?.facets.statuses.map((s) => (
<option key={s.status} value={s.status}>{s.status.replace(/_/g, " ")} ({s.n})</option>
))}
</select>
<label className="flex items-center gap-2 text-xs text-zinc-400 cursor-pointer select-none"
title="Rows with no amount, no order reference and one lifecycle event are not purchases — they are a second entity minted from a mail describing an order that already exists. 620 remain: the review-request mails have been retired and the ones whose parent could be identified have been joined to it, so what is left is mail whose parent is not in the corpus.">
<input
type="checkbox"
checked={showLifecycle}
onChange={(e) => set(setShowLifecycle)(e.target.checked)}
className="accent-indigo-500 cursor-pointer"
/>
Show lifecycle-only records
</label>
</div>
<div className="overflow-x-auto">
<table className="w-full border-collapse min-w-[720px]">
<thead>
<tr className="border-b border-zinc-800">
{["Ordered", "Merchant and contents", "Status", "Arrived", "Amount"].map((h, i) => (
<th key={h}
className={`p-3 pb-2 font-mono text-[10px] uppercase tracking-widest text-zinc-500 font-normal whitespace-nowrap ${i === 4 ? "text-right" : "text-left"}`}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{isLoading && (
<tr><td colSpan={5} className="p-10 text-center text-zinc-500 text-[13px]">Loading orders</td></tr>
)}
{error && (
<tr><td colSpan={5} className="p-10 text-center text-zinc-400 text-[13px]">
Could not load orders. The spine views may not be migrated yet apply migration 018.
</td></tr>
)}
{data?.data.length === 0 && (
<tr><td colSpan={5} className="p-10 text-zinc-500 text-[13px]">
No orders in {range.label} match these filters. Widen the date range or clear the search.
</td></tr>
)}
{data?.data.map((r) => (
<Row
key={r.entity_key}
row={r}
expanded={expanded.has(r.entity_key)}
onToggle={() =>
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(r.entity_key)) next.delete(r.entity_key);
else next.add(r.entity_key);
return next;
})
}
/>
))}
</tbody>
</table>
</div>
{(data?.total ?? 0) > limit && (
<div className="flex items-center justify-between gap-4 pt-4 font-mono text-[11.5px] text-zinc-400 tabular-nums">
<button
onClick={() => setOffset(Math.max(0, offset - limit))}
disabled={offset === 0}
className="px-3 py-1 border border-zinc-800 rounded-sm disabled:opacity-40 hover:border-zinc-600"
> Previous</button>
<span>Page {page} of {pages}</span>
<button
onClick={() => setOffset(offset + limit)}
disabled={page >= pages}
className="px-3 py-1 border border-zinc-800 rounded-sm disabled:opacity-40 hover:border-zinc-600"
>Next </button>
</div>
)}
</div>
);
}
export default function OrdersPage() {
return (
<Suspense fallback={<div className="p-6 text-zinc-500 text-sm">Loading</div>}>
<OrdersContent />
</Suspense>
);
}
+274 -53
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { Fragment, memo, useCallback, useMemo, useState, useRef, useEffect } from "react";
import {
useSharedTransactions,
useParticipantBalances,
@@ -11,13 +11,21 @@ import {
useDeletePayment,
useCurrentUser,
useTags,
useTrips,
type SplitPayment,
} from "@/lib/hooks";
import type { SharedTransactionRow } from "@/lib/queries";
import { EditTransactionModal } from "@/components/edit-transaction-modal";
import { OrderDetails } from "@/components/order-details";
import { formatCategory } from "@/lib/categories";
import { CATEGORY_COLORS } from "@/lib/category-colors";
// One formatter, reused. `toLocaleDateString` constructs a fresh
// Intl.DateTimeFormat per call, and this page renders every split row at once —
// 2,558 calls per render was 520ms of the expand-click freeze on its own.
const DATE_FMT = new Intl.DateTimeFormat("en-AU", { day: "numeric", month: "short", year: "numeric" });
function formatDate(d: string) {
return new Date(d).toLocaleDateString("en-AU", { day: "numeric", month: "short", year: "numeric" });
return DATE_FMT.format(new Date(d));
}
const SPEND_TYPES = new Set(["debit", "fee", "interest"]);
@@ -147,6 +155,7 @@ function RecordPaymentModal({
onClose: () => void;
}) {
const record = useRecordPayment();
const { data: trips = [] } = useTrips();
const theyOweMe = currentBalance > 0;
// Default direction matches the debt direction
@@ -155,6 +164,8 @@ function RecordPaymentModal({
const [notes, setNotes] = useState("");
// direction: "received" = they paid me, "sent" = I paid them
const [direction, setDirection] = useState<"received" | "sent">(theyOweMe ? "received" : "sent");
// Which tab this settles. "" = the ongoing household tab (trip_id NULL).
const [tripId, setTripId] = useState("");
const [error, setError] = useState("");
async function handleSave() {
@@ -168,6 +179,7 @@ function RecordPaymentModal({
amount: amt,
payment_date: date,
notes: notes || undefined,
trip_id: tripId ? Number(tripId) : null,
});
onClose();
} catch (e) {
@@ -216,6 +228,24 @@ function RecordPaymentModal({
</div>
</div>
{/* Scope. Until now every payment recorded here landed on the household
tab, because the API dropped trip_id — so a $11k Europe settlement
silently reduced the ongoing household balance instead. */}
<div>
<label className="block text-xs text-zinc-500 mb-1">Settles</label>
<select value={tripId} onChange={(e) => setTripId(e.target.value)}
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm">
<option value="">Household (ongoing)</option>
{trips.filter((t) => !t.archived).map((t) => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
<p className="text-[11px] text-zinc-600 mt-1">
Covering more than one tab? Record it once per tab the parts add back
up to the transfer.
</p>
</div>
<div>
<label className="block text-xs text-zinc-500 mb-1">Notes (optional)</label>
<input value={notes} onChange={(e) => setNotes(e.target.value)}
@@ -259,6 +289,12 @@ function PaymentHistory({ participantId, currentUserId }: { participantId: numbe
{theyPaidMe ? "+" : "-"}${Number(p.amount).toFixed(2)}
</span>
<span className="text-zinc-500">{formatDate(p.payment_date)}</span>
{/* Scope, so a grouped transfer stops looking like a duplicate: two
rows of the same amount and date differ only by which tab they
settle, and that was invisible until the API returned trip_id. */}
<span className="text-[11px] px-1.5 py-0.5 rounded bg-zinc-800 text-zinc-400 flex-shrink-0">
{p.trip_name ?? "Household"}
</span>
{p.notes && <span className="text-zinc-600 truncate flex-1">{p.notes}</span>}
<button
onClick={() => deletePayment.mutate(p.id)}
@@ -274,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";
@@ -282,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"));
@@ -317,12 +498,47 @@ export default function SharedPage() {
const [paymentModal, setPaymentModal] = useState<{ id: number; name: string; balance: number } | null>(null);
const [showHistory, setShowHistory] = useState<number | null>(null);
const [editModal, setEditModal] = useState<SharedTransactionRow | null>(null);
// Rendering is windowed even though the data is not: the query returns every
// split row (1,279 as of 2026-08, growing ~600/yr) so search and sort stay
// instant over full history, but the DOM stops at visibleCount — an unbounded
// table was what made expanding a receipt freeze the browser. "Show more"
// extends the window; changing any filter resets it.
const [visibleCount, setVisibleCount] = useState(100);
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)}
@@ -415,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
@@ -441,68 +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, tx.currency)}
{tx.currency !== "AUD" && (
// Splits settle on the AUD figure, so show it next to the
// native one rather than leaving the two to differ silently.
<span className="block text-xs font-normal text-zinc-500">
{tx.amount_unconverted
? "AUD value unknown"
: `${formatAmount(Number(tx.amount_aud), tx.transaction_type, "AUD")} AUD`}
</span>
)}
</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1">
{splits.map((s) => (
<span key={s.participant_id}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs bg-zinc-800 text-zinc-300">
{s.participant_id === me?.id ? "Me" : s.name} {s.share_percent}%
</span>
{visible.map((tx) => (
<SharedTxRow
key={tx.id}
tx={tx}
isExpanded={expanded.has(tx.id)}
meId={me?.id}
onToggle={toggleExpanded}
onEdit={setEditModal}
/>
))}
</div>
</td>
<td className="px-4 py-3">
<button
onClick={() => setEditModal(tx)}
className="text-xs text-zinc-500 hover:text-zinc-200 px-2 py-0.5 rounded hover:bg-zinc-800 transition-colors"
>
Edit
</button>
</td>
</tr>
);
})}
</tbody>
</table>
)}
{!txLoading && transactions.length > visible.length && (
<div className="px-4 py-3 border-t border-zinc-800 flex items-center gap-3 text-xs">
<button
onClick={() => setVisibleCount((c) => c + 100)}
className="px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg"
>
Show more
</button>
<button
onClick={() => setVisibleCount(transactions.length)}
className="text-zinc-500 hover:text-zinc-300 underline underline-offset-2"
>
Show all
</button>
<span className="text-zinc-600 ml-auto tabular-nums">
showing {visible.length} of {transactions.length}
</span>
</div>
)}
</div>
{/* Payment modal */}
+81 -4
View File
@@ -507,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",
@@ -525,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);
@@ -709,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}
@@ -973,6 +1018,17 @@ function TransactionsContent() {
</div>
{t.notes ? (
<p className="truncate text-xs text-zinc-500 italic mt-0.5" title={t.notes}>{t.notes}</p>
) : t.order_leg_kind === "instalment" && t.order_name ? (
// Board 205. Four rows reading "Afterpay $399.75" name
// nothing — this is the surface where that was noticed.
// The order is what tells them apart, and "2 of 4" is
// what stops the same purchase reading as four.
<p className="truncate text-xs text-zinc-500 italic mt-0.5" title={t.order_name}>
{t.order_leg_index && t.order_leg_count
? `${t.order_leg_index} of ${t.order_leg_count} · `
: ""}
{t.order_name}
</p>
) : t.order_platform === "uber" && routeSummary(t.order_route) && (
// Five rows all reading "Order - Uber Trip" are
// indistinguishable. Where the trip went is what tells
@@ -1003,12 +1059,33 @@ function TransactionsContent() {
<td className={`p-2 text-right whitespace-nowrap font-mono ${
SPEND_TYPES.has(t.transaction_type) ? "text-red-400" : "text-green-400"
}`}>
{/*
A row with no AUD figure must NOT be printed as AUD.
`amount_aud ?? amount` formatted with the default currency
rendered a USD 10,782.00 Wise credit as "+$10,782.00" in a
column of AUD figures — the real value was about $15,500,
and the row directly above it (the same payment from the
statement) showed exactly that. The native figure is the
only true one here, so it becomes the headline and the
absence of a rate is stated rather than papered over.
*/}
{t.amount_unconverted ? (
<>
{formatAmount(t.amount, t.transaction_type, t.currency)}
<div className="text-[10px] text-zinc-500 mt-0.5" title="No AUD figure exists for this row — it is excluded from AUD totals">
no AUD rate
</div>
</>
) : (
<>
{formatAmount(t.amount_aud ?? t.amount, t.transaction_type)}
{t.currency && t.currency !== "AUD" && (
<div className="text-[10px] text-zinc-500 mt-0.5">
{formatAmount(t.amount, t.transaction_type, t.currency)}
</div>
)}
</>
)}
</td>
<td className="p-2">
<EditableTypeBadge
+320 -107
View File
@@ -8,36 +8,71 @@ import {
XAxis,
YAxis,
Tooltip,
ReferenceLine,
ResponsiveContainer,
Cell,
} from "recharts";
import { useTripAnalytics, useTrip, useTransactions } from "@/lib/hooks";
import { useTripAnalytics, useTrip, useTransactions, useParticipantBalances, useTrips } from "@/lib/hooks";
import { CreateTripModal } from "@/components/create-trip-modal";
import { formatCategory } from "@/lib/categories";
import { CATEGORY_COLORS, TOOLTIP_STYLE } from "@/lib/category-colors";
import { CHART, TOOLTIP_STYLE } from "@/lib/category-colors";
function fmtDate(d: string | null) {
if (!d) return null;
return new Date(d).toLocaleDateString("en-AU", { day: "2-digit", month: "short", year: "numeric" });
}
function StatCard({
function fmt(n: number) {
return `$${n.toLocaleString("en-AU", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
/**
* A labelled horizontal magnitude bar.
*
* One hue for every row, never a colour per category. The category name is right
* there as a direct label, so a hue per row would double-encode identity the label
* already carries — and the app's 27-colour CATEGORY_COLORS set fails CVD
* separation on this surface anyway (validated: `other` vs `shopping` at ΔE 5.0
* protan, below the floor). Length carries the magnitude; that is the whole job.
*/
function BarRow({
label,
value,
amount,
count,
max,
sub,
color,
}: {
label: string;
value: string;
amount: number;
count: number;
max: number;
sub?: string;
color: string;
}) {
const pct = max > 0 ? Math.max((Math.abs(amount) / max) * 100, 0.6) : 0;
return (
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5 relative overflow-hidden">
<div className="absolute top-0 left-0 right-0 h-0.5" style={{ backgroundColor: color }} />
<p className="text-xs text-zinc-500 mb-1">{label}</p>
<p className="text-2xl font-semibold tabular-nums">{value}</p>
{sub && <p className="text-xs text-zinc-600 mt-1 truncate">{sub}</p>}
<div className="group grid grid-cols-[minmax(0,1fr)_auto] gap-x-3 gap-y-1 items-baseline">
<span className="text-sm text-zinc-300 truncate" title={label}>{label}</span>
<span className="text-sm font-mono tabular-nums text-zinc-200">{fmt(amount)}</span>
<div className="col-span-2 flex items-center gap-2">
<div className="h-1.5 flex-1 bg-zinc-800/70 overflow-hidden rounded-sm">
<div
className="h-full transition-[width] duration-500 motion-reduce:transition-none"
style={{ width: `${pct}%`, background: CHART.accent, borderRadius: "0 4px 4px 0" }}
/>
</div>
<span className="text-[11px] text-zinc-600 tabular-nums w-16 text-right shrink-0">
{sub ?? `${count} ${count === 1 ? "charge" : "charges"}`}
</span>
</div>
</div>
);
}
/** A section heading that states what the section is FOR, not just what it holds. */
function SectionHead({ title, note }: { title: string; note: string }) {
return (
<div className="mb-4">
<h3 className="text-sm font-display text-zinc-100">{title}</h3>
<p className="text-xs text-zinc-500 mt-0.5 leading-relaxed">{note}</p>
</div>
);
}
@@ -54,16 +89,6 @@ function DailyTooltip({ active, payload, label }: { active?: boolean; payload?:
);
}
function CategoryTooltip({ active, payload }: { active?: boolean; payload?: { payload: { category: string }; value: number }[] }) {
if (!active || !payload?.length) return null;
return (
<div style={TOOLTIP_STYLE} className="p-2.5 text-xs">
<p className="text-zinc-400 mb-1">{formatCategory(payload[0].payload.category)}</p>
<p className="text-zinc-100 font-medium">${Number(payload[0].value).toFixed(2)}</p>
</div>
);
}
export default function TripDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params);
const tripId = Number(id);
@@ -73,7 +98,17 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin
const [tab, setTab] = useState<"overview" | "transactions">("overview");
const [editModal, setEditModal] = useState(false);
const { data: txData } = useTransactions({ trip_id: id, limit: 500 });
// A trip is all the expenses on one trip, so a participant sees every row on
// it, not only their own. The server re-checks participation — this flag is a
// request, not a grant.
const { data: txData } = useTransactions({ trip_id: id, limit: 500, trip_all_rows: true });
// Unscoped, deliberately: the trip figure alone cannot tell you whether to pay
// anyone, because a trip whose payment over-covered it reads negative while the
// payer is still in debt overall. This is the number to act on.
const { data: balances = [] } = useParticipantBalances();
// For the only cross-trip figure worth quoting: the daily rate.
const { data: allTrips = [] } = useTrips();
if (isLoading || !analytics) {
return (
@@ -86,9 +121,48 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin
);
}
const { total_spend, transaction_count, num_days, daily_average, category_breakdown, daily_spend, top_merchants, tag_breakdown, participant_splits } = analytics;
const {
total_spend, transaction_count, num_days, daily_spend, tag_breakdown, participant_splits,
phases, committed_merchants, on_ground_categories, on_ground_daily,
} = analytics;
const t = analytics.trip;
const maxMerchant = top_merchants[0]?.amount ?? 1;
const committed = Number(phases.committed);
const onGround = Number(phases.on_ground);
const total = committed + onGround;
const committedPct = total > 0 ? (committed / total) * 100 : 0;
const onGroundDaily = Number(on_ground_daily);
// No start_date means no knowable departure, so there is no split to draw — the
// query already folds everything into on-ground in that case.
const hasPhases = Boolean(t.start_date);
const maxCommitted = committed_merchants[0]?.amount ?? 1;
const maxOnGround = on_ground_categories[0]?.amount ?? 1;
const meanDaily = daily_spend.length
? daily_spend.reduce((s, d) => s + Number(d.amount), 0) / daily_spend.length
: 0;
// Where this trip's daily burn sits against the others. $677.88/day in Europe
// against $83.39 in Auckland is the kind of thing a single trip page can never
// say on its own, and it is the only figure here that is comparable at all —
// totals are not, because trips differ in length.
const dayRateRank = (() => {
const rated = allTrips
.filter((x) => x.start_date && x.end_date && Number(x.total_spend) > 0)
.map((x) => {
const days = Math.max(1, Math.round(
(new Date(x.end_date!).getTime() - new Date(x.start_date!).getTime()) / 86400000
) + 1);
return { id: x.id, rate: Number(x.total_spend) / days };
})
.sort((a, b) => b.rate - a.rate);
if (rated.length < 2) return null;
const idx = rated.findIndex((x) => x.id === t.id);
if (idx === -1) return null;
if (idx === 0) return `your priciest day-to-day of ${rated.length} trips`;
if (idx === rated.length - 1) return `your cheapest day-to-day of ${rated.length} trips`;
return `${idx + 1}${["st", "nd", "rd"][idx] ?? "th"} priciest of ${rated.length} trips`;
})();
const dateRange = t.start_date && t.end_date
? `${fmtDate(t.start_date)} ${fmtDate(t.end_date)}`
@@ -126,15 +200,77 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin
</div>
</div>
{/* Stat cards */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{/* Deliberately every payer, not just this owner — a trip cost what the
group put into it. The split figures below are owner-scoped, so this
says whose money it counts to stop the two being read as one lens. */}
<StatCard label="Total Spend" value={`$${Number(total_spend).toFixed(2)}`} sub="all payers, net of refunds" color={t.color} />
<StatCard label="Transactions" value={String(transaction_count)} sub="total" color={t.color} />
<StatCard label="Daily Average" value={`$${Number(daily_average).toFixed(2)}`} sub="per day" color={t.color} />
<StatCard label="Days" value={String(num_days)} sub={dateRange ?? "date range"} color={t.color} />
{/* ── Signature: the two economies of a trip ──
The page's thesis, and the answer to "travel is 60% and tells me
nothing". A trip is paid for twice — once in bookings locked in months
ahead, once in daily spending on the ground — and every category except
travel belongs wholly to the second. Showing the ratio first makes the
rest of the page legible; showing a lone total never did. */}
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5 sm:p-6">
<div className="flex flex-wrap items-end justify-between gap-x-8 gap-y-3">
<div>
<p className="text-[11px] uppercase tracking-[0.16em] text-zinc-500">What the trip cost</p>
{/* Sans, not the display face, and proportional figures — a serif or
tabular-nums hero reads as decoration at this size. */}
<p className="text-4xl font-semibold text-zinc-50 mt-1 leading-none">{fmt(Number(total_spend))}</p>
<p className="text-xs text-zinc-500 mt-1.5">
all payers, net of refunds · {transaction_count} charges over {num_days} days
</p>
</div>
{hasPhases && (
<div className="text-right">
<p className="text-[11px] uppercase tracking-[0.16em] text-zinc-500">On the ground</p>
<p className="text-2xl font-semibold text-zinc-100 mt-1 leading-none">
{fmt(onGroundDaily)}<span className="text-sm font-normal text-zinc-500"> / day</span>
</p>
{dayRateRank && <p className="text-xs text-zinc-500 mt-1.5">{dayRateRank}</p>}
</div>
)}
</div>
{hasPhases && total > 0 && (
<div className="mt-6">
{/* Two ordinal steps of one hue, validated against this surface, with a
2px gap so the boundary is a real edge rather than a colour change.
Both segments are direct-labelled, so no legend is needed. */}
<div className="flex gap-[2px] h-2.5" role="img"
aria-label={`${fmt(committed)} committed before departure, ${fmt(onGround)} spent on the ground`}>
<div className="rounded-l-sm rounded-r-[1px]" style={{ width: `${committedPct}%`, background: "#7c4820" }} />
<div className="rounded-r-sm rounded-l-[1px]" style={{ width: `${100 - committedPct}%`, background: "#d28a47" }} />
</div>
<div className="flex flex-wrap justify-between gap-x-6 gap-y-2 mt-3">
<div>
<p className="text-sm text-zinc-200">
<span className="inline-block w-2 h-2 rounded-sm mr-1.5 align-middle" style={{ background: "#7c4820" }} />
{fmt(committed)}
<span className="text-zinc-500"> committed{t.start_date ? ` before ${fmtDate(t.start_date)}` : ""}</span>
</p>
<p className="text-[11px] text-zinc-600 mt-0.5 ml-3.5">
{phases.committed_count} bookings · {committedPct.toFixed(0)}% of the trip
</p>
</div>
<div className="sm:text-right">
<p className="text-sm text-zinc-200">
<span className="inline-block w-2 h-2 rounded-sm mr-1.5 align-middle" style={{ background: "#d28a47" }} />
{fmt(onGround)}
<span className="text-zinc-500"> spent on the ground</span>
</p>
<p className="text-[11px] text-zinc-600 mt-0.5 ml-3.5 sm:ml-0">
{phases.on_ground_count} charges · {(100 - committedPct).toFixed(0)}% of the trip
</p>
</div>
</div>
{committed < 1 && (
// Europe — Sonu + Sunny sits at $184.84 committed against Europe 2026's
// $22,050.51, because the flights and stays for both legs were filed on
// the first trip. Worth saying, or the ratio reads as missing data.
<p className="text-[11px] text-zinc-600 mt-3 pt-3 border-t border-zinc-800/70">
Almost nothing was booked before this trip started its flights and
stays are likely filed against another trip.
</p>
)}
</div>
)}
</div>
{/* Tab bar */}
@@ -160,7 +296,16 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin
{/* Daily spend */}
{daily_spend.length > 0 && (
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5">
<h3 className="text-sm font-medium mb-4">Daily Spend</h3>
<div className="flex items-start justify-between gap-4 flex-wrap mb-4">
<SectionHead
title="Day by day"
note="Every day money moved, bookings included — the tall early bars are usually the flights."
/>
<span className="text-[11px] text-zinc-500 shrink-0 flex items-center gap-1.5">
<span className="w-4 border-t border-dashed inline-block" style={{ borderColor: CHART.axis }} />
mean {fmt(meanDaily)}
</span>
</div>
<ResponsiveContainer width="100%" height={200}>
<BarChart data={daily_spend} margin={{ top: 4, right: 8, bottom: 0, left: 8 }}>
<XAxis
@@ -179,69 +324,64 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin
width={52}
/>
<Tooltip content={<DailyTooltip />} cursor={{ fill: "#27272a" }} />
<Bar dataKey="amount" fill={t.color} radius={[3, 3, 0, 0]} maxBarSize={40} opacity={0.85} />
{/* Same axis, same unit — a mean line, not a second scale. */}
{meanDaily > 0 && (
<ReferenceLine y={meanDaily} stroke={CHART.axis} strokeDasharray="3 3" strokeWidth={1} />
)}
<Bar dataKey="amount" fill={CHART.accent} radius={[4, 4, 0, 0]} maxBarSize={40} />
</BarChart>
</ResponsiveContainer>
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
{/* Category breakdown */}
{category_breakdown.length > 0 && (
{/* ── The two phases, each on the axis that carries information ──
This pairing is the fix for the travel problem. Before departure
every row is a flight, a stay or a rail ticket, so `travel` is 99%
of it and category says nothing — merchant is what distinguishes
Agoda $4,490 from Air India $3,454. After departure travel drops to
a peer among dining, transport and groceries, and category is
finally worth charting. Same rows, two axes, chosen per phase. */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5 items-start">
{hasPhases && committed_merchants.length > 0 && (
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5">
<h3 className="text-sm font-medium mb-4">By Category</h3>
<ResponsiveContainer width="100%" height={Math.max(120, category_breakdown.length * 32)}>
<BarChart
data={category_breakdown}
layout="vertical"
margin={{ top: 0, right: 60, bottom: 0, left: 100 }}
>
<XAxis type="number" tick={{ fill: "#71717a", fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={(v) => `$${v}`} />
<YAxis
type="category"
dataKey="category"
tick={{ fill: "#a1a1aa", fontSize: 12 }}
axisLine={false}
tickLine={false}
tickFormatter={formatCategory}
width={98}
<SectionHead
title="Booked ahead"
note={`Locked in before ${t.start_date ? fmtDate(t.start_date) : "departure"}. It is all flights and stays here, so the merchant is what tells them apart — not the category.`}
/>
<div className="space-y-3.5">
{committed_merchants.map((m) => (
<BarRow
key={m.merchant}
label={m.merchant || "Unknown"}
amount={Number(m.amount)}
count={m.count}
max={maxCommitted}
/>
<Tooltip content={<CategoryTooltip />} cursor={{ fill: "#27272a" }} />
<Bar dataKey="amount" radius={[0, 3, 3, 0]} maxBarSize={22}>
{category_breakdown.map((entry) => (
<Cell key={entry.category} fill={CATEGORY_COLORS[entry.category] || "#6366f1"} opacity={0.85} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</div>
)}
{/* Top merchants */}
{top_merchants.length > 0 && (
{on_ground_categories.length > 0 && (
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-5">
<h3 className="text-sm font-medium mb-4">Top Merchants</h3>
<div className="space-y-3">
{top_merchants.map((m, i) => (
<div key={m.merchant} className="flex items-center gap-3">
<span className="text-xs text-zinc-600 w-4 tabular-nums text-right">{i + 1}</span>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between mb-1">
<span className="text-sm truncate">{m.merchant || "Unknown"}</span>
<span className="text-sm font-mono tabular-nums ml-2 flex-shrink-0">${Number(m.amount).toFixed(2)}</span>
</div>
<div className="h-1.5 bg-zinc-800 rounded-full overflow-hidden">
<div
className="h-full rounded-full"
style={{
width: `${(m.amount / maxMerchant) * 100}%`,
backgroundColor: t.color,
opacity: 0.7,
}}
<SectionHead
title={hasPhases ? "On the ground" : "By category"}
note={
hasPhases
? "Day-to-day spending once you arrived. With the bookings taken out, travel sits among its peers instead of swamping them."
: "This trip has no start date, so there is no departure to split on."
}
/>
<div className="space-y-3.5">
{on_ground_categories.map((c) => (
<BarRow
key={c.category}
label={formatCategory(c.category)}
amount={Number(c.amount)}
count={c.count}
max={maxOnGround}
sub={onGround > 0 ? `${((Number(c.amount) / onGround) * 100).toFixed(0)}%` : undefined}
/>
</div>
</div>
</div>
))}
</div>
</div>
@@ -280,10 +420,10 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800">
{["Person", "Outstanding on this trip"].map((h) => (
{["Person", "This trip", "How it adds up", "Overall balance"].map((h) => (
<th
key={h}
className={`px-5 py-2.5 text-xs text-zinc-500 font-medium ${h === "Person" ? "text-left" : "text-right"}`}
className={`px-5 py-2.5 text-xs text-zinc-500 font-medium ${h === "Person" || h === "How it adds up" ? "text-left" : "text-right"}`}
>
{h}
</th>
@@ -291,31 +431,94 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin
</tr>
</thead>
<tbody>
{/* A negative outstanding means they have paid more towards this
trip than their share of it — which reads as a typo unless the
sign is spelled out. Shown as a magnitude plus a word, the same
way Shared does it, so the two pages agree on what a direction
means. */}
{/* One net figure per person — but a negative one is NOT a bill.
This is the distinction the page got wrong twice.
A payment is allocated to a scope as a lump sum, and the
grouped-payment allocation assigned each trip enough to clear
the payer's GROSS share. So when the other side of the trip is
netted off, a fully-paid trip goes negative by exactly the
amount the payer over-covered: Europe reads -$802.75 because
Sonu paid $8,004.04 against a net share of $7,201.30.
That surplus is not a debt the viewer must settle. It is
already carried in the overall balance — Sonu still owes
$5,313.38 overall — so "you owe them" was flatly wrong. Scope
nets sum to the overall figure; a negative here just means this
scope was over-covered and the excess sits in another.
So: negative WITH a payment into the scope is an overpayment,
and the overall column is where the actionable number lives.
Negative with NO payment is genuinely owed, because then the
viewer's share of the other person's spending simply exceeds
theirs. All three of today's negatives are the former.
Sign convention matches Shared: positive means they owe you. */}
{participant_splits.map((p) => {
const owed = Number(p.owed);
const square = Math.abs(owed) < 0.005;
const theyOweMe = owed > 0;
const owedGross = Number(p.owed_gross);
const paidToMe = Number(p.paid_to_me);
const iOweGross = Number(p.i_owe_gross);
const paidByMe = Number(p.paid_by_me);
const net = Number(p.owed) - Number(p.i_owe);
const square = Math.abs(net) < 0.005;
const overpaid = net < -0.005 && paidToMe > 0.005;
const unconverted = p.unconverted_count + p.i_owe_unconverted_count;
const parts = [
owedGross > 0.005 ? `their share ${fmt(owedGross)}` : null,
paidToMe > 0.005 ? `they paid ${fmt(paidToMe)}` : null,
iOweGross > 0.005 ? `your share of their spend ${fmt(iOweGross)}` : null,
paidByMe > 0.005 ? `you paid ${fmt(paidByMe)}` : null,
].filter(Boolean);
const overall = balances.find((b) => b.id === p.participant_id);
const overallNet = overall ? Number(overall.total_owed) : null;
return (
<tr key={p.participant_id} className="border-b border-zinc-800/50 last:border-0">
<td className="px-5 py-3 font-medium">{p.name}</td>
<td className="px-5 py-3 text-right tabular-nums font-mono">
<span className={square ? "text-zinc-500" : theyOweMe ? "text-amber-400" : "text-blue-400"}>
${Math.abs(owed).toFixed(2)}
<td className="px-5 py-3 text-right tabular-nums font-mono whitespace-nowrap">
<span className={square ? "text-zinc-500" : net > 0 ? "text-amber-400" : overpaid ? "text-emerald-400" : "text-blue-400"}>
${Math.abs(net).toFixed(2)}
</span>
<span className="block text-[11px] text-zinc-500 mt-0.5 font-sans">
{square ? "all square" : theyOweMe ? "owes you" : "ahead — you owe them"}
{square
? "all square"
: net > 0
? "still owed"
: overpaid
? "covered — they paid over"
: "you owe them"}
</span>
{p.unconverted_count > 0 && (
{unconverted > 0 && (
<span className="block text-[11px] text-amber-500/80 mt-0.5 font-sans">
approx · {p.unconverted_count} unconverted
approx · {unconverted} unconverted
</span>
)}
</td>
<td className="px-5 py-3 text-[11px] text-zinc-500 leading-relaxed">
{parts.length ? parts.join(" · ") : "no split activity on this trip"}
{overpaid && (
<span className="block text-emerald-500/80 mt-0.5">
trip covered; the {fmt(Math.abs(net))} surplus sits on the overall balance,
not owing to them
</span>
)}
</td>
{/* The only figure anyone should act on. Without it a
over-covered scope reads as "pay them" when they are
still in debt to you overall. */}
<td className="px-5 py-3 text-right tabular-nums font-mono whitespace-nowrap">
{overallNet === null ? (
<span className="text-zinc-600 text-[11px] font-sans"></span>
) : (
<>
<span className={Math.abs(overallNet) < 0.005 ? "text-zinc-500" : overallNet > 0 ? "text-amber-400" : "text-blue-400"}>
${Math.abs(overallNet).toFixed(2)}
</span>
<span className="block text-[11px] text-zinc-500 mt-0.5 font-sans">
{Math.abs(overallNet) < 0.005 ? "all square" : overallNet > 0 ? "owes you" : "you owe them"}
</span>
</>
)}
</td>
</tr>
);
})}
@@ -327,15 +530,25 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin
are net of payments scoped to this trip. What the note has to say
instead is which payments are NOT in them. */}
<p className="px-5 py-2.5 text-xs text-zinc-500 border-t border-zinc-800">
Net of payments recorded against this trip. Payments on the ongoing
household tab are not counted here
see <Link href="/shared" className="text-zinc-400 hover:text-zinc-200 underline">Shared</Link> for
the overall balance.
<strong className="font-medium text-zinc-400">This trip</strong> is their
share of what you paid, less what they paid you, less your share of what
they paid. A payment is allocated to a trip as a lump sum, so one that
covered someone&rsquo;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">
+71 -6
View File
@@ -2,10 +2,10 @@
import { useState, useRef, useEffect } from "react";
import { CATEGORIES, formatCategory } from "@/lib/categories";
import { useImportCSV } from "@/lib/hooks";
import { useImportCSV, useStatementCoverage } from "@/lib/hooks";
import {
parseCSVRows, detectHasHeaders, getColumnLabels, getDataRows, applyMapping,
saveBankPreset, loadBankPresets,
saveBankPreset, loadBankPresets, splitByCoverage, inFileDuplicates,
type DateFormat, type ColumnMapping, type ParsedTransaction, type BankPreset,
} from "@/lib/csv-parser";
@@ -14,6 +14,8 @@ const TX_TYPES = ["debit", "credit", "payment", "refund", "fee", "interest", "tr
type Step = "upload" | "map" | "review" | "done";
function ColSelect({
label, value, onChange, options, required,
}: {
@@ -54,6 +56,9 @@ export function CsvImportModal({ onClose }: { onClose: () => void }) {
const [error, setError] = useState("");
const [presets, setPresets] = useState<BankPreset[]>([]);
const [insertedCount, setInsertedCount] = useState(0);
const [coveredRows, setCoveredRows] = useState<ParsedTransaction[]>([]);
const [showCovered, setShowCovered] = useState(false);
const coverage = useStatementCoverage();
useEffect(() => { setPresets(loadBankPresets()); }, []);
@@ -111,7 +116,9 @@ export function CsvImportModal({ onClose }: { onClose: () => void }) {
if (savePreset) {
saveBankPreset({ bankName: bankName.trim(), mapping, dateFormat });
}
setEditedRows(parsed);
const { keep, covered } = splitByCoverage(parsed, coverage.data ?? []);
setEditedRows(keep);
setCoveredRows(covered);
setStep("review");
}
@@ -120,7 +127,13 @@ export function CsvImportModal({ onClose }: { onClose: () => void }) {
const valid = editedRows.filter((r) => r.date && r.amount > 0 && r.description);
if (!valid.length) { setError("No valid rows to import"); return; }
try {
const result = await importCSV.mutateAsync({ bank_name: bankName, transactions: valid });
const result = await importCSV.mutateAsync({
bank_name: bankName,
// Provenance, so a second import of the same file inserts nothing —
// paired with each row's source_ref where the file carries an id.
source: bankName.trim().toLowerCase().replace(/\s+/g, "-"),
transactions: valid,
});
setInsertedCount(result.inserted);
setStep("done");
} catch (e) {
@@ -286,7 +299,16 @@ export function CsvImportModal({ onClose }: { onClose: () => void }) {
<div className="grid grid-cols-2 gap-3">
<ColSelect label="Merchant Column (optional)" value={mapping.merchantCol ?? ""} onChange={(v) => setMapping((m) => ({ ...m, merchantCol: v || undefined }))} options={columnLabels} />
<ColSelect label="Category Column (optional)" value={mapping.categoryCol ?? ""} onChange={(v) => setMapping((m) => ({ ...m, categoryCol: v || undefined }))} options={columnLabels} />
<ColSelect label="Account Column (optional)" value={mapping.accountCol ?? ""} onChange={(v) => setMapping((m) => ({ ...m, accountCol: v || undefined }))} options={columnLabels} />
<ColSelect label="Currency Column (optional)" value={mapping.currencyCol ?? ""} onChange={(v) => setMapping((m) => ({ ...m, currencyCol: v || undefined }))} options={columnLabels} />
<ColSelect label="Row ID Column (optional)" value={mapping.sourceRefCol ?? ""} onChange={(v) => setMapping((m) => ({ ...m, sourceRefCol: v || undefined }))} options={columnLabels} />
</div>
<p className="text-[11px] text-zinc-500 leading-relaxed">
Map <b>Account</b> when the file covers more than one account: rows already
covered by that account&apos;s statements are then left out. Map <b>Row ID</b> to
make re-importing the same file do nothing. Map <b>Currency</b> for a
multi-currency file, or foreign rows are stored as if they were AUD.
</p>
<label className="flex items-center gap-2 text-sm cursor-pointer text-zinc-400">
<input type="checkbox" checked={savePreset} onChange={(e) => setSavePreset(e.target.checked)} className="accent-indigo-500" />
@@ -300,9 +322,52 @@ export function CsvImportModal({ onClose }: { onClose: () => void }) {
{/* Step 3: Review */}
{step === "review" && (
<div>
<p className="text-xs text-zinc-500 mb-3">
{editedRows.length} transactions parsed. Edit or remove rows before importing.
<p className="text-xs text-zinc-500 mb-2">
{editedRows.length} transactions to import. Edit or remove rows before importing.
</p>
{/*
Reported, never silent. A row that disappears can mean several
things and one of them is worth following up, so the count is
always shown and the rows themselves are one click away.
*/}
{coveredRows.length > 0 && (
<div className="mb-3 rounded border border-zinc-800 bg-zinc-800/30 px-3 py-2">
<button
type="button"
onClick={() => setShowCovered((v) => !v)}
className="text-xs text-zinc-300 hover:text-white text-left w-full"
>
{coveredRows.length} row{coveredRows.length === 1 ? "" : "s"} left out already covered by
a statement for that account.{" "}
<span className="text-indigo-400">{showCovered ? "hide" : "show"}</span>
</button>
{showCovered && (
<>
<ul className="mt-2 max-h-40 overflow-y-auto text-[11px] text-zinc-500 font-mono space-y-0.5">
{coveredRows.slice(0, 200).map((r, i) => (
<li key={i}>{r.date} {r.amount.toFixed(2).padStart(10)} {r.description.slice(0, 44)}</li>
))}
</ul>
{coveredRows.length > 200 && (
<p className="mt-1 text-[11px] text-zinc-600">and {coveredRows.length - 200} more.</p>
)}
<p className="mt-2 text-[11px] text-zinc-500">
Statements cover:{" "}
{(coverage.data ?? []).map((c) => `${c.last4} to ${c.coveredTo}`).join(" · ")}
</p>
</>
)}
</div>
)}
{inFileDuplicates(editedRows) > 0 && (
<p className="mb-3 rounded border border-amber-900/50 bg-amber-950/30 px-3 py-2 text-xs text-amber-300">
{inFileDuplicates(editedRows)} row{inFileDuplicates(editedRows) === 1 ? " duplicates another row" : "s duplicate other rows"} in
this same file (same date, amount and description). A re-consent can make an
aggregator re-export history under new ids check before importing.
</p>
)}
<div className="overflow-x-auto rounded border border-zinc-800">
<table className="w-full text-xs">
<thead className="border-b border-zinc-800">
+393 -8
View File
@@ -1,6 +1,18 @@
"use client";
import { useOrderReceipt, type OrderReceipt } from "@/lib/hooks";
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",
@@ -9,11 +21,42 @@ const PLATFORM_LABEL: Record<string, string> = {
};
/**
* The receipt behind a delivery order: what was actually bought, and where it
* went. All of it was already stored at ingest and none of it was reachable —
* the row showed a merchant and a total and nothing else.
* 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.
*
* Read-only on purpose. This is what a provider sent, not something to edit.
* 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,
@@ -26,13 +69,30 @@ export function OrderDetails({
bare?: boolean;
}) {
const { data: receipt, isLoading } = useOrderReceipt(transactionId);
if (isLoading || !receipt) return null;
const { data: review } = useOrderReview(transactionId);
const [reviewer, setReviewer] = useState(OWNER_PARTICIPANT_ID);
// `platform` is the marker of a real receipt. Since phase 2 the endpoint also
// answers with `{ order_link }` alone for a transaction that is merely LINKED
// to an order (a BNPL leg has no receipt of its own), and that object is
// truthy — without this check the panel would render a receipt shell with no
// merchant, no items and no total.
if (isLoading || !receipt || !receipt.platform) return null;
const cur = receipt.currency ?? currency ?? "AUD";
const fmt = (n: number) => (cur === "AUD" ? `$${n.toFixed(2)}` : `${cur} ${n.toFixed(2)}`);
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">
@@ -50,15 +110,30 @@ export function OrderDetails({
{items.length > 0 ? (
<ul className="space-y-1.5 mb-3">
{items.map((it, i) => (
<li key={i} className="flex gap-2 text-xs">
<span className="text-zinc-600 tabular-nums shrink-0">{it.qty}×</span>
<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>
@@ -90,6 +165,316 @@ export function OrderDetails({
{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 &ldquo;never again&rdquo; 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
View File
@@ -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" />
+27
View File
@@ -192,6 +192,33 @@ export const SPEND_SIGNED = `CASE
ELSE (${SPEND_BASE})
END`;
/**
* The investment line, signed: withdrawals come back as negatives so they net
* against contributions.
*
* A withdrawal from a fund is a disinvestment — units converted back to cash,
* net worth unchanged. Summed unsigned it read as *more* money invested: March
* 2026 showed $38,615.34 of investing in a month that was net -$11,384.66,
* because a $25,000 Raiz withdrawal was added to a $8,563.80 IBKR deposit
* instead of cancelling it. Overstated by $50,000 in that month alone — each
* credit costs twice, once for being added and once for not being subtracted.
*
* Filing withdrawals as `income` instead is the other tempting answer and is
* worse: it books an asset disposal as earnings and feeds the same figure into
* `net = income - spent - investments` with a flattering sign. Same reason the
* Up item sales in Known Gaps do not belong on the income line.
*
* Caveat this cannot resolve: part of a withdrawal genuinely is income — the
* capital gain. The bank descriptor is a single gross figure with no cost base,
* so it cannot be decomposed here. Netting tracks cash committed against cash
* returned and leaves the gain for holdings data to surface; it does not claim
* the gain is zero.
*/
export const INVESTMENT_SIGNED = `CASE
WHEN t.transaction_type IN ('refund', 'credit') THEN -COALESCE(t.amount_aud, t.amount)
ELSE COALESCE(t.amount_aud, t.amount)
END`;
/**
* A split that still counts towards what someone owes.
*
+101
View File
@@ -9,6 +9,32 @@ export interface ColumnMapping {
creditCol?: string;
merchantCol?: string;
categoryCol?: string;
/**
* Column holding the account number, when the file covers more than one.
*
* Optional and usually absent — a bank's own export is one account per file.
* An aggregator export (Frollo) is not, and without this every row would be
* measured against the same statement coverage.
*/
accountCol?: string;
/**
* Column holding the ISO currency code, when the file is multi-currency.
*
* Without it a foreign row is stored as if it were AUD — a USD 10,782 salary
* lands as A$10,782, understating it by about a third. Mapping it records the
* native figure with `foreign_currency_code` set and `amount_aud` left NULL,
* which is the same shape order ingestion uses and what `AMOUNT_UNCONVERTED`
* looks for.
*/
currencyCol?: string;
/**
* Column holding the provider's own row id, when it has one.
*
* Stored as `source_ref` so re-importing the same file inserts nothing. The
* importer has no other idempotency: `row_index` is assigned MAX+1 on every
* run, which makes `uq_transaction_identity` structurally unable to fire.
*/
sourceRefCol?: string;
}
export interface BankPreset {
@@ -24,6 +50,13 @@ export interface ParsedTransaction {
transaction_type: string;
merchant_name?: string;
category?: string;
/** Raw account identifier from the file, when a column was mapped. */
account?: string;
/** Provider row id, when a column was mapped. */
source_ref?: string;
/** Set only for a non-AUD row: the native amount and its code. */
foreign_currency_amount?: number;
foreign_currency_code?: string;
}
export function parseCSVRows(text: string): string[][] {
@@ -108,6 +141,9 @@ export function applyMapping(
const descIdx = idx(mapping.descriptionCol);
const merchantIdx = mapping.merchantCol ? idx(mapping.merchantCol) : -1;
const categoryIdx = mapping.categoryCol ? idx(mapping.categoryCol) : -1;
const accountIdx = mapping.accountCol ? idx(mapping.accountCol) : -1;
const sourceRefIdx = mapping.sourceRefCol ? idx(mapping.sourceRefCol) : -1;
const currencyIdx = mapping.currencyCol ? idx(mapping.currencyCol) : -1;
const results: ParsedTransaction[] = [];
for (const row of dataRows) {
@@ -138,6 +174,17 @@ export function applyMapping(
const tx: ParsedTransaction = { date, description, amount, transaction_type };
if (merchantIdx >= 0 && row[merchantIdx]) tx.merchant_name = row[merchantIdx].trim();
if (categoryIdx >= 0 && row[categoryIdx]) tx.category = row[categoryIdx].trim();
if (accountIdx >= 0 && row[accountIdx]) tx.account = row[accountIdx].trim();
if (sourceRefIdx >= 0 && row[sourceRefIdx]) tx.source_ref = row[sourceRefIdx].trim();
if (currencyIdx >= 0) {
const code = (row[currencyIdx] ?? "").trim().toUpperCase();
// AUD needs no marking; anything else is native and unconverted. No rate
// is invented — the statement supplies the AUD figure when it arrives.
if (/^[A-Z]{3}$/.test(code) && code !== "AUD") {
tx.foreign_currency_code = code;
tx.foreign_currency_amount = amount;
}
}
results.push(tx);
}
return results;
@@ -154,3 +201,57 @@ export function loadBankPresets(): BankPreset[] {
try { return JSON.parse(localStorage.getItem("csv-presets") || "[]"); }
catch { return []; }
}
/** Last four digits of an account identifier, however it is written. */
export function last4(s: string): string {
const d = s.replace(/\D/g, "");
return d.length >= 4 ? d.slice(-4) : "";
}
/**
* Splits parsed rows into what to import and what the statements already have.
*
* A statement's end date is a hard watermark: everything on that account up to
* it is already in the ledger, itemised and converted. Without this the first
* aggregator import wrote 422 duplicates out of 550 rows; with it the same
* 2,607-row export offers 171.
*
* Rows are only excluded when an account column was mapped AND that account has
* statements. Anything else is imported — a row is never dropped on a guess.
*/
export function splitByCoverage(
rows: ParsedTransaction[],
coverage: { last4: string; coveredTo: string }[]
): { keep: ParsedTransaction[]; covered: ParsedTransaction[] } {
const wm = new Map(coverage.map((c) => [c.last4, c.coveredTo]));
const keep: ParsedTransaction[] = [];
const covered: ParsedTransaction[] = [];
for (const r of rows) {
const to = r.account ? wm.get(last4(r.account)) : undefined;
// Both are YYYY-MM-DD, so a string compare is a date compare.
if (to && r.date <= to) covered.push(r);
else keep.push(r);
}
return { keep, covered };
}
/**
* Rows that duplicate another row in the SAME file.
*
* Distinct from the coverage check, and not catchable by `source_ref`: a CDR
* re-consent makes the provider re-export an account's whole history under
* fresh ids while the originals survive, so the twins are identical in
* everything except the id. That put 385 duplicates in one 2,563-row export and
* doubled every salary payment. 385 rows is not something you spot by eye in a
* review table, so it is counted here.
*/
export function inFileDuplicates(rows: ParsedTransaction[]): number {
const seen = new Set<string>();
let n = 0;
for (const r of rows) {
const key = [r.date, r.description, r.amount, r.transaction_type, r.account ?? ""].join("|");
if (seen.has(key)) n += 1;
else seen.add(key);
}
return n;
}
+219 -2
View File
@@ -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));
}
@@ -251,7 +257,9 @@ export function useTransactionSplits(transactionId: number) {
}
export interface OrderReceipt {
platform: "doordash" | "ubereats" | "uber" | null;
/** 'email' | 'receipt-scan' | 'order-bridge' | ... — which lane wrote the row. */
source: string | null;
platform: string | null;
order_reference: string | null;
line_items: { qty: number; description: string; amount: number; options?: string[] }[];
route: { label: string; time: string | null; address: string }[];
@@ -277,6 +285,103 @@ export function useOrderReceipt(transactionId: number) {
});
}
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({
@@ -302,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"] });
},
});
}
@@ -316,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;
}
@@ -341,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"] });
},
});
}
@@ -905,9 +1053,11 @@ export function useImportCSV() {
return useMutation({
mutationFn: async (body: {
bank_name: string;
source?: string;
transactions: {
date: string; description: string; amount: number; transaction_type: string;
merchant_name?: string; foreign_currency_amount?: number; foreign_currency_code?: string; category?: string;
merchant_name?: string; foreign_currency_amount?: number; foreign_currency_code?: string;
category?: string; account?: string; source_ref?: string;
}[];
}) => {
const res = await fetch("/api/import/csv", {
@@ -1059,3 +1209,70 @@ export function useAssignTransactionsToTrip() {
},
});
}
// ---------------------------------------------------------------- orders --
// Browse surface over the ingestion spine. See lib/order-feed.ts for why the
// spine is read raw and why /orders is gated by an explicit viewer allowlist.
import type { OrderRow, OrderFacets, OrderDetail, OrderFilters } from "./order-feed";
export type { OrderRow, OrderFacets, OrderDetail, OrderFilters };
interface OrdersResponse {
data: OrderRow[];
total: number;
limit: number;
offset: number;
facets: OrderFacets;
}
export function useOrders(filters: Record<string, unknown>) {
return useQuery<OrdersResponse>({
queryKey: ["orders", filters],
queryFn: async () => {
const res = await fetch(`/api/orders?${buildParams(filters as never)}`);
if (!res.ok) throw new Error("Failed to load orders");
return res.json();
},
});
}
export function useOrderDetail(entityKey: string | null) {
return useQuery<OrderDetail>({
queryKey: ["order", entityKey],
enabled: Boolean(entityKey),
// An order's history does not change while you are looking at it; the
// lifecycle only moves when the ingestion runner ticks.
staleTime: 60_000,
queryFn: async () => {
const res = await fetch(`/api/orders/${encodeURIComponent(entityKey!)}`);
if (!res.ok) {
// Carry the status. Collapsing every failure into one Error is how a
// 500 on this route rendered as "that order could not be found" on
// every single order for weeks — a server fault wearing the costume of
// a data condition, which reads as "the spine is empty" and gets
// investigated nowhere near the actual bug.
const err = new Error(res.status === 404 ? "Order not found" : "Failed to load order");
(err as Error & { status?: number }).status = res.status;
throw err;
}
return res.json();
},
});
}
/**
* How far each account's statements already reach, for the CSV import modal.
*
* Fetched once when the modal opens: the review step uses it to drop rows the
* ledger already holds rather than offering them for import.
*/
export function useStatementCoverage() {
return useQuery({
queryKey: ["statement-coverage"],
queryFn: async () => {
const res = await fetch("/api/import/statement-coverage");
if (!res.ok) throw new Error("Could not load statement coverage");
return (await res.json()).coverage as { last4: string; coveredTo: string }[];
},
});
}
+51
View File
@@ -0,0 +1,51 @@
/**
* Presentational tidy for a merchant name (board 212).
*
* PRESENTATION ONLY. It merges nothing and must never be used to decide that
* two rows are the same merchant. Real unification is the merchant-alias bridge
* (ticket 176, `jobs/merchant_merge.py`), which has already merged what can be
* merged safely; what is left on screen is one entity looking scruffy, not two
* entities that should be one.
*
* `Amazon.in` MUST NOT become `amazon.com.au`. They are different marketplaces
* with different currency and geography — a genuine country distinction, not a
* name variant. `Kogan.com`, `GOG.com`, `AliExpress.com` and `Catch.com.au` are
* real brand names that happen to contain a TLD and are already correct.
*
* Two rules, both mechanical:
* 1. drop trailing corporate suffixes
* 2. capitalise a name that arrived lower-cased FROM A DOMAIN
*/
const CORP_SUFFIX =
/,?\s+(pty\.?\s+ltd\.?|pty\.?\s+limited|pte\.?\s+ltd\.?|pte\.?|p\/l|ltd\.?|limited|inc\.?|llc|pbc|gmbh|b\.?v\.?|s\.?a\.?r\.?l\.?|oü|co\.?)$/i;
/** A bare domain used as a name: all lower case, and a dotted TLD. */
const BARE_DOMAIN = /^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}(\.[a-z]{2,})?$/;
/**
* Stripping must not leave a dangling connector. `Coburger & Co` is a brand
* whose last word happens to match the suffix list, and removing it produced
* `Coburger &` — visibly broken, and the kind of thing that only shows up when
* you run the rule over the real corpus rather than over examples you chose.
*/
const DANGLING = /(\s[&+-]|\sand)$/i;
export function tidyMerchant(name: string): string {
let n = name.replace(/\s+/g, " ").trim();
// At most two passes: "Samsung Electronics Co. Ltd." needs Ltd. then Co.
for (let i = 0; i < 2; i++) {
const stripped = n.replace(CORP_SUFFIX, "").trim().replace(/,$/, "");
if (stripped === n || stripped.length < 3 || DANGLING.test(stripped)) break;
n = stripped;
}
// "amazon.com.au" reads as a machine artefact; "Amazon.com.au" reads as a
// name. Gated on BARE_DOMAIN, not on "starts with a lower-case letter": the
// looser test turned `eBay Commerce Australia Pty Ltd.` into `EBay Commerce
// Australia`, mangling a brand that is deliberately lower-cased.
if (BARE_DOMAIN.test(n)) n = n[0].toUpperCase() + n.slice(1);
return n;
}
+573
View File
@@ -0,0 +1,573 @@
/**
* 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;
/** Worst sender-authentication verdict across the order's source documents.
* 93% are 'pass' — the badge exists for the 7% that are not. Do NOT badge
* source_trust instead: it is 'untrusted_external' on 100% of rows, because
* every order here came from email. */
auth_verdict: string | null;
injection_flagged: boolean | null;
/** An annual tax/donation receipt restating a year of payments already made,
* not a purchase (board 211). It stays on this page — the figure is the
* year's giving total and is worth seeing — but `order_spend` excludes it,
* so the amount here is deliberately NOT part of any total on the page.
* Badge it, or a $2,376 row reads as a single purchase. */
summarises_period: boolean;
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 (DoorDash no-contact delivery details, shipment notices).
* 620 of them as of 2026-08-12.
*
* The count moved three times that day and the history is the point:
* 755 when this predicate was written
* 904 after board 219 turned unstated zeros into NULLs — those 149 rows had
* been failing the "no amount" test on a technicality
* 699 after board 210a retired 205 Amazon "share your experience" mails,
* which were never lifecycle events at all (ingest/order_noise.py)
* 620 after board 210b joined 79 phantoms to their real parent
* (jobs/phantom_order_joiner.py, placebo-verified at a 1.9% error rate)
*
* What is left is the corpus coverage ceiling rather than a defect: a mail
* whose parent is not in the corpus, or whose merchant is ambiguous, has
* nothing to join to. This stays a UI workaround for those, and the toggle
* still says so.
*
* 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'
-- TRIED AND REVERTED, 2026-08-12: exempting rows that carry line items, on
-- the theory that naming what was bought proves a purchase. It surfaced 442
-- rows and the very first window showed why it is wrong — the Amazon
-- "Ordered:" mail that mints a msg- phantom carries the item list too, so
-- Xelsluthe and YIWENTEC each rendered TWICE again, which is the exact
-- complaint this page was fixed for. Items do not separate an orphan
-- purchase from an item-carrying echo; only real deduplication does, and
-- that is board 210, deliberately not attempted here.
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 2 (board 205).
*
* `order_transaction_links` is the source of truth: it is many-to-many, so a
* BNPL plan is four rows against one order. `expense_metadata` remains as a
* FALLBACK because `jobs/order_transaction_bridge.py` still writes there on
* every hourly tick; until a bridge row is mirrored into links, dropping the
* fallback would make freshly bridged orders read as unpaid.
*
* UNION, not UNION ALL, on transaction_id: after the 0030 backfill the same
* charge is legitimately present in both stores, and counting it twice would
* show "2 charges" on a single-payment order.
*
* All four readers move together — this lateral, the detail query below,
* /api/transactions/[id]/order, and the order_ctx lateral in queries.ts.
* Moving fewer is not a smaller change, it is an inconsistent one: once the
* matcher inserts four links for order_ebay_14-11714-95953 that order still
* has no expense_metadata row, so the detail page would show four instalments
* while the list showed txn_count = 0 and the has_transaction facet put it on
* the wrong side of both filters.
*/
const LINK_LATERAL = `
LEFT JOIN LATERAL (
SELECT count(*)::int AS txn_count, min(txn_id) AS first_txn_id
FROM (
SELECT l.transaction_id AS txn_id
FROM order_transaction_links l
WHERE l.entity_key = f.entity_key
UNION
SELECT COALESCE(em.matched_transaction_id, em.transaction_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%')
AND COALESCE(em.matched_transaction_id, em.transaction_id) IS NOT NULL
) both_stores
) 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,
f.auth_verdict, f.injection_flagged, f.summarises_period,
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;
/** 'charge' | 'shipment' | 'instalment' | 'refund' | 'fee'. A plan's legs are
* four 'instalment' rows; a whole-order card charge is one 'charge'. */
leg_kind: string;
/** Human position, "2 of 4" — populated for instalments only. */
leg_index: number | null;
leg_count: number | null;
}
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;
/** Board 211 — an annual receipt restating a year of payments already made.
* Same shape as is_settled_duplicate: browsable, excluded from spend. */
summarises_period: 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,
o.summarises_period,
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,
-- ::int is mandatory. order_merchant_cadence.order_count is a
-- count(*), so BIGINT, and JSON.stringify throws outright on a
-- BigInt — "Do not know how to serialize a BigInt", a 500 with no
-- SQL in it. It only fires on merchants that HAVE a cadence, so
-- most orders open fine and the recurring ones die; testing one
-- arbitrary order will not find it.
rec.order_count::int AS 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
-- Cadence is keyed on the NORMALISED MERCHANT NAME, not merchant_entity_id.
-- Migration 021 rekeyed the view (that column is NULL on ~9% of the feed
-- and duplicate merchant entities split the same shop's history); 027 and
-- 029 moved order_feed/order_spend onto merchant_key, and this query was
-- missed. The result was a 42703 on EVERY order detail page — the join
-- referenced a column the view no longer had. Keep this expression
-- identical to the one in migration 027.
LEFT JOIN order_merchant_cadence rec
ON rec.merchant_key = regexp_replace(
lower(COALESCE(me2.canonical_name, o.platform)), '[^a-z0-9]', '', 'g')
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>(
// Links first, expense_metadata as fallback, deduped on transaction_id —
// see LINK_LATERAL above for why both stores are read. DISTINCT ON keeps
// the LINK row when a transaction is in both, because only that row knows
// whether the payment was one charge or leg 3 of 4.
// The DISTINCT ON must be ordered by t.id, so the chronological sort the
// page needs goes on the OUTER query — legs read as "1, 2, 3, 4" only if
// they come back by date.
`SELECT * FROM (
SELECT DISTINCT ON (t.id)
t.id::int AS transaction_id, t.transaction_date, t.description,
t.amount, both_stores.source_message_id,
both_stores.leg_kind, both_stores.leg_index, both_stores.leg_count
FROM (
SELECT l.transaction_id, l.leg_kind, l.leg_index::int, l.leg_count::int,
l.entity_key AS source_message_id, 0 AS pref
FROM order_transaction_links l
WHERE l.entity_key = $1
UNION ALL
SELECT COALESCE(em.matched_transaction_id, em.transaction_id),
'charge', NULL::int, NULL::int, em.source_message_id, 1
FROM expense_metadata em
WHERE em.source = 'order-bridge'
AND (em.source_message_id = $1 OR em.source_message_id LIKE $1 || '#f%')
AND COALESCE(em.matched_transaction_id, em.transaction_id) IS NOT NULL
) both_stores
JOIN transactions t ON t.id = both_stores.transaction_id
ORDER BY t.id, both_stores.pref
) deduped
ORDER BY transaction_date, leg_index NULLS FIRST`,
[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,
};
}
+32 -9
View File
@@ -26,13 +26,16 @@ export const PLATFORM_LABEL: Record<ParsedOrder["platform"], string> = {
* 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 {
const label = PLATFORM_LABEL[platform];
// A trip's merchant is literally "Uber Trip", so the suffix would restate it
// — "Order - Uber Trip (Uber)". The addresses that actually identify a trip
// are in the Order details panel, not squeezed into the description.
if (merchant.toLowerCase().includes(label.toLowerCase())) return `Order - ${merchant}`;
return `Order - ${merchant} (${label})`;
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 {
@@ -168,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 ------------------------------------------------------------
+164 -20
View File
@@ -101,14 +101,35 @@ export class OrderParseError extends Error {
const stripTags = (s: string) => s.replace(/<[^>]+>/g, " ");
/**
* Entity decoding, ordered so `&amp;` resolves last.
*
* `&bull;` 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) &bull; 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 &#36; came to be listed individually while its neighbours were not.
*
* `&amp;` goes last because decoding it first turns a literal "&amp;bull;" —
* text that should stay as written — into a bullet.
*/
const decodeEntities = (s: string) =>
s
.replace(/&nbsp;/gi, " ")
.replace(/&amp;/gi, "&")
.replace(/&#39;|&apos;/gi, "'")
.replace(/&quot;/gi, '"')
.replace(/&#36;/g, "$")
.replace(/&hellip;/gi, "");
.replace(/&hellip;/gi, "")
.replace(/&bull;/gi, "")
.replace(/&middot;/gi, "·")
.replace(/&ndash;/gi, "")
.replace(/&mdash;/gi, "—")
.replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCodePoint(parseInt(h, 16)))
.replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(parseInt(d, 10)))
.replace(/&amp;/gi, "&");
const collapse = (s: string) => s.replace(/\s+/g, " ").trim();
@@ -282,7 +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 {
/**
* 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,
@@ -325,22 +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]);
// "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 (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.
const card = text.match(
/(?:••••|\*{4}|\u2022{4})\s*(\d{4})[^\d]{0,40}?\$?\s*([\d,]+\.\d{2})/
// 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 (card) {
out.card_last4 = card[1];
out.card_amount = money(card[2]);
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;
@@ -467,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"),
@@ -476,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"),
@@ -491,13 +618,21 @@ 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"). An earlier version read that as credits-funded. It is
@@ -584,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
);
+204
View File
@@ -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)
),
};
}
+463 -52
View File
@@ -37,7 +37,16 @@ export interface TransactionRow {
payment_method: string | null;
/** Uber pick-up/drop-off, when this row came from an order receipt. */
order_route: RoutePointRow[] | null;
/** Receipt platform ONLY — it gates the disclosure arrow, so it must stay
* true to "there is a receipt behind this row". */
order_platform: "doordash" | "ubereats" | "uber" | null;
/** Phase 2 (board 205) — set when this transaction is linked to an order.
* 'instalment' means it is one leg of a plan; leg_index/leg_count carry
* "2 of 4", which is what stops one purchase reading as four. */
order_leg_kind: string | null;
order_leg_index: number | null;
order_leg_count: number | null;
order_name: string | null;
// override fields
category_override: string | null;
merchant_override: string | null;
@@ -117,6 +126,13 @@ interface TransactionFilters {
from?: string;
to?: string;
categories?: string[];
/**
* Categories to hide. Opt-in per caller and never defaulted here — the rules
* preview and the bulk rule-apply path both go through getTransactions, and a
* default exclusion would silently shrink what a rule can see and reach. The
* transactions view sets this; nothing else does.
*/
exclude_categories?: string[];
bank_names?: string[];
tag_ids?: string[];
transaction_types?: string[];
@@ -130,13 +146,33 @@ interface TransactionFilters {
amount_max?: number;
has_split?: string;
trip_id?: string;
/**
* With a `trip_id` set, show every row on the trip rather than only the
* viewer's own. A trip is all the expenses on one trip, so a participant sees
* the whole thing — the trip total already counts every payer.
*
* Opt-in, and deliberately NOT implied by `trip_id` being present, because
* `GET /api/transactions` is also the main transactions list and its trip
* filter must keep owner scoping — otherwise filtering your own ledger by
* "Europe 2026" would quietly fill it with someone else's rows and skew every
* total on the page. Only the trip detail view sets this.
*/
trip_all_rows?: boolean;
}
export async function getTransactions(ownerId: number, filters: TransactionFilters) {
const conditions: string[] = [
`(COALESCE(t.owner_id, s.owner_id) = $1 OR EXISTS (SELECT 1 FROM transaction_splits ts_me WHERE ts_me.transaction_id = t.id AND ts_me.participant_id = $1))`,
EXCLUDE_RECONCILED_SOURCE,
];
// A real trip id, not "unassigned" — there is no trip to participate in for
// rows that belong to none, so the owner scoping has to stand there.
const tripAllRows = Boolean(
filters.trip_all_rows && filters.trip_id && filters.trip_id !== "unassigned"
);
const conditions: string[] = [EXCLUDE_RECONCILED_SOURCE];
if (!tripAllRows) {
conditions.push(
`(COALESCE(t.owner_id, s.owner_id) = $1 OR EXISTS (SELECT 1 FROM transaction_splits ts_me WHERE ts_me.transaction_id = t.id AND ts_me.participant_id = $1))`
);
}
const params: unknown[] = [ownerId];
let paramIdx = 2;
@@ -152,6 +188,19 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte
conditions.push(`COALESCE(o.category_override, t.category) = ANY($${paramIdx++}::text[])`);
params.push(filters.categories);
}
if (filters.exclude_categories?.length) {
// Picking a category explicitly beats hiding it. Without this, selecting
// "Transfers" while the hide-transfers default is on returns zero rows and
// reads as "you have no transfers".
const hidden = filters.exclude_categories.filter((c) => !filters.categories?.includes(c));
if (hidden.length) {
// COALESCE to '' rather than leaving it NULL: `NULL <> ALL(...)` is NULL,
// not true, so an uncategorised row would be filtered out by a hide rule
// that never named it. Same trap EXCLUDE_NON_SPEND documents.
conditions.push(`COALESCE(o.category_override, t.category, '') <> ALL($${paramIdx++}::text[])`);
params.push(hidden);
}
}
if (filters.bank_names?.length) {
// "Manual" and "Gift Card" are not banks — they are the two shapes a
// statement-less row can take, and bankLabel() decides which. The filter
@@ -211,8 +260,15 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte
if (filters.trip_id === "unassigned") {
conditions.push(`o.trip_id IS NULL`);
} else if (filters.trip_id) {
conditions.push(`o.trip_id = $${paramIdx++}`);
const tripParam = paramIdx++;
conditions.push(`o.trip_id = $${tripParam}`);
params.push(Number(filters.trip_id));
if (tripAllRows) {
// The gate for having dropped the owner filter above. Enforced in SQL
// rather than trusted from the route, so passing trip_all_rows with a
// trip you are not on returns nothing instead of everything.
conditions.push(TRIP_PARTICIPANT(`$${tripParam}`, `$1`));
}
}
const where = `WHERE ${conditions.join(" AND ")}`;
@@ -267,7 +323,17 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte
txn_tags.tags,
txn_splits.splits,
order_ctx.route as order_route,
order_ctx.platform as order_platform
-- Deliberately NOT COALESCEd with the link's platform. order_platform
-- gates the receipt disclosure arrow on /transactions, and a BNPL leg
-- has no receipt behind it — filling this in put an arrow on four
-- Afterpay rows that expand to nothing, which is the exact promise the
-- arrow exists to avoid making. The leg fields below carry the sub-line
-- instead.
order_ctx.platform as order_platform,
order_link.leg_kind as order_leg_kind,
order_link.leg_index as order_leg_index,
order_link.leg_count as order_leg_count,
order_link.canonical_name as order_name
FROM transactions t
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
LEFT JOIN statements s ON s.id = t.statement_id
@@ -276,12 +342,27 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte
-- only thing that tells them apart, and it was already stored.
-- Both directions, because a card-settled order has no transaction of its
-- own and points at the statement line instead (I5).
-- Phase 2 (board 205): a BNPL leg has no expense_metadata row of its own,
-- so COALESCE in the link's platform. Without it the four Afterpay debits
-- behind the A$1,599 drone keep an empty sub-line while their order sits
-- one join away. The route column only ever exists on the receipt side.
-- (No backticks in here: this SQL lives in a TS template literal and a
-- backtick ends the string — TS1005 on a line that looks like a comment.)
LEFT JOIN LATERAL (
SELECT em.route, em.platform
FROM expense_metadata em
WHERE em.transaction_id = t.id OR em.matched_transaction_id = t.id
LIMIT 1
) order_ctx ON true
LEFT JOIN LATERAL (
SELECT o.platform, l.leg_kind, l.leg_index::int AS leg_index,
l.leg_count::int AS leg_count, e.canonical_name
FROM order_transaction_links l
LEFT JOIN entities e ON e.entity_key = l.entity_key
LEFT JOIN entity_orders o ON o.entity_id = e.id
WHERE l.transaction_id = t.id
LIMIT 1
) order_link ON true
LEFT JOIN participants p ON p.id = COALESCE(t.owner_id, s.owner_id)
LEFT JOIN transactions src ON src.reconciled_with_id = t.id AND src.statement_id IS NULL
LEFT JOIN trips tr ON tr.id = o.trip_id
@@ -589,8 +670,11 @@ export async function batchInsertCSVTransactions(
foreign_currency_amount?: number;
foreign_currency_code?: string;
category?: string;
account?: string;
source_ref?: string;
}[],
tagId: number
tagId: number,
source?: string
): Promise<number> {
if (rows.length === 0) return 0;
@@ -604,13 +688,32 @@ export async function batchInsertCSVTransactions(
const params: unknown[] = [ownerId];
let p = 2;
rows.forEach((r, i) => {
valueClauses.push(`(NULL, $1, $${p++}, $${p++}, $${p++}, $${p++}, $${p++}, $${p++}, $${p++}, ${base + 1 + i})`);
params.push(r.date, r.description, r.amount, r.transaction_type, r.merchant_name ?? null, r.foreign_currency_amount ?? null, r.foreign_currency_code ?? null);
valueClauses.push(
`(NULL, $1, $${p++}, $${p++}, $${p++}, $${p++}, $${p++}, $${p++}, $${p++}, $${p++}, $${p++}, $${p++}, $${p++}, ${base + 1 + i})`
);
params.push(
r.date, r.description, r.amount, r.transaction_type, r.merchant_name ?? null,
r.foreign_currency_amount ?? null, r.foreign_currency_code ?? null,
// The category the reviewer chose. It used to be accepted here and then
// left out of the column list, so every category picked in the review
// step was silently discarded and the trigger wrote 'other'. That is not
// cosmetic: an uncategorised credit is admitted by NET_SPEND_ROWS and
// negated by SPEND_SIGNED, so 62 imported transfers cancelled $74,338 of
// spend and none of them counted as income.
r.category ?? null,
source ?? null,
// source + source_ref is the only idempotency this path has. Without it a
// second import of the same file duplicates every row, because row_index
// is assigned MAX+1 and uq_transaction_identity can never fire.
r.source_ref ?? null,
r.account ?? null
);
});
const txIds = await queryRaw<{ id: number }>(
`INSERT INTO transactions (statement_id, owner_id, transaction_date, description, amount, transaction_type, merchant_name, foreign_currency_amount, foreign_currency_code, row_index)
`INSERT INTO transactions (statement_id, owner_id, transaction_date, description, amount, transaction_type, merchant_name, foreign_currency_amount, foreign_currency_code, category, source, source_ref, source_account, row_index)
VALUES ${valueClauses.join(", ")}
ON CONFLICT (source, source_ref) WHERE source IS NOT NULL AND source_ref IS NOT NULL DO NOTHING
RETURNING id`,
params
);
@@ -651,6 +754,27 @@ export async function batchInsertCSVTransactions(
export const needsCardMatch = (alias = "t") =>
`(${alias}.payment_method IS NULL OR ${alias}.payment_method NOT IN ('cash', 'credits'))`;
/**
* REMOVED 2026-08-13. There used to be an `awaitsStatementLine()` predicate
* here, excluding account-feed rows from the pending-reconciliation queue.
*
* Its premise — a feed row is the account's own ledger entry, so no statement
* line is coming — was asserted and never tested. It was false for almost every
* account: 422 of the first 550 imported rows already had a statement twin.
*
* How it got in matters more than what it did. The queue jumped from 8 to 558
* the moment the feed landed, and that jump was read as noise and filtered
* away. The queue was right — those rows genuinely were provisional entries
* awaiting statement lines — and hiding them removed the only mechanism that
* would ever have collapsed them, so a transient overlap became a permanent
* double-count and stayed invisible until a human saw one salary payment listed
* twice in two currencies.
*
* An imported row that has no statement belongs in the queue. Volume is solved
* upstream, by not importing what the statements already cover
* (`getStatementCoverage`), never downstream by hiding what is there.
*/
/**
* Bank label for a transaction. A row with no statement was not imported from
* one, and the label has to say *why*: "Manual" reads as "hand-entered, still
@@ -819,6 +943,11 @@ export async function getSharedTransactions(ownerId: number, tagIds?: number[],
COALESCE(t.owner_id, s.owner_id) as owner_id,
p_owner.name as owner_name,
COALESCE(src.created_at, t.created_at) as created_at,
-- Receipt flag, same LATERAL the transactions page uses: the shared
-- viewer is a split participant, so the order API authorises them —
-- without the flag here the disclosure arrow (and the item list behind
-- it) existed only on the owner's page.
order_ctx.platform as order_platform,
json_agg(json_build_object(
'split_id', ts.id,
'participant_id', ts.participant_id,
@@ -832,6 +961,12 @@ export async function getSharedTransactions(ownerId: number, tagIds?: number[],
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
LEFT JOIN statements s ON s.id = t.statement_id
LEFT JOIN participants p_owner ON p_owner.id = COALESCE(t.owner_id, s.owner_id)
LEFT JOIN LATERAL (
SELECT em.platform
FROM expense_metadata em
WHERE em.transaction_id = t.id OR em.matched_transaction_id = t.id
LIMIT 1
) order_ctx ON true
LEFT JOIN transactions src ON src.reconciled_with_id = t.id AND src.statement_id IS NULL
WHERE (
(
@@ -845,7 +980,7 @@ export async function getSharedTransactions(ownerId: number, tagIds?: number[],
AND ${EXCLUDE_RECONCILED_SOURCE}
${tagClause}
${participantClause}
GROUP BY t.id, o.category_override, o.merchant_normalized, o.notes, s.bank_name, s.currency, s.owner_id, p_owner.name, src.created_at
GROUP BY t.id, o.category_override, o.merchant_normalized, o.notes, s.bank_name, s.currency, s.owner_id, p_owner.name, src.created_at, order_ctx.platform
ORDER BY t.transaction_date DESC
`, params);
@@ -880,15 +1015,100 @@ export interface TripAnalytics {
category_breakdown: { category: string; amount: number; count: number }[];
daily_spend: { date: string; amount: number }[];
top_merchants: { merchant: string; amount: number; count: number }[];
/**
* A trip has two economies, and mixing them is what made `travel` look like an
* uninformative 60% slab: it is the ONLY category that spans both. Measured on
* Europe 2026, every other category is 100% on-the-ground — dining, transport,
* entertainment, groceries and shopping are all exactly $0.00 before departure.
*
* So the fix is not a finer travel taxonomy (which would need a hand-maintained
* merchant list, the trap ticket #19 already describes). It is to split by phase
* and use the axis that carries information in each: merchant before departure,
* where everything is a flight or a booking, and category after it, where travel
* drops to a normal-sized slice among peers.
*
* `committed` is dated before `start_date`; everything else is `on_ground`. A trip
* with no start_date has no knowable split, so it all reads as on-ground.
*/
phases: {
committed: number;
committed_count: number;
on_ground: number;
on_ground_count: number;
};
/** Pre-departure spend by merchant — the bookings that make up the commitment. */
committed_merchants: { merchant: string; amount: number; count: number }[];
/** On-the-ground spend by category, where category is finally worth charting. */
on_ground_categories: { category: string; amount: number; count: number }[];
/** On-ground spend per day of the trip window. The comparable rate between trips. */
on_ground_daily: number;
tag_breakdown: { tag_id: number; name: string; color: string; amount: number; count: number }[];
participant_splits: {
participant_id: number;
name: string;
/** Their share of this trip, net of payments scoped to it. */
/** Their share of rows the VIEWER paid, net of payments scoped to this trip. */
owed: number;
/** That same figure before payments, so the UI can say "settled" rather than just "0.00". */
owed_gross: number;
/** Payments from them to the viewer, scoped to this trip. */
paid_to_me: number;
/** The VIEWER's share of rows THIS PARTICIPANT paid, net of the viewer's payments to them. */
i_owe: number;
/** Ditto, before payments. */
i_owe_gross: number;
/** Payments from the viewer to them, scoped to this trip. */
paid_by_me: number;
/** Splits counted at a non-AUD figure because no converted amount exists. */
unconverted_count: number;
i_owe_unconverted_count: number;
}[];
/** True when the viewer is a participant but not the trip's owner. */
viewer_is_owner: boolean;
}
/**
* Who counts as a participant on a trip. Derived, never stored.
*
* A trip is *all the expenses on one trip*, so being a participant is a fact
* about those expenses: you hold a split on one, you paid for one, or a payment
* of yours is scoped to the trip. Storing it as a membership list would be a
* second record of the same fact, and two records of one fact drift — the same
* reason `CLAUDE.md` insists sharing is a real split rather than a flag, and the
* reason the 2026-07-27 settlement design concluded an extra party on a trip
* "needs no schema at all".
*
* It also gets the exclusions right for free. Singapore + Bangkok 2026 has no
* Sonu split and no Sonu payment, so she is not a participant and never sees it,
* with no backfill to keep in sync as transactions are assigned and unassigned.
*
* Aliased `p*` throughout so it can be inlined anywhere without colliding with
* the `t`/`s`/`o` aliases the shared fragments assume.
*/
const TRIP_PARTICIPANT = (tripIdExpr: string, participantExpr: string) => `(
EXISTS (
SELECT 1 FROM transaction_overrides po
JOIN transactions pt ON pt.id = po.transaction_id
LEFT JOIN statements ps ON ps.id = pt.statement_id
LEFT JOIN transaction_splits pts ON pts.transaction_id = pt.id
WHERE po.trip_id = ${tripIdExpr}
AND (COALESCE(pt.owner_id, ps.owner_id) = ${participantExpr}
OR pts.participant_id = ${participantExpr})
)
OR EXISTS (
SELECT 1 FROM split_payments psp
WHERE psp.trip_id = ${tripIdExpr}
AND (psp.from_participant_id = ${participantExpr}
OR psp.to_participant_id = ${participantExpr})
)
)`;
/** Exported for the routes that gate a write on participation. */
export async function isTripParticipant(tripId: number, participantId: number): Promise<boolean> {
const rows = await queryRaw<{ ok: boolean }>(`
SELECT (tr.owner_id = $2 OR ${TRIP_PARTICIPANT('tr.id', '$2')}) AS ok
FROM trips tr WHERE tr.id = $1
`, [tripId, participantId]);
return rows[0]?.ok === true;
}
// `total_spend` is the headline figure on the trips list and the trip header,
@@ -902,7 +1122,11 @@ const TRIP_TOTAL_SPEND = `COALESCE(SUM(
THEN ${SPEND_SIGNED} ELSE 0 END
), 0)::float AS total_spend`;
export async function getTrips(ownerId: number): Promise<TripRow[]> {
// A trip is visible to its owner and to anyone who participates in it. Scoping
// visibility to `owner_id` alone meant Sonu could not see a single trip despite
// paying for 104 of the tagged rows herself — her own spending was invisible on
// the only page organised around it.
export async function getTrips(viewerId: number): Promise<TripRow[]> {
return queryRaw<TripRow>(`
SELECT
tr.*,
@@ -911,13 +1135,13 @@ export async function getTrips(ownerId: number): Promise<TripRow[]> {
FROM trips tr
LEFT JOIN transaction_overrides o ON o.trip_id = tr.id
LEFT JOIN transactions t ON t.id = o.transaction_id
WHERE tr.owner_id = $1
WHERE tr.owner_id = $1 OR ${TRIP_PARTICIPANT('tr.id', '$1')}
GROUP BY tr.id
ORDER BY tr.created_at DESC
`, [ownerId]);
`, [viewerId]);
}
export async function getTripById(id: number, ownerId: number): Promise<TripRow | null> {
export async function getTripById(id: number, viewerId: number): Promise<TripRow | null> {
const rows = await queryRaw<TripRow>(`
SELECT
tr.*,
@@ -926,14 +1150,14 @@ export async function getTripById(id: number, ownerId: number): Promise<TripRow
FROM trips tr
LEFT JOIN transaction_overrides o ON o.trip_id = tr.id
LEFT JOIN transactions t ON t.id = o.transaction_id
WHERE tr.id = $1 AND tr.owner_id = $2
WHERE tr.id = $1 AND (tr.owner_id = $2 OR ${TRIP_PARTICIPANT('tr.id', '$2')})
GROUP BY tr.id
`, [id, ownerId]);
`, [id, viewerId]);
return rows[0] ?? null;
}
export async function getTripAnalytics(tripId: number, ownerId: number): Promise<TripAnalytics> {
const trip = await getTripById(tripId, ownerId);
export async function getTripAnalytics(tripId: number, viewerId: number): Promise<TripAnalytics> {
const trip = await getTripById(tripId, viewerId);
if (!trip) throw new Error("Trip not found");
// What the trip cost, with refunds subtracted.
@@ -955,7 +1179,10 @@ export async function getTripAnalytics(tripId: number, ownerId: number): Promise
//
// COUNT(*) deliberately still counts refund rows: a refund is a transaction
// that occurred on the trip, even though it subtracts from the total.
const [categoryRows, dailyRows, merchantRows, tagRows, splitRows] = await Promise.all([
const [
categoryRows, dailyRows, merchantRows, tagRows, splitRows,
phaseRows, committedMerchantRows, onGroundCategoryRows,
] = await Promise.all([
queryRaw<{ category: string; amount: number; count: number }>(`
SELECT
COALESCE(o.category_override, t.category, 'other') AS category,
@@ -1039,7 +1266,7 @@ export async function getTripAnalytics(tripId: number, ownerId: number): Promise
// so netting a EUR figure against AUD ones silently is most likely to
// bite exactly here.
//
// A fourth, and it is what "owed" actually means: only rows THIS owner paid
// A fourth, and it is what "owed" actually means: only rows THIS viewer paid
// for. Without ${OWNER_SCOPE} the figure sums every split on every trip
// transaction regardless of who paid, so it silently mixes debts owed to
// different people. On Europe 2026 that put $1,605.49 of Molina's share of
@@ -1052,43 +1279,139 @@ export async function getTripAnalytics(tripId: number, ownerId: number): Promise
// `transactions` is aliased `t` so the shared fragments apply directly —
// they assume that alias, and hand-inlining a copy is what let the
// reconciled-row exclusion drift out of the analytics routes to begin with.
queryRaw<{ participant_id: number; name: string; owed: number; unconverted_count: number }>(`
WITH owed AS (
SELECT ts.participant_id AS pid,
SUM(ts.share_percent / 100.0 * COALESCE(t.amount_aud, t.amount)) AS gross,
SUM(CASE WHEN ${AMOUNT_UNCONVERTED} THEN 1 ELSE 0 END) AS unconverted
//
// ── Both directions, and deliberately NOT 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 — a non-owner used to get a
// page where their own obligation could not appear, so Sonu's Europe 2026
// read "you are owed $2,408.24" while omitting the $8,004.04 she owed.
//
// Collapsing the two into one signed net was the obvious next step and is
// WRONG. The grouped-payment allocation (memory case `allocate_grouped_payments`)
// cleared Sonu's transfers against the trip debts chronologically, Europe
// first, remainder to household — and the debt it cleared was this
// one-directional gross. Netting redefines Europe's debt as $7,201.30 after
// the fact, which turns the $8,004.04 already allocated into an $802.75
// over-allocation and leaves household understated by the same amount. The
// total stays right and the split between scopes silently stops being. So
// both halves are returned whole, with their gross and payments, and the UI
// states them separately.
queryRaw<{
participant_id: number; name: string;
owed: number; owed_gross: number; paid_to_me: number;
i_owe: number; i_owe_gross: number; paid_by_me: number;
unconverted_count: number; i_owe_unconverted_count: number;
}>(`
WITH scoped AS (
SELECT ts.participant_id AS split_pid,
${OWNER_SCOPE} AS payer_pid,
ts.share_percent / 100.0 * COALESCE(t.amount_aud, t.amount) AS amt,
CASE WHEN ${AMOUNT_UNCONVERTED} THEN 1 ELSE 0 END AS unconverted
FROM transaction_overrides o
JOIN transactions t ON t.id = o.transaction_id
${STATEMENTS_JOIN}
JOIN transaction_splits ts ON ts.transaction_id = t.id
WHERE o.trip_id = $1
AND ${OWNER_SCOPE} = $2
AND ts.participant_id <> $2
AND t.transaction_type IN ('debit','fee','interest')
AND COALESCE(o.category_override, t.category, 'other') NOT IN ('transfers', 'investment')
AND ${ACTIVE_OBLIGATION}
AND ${EXCLUDE_RECONCILED_SOURCE}
GROUP BY ts.participant_id
),
-- Only payments made TO this owner. Payment 5 on Europe is Molina -> Sonu:
-- a real settlement, but of a debt between those two, so it must not
-- reduce what Molina owes here. Symmetrical with the owner scoping above.
paid AS (
owed AS (
SELECT split_pid AS pid, SUM(amt) AS gross, SUM(unconverted) AS unconverted
FROM scoped WHERE payer_pid = $2 AND split_pid <> $2 GROUP BY 1
),
mine AS (
SELECT payer_pid AS pid, SUM(amt) AS gross, SUM(unconverted) AS unconverted
FROM scoped WHERE split_pid = $2 AND payer_pid <> $2 GROUP BY 1
),
-- Only payments made TO the viewer. Payment 5 is Molina -> Sonu: a real
-- settlement, but of a debt between those two, so it must not reduce what
-- Molina owes the viewer. Symmetrical with the payer scoping above.
paid_to_me AS (
SELECT sp.from_participant_id AS pid, SUM(sp.amount) AS amt
FROM split_payments sp
WHERE sp.trip_id = $1
AND sp.to_participant_id = $2
GROUP BY sp.from_participant_id
WHERE sp.trip_id = $1 AND sp.to_participant_id = $2
GROUP BY 1
),
paid_by_me AS (
SELECT sp.to_participant_id AS pid, SUM(sp.amount) AS amt
FROM split_payments sp
WHERE sp.trip_id = $1 AND sp.from_participant_id = $2
GROUP BY 1
)
SELECT p.id AS participant_id, p.name,
(COALESCE(owed.gross, 0) - COALESCE(paid.amt, 0))::float AS owed,
COALESCE(owed.unconverted, 0)::int AS unconverted_count
(COALESCE(owed.gross, 0) - COALESCE(paid_to_me.amt, 0))::float AS owed,
COALESCE(owed.gross, 0)::float AS owed_gross,
COALESCE(paid_to_me.amt, 0)::float AS paid_to_me,
(COALESCE(mine.gross, 0) - COALESCE(paid_by_me.amt, 0))::float AS i_owe,
COALESCE(mine.gross, 0)::float AS i_owe_gross,
COALESCE(paid_by_me.amt, 0)::float AS paid_by_me,
COALESCE(owed.unconverted, 0)::int AS unconverted_count,
COALESCE(mine.unconverted, 0)::int AS i_owe_unconverted_count
FROM participants p
LEFT JOIN owed ON owed.pid = p.id
LEFT JOIN paid ON paid.pid = p.id
WHERE owed.pid IS NOT NULL OR paid.pid IS NOT NULL
LEFT JOIN mine ON mine.pid = p.id
LEFT JOIN paid_to_me ON paid_to_me.pid = p.id
LEFT JOIN paid_by_me ON paid_by_me.pid = p.id
WHERE owed.pid IS NOT NULL OR mine.pid IS NOT NULL
OR paid_to_me.pid IS NOT NULL OR paid_by_me.pid IS NOT NULL
ORDER BY 3 DESC
`, [tripId, ownerId]),
`, [tripId, viewerId]),
// ── The phase split, and the right axis on each side of it ──
//
// $3 is the trip's start_date. NULL makes every comparison NULL, so a trip with
// no dates collapses to all-on-ground rather than erroring or silently
// reporting everything as committed.
queryRaw<{ committed: number; committed_count: number; on_ground: number; on_ground_count: number }>(`
SELECT
COALESCE(SUM(CASE WHEN t.transaction_date < $2::date THEN ${SPEND_SIGNED} END), 0)::float AS committed,
COUNT(*) FILTER (WHERE t.transaction_date < $2::date)::int AS committed_count,
COALESCE(SUM(CASE WHEN t.transaction_date >= $2::date OR $2 IS NULL THEN ${SPEND_SIGNED} END), 0)::float AS on_ground,
COUNT(*) FILTER (WHERE t.transaction_date >= $2::date OR $2 IS NULL)::int AS on_ground_count
FROM transaction_overrides o
JOIN transactions t ON t.id = o.transaction_id
WHERE o.trip_id = $1
AND ${NET_SPEND_ROWS}
AND ${EXCLUDE_RECONCILED_SOURCE}
AND COALESCE(o.category_override, t.category, 'other') NOT IN ('transfers', 'investment')
`, [tripId, trip.start_date]),
queryRaw<{ merchant: string; amount: number; count: number }>(`
SELECT
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name, t.description) AS merchant,
SUM(${SPEND_SIGNED})::float AS amount,
COUNT(*)::int AS count
FROM transaction_overrides o
JOIN transactions t ON t.id = o.transaction_id
WHERE o.trip_id = $1
AND t.transaction_date < $2::date
AND ${NET_SPEND_ROWS}
AND ${EXCLUDE_RECONCILED_SOURCE}
AND COALESCE(o.category_override, t.category, 'other') NOT IN ('transfers', 'investment')
GROUP BY 1
ORDER BY 2 DESC
LIMIT 12
`, [tripId, trip.start_date]),
queryRaw<{ category: string; amount: number; count: number }>(`
SELECT
COALESCE(o.category_override, t.category, 'other') AS category,
SUM(${SPEND_SIGNED})::float AS amount,
COUNT(*)::int AS count
FROM transaction_overrides o
JOIN transactions t ON t.id = o.transaction_id
WHERE o.trip_id = $1
AND (t.transaction_date >= $2::date OR $2 IS NULL)
AND ${NET_SPEND_ROWS}
AND ${EXCLUDE_RECONCILED_SOURCE}
AND COALESCE(o.category_override, t.category, 'other') NOT IN ('transfers', 'investment')
GROUP BY 1
ORDER BY 2 DESC
`, [tripId, trip.start_date]),
]);
const num_days = (trip.start_date && trip.end_date)
@@ -1106,6 +1429,13 @@ export async function getTripAnalytics(tripId: number, ownerId: number): Promise
top_merchants: merchantRows,
tag_breakdown: tagRows,
participant_splits: splitRows,
viewer_is_owner: trip.owner_id === viewerId,
phases: phaseRows[0] ?? { committed: 0, committed_count: 0, on_ground: 0, on_ground_count: 0 },
committed_merchants: committedMerchantRows,
on_ground_categories: onGroundCategoryRows,
// Per day of the trip window, not per day of the whole span — the commitment
// was made over months and dividing it by trip length would be meaningless.
on_ground_daily: (phaseRows[0]?.on_ground ?? 0) / num_days,
};
}
@@ -1121,9 +1451,11 @@ export async function createTrip(
return rows[0];
}
// Editable by any participant: a trip is a shared record of a shared journey,
// and dates or a colour are not the owner's private property.
export async function updateTrip(
id: number,
ownerId: number,
viewerId: number,
data: Partial<{ name: string; description: string | null; start_date: string | null; end_date: string | null; color: string; archived: boolean }>
): Promise<TripRow | null> {
const setClauses: string[] = [];
@@ -1135,31 +1467,74 @@ export async function updateTrip(
if ('end_date' in data) { setClauses.push(`end_date = $${idx++}`); params.push(data.end_date ?? null); }
if (data.color !== undefined) { setClauses.push(`color = $${idx++}`); params.push(data.color); }
if (data.archived !== undefined) { setClauses.push(`archived = $${idx++}`); params.push(data.archived); }
if (!setClauses.length) return getTripById(id, ownerId);
params.push(id, ownerId);
if (!setClauses.length) return getTripById(id, viewerId);
const idParam = idx++;
const viewerParam = idx;
params.push(id, viewerId);
const rows = await queryRaw<TripRow>(`
UPDATE trips SET ${setClauses.join(', ')}
WHERE id = $${idx++} AND owner_id = $${idx}
WHERE id = $${idParam}
AND (owner_id = $${viewerParam} OR ${TRIP_PARTICIPANT(`$${idParam}`, `$${viewerParam}`)})
RETURNING *, 0::float AS total_spend, 0::int AS transaction_count
`, params);
return rows[0] ?? null;
}
/**
* Delete stays OWNER-ONLY, deliberately, even though everything else about a
* trip is now shared.
*
* Both trip foreign keys are ON DELETE SET NULL, so deleting Europe 2026 untags
* 210 transactions *and* NULLs the trip scope on 6 payments. That scope is where
* the Europe-first allocation of Sonu's grouped transfers lives, and it was
* derived by hand — nothing in the app recomputes it. Handing that to any
* participant makes an unrecoverable loss one click away.
*/
export async function deleteTrip(id: number, ownerId: number): Promise<void> {
await queryRaw(`DELETE FROM trips WHERE id = $1 AND owner_id = $2`, [id, ownerId]);
}
/**
* Assign transactions to a trip, or to none when `tripId` is null.
*
* Both the scoping clauses here are new and both closed a live hole: this took
* no viewer at all and checked nothing, so `PATCH /api/trips/[id]/transactions`
* let any authenticated participant 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.
*
* - Only rows the viewer can already see may be moved (`owner OR split`, the
* same test getTransactions applies).
* - A non-null destination must be a trip the viewer participates in. Checked
* here rather than only in the route so the guarantee cannot be bypassed by
* the other caller (`POST /api/transactions/bulk`, `assign_trip`).
*
* Returns the number of rows actually moved, which is how a caller detects that
* some ids were silently out of reach.
*/
export async function assignTransactionsToTrip(
tripId: number | null,
transactionIds: number[]
): Promise<void> {
if (!transactionIds.length) return;
await queryRaw(`
transactionIds: number[],
viewerId: number
): Promise<number> {
if (!transactionIds.length) return 0;
if (tripId !== null && !(await isTripParticipant(tripId, viewerId))) {
throw new Error("Not a participant on that trip");
}
const rows = await queryRaw<{ transaction_id: number }>(`
INSERT INTO transaction_overrides (transaction_id, trip_id)
SELECT unnest($1::int[]), $2
SELECT t.id, $2
FROM transactions t
LEFT JOIN statements s ON s.id = t.statement_id
WHERE t.id = ANY($1::int[])
AND (COALESCE(t.owner_id, s.owner_id) = $3
OR EXISTS (SELECT 1 FROM transaction_splits ts
WHERE ts.transaction_id = t.id AND ts.participant_id = $3))
ON CONFLICT (transaction_id)
DO UPDATE SET trip_id = EXCLUDED.trip_id
`, [transactionIds, tripId]);
RETURNING transaction_id
`, [transactionIds, tripId, viewerId]);
return rows.length;
}
export async function getTagTransactionIds(tagId: number): Promise<number[]> {
@@ -1169,3 +1544,39 @@ export async function getTagTransactionIds(tagId: number): Promise<number[]> {
);
return rows.map((r) => r.transaction_id);
}
/**
* The last date each account's statements cover, keyed by the account's last
* four digits.
*
* This is the coverage test that matters, and it is not the one that was tried
* first. The first attempt asked whether a row's date fell inside a statement's
* min-max *window*, which for accounts whose statements span 182 to 460 days
* swallows a year and answers nothing — 422 duplicates out of 550 rows got in
* that way. `billing_end_date` asks a question that has an answer: up to what
* date is this account complete?
*
* Amount matching cannot substitute for it. Two sources decompose the same
* event differently — an aggregator bundles the Wise transfer fee into the
* transfer (10001.13) where the statement itemises it (10000.00 + 1.13) — so
* the rows are the same money with different numbers.
*
* Keyed on last4 because account numbers are written differently everywhere
* ("235242176", "xxxxxxxxxxxx2176", "4085-56264"). Where two banks share a
* last4 the later date wins, which errs towards excluding a row rather than
* duplicating one; the caller shows every watermark so a wrong one is visible.
*/
export async function getStatementCoverage(): Promise<{ last4: string; coveredTo: string }[]> {
const rows = await queryRaw<{ last4: string; covered_to: string }>(
`SELECT right(regexp_replace(account_number, '[^0-9]', '', 'g'), 4) AS last4,
MAX(billing_end_date)::text AS covered_to
FROM statements
WHERE account_number IS NOT NULL
GROUP BY 1`,
[]
);
return rows
.filter((r) => r.last4 && r.last4.length === 4)
.map((r) => ({ last4: r.last4, coveredTo: r.covered_to }))
.sort((a, b) => a.last4.localeCompare(b.last4));
}
+221
View File
@@ -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`
);
}
+5
View File
@@ -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);
}
}
+286
View File
@@ -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`,
},
],
},
];
}
+70
View File
@@ -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;
}
+94
View File
@@ -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);
}
+4
View File
@@ -9,6 +9,10 @@
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
// scripts/*.mts run under `node --experimental-strip-types`, which resolves
// ESM specifiers literally and so needs the `.ts` extension written out.
// Legal here because noEmit is set nothing is rewritten on the way out.
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",