Author SHA1 Message Date
siddharthd d88ca2a24c intake: a duplicate card that refuses a purchase must say what to do instead
ci / lint-test (push) Successful in 1m5s
The header (merchant, date, total) is a purchase's identity here, so a genuine
second identical purchase — two of the same coffee, one shop, one day — reads
as a re-share and is refused. That default is right; a re-share is far commoner
than a repeat. But the card said only "Nothing was changed", which is a dead
end: the money is real and the ledger silently never gets it. Say to add it in
the app.
2026-08-21 18:27:53 +10:00
siddharthd 91586ce4a4 test(intake): the second review pass, and CLAUDE.md for the rules it set
ci / lint-test (push) Successful in 45s
Six of the nine fixes have a test that fails against the previous tree: the
metadata row's currency, the Create-path hold, two concurrent batches booking
once, mrkdwn escaping including plain_text, the escaped currency, and
candidate-row currency.

Three do not, and the reason is the same each time — the window needs a crash
or a lost race that a test cannot force. Asserted instead: a stale metadataId
falls back to the live row rather than reporting a phantom write; two writes
racing to create a metadata row both resolve to one; the create path records a
baseline at all. All three passed before as well.
2026-08-21 18:23:52 +10:00
siddharthd 4b09d4136f intake: lock the purchase, not the batch, and hold on both write paths
Two different unresolved batches for one purchase could each create a
transaction. ON CONFLICT (source, source_ref) cannot stop it — their source_refs
differ — and both taps clear their match and duplicate checks before either
inserts. The create now takes a transaction-scoped advisory lock keyed on
(platform, merchant, date, total), re-checks inside it, and inserts; the loser
reports `duplicate` against the winner's transaction, the same answer the
sequential path gives.

The lock has to be inside a real transaction, which this app did not have.
queryRaw is $queryRawUnsafe on a POOLED client, so every call is its own
implicit transaction and pg_advisory_xact_lock taken in a statement of its own
is released before the next statement runs — the lock would have looked right
and held nothing. withTransaction() in db.ts is the scope; the integration
helper mirrors it with BEGIN/COMMIT on one pooled connection.

Also: the "hold rather than replace an itemised receipt with an unpriced
upload" guard lived only in routeFood, and createFromBatch's re-match walked
straight past it — the same upload destroyed the same receipt depending on
which button reached it. One function, both callers.
2026-08-21 18:23:51 +10:00
siddharthd 4f0f0dd285 intake: escape the currency and the button label too
money() interpolated the extracted currency raw, and parseExtraction accepts
any string for it — so "<@U123>" would have been a live mention on every card
the batch produced. And plain_text disables *formatting*, not entity decoding:
&, < and > are control characters everywhere in a Slack payload, so the Create
button's label is escaped like any other value we did not write. My earlier
note claiming the opposite was wrong.

Candidate rows also carry their transaction's currency (NATIVE_CURRENCY) rather
than always printing "$" under a header reading "USD 22.40".
2026-08-21 18:23:51 +10:00
siddharthd b788b4e8fa line-items: snapshot before writing, and stop reporting writes that did not happen
Three faults on the same function, all of them "the write is assumed to have
worked".

- The create path snapshotted the undo baseline AFTER its INSERT. A crash in
  between left the retry recording the intake's own write as "previous", so
  undo restored the thing it was meant to reverse — the exact failure
  previous_line_items exists to prevent. Both paths now snapshot first.
- An UPDATE against a row deleted between the select and the write affects
  nothing and still returned a metadata_id, so the batch was marked enriched
  against a row nobody had written to. The write is now checked and falls
  through to the ordered lookup, then to a create.
- Two calls for one transaction could both find no row and both INSERT;
  expense_metadata.transaction_id is UNIQUE, so one lost with a constraint
  error that reached the user as an error card. ON CONFLICT (transaction_id)
  DO UPDATE, with (xmax = 0) so created_metadata stays truthful.

And the new row's currency comes from the batch instead of a hard-coded 'AUD'.
OrderDetails prefers expense_metadata.currency, so a USD order — booked
correctly on the transaction with foreign_currency_code — printed "$".
2026-08-21 18:23:32 +10:00
siddharthd b5569b8ccc schema: previous_line_items on the model; say what the finish_reason rule is
Migration 0034 has the column and the Prisma model did not, so a regenerated
client and the database disagreed.

The extraction comment claimed only a finished reply is accepted while the code
accepts an absent finish_reason. The code is right — failing every call on a
provider that omits an optional field would be a certain outage in exchange for
a case that also requires syntactically complete JSON, and the harm a truncated
reply does is closed on the write side. The prose now states the rule as it is:
a reason that is PRESENT must be `stop`.
2026-08-21 18:23:32 +10:00
siddharthd d4ff68363a expense_metadata: every reader resolves the row a write lands on
getTransactions' provenance LATERAL, merchantForTransaction and the Slack
details modal all select on transaction_id = $1 OR matched_transaction_id = $1
with a bare LIMIT 1, so they could read a different row than replaceLineItems
writes. Same ORDER BY as the match, the write and the order-detail GET.

Defensive, not a live bug: measured 2026-08-21, expense_metadata holds 667
rows, 147 of them pointing-only, and ZERO transactions resolve to more than one
row by either link. This changes nothing today. It is here so the readers still
agree on the day multiplicity appears, which is the only condition under which
any of them was ever wrong.
2026-08-21 18:23:31 +10:00
siddharthd 631e75e58f test(intake): cover the review findings, and CLAUDE.md for the rules they set
ci / lint-test (push) Successful in 59s
Unit: word-boundary merchant matching (Bigals Pizza is not an IGA), mrkdwn
escaping vs the plain_text button label, a foreign total on a card, and the
finish_reason gate.

Integration: the NULL-platform wildcard on both the food and grocery paths,
candidates crossing platforms, EXCLUDE_RECONCILED_SOURCE, the pinned metadata
row, the panel reading the row the write landed on, the once-per-batch undo
baseline, the priceless-upload hold, Create re-matching, two pending cards
booking once, a pantry-resolved sibling, and a USD receipt.

Eleven of the thirteen fail against the pre-fix tree; the exceptions are the
no-batch-id baseline guard (a no-change assertion) and the concurrent-tap one,
which is a race and passed either way.
2026-08-21 17:53:06 +10:00
siddharthd c25db5211b intake: a NULL platform is "no platform", and Create re-checks before it inserts
Six fixes in the router, all of them cases where a stale or over-wide answer
was written rather than re-asked.

- ($3 IS NULL OR em.platform = $3) made an in-store receipt a wildcard: a till
  receipt from the Coles tap matched the Uber Eats Coles order for the same
  day and money, and overwrote its items. In-store now matches only rows with
  no platform of their own.
- findCandidates loses its platform filter entirely. That card is a human
  choice, shown precisely because the exact key missed — the platform is one
  of the things that may be wrong, so the list must widen there, not narrow.
- createFromBatch re-runs the match. The card is minutes old by the time it is
  tapped and the email/order bridge books orders on its own schedule; the
  match it was drawn from is not current.
- ...and checks for a resolved sibling batch. findDuplicateBatch ignores
  unresolved batches by design, so two pending cards for one order each booked
  it.
- INSERT ... ON CONFLICT (source, source_ref) DO NOTHING with a re-select,
  rather than SELECT-then-INSERT, which loses a double tap.
- A non-AUD receipt is booked as order-ingestion.ts books one: amount native,
  amount_aud NULL, foreign_currency_* set. No FX rate exists at intake.

Also: EXCLUDE_RECONCILED_SOURCE in both queries instead of a bare
superseded_by_id check — a reconciled manual twin at the exact key read as an
ambiguity, which is the same spend counted twice. And routeFood refuses to
replace an itemised receipt with an extraction carrying no prices at all: the
reconciliation check passes those (there is nothing to check), so without this
a priceless upload silently destroys a good record. It holds instead.

RouteContext.subtotal was written and never read; it is replaced by currency,
which the cards need.
2026-08-21 17:53:06 +10:00
siddharthd 9a442b7355 intake: escape mrkdwn, and stop printing every total as dollars
Merchant names, transaction descriptions and model output are none of ours:
Slack reads &, < and > as markup, so "Ben & Jerry's" and a description
carrying <http://…|text> rendered as something the card never meant to show.
esc() on every interpolated value in mrkdwn — never on our own literal *bold*,
and never on a button's plain_text label, where entities would print verbatim.

money() also hard-coded "$" while the order nudge already prints "USD 15.00".
The cards now carry the batch's currency.
2026-08-21 17:52:47 +10:00
siddharthd f9638cc5e5 line-items: one metadata row, agreed on by the write and the read
Three defects on one surface, all invisible while a transaction has exactly
one expense_metadata row — which is almost always.

1. ORDER BY (transaction_id = $1) DESC did the opposite of its comment.
   Postgres orders DESC as NULLS FIRST, and the row that merely POINTS at the
   transaction has a NULL transaction_id, so the clause written to prefer the
   owned row preferred the other one — in exactly the case it exists for.
   matchTransaction already spelled out NULLS LAST; now so do this and
   GET /api/transactions/[id]/order, which had no ORDER BY at all and could
   read a different row from the one an enrichment had just written.

2. replaceLineItems takes an optional metadataId. The match resolves a row
   through a platform-filtered join; re-picking independently here can land
   the write on a row the match ruled out.

3. previous_line_items on intake_batches is the undo baseline, recorded once
   per batch under COALESCE and read back. Returning whatever was just read
   hands a pantry retry its own write as the "previous" state, so undo
   restores the thing it undid. Migration 0034 is unapplied anywhere, so the
   column is added there rather than in a 0035.
2026-08-21 17:52:47 +10:00
siddharthd 7424d13858 intake: only a reply that finished is a reply the schema constrained
liteLLMExtractor rejected finish_reason "length" and accepted everything else,
so a content_filter or tool_calls stop was parsed as if the strict json_schema
had held. Reject any reason present and not "stop", naming it.
2026-08-21 17:52:25 +10:00
siddharthd be3d10d4f1 intake: match merchants on whole words, not substrings
merchantKey("Bigals Pizza") contains "iga", so GROCERY_PATTERN filed a pizza
shop as a grocer and merchantMatches matched it against every IGA on the same
date and total. Short names in a substring list collide with ordinary words,
and the collision reads as a correct classification rather than a bug.

GROCERY_PATTERN is now a word-boundary alternation; merchantMatches asks
whether one name's words appear as a contiguous run inside the other's, which
keeps Coles <-> Coles (Wyndham Vale) and Order - Coles (Wyndham Vale).

Also collapses the third PLATFORM_LABEL copy: order-ingestion's was exported
and read nowhere, order-details.tsx had its own, and the intake one — the only
live copy — was missing `uber`. One map, in intake-rules.
2026-08-21 17:52:25 +10:00
siddharthd a4e5f540cc docs: intake router routes, env vars, migration 0034
ci / lint-test (push) Successful in 48s
2026-08-21 12:10:06 +10:00
siddharthd 32a47bf8ee test(intake): rules, PDF text, C2 idempotency, enrich-vs-candidates, pantry hand-off, Slack taps
Unit: merchant pattern, header validation, reconciliation tolerance, verbatim
line items, one-call message shape, card action ids; unpdf over the two real
Uber Eats exports (Coles $85.74 / 15 lines, Burgertory $22.40 / Michael +
Joel). Integration: fetch stubbed at the LiteLLM and pantry boundaries so the
PDF path, persistence, match and writes run for real against personal_test.
intake-live.test.ts is opt-in (INTAKE_LIVE_TEST=1) and was run green against
cloud-budget on both PDFs.
2026-08-21 12:10:06 +10:00
siddharthd c7ea516c34 slack: dispatch the four intake taps on action_id (plan §11 C5)
intake_use_candidate / intake_create / intake_grocery / intake_meal carry JSON
{batch_id, ...} rather than <id>:<verb>, so they branch before the order-nudge
parse. They act on the persisted batch: enrich the chosen transaction, create
ING-10-style (statement_id NULL, owner_id from shared_by, source/source_ref
make a second press a no-op), or remember the merchant's kind and route. A tap
on an already-resolved batch returns its current card and writes nothing. Card
updates go to response_url, as the existing handlers do.
2026-08-21 12:10:05 +10:00
siddharthd db5c9a02a7 intake: POST /api/intake router + PUT /api/transactions/[id]/line-items (plan §11 C1, C2)
One upload point, every applicable step. Persist the batch, classify each
file, ONE LiteLLM call (cloud-budget, temperature 0, strict json_schema; PDFs
as unpdf text, never pixels) with an order-item prompt that keeps names
verbatim, header (merchant, date, total), merchant pattern
(coles|woolworths|woolies|aldi|iga|foodworks|costco) -> grocery | food | ask,
exact (platform, merchant, date, total) match against transactions +
expense_metadata, then act:

- food + unique match  -> replace line_items via the C2 path, 'enriched'
- food + miss/ambiguity -> 'candidates' (same merchant ±3d) with
  intake_use_candidate / intake_create buttons; never auto-creates
- grocery              -> original files to pantry POST /api/capture with
  finance_target_transaction_id + finance_action enrich|create (C3);
  409 -> 'duplicate'
- ask / needs_header / held / duplicate / unsupported as replies, not writes

Reconciliation is against the printed item subtotal (a delivery total is
items + fees - promotions: 18.94 of Coles lines settled at 5.74),  or
2%, else 'held'. Originals are kept under INTAKE_FILE_DIR by sha256 so a later
'Groceries' tap can still forward them.

C2 is an idempotent replace on transaction_id = :id OR
matched_transaction_id = :id, accepts INTAKE_TOKEN or RECEIPT_INGEST_TOKEN,
creates the expense_metadata row when absent, 404 when the transaction does
not exist, returns {previous_line_items, line_items} so pantry's undo is the
same call with the previous list.
2026-08-21 12:10:05 +10:00
siddharthd a538376638 db(0034): intake_batches + intake_merchant_kinds for the intake router
One row per upload through POST /api/intake, written BEFORE extraction so a
Slack tap minutes later has something to act on and a re-share (same sha256
set, or the same (platform, merchant, date, total) header already resolved)
is detected. Columns are the §11 C1 contract in smarthome
plans/intake-router-slice-plan.md. intake_merchant_kinds holds the remembered
'Groceries or a meal?' answer per normalised merchant (plan §2, Codex #6).

Not applied to production — finance has no migrate service; apply by hand at
deploy, then npm run test:setup.
2026-08-21 12:10:05 +10:00
siddharthd b958d05287 test(db): resolve the integration DSN at run time instead of storing an IP (#161)
ci / lint-test (push) Successful in 1m5s
setup-test-db.sh wrote .env.test once with the Postgres container's bridge IP
baked in, and Docker reassigns those on recreation. The file rotted silently:
its stored 172.22.0.18 belongs to **gitea** as of today, and an earlier drift
had the suite pointing its TRUNCATE at **postgres-pantry** — which it would
have wiped had the credentials happened to match.

Root-cause fix: stop persisting a host at all.

- scripts/resolve-test-db.sh re-derives the DSN from the live container and
  rewrites .env.test. Resolution order is TEST_DATABASE_URL, then the
  container's published host port, then the bridge IP. `docker port
  postgres-personal` reports 127.0.0.1:5432 since the 2026-08-17 PG17
  migration, so loopback is what gets used here — and a loopback address
  cannot be reassigned to another container, which is the whole point.
  (Note this supersedes the smarthome CLAUDE.md's "postgres-personal publishes
  no host port"; that is now stale.) The bridge-IP fallback is kept for a host
  where the port is not published, and the script never prints the password.
- Wired as `pretest:integration`, so npm runs it before every integration run.
  .env.test becomes a generated artifact rather than stored configuration.
- setup-test-db.sh now only creates the database and copies the schema, then
  delegates to the resolver.
- helpers.ts::assertTestDatabase stays and its message now points at the
  resolver rather than at `docker inspect`. It still earns its place: running
  vitest directly bypasses the pretest hook, and a stale .env.test would then
  still be read.

Docs: CLAUDE.md gains a Testing section carrying this, the prisma-generate
trap for a fresh checkout or worktree, and the frozen-clock rule for tests
whose subject is defined relative to "now". AGENTS.md updated for the same, and
for lint now being blocking.
2026-08-21 00:52:54 +10:00
siddharthd eaf5d5ac66 test(analytics): guard the three unguarded Priority 0 behaviours (#155)
docs/ui-information-architecture-review.md claimed Priority 0 was guarded by
analytics-sql.test.ts. That file only ever covered the three SQL *fragments*
(EXCLUDE_RECONCILED_SOURCE, NATIVE_CURRENCY, INVESTMENT_SIGNED). Items 2, 3 and
6 of the Priority 0 list had nothing, and all three fail silently — no error,
no empty screen, just a number that quietly disagrees with the one beside it.

New: src/__tests__/integration/analytics-priority0.test.ts, 18 tests.

- **Item 2, pace/headline parity.** /api/analytics/daily summed over a month
  must equal /api/analytics/monthly's headline to the cent, per category, and
  across every month in the window. Cents rather than exact equality is the
  assertion because the two routes group at different grains (month+category
  vs month+day+category) and both carry 4dp — the displayed grain is what has
  to agree, which is precisely the bug (the chart ending the month a few cents
  off the headline above it). Fixtures are only shapes where a naive gross
  debit sum disagrees: a 50% split, a refund, fee and interest rows, an
  itemised loan repayment, a reconciled source row, family-tagged spend,
  transfers/investment/income, and a row someone else paid that I hold a split
  on. A fourth test asserts the naive sum does NOT match — without it, parity
  between two queries identical by construction would pass on fixtures too
  plain to discriminate.
- **Item 3, fees date range.** Default 12 months with the window reported
  (from=2025-09-01, not 08-31 — toDateStr, not toISOString); the older tail
  excluded; months=0 all-time with a null range rather than a period claim the
  number does not make; a narrower months=3; the transaction-level fee lines
  bounded by the same window as the statement totals, since a bounded total
  beside an unbounded list is the same defect wearing the fix; and a nonsense
  months param clamped rather than producing an Invalid Date window.
- **Item 6, partial-month comparisons.** The in-progress month never enters a
  baseline; a partial month is compared through the same day of each prior
  month; a complete month keeps full totals; top movers truncate both sides;
  year boundary and leap February.

Item 6 was untestable by construction, which is why it had no test: the rules
were closures in budget/page.tsx reading `new Date()` inline. Extracted to
src/lib/partial-month.ts with an injectable clock, and budget/page.tsx now
imports them. The extraction also de-duplicates the baseline filter, which was
written out identically in two places.

The file freezes the clock (vi.useFakeTimers, toFake: ["Date"] only — pg needs
real timers for connection handling). Without it the fixtures drift out of the
routes' rolling windows over time and the assertions go vacuous, which is the
same class of silent failure the file exists to catch.

Mutation-checked, all three: breaking daily's spend semantics fails 3 of the
item-2 tests (the placebo correctly still passes); forcing the fees window to
all-time fails 5; letting the current month into the baseline fails 1; and
dropping the elapsed-day truncation fails 3.
2026-08-21 00:52:36 +10:00
siddharthd fc9ffaba25 chore(ci): clear the lint debt and make lint blocking (#79)
CI ran lint with continue-on-error because 23 errors predated it. All are
fixed, so the flag is gone and a typecheck step is added beside it — nothing
else in CI compiles the test files, so a type error in a fixture could only
ever be found on a developer's machine.

Real fixes, no blanket disables and no suppressions:

- react-hooks/set-state-in-effect (6): each was "reset or seed state when an
  input changes", so each becomes a state adjustment during render behind a
  previous-value guard — the documented React pattern for it. Two were also
  latent bugs. transactions/page.tsx depended on `others`, rebuilt by .filter()
  every render, so the effect already ran every render, one commit late.
  shared/page.tsx depended on the `tagIds` array identity, so the row window
  was reset more often than "changing any filter" claims — including after
  "Show more", the one interaction it must not undo; the key now joins by
  value. csv-import-modal reads localStorage once at mount, which is a lazy
  useState initializer, not an effect (SSR-guarded, though the modal is
  mounted from client state and never prerenders).
- react-hooks/static-components (3): SortIcon was declared inside SharedPage,
  making it a new component type on every render. Hoisted to module scope with
  the sort state passed as props.
- @typescript-eslint/no-explicit-any (9): the dynamically imported route
  handlers in two integration tests. Typed as their real signatures with one
  `as unknown as NextRequest` cast at the fixture boundary, so the call sites
  and response bodies get checked instead of opted out.
- react/no-unescaped-entities (5): exact-equivalent entities (&apos;, &quot;),
  so no rendered text changes.

Also fixes the one pre-existing tsc error (unit/order-ingestion.test.ts:511):
`options` is optional, and `?? 0` keeps the assertion failing on an absent
list rather than silencing it with a non-null bang.

9 warnings remain (no-unused-vars, exhaustive-deps). eslint exits 0 on
warnings, so lint blocks on errors only — deliberately, since none of the
remaining warnings should stop a push.
2026-08-21 00:52:14 +10:00
siddharthd 9f1be74330 transactions: let a gift card be a payment method you can pick
ci / lint-test (push) Successful in 43s
`credits` has been a valid payment_method since migration 0018, which added it
so the order pipeline could file gift-card-funded orders. It reached the
database, `needsCardMatch()` (which holds those rows out of reconciliation) and
`bankLabel()` (which renders them "Gift Card"). It never reached the two places
a human touches it.

So entering a gift-card purchase by hand was impossible: the dropdown stopped at
"Other", the row took the NULL default, and NULL is *reconcilable* — it joined
the pending queue to be offered card matches within 3 days and 1% that cannot
exist, because the balance already paid the bill. Accepting one is the
destructive case the cash exclusion documents: reconciled manual rows are
filtered out of every query, so the spend vanishes while an unrelated card row
claims to be it.

- `src/lib/payment-methods.ts` — one canonical list, mirroring the
  `statement-types.ts` pattern, plus the TS mirror of `needsCardMatch()`.
- `PATCH /api/transactions/[id]` — the `VALID_METHODS` literal omitted
  `credits`, so the API answered 400 for a value the CHECK constraint admits
  and 372 live rows already carry. It validates against the list now.
- `POST /api/transactions` — validated at all, so a bad value is a 400 naming
  the field rather than a 500 carrying a Postgres error.
- Add modal — offers every method, and explains the consequence when you pick
  one that suppresses reconciliation. Relabelled "Paid by" → "Payment method",
  which the edit modal uses for the *owner*; they are different questions.
- Edit modal — had no payment-method field at all, so an existing row wrongly
  in the queue could not be corrected. Manual rows only: the queue is
  `statement_id IS NULL`, so it decides nothing on a statement row.

The test asserts the list against `pg_get_constraintdef` and evaluates
`needsCardMatch()` as real SQL rather than re-implementing it, so the next
migration that widens the constraint fails the suite instead of drifting for
two months. Mutation-tested by dropping `credits`.

Unit 130 pass, integration 206 pass (the integration DB needed `npm run
test:setup` first — `.env.test` still named a stale container IP).
2026-08-17 18:14:58 +10:00
siddharthd 4f307e9517 docs: what importing a BNPL account changes, and the Zip data decisions
ci / lint-test (push) Successful in 41s
24 Zip statements landed (Dec 2020 - Jun 2026), all balance-asserting at
$0.00. Zip is a credit account, so three existing row types had to be
re-read. Data changes applied via transaction_overrides; this records why.

A ZIPPAY debit leaving the bank duplicates the Zip statement's purchases
only where a Zip statement covers that period. 2 of 8 do, and are now
transfers ($125.97). The other six stay spend: for those periods the bank
debit is the only record the purchase happened, so moving them would have
deleted $703.35 of real spend.

'Account Credit' is cashback, confirmed by the owner and consistent with
Zip labelling its other money-in types explicitly (Payment = repayment,
'Refund <merchant>' = merchant refund). 34 rows, $740, round amounts, no
matching bank debit. Gemini split the identical description 23 'other'
(subtracts from spend) / 11 'transfers' (excluded), so one thing did two
opposite things. Now all 34 shopping+credit, netting against spend the
way refunds already do. Income was rejected for the reason the Up item
sales note gives.

Prezzee gift cards ($1,580) stay spend — stored value only nets out if
every downstream purchase is logged, and for 2020 it is not. Same
reasoning as the ATM-withdrawal rule.

31 uncategorised debits mapped by merchant with merchant_normalized set
too, since the raw descriptions carry card numbers and order UUIDs. All
137 Zip rows are now categorised; $11,135.11 net spend, $10,085.11
transfers.

Flags statement 200 (Sept 2021): extracted without billing_end_date or
balances, so it is the one statement uq_statement_identity cannot cover
(the index is partial on billing_end_date IS NOT NULL) and a re-import
would duplicate it.
2026-08-15 19:11:50 +10:00
siddharthd 2cfaf8e8c8 statements: derive account identity, fix the new-bank alert (0033)
ci / lint-test (push) Successful in 42s
account_number fragments exactly like bank_name did, from the same cause:
ANZ's Access Advantage arrived as both '4085-56264' (4 statements) and
'408556264' (1). uq_statement_identity keyed on it, so re-importing one
period under the other spelling evaded the duplicate check.

account_number_key is GENERATED ALWAYS — the number with separators
stripped — and the unique index moves onto it. Derived rather than
rewritten because the raw number is the readable one and some of it is
structure: Up stores '633-123 / 176540052', a BSB and an account number,
and flattening it would lose a distinction a human reads at a glance to
fix a machine problem. The Amex mask and case are preserved; 'X' records
which digits were redacted.

Not the cause of the documented 31-row / $42,040.68 ANZ duplication —
107/142/143 overlap on different end dates, which that index cannot catch
at any spelling.

Adds statement_identity_drift: one account under two bank names, or one
bank under two spellings of one number. Non-empty means fragmentation the
normaliser could not know about. Currently one row, the ANZ pair above,
now unified by key.

Also documents the N8N regression this series caused. Check Known Bank
compared the raw Gemini name against the canonical column before the
insert, so nothing ever matched and every Zip statement was tagged
pending and held for Slack approval — a stall, not just a noisy alert.
Fixed in workflow FysADdFwEtwONQl4 by comparing like with like through
normalize_bank_name(), passing currency so a genuinely new national
entity still alerts. Verified live: 12s after vs 30-53s before.
2026-08-15 17:03:13 +10:00
siddharthd c3b623052d statements: keep one bank's national entities apart (migration 0032)
ci / lint-test (push) Successful in 44s
Fixes 0031. Every merge it made was provably one account — each split
pair shared an account_number — but the PREFIX generalises past that
evidence. Citibank Australia and Citibank India are different banks with
different accounts and currency, and `citi%` flattens them into one.
Older Citi India statements are queued to load, so this would have merged
them on arrival. Not Citi-specific: `hsbc%` and `%american express%`
carry the identical defect.

Currency alone cannot be the discriminator: Wise holds AUD, EUR and USD
accounts under one provider, so a blanket "non-AUD is a different bank"
rule would shatter it into three. Wise stays unmapped for exactly that
reason, now a documented property rather than an accident.

The country comes from the name when the name states it and from the
currency otherwise, with AUD as home taking no suffix — so nothing
renames: the backfill touched 0 rows.

  'Citibank'       + AUD -> 'Citibank'        (unchanged)
  'Citibank India' + INR -> 'Citibank India'
  'Citibank N.A.'  + INR -> 'Citibank India'  (currency carries it)
  'CITIBANK INDIA' + AUD -> 'Citibank India'  (name beats a wrong currency)

'N.A.' is deliberately not read as a country marker: it means "National
Association", a US legal form printed on Citibank letterhead worldwide,
including India. Reading it as US is precisely the mistake this fixes.

The trigger now fires on UPDATE OF bank_name, currency, since the country
is partly derived from the currency.
2026-08-15 16:52:14 +10:00
siddharthd 0bb4329249 statements: one institution, one bank name (migration 0031)
ci / lint-test (push) Successful in 46s
bank_name is a component of uq_statement_identity
(bank_name, account_number, billing_end_date), so two spellings of one
institution mean the same statement can be ingested twice without the
index ever firing. Gemini reads the name off whatever the PDF prints, so
the spelling varies per document, not per account.

Zip exposed it: account 2705256 arrived under four names in sixteen
minutes — ZipMoney Payments Pty Ltd, ZipMoney Payments Pty Limited,
Zip Pay, ZipPay — with ten more in the ingestion queue. Not unique:
17 bank_name values represented 13 institutions, every split pair sharing
an account number (Amex 14/1, Citibank/Citi 8/4, NAB 3/1). Fragmentation
also splits both bank filters and by_bank in /api/analytics/fees.

normalize_bank_name() + a BEFORE INSERT OR UPDATE trigger, mirroring the
0013 statement_type pattern with one deliberate difference: the
vocabulary is OPEN. An unrecognised name passes through tidied rather
than collapsing to a fallback, and there is no CHECK constraint — a bank
never used before must be able to arrive without a migration, and
destroying its name on first contact is worse than leaving it unmapped.

Matching is by prefix, not by enumerated spelling, so unseen variants
normalise with no code change. Branches for Westpac, ANZ, HSBC, ING, AMP,
Up and CommBank are no-ops that map to the spelling already in use, to
catch the legal-entity variant a future PDF might print. Wise is left
alone: one spelling is a rename, not a merge.

Backfill verified collision-free against uq_statement_identity first.
17 names -> 12. account_owner_mappings keys on (bank_name,
account_number) with its own UNIQUE constraint and is updated too; it is
empty today.
2026-08-15 16:45:04 +10:00
siddharthd 95d8544752 transactions: change who paid, on a row and on a statement
ci / lint-test (push) Successful in 46s
Owner was write-once for every ingestion path — a pantry receipt hardcodes
DEFAULT_OWNER_ID and there was not one `UPDATE ... SET owner_id` in src/ —
so a shop the other person paid for was permanently filed as yours.

PATCH /api/transactions/[id] now takes owner_id, for manual rows only. A
statement row returns 400 statement_owned and points at the statements
page: its effective owner is COALESCE(t.owner_id, s.owner_id), so writing
it there would either no-op or detach one row from the account it came
from.

PATCH /api/statements/[id] is new. The statements page has had an owner
dropdown since it was built, wired to a route with no PATCH handler —
every change 405'd, and because useUpdateStatement never checked res.ok
it failed silently and the select snapped back on refetch. It writes both
tables: 2,194 statement rows carry their own owner_id against 1,803 that
inherit, so updating `statements` alone moves less than half and splits
one account's history between two people.

The guard is the point. Access is "owner OR holds a split", so handing a
row over while holding no split removes it from your list and 404s every
route that could put it back — only the new owner can undo it. That is
409 would_lose_access, and the modal offers both ways forward: add my
split first, or give it away anyway. Taking a row onto your own ledger is
never blocked, and claiming a row you cannot see is a 404 before any
owner logic runs.

Splits are deliberately not rewritten. They record shares, not direction,
so a 50/50 flips from "they owe me" to "I owe them" untouched, settled
included.

Also adds the missing res.ok check to useUpdateTransaction, without which
every rejection resolved as success: the modal closed, the list
refetched, and the edit silently vanished.

14 new integration tests; 203 integration + 130 unit green.
2026-08-15 16:17:38 +10:00
siddharthd 6c14b493ef analytics: your share of what someone else paid is your spend
ci / lint-test (push) Successful in 1m25s
Every spend analytic gated on `OWNER_SCOPE = $1` and scaled by `mySplitOf`
*within* that gate, so ownership was a precondition for an expense being
yours. Half a grocery shop Sonu paid for counted as zero — in monthly,
daily, merchants, subscriptions, fees and the budget page. 167 rows /
$3,210.91 across Jan-Jul 2026, worst in April (+$1,354.83, the Europe
trips), while getParticipantBalances booked the matching debt correctly.
The app could say you owed her for a shop while insisting you had not
spent anything on it.

New MY_SPEND_SCOPE(): owner = me OR I hold a split. OWNER_SCOPE stays on
the things that measure an *account* rather than a person — the income
and investment lines, and the statement-level fee rollup.

myShare had to change with it, and widening the gate alone would have
been worse than the bug: its `100 - everyone else` fallback is the
payer's remainder, so on someone else's unsplit row it returns 100 and
moves their whole bill onto you. It now branches on ownership — my row
resolves as before; their row takes an explicit split row only, absent
meaning 0. That 0 is what makes the wider gate safe.

my_share_percent is deliberately not read on someone else's row: one
unscoped column, writable by anyone who can see the row, so "my" can
only mean the owner's. All 402 rows carrying one today are owner-side.

MY_SHARE_PCT mirrors myShare for the transactions list, which has no
viewer-scoped ts join; a test asserts the two agree across seven fixture
shapes.

No historical restatement — every non-owner split is 2026-dated, and the
1,266 pre-2026 SplitMyExpenses splits are all on rows you own.

Also fixes a latent failure in the NATIVE_CURRENCY test, which inserted a
statement relying on participant id 1 existing (owner_id is NOT NULL
DEFAULT 1 with an FK) and only passed when a sibling file had left one
behind. It now owns its fixture.

15 new integration tests; 189 integration + 130 unit green.
2026-08-15 15:37:32 +10:00
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
siddharthd d081d80a3f docs: rewrite the shared-expenses section, which had gone false
ci / lint-test (push) Successful in 39s
Every warning in it was inverted by this week's work:

  - "settled is dead data, false on every row" -- there are now 1,266 settled
    splits across 657 pre-2026 transactions.
  - "do not fix getParticipantBalances to exclude settled splits" -- it now
    excludes them, via ACTIVE_OBLIGATION, and must.
  - "settlement cannot be attributed per trip" -- migration 0022 added
    split_payments.trip_id and it is attributed.
  - "splits exist from 2026-01-09 only" -- pre-2026 transactions are now split
    deliberately, to stop them inflating spend.

Replaced with what is actually true, including the rule that matters most: the
cutover DATE is the primary balance gate and the settled flag only refines it,
so pre-2026 expenses can be split freely.
2026-07-28 14:41:05 +10:00
siddharthd db6b7f8375 docs(shared): the cutover date is the gate, the flag refines it
ci / lint-test (push) Successful in 44s
2026-07-28 13:54:46 +10:00
siddharthd 788219b9fd fix(splits): the cutover date, not a boolean, is what gates a balance
ci / lint-test (push) Successful in 46s
Nothing dated before 2026-01-09 can be owed, because carryover transaction 2348
already carries the entire pre-cutover balance as a single figure. ACTIVE_OBLIGATION
now says so directly.

This inverts which mechanism is load-bearing, and that is the point. Until now
the only thing keeping $37,233.28 of paid debt out of the balances was
transaction_splits.settled -- a boolean that any delete-and-recreate write path
resets to false, as the split modal did until commit 6add958. Losing the flag on
a pre-cutover row now costs nothing: the date still excludes it. The flag matters
only on or after the cutover, marking the few settled outside this app.

It also makes splitting history safe to do freely. A split on a 2024 grocery
shop can now describe how the expense was shared -- which is what stops it
inflating spend -- without asserting a debt that was settled years ago. That was
the whole reason not to split pre-2026 expenses, and it no longer applies.

The bound is inclusive because transaction 2348 is itself dated 2026-01-09; an
exclusive one would drop the carryover and with it the entire pre-cutover
balance.

Corrects one live figure: a Woolworths on 2026-01-06 was split 50/50 three days
before the cutover, double-counting $7.55 against the carryover. Sonu
$5,428.08 -> $5,420.53.

The test fixture default moved to 2026-06-15 -- it was 2024-06-15, which is now
pre-cutover and made every balance fixture read zero. That the suite caught this
is the guard working.
2026-07-28 13:52:30 +10:00
siddharthd 6add958132 fix(splits): editing a split must not resurrect a settled debt
ci / lint-test (push) Successful in 47s
The route replaces every split for a transaction rather than editing in place,
so the recreated rows took the column default settled=false. Opening the split
modal on a historical transaction and saving it therefore converted a
discharged obligation into a live one, with nothing on screen saying so.

That is not theoretical. 657 pre-2026 transactions now carry settled splits
imported from SplitMyExpenses -- $37,233.28 of balance that the carryover
(transaction 2348) already accounts for. Editing one would double-count its
share against a debt that was paid years ago.

Now carries settled and settled_at across the rewrite, per participant, the
same way the rules revert route already does. Changing someone's percentage
does not re-open the obligation: it was settled outside this app and stays
settled. A participant who was not on the transaction before is a genuinely new
obligation and correctly starts unsettled.

rule-actions.ts was already safe here -- it upserts ON CONFLICT DO UPDATE SET
share_percent, so it never touches the flag.
2026-07-28 13:40:48 +10:00
siddharthd 3339a0b9b7 chore: ignore the scripts venv
ci / lint-test (push) Successful in 45s
scripts/split_csv_match.py needs psycopg2, so scripts/ now has a venv beside it.
This repo deploys from its working tree, so an untracked .venv would be swept
into the Docker build context.
2026-07-28 13:32:31 +10:00
siddharthd 3b9d302ce2 docs(shared): record the grouped-payment allocation
ci / lint-test (push) Successful in 49s
Sonu's two "transfer" payments are split by scope, Europe first and the
remainder to household, chronologically so each settles what was outstanding
when it was made. Both Europe tabs now read $0.00.

No schema change was needed and that is the point worth writing down:
split_payments has no unique constraint on linked_transaction_id, so one bank
transfer carries one row per scope and the rows re-add to the transfer --
verified, 4111 sums to $3,779.33 and 4121 to $4,794.06.

Her overall balance is unchanged at $5,428.08. Allocation moves money between
tabs, never between people; that invariance is the check to repeat on any
future re-allocation.
2026-07-28 13:02:56 +10:00
siddharthd 4fc8eeac95 docs(shared): record the Sonu+Sunny leg as its own trip
ci / lint-test (push) Successful in 51s
Trip 3, 2026-04-12 to 2026-04-28, 124 rows, $9,914.24. It was marked only by
tag 5 and so was invisible to every trip figure.

It reads at first like a sharing scope overlapping the group trip, because the
tag's earliest row is 17 March. It is not: that is a single advance booking
(Ticketmaster Nanterre), 8 more rows fall on the 12 April handover day and were
already held out of Europe 2026, and the remaining 115 run 13-28 April. A clean
sequential leg.

Also records that grouped payments need no schema change -- split_payments has
no unique constraint on linked_transaction_id, so one transfer can carry a row
per scope -- and that the 23 Apr Qantas booking is the flight to Bangkok
starting a solo leg, which is why it stays out of this trip.
2026-07-28 12:59:53 +10:00
siddharthd 89300450a7 docs(shared): describe what was built, not what was proposed
ci / lint-test (push) Successful in 50s
The 2026-07-26 document was a proposal marked "nothing built". Everything it
described as broken is now fixed, and the fix is not the one it proposed, so
leaving it in place would misdescribe the system to whoever reads it next.

Records what the code now does: settled as the single balance gate, settled and
trip_id as orthogonal axes, settling up by recording a payment rather than
flipping a flag, and the reasons duplicates are superseded rather than deleted.

Keeps the loan design intact and clearly marked as still a proposal -- it was
never built and nothing in this work touched it.

Also records that the proposal's own recommendation not to restate history from
the CSVs was overturned, and why it was wrong: it measured the value in
balances, where it is nil, and missed it in spend, where it is $35,259.
2026-07-28 12:06:56 +10:00
siddharthd b4a116c134 feat(scripts): import the matched split history as settled
ci / lint-test (push) Successful in 46s
Adds --write to the matcher. Wrote 1,242 split rows across 657 transactions.

Imported settled, and that is the whole design. These obligations were
discharged years ago on a platform we no longer run, and their residual is
already carried by transaction 2348. Writing them unsettled would re-open
roughly $40k of debts that were paid. ACTIVE_OBLIGATION keeps settled splits
out of every owed figure while myShare/mySplitOf still count them, which is
exactly the asymmetry this needs: the import exists to correct historical
SPEND, not to move a balance.

Effect: $35,259 leaves my historical spend -- $13,088 in 2024, $22,117 in
2025 -- because a $200 grocery shop that was always half hers no longer reads
as $200 of mine. Balances are byte-identical before and after (Molina
-1226.72/145, Sonu 5428.08/419), which is the assertion that matters.

Shares are written as the CSV computed them, so a 50/50 row can land as
50.01/49.99. That is faithful rather than tidy; no transaction exceeds 100%.

Rehearsed on the 37-row Rome file first (24 rows) and verified before the full
run -- both the balances and one split read back through the API.
2026-07-28 12:02:57 +10:00
siddharthd c9b000a428 feat(scripts): dry-run matcher for the SplitMyExpenses history
ci / lint-test (push) Successful in 48s
Matches the five CSV exports against transactions already in the ledger and
reports what it would do. Writes nothing -- importing is a separate step, and
rehearsing it first is what catches the defects that tests do not.

What it found, and why the number is what it is: 676 of 1,536 shareable rows
match (44%). The ceiling is ledger coverage, not matcher quality. The CSVs
describe 678 shared expenses in 2024 alone; the ledger holds 591 rows for the
whole of that year, 3 to 72 a month, which is far less than a household
actually spends. Most 2024 CSV rows have no transaction to attach a split to
and never will. South Korea April 2024 matches 4 of 158 for that reason.

Three decisions are encoded deliberately:

  - Date format is decided per FILE, not per row. The household export writes
    D/M/YYYY and the four trip exports write ISO, and 474 rows parse validly
    under both readings -- per-row guessing silently swaps January and February
    for some rows and not others.
  - A person's column is their net balance impact, not their share. The payer
    is whoever is positive; the other's share is |their negative| / cost. So a
    +cost/-cost row means the other party owes 100%, not that the expense was
    unshared -- the reading that would fake an arrangement change.
  - Matching is one-to-one, best pair first. The NZ trip has two identical
    $10.16 Uber rows against three ledger rows and four PayMyPark rows in the
    same shape; without this a ledger row is claimed repeatedly and the second
    CSV row looks matched while being unrepresented.
2026-07-28 12:00:18 +10:00
siddharthd dbfbd5196d fix(transactions): supersede rows imported twice instead of deleting them
ci / lint-test (push) Successful in 48s
Statements 107, 142 and 143 bill overlapping periods on one ANZ account, so 31
transactions -- $42,040.68 -- are in the ledger twice.

They are marked superseded, not deleted. Every child of transactions is ON
DELETE CASCADE (splits, tags, overrides, expense_metadata, order_reviews), so
deleting "the duplicate" destroys whatever curation sits on it, and which
member of a pair holds that curation is an accident of import order: here 1
pair carries splits and 6 carry overrides, all on the surviving side, but
nothing guarantees that. Superseding keeps the row, keeps its children, and
makes a mistake one UPDATE to undo rather than a restore from backup.

reconciled_with_id could not be reused. Its predicate is scoped to
statement_id IS NULL on purpose -- a statement line pointing at something else
is the survivor, not the duplicate -- and here both rows are statement lines.

The exclusion goes into EXCLUDE_RECONCILED_SOURCE rather than into a new
fragment, so every query already asking "count each purchase once" gets it
without being edited. The trip cost queries did not use that fragment at all
and now do; verified a no-op on current data (0 trip-tagged rows are either
reconciled sources or duplicates), but they were one import away from
double-counting.

Most of the $42k is transfers and investments, which spend already excludes.
The damage was elsewhere: duplicated rows in the list, and rules re-splitting a
duplicate -- txn 3807 is one of these 31 and was a candidate for splitting
earlier today.

Balances are unchanged: no duplicate carried a split.
2026-07-28 11:54:25 +10:00
siddharthd d5589b2980 feat(statements): flag billing periods that overlap another statement
ci / lint-test (push) Successful in 46s
An account cannot be billed twice for the same day, so an overlap means those
transactions are in the ledger twice. ANZ statements 107 and 143 overlap by 118
days and put roughly $42,000 of duplicate rows in; nothing anywhere said so.

Two details decide whether this catches the real case:

  - Account numbers compare with non-digits stripped. The duplicate got in
    because the existing key compared raw text and ANZ wrote the same account
    as 408556264 on one statement and 4085-56264 on the other.
  - The range is half-open. These statements are issued back-to-back with one
    period ending the day the next starts, so inclusive bounds flagged 5 pairs
    of which 3 were consecutive and fine. Half-open leaves exactly the 2 real
    ones.

NULL bounds are excluded rather than handed to daterange, where NULL means
unbounded and an undated statement would overlap all of history.

Detection only. It does not refuse the import or touch the duplicate rows --
cleaning those is separate, and must supersede rather than delete because every
child of transactions is ON DELETE CASCADE and the curation sits on the
duplicate side.

Both subtleties have a test, and both fail if you undo them.
2026-07-28 11:46:45 +10:00
siddharthd 7a1acc32a9 feat(trips): say which direction a trip balance points
ci / lint-test (push) Successful in 45s
A participant who has overpaid a trip showed as "$-816.16" under a column
headed "Outstanding on this trip". A negative outstanding reads as a bug
rather than as "they are ahead", so the sign is now spelled out: magnitude
plus one of all square / owes you / ahead — you owe them, coloured the same
way Shared colours the same three states.

Also corrects the footer, which had gone stale and was now simply false. It
said settlement could not be computed per trip because payments carried no
trip attribution. Migration 0022 added split_payments.trip_id and the figures
above it have been net of trip-scoped payments since. What a reader needs to
know is the opposite of what it said: household-tab payments are the ones NOT
counted here.
2026-07-28 11:39:11 +10:00
siddharthd e92fcb709f docs(trips): say whose money the trip total counts
ci / lint-test (push) Successful in 48s
Total Spend is every payer's trip-tagged spending; the split figures directly
below it are scoped to the owner. Two lenses on one screen read as one unless
the card says which it is. The number is unchanged and deliberate -- a trip
cost what the group put into it -- so this is a label, not a fix.
2026-07-28 11:30:37 +10:00
siddharthd 8c21893cc2 fix(trips): money that came back is not what the trip cost
ci / lint-test (push) Successful in 1m28s
Every trip figure filtered on transaction_type IN ('debit','fee','interest'),
which drops refunds and credits outright. A partly-refunded booking therefore
read at its full price and the refund subtracted nothing, anywhere: the
headline total_spend, the category breakdown, the daily chart, top merchants
and the tag breakdown were all gross.

This is the same defect the general analytics fixed once already, which is why
NET_SPEND_ROWS and SPEND_SIGNED exist -- a refunded Expedia purchase read as
$2,888.92 of spend until they did. Trip analytics never adopted them. Doing so
now costs one predicate and one expression per query.

getTrips/getTripById needed the trips alias moved to `tr`: the fragments assume
`t` is `transactions`, and hand-inlining a copy rather than renaming is exactly
how the reconciled-row exclusion drifted out of the analytics routes before.

On Europe 2026 this is $821.12 -- a LuxuryEscapes booking with two part-credits
against it, and a FreeNow hold adjustment. Fully cancelled bookings are a
different case and are handled by untagging both legs from the trip by hand,
because a trip never incurred a cost it cancelled.

No balance moves: the owed query already excludes credits and a refund carries
no split. There is a test asserting exactly that, and it passes with or without
this change -- it is a guard, not a proof. The three that do prove it fail
without it.
2026-07-28 11:25:03 +10:00
siddharthd 4fcb135805 fix(trips): a trip figure must only count what the owner is owed
ci / lint-test (push) Successful in 46s
The per-trip owed number shipped in 689fadc counted every split on every
trip transaction regardless of who paid, so it silently mixed debts owed to
different people under one label.

On Europe 2026 that meant Molina "owed" $21,572.12, of which $1,605.49 was
her share of rows Sonu paid for — a real debt, but between the other two
participants, and one they had already settled directly (split_payments id
5, Molina -> Sonu, exactly $1,605.49). A participant's own share of a row
they themselves paid for was in there too, which is nobody's debt at all.

Both sides needed scoping, not just one: the owed side to rows this owner
paid for, and the paid side to payments made to this owner. Scoping only
the first would have let a Molina -> Sonu payment reduce what Molina owes
the owner.

The corrected figures reproduce a number derived independently, months of
data apart: Molina now reads -$816.16 on Europe, matching her known
overpayment to the cent ($19,966.63 of splits against $20,782.79 paid).
Sonu goes from $8,793.10 to $1,084.61, and the owner correctly disappears
from a list of people who owe the owner.

Found by checking a household total against what the app had been showing
all along — the query was gross, gave a number about twice the real one,
and I had quoted it as "owed". Worth stating plainly: the defect was not in
the number the app displayed, it was in the number I computed to explain it.
2026-07-27 23:40:58 +10:00
siddharthd ff0629462c fix(ci): generate the Prisma client before running tests
ci / lint-test (push) Successful in 53s
The pipeline has been red on every run since at least ae0c34f. npm ci
installs dependencies but the Prisma client is generated into
src/generated/prisma, which is gitignored — so a fresh CI checkout has no
client and anything importing src/lib/db.ts fails with 'Cannot find package
@/generated/prisma/client' before a single assertion runs.

prisma generate reads only the schema, so it needs no database and no
secrets.

Worth noting why this went unnoticed for a dozen commits: a pipeline that
is always red carries no signal, so it stopped being read.
2026-07-27 23:23:32 +10:00
siddharthd ae23b03d5d fix(trips): carry the currency and reconcile rules into the trip figure
ci / lint-test (pull_request) Failing after 43s
ci / lint-test (push) Failing after 43s
a4ab543 landed six hours ago and this branch rewrote one of the queries it
had just fixed, quietly dropping both of its guarantees.

That commit made EXCLUDE_RECONCILED_SOURCE "one fragment both sides import"
because an inlined copy is how the reconciled-row exclusion drifted out of
the analytics routes and double-counted 48 rows / $4,474.79. The trip owed
query here had hand-inlined its own copy — the fragment assumes the alias
`t` and this query used `tx`, so the path of least resistance was to
re-create exactly the divergence that was being removed. Aliased to `t` so
the fragments apply directly.

The same commit made balances count rows whose AUD value is unknown rather
than netting a foreign figure against AUD ones. The trip figure had no
equivalent — on the query where it matters most, because a trip is where
foreign rows actually live. A Europe total silently mixing EUR into AUD is
the whole failure that fix was written to prevent.

The column header still read "Share of this trip" while the number is now
net of payments, which is the same class of drift a4ab543 set out to fix.
It reads "Outstanding on this trip", carries the approx/unconverted caveat
the Shared cards use, and greys a settled zero.
2026-07-27 23:15:28 +10:00
siddharthd 689fadc8b9 feat(shared): give a payment a tab to settle
A payment has only ever recorded from, to, amount and date. That is why
the per-trip owed figure did not exist — getTripAnalytics said so where
the number should have been: "split_payments carries no trip attribution,
so a payment cannot be assigned to a trip. Settlement is a property of the
whole relationship." Every trip therefore read 100% unsettled, including
trips paid in full.

It is also why the Shared page silently drops payments under a tag filter.
With one global pool there was nothing honest to subtract, so it showed
gross splits under the same label. A tag is a view; a scope is a ledger.

The scope is a trip, not a new settlement_contexts table. trips already
has owner_id, dates and archived, and transaction_overrides.trip_id
already decides membership. A second grouping beside it would be two
unsynchronised scopes over the same rows, with no invariant saying which
governs. NULL means the ongoing household tab, which never closes.

settled answers a different question and the two must not be collapsed:
trip_id is which tab, settled is whether the obligation is still live.
Critically, a live obligation is NOT settled by flipping the flag — it is
settled by recording the payment, and the balance nets to zero on its own.
Doing both would subtract the settlement twice. So settled is written only
by the historical import, for repayments made on a platform we no longer
run, and there is deliberately no "mark settled" action.

Both owed figures now exclude settled splits and the trip figure nets its
own payments. Spend analytics (myShare/mySplitOf) deliberately still count
settled rows: my half of a 2025 grocery shop is my spend whether or not the
other half was ever repaid, and filtering them would re-inflate exactly the
figures importing settled history exists to correct.

Also drops /api/participants/[id]/balance. It had no consumers, no owner
scoping, no debit/credit signs and no EXCLUDE_RECONCILED_SOURCE — a fourth
balance implementation that disagreed with the others and would have
imported three bugs if anything had aligned to it.

getTripAnalytics had no test at all. It has five now, including the one
that matters: a household payment must not make a trip look paid. Verified
by mutation — neutering the settled filter fails three, and dropping the
trip filter on payments fails that one.
2026-07-27 23:12:05 +10:00
siddharthd a4ab543a6c fix(analytics): make the displayed numbers mean what they say
ci / lint-test (push) Failing after 44s
Six metric-integrity defects from the UI/IA review, plus two found while
verifying the review's own claims against the code.

The reconciled-row exclusion existed only in queries.ts. Every analytics
route counted the superseded manual rows as spend — 48 rows, $4,474.79 of
double count, invisible precisely because the transaction list looked
right. It is now one fragment both sides import.

The spend-pace chart computed its own totals in the browser: gross
amounts, debits only, no personal share, no refunds, fees, interest or
itemised loan repayments. On live data it ended July at $4,747.31 under a
headline reading $3,597.10 — and its own baseline line was drawn from the
split-adjusted monthly totals, so the two series in one chart disagreed
with each other. Both now come from /api/analytics/daily, built from the
same fragments as the headline.

Fees aggregated every statement ever imported with no date filter, under
a heading with no period, so a lifetime figure read as a current one and
grew forever. Now bounded, labelled, and selectable.

Comparisons no longer measure a month in progress against complete ones:
the in-progress month is out of every baseline, and a selected current
month is compared through the same day.

Two the review did not catch:

- Every analytics window was a day early. toISOString() on a
  local-midnight Date converts backwards through UTC. Surfaced only once
  fees started reporting the range it had used.
- /monthly rounded per category, /daily per category-day, so the pace
  chart ended a few cents off the headline above it.

Shared currency needed amending rather than applying. Reading s.currency
would have labelled every order row AUD, since an order receipt has no
statement and carries its own currency — the opposite convention from a
foreign charge on an AUD statement, where amount IS AUD. NATIVE_CURRENCY's
COALESCE order keeps the two apart. Balances also now count rows whose AUD
value is genuinely unknown instead of netting a foreign figure against AUD
ones. Latent today: no foreign transaction is currently split.

Tag-filtered balance cards no longer claim "owes you". With a filter on,
payments are deliberately not subtracted, so the figure is a split total
and settling against it would record a payment for a debt that never was.

Split-coverage warnings deliberately omitted (user decision).
2026-07-27 17:10:27 +10:00
siddharthd 5ee5ee24cf feat(orders): expand a row to see the receipt it came from
ci / lint-test (push) Failing after 44s
Enrichment is the point of the ingestion pipeline (DECISIONS ING-8) — a bank
statement gives a date, an amount and a mangled descriptor, and everything that
makes a transaction understandable arrives by email. It was all reachable only
by opening the edit modal, which is a strange place to look for "what was in
this order".

Rows with a receipt behind them get a disclosure arrow in the description cell;
clicking expands an inline panel with the line items, the pick-up and drop-off
stops, the card tail and the provider's reference. Several rows can be open at
once — the point is comparing orders without losing your place.

The arrow appears only where `order_platform` is set. Putting one on every
transaction would promise detail that mostly does not exist.

OrderDetails moves out of edit-transaction-modal.tsx into its own component so
both surfaces render the same thing; `bare` drops the modal's top border when
it sits in a table row.
2026-07-27 11:52:33 +10:00
siddharthd 3bb67f370d feat(orders): show where an Uber trip went, in the list
ci / lint-test (push) Failing after 41s
Five rows all reading "Order - Uber Trip" are indistinguishable — the list gives
you a date and an amount and nothing to tell one ride from another (user,
2026-07-27). Where the trip went is exactly what separates them, and it was
already stored on expense_metadata.route since this morning; nothing in the list
read it.

getTransactions now joins the receipt (both directions — transaction_id OR
matched_transaction_id, since a card-settled order points at the statement line
instead) and the description cell renders "Terminal 2, Melbourne Airport (MEL)
→ 19 Lady Penrhyn Dr" in the same italic sub-line notes use.

Two deliberate limits:

- **A note the user wrote always wins.** This only fills an empty sub-line; it
  never occupies the notes field, which is theirs.
- **Deliveries are excluded.** Their merchant already identifies them, so the
  restaurant's street address would be clutter on every food order. Gated on
  platform = 'uber'.

The summary keeps the first two comma-segments of each address — a truncation,
not a guess about geography. Uber puts the venue or street first, which is the
identifying part; the full stops with their times stay in the title attribute.
2026-07-27 11:43:55 +10:00
siddharthd 6161ddc9de fix(orders): don't restate a platform the merchant already names
ci / lint-test (push) Failing after 41s
Trip rows read "Order - Uber Trip (Uber)". The suffix exists so you can tell
where to go and look; when the merchant is literally "Uber Trip" it says
nothing. What identifies a trip is its two addresses, and those are in the
Order details panel. Existing rows updated in prod.
2026-07-27 11:33:37 +10:00
siddharthd c656f5d26b feat(orders): read Uber trips, and reject the charge summary that duplicates them
ci / lint-test (push) Failing after 45s
Local rides are paid with credits (only overseas ones go on a card), so trips
belong to this slice and were simply never fetched — the Graph query searched
"order with Uber", the Eats subject. Captured 15 real messages from the mailbox
via a dry-run before touching anything, which found two defects that no amount
of reasoning about the template would have:

**Uber sends two mails per trip.** A "charge summary" when the ride ends, then
the real receipt when payment settles — same subject, same total. The summary
carries no tripReference, so order_reference fell back to `msg:<message-id>`
and I7 could not dedupe it against the receipt that follows. Every trip would
have been recorded twice. It says so itself ("This is not a payment receipt ...
You will receive a trip receipt when the payment is processed"), so it is now a
NotAReceiptError — 200 and silent, like every other expected non-receipt.

**Trip receipts label neither end of the journey.** Delivery receipts write
"1:20 pm - Pick-up"; trips print the time alone. The split regex put the time
into `label` and left `time` null. Time is now read properly, and a two-stop
trip is labelled Pick-up/Drop-off positionally — only where the receipt was
silent, so a template that does label its stops keeps its own wording.

Verified against all 15 captured messages: 7 trips recorded, 5 charge summaries
and 3 promotions skipped, 0 failures, no duplicate references. Two of the seven
are AUD credits-funded ($84.78 + $47.97) and would become transactions; the
five NZD ones are card-settled and correctly create provenance only (I5).

Fixtures ut-00 (local credits trip), ut-01 (overseas card trip) and ut-summary
(the charge summary) are captured mail, not written by hand.
2026-07-27 11:21:13 +10:00
siddharthd 4febf38292 test(orders): clean statement fixtures before ingest, not after
ci / lint-test (push) Failing after 40s
These tests insert a Westpac statement and a `DD *DOORDASH ...` charge, and
only removed them at the end of the test — so they survived into the next run,
where `reconcileCardLeg` could match one at ingest time and resolve an order
that was meant to park `awaiting_card_statement`.

That is a real ordering bug in the fixtures regardless. It is my best
explanation for the intermittent failure in "parks an unresolvable split",
but I could not reproduce it: seeding the exact leftover row and running the
old code passed anyway. So this is hygiene with a plausible mechanism, not a
confirmed fix — if that test fails again, this was not the cause.
2026-07-27 11:01:23 +10:00
siddharthd b6cd62f7b5 feat(orders): show the receipt in the transaction detail panel
ci / lint-test (push) Failing after 47s
`expense_metadata` has held the itemised receipt since ingestion started and
nothing in the UI ever read it. A transaction that came from a DoorDash or Uber
Eats receipt showed a merchant and an amount, with the item list and the
delivery addresses sitting unread in the row behind it (user, 2026-07-27).

Adds GET /api/transactions/[id]/order and an "Order details" section in the
edit modal: line items with their options, pick-up/delivery stops with times
and addresses, the card tail when one was involved, and the provider's own
order reference.

Two details that matter:

- The lookup resolves from **both** sides — `transaction_id` OR
  `matched_transaction_id`. A card-settled order creates no transaction of its
  own (I5); the receipt points at the statement line instead. Matching only on
  transaction_id would have left the panel blank on exactly the card-paid
  orders, which are the ones whose detail is hardest to find elsewhere.
- An empty item list says so in words rather than rendering nothing. Uber
  itemises groceries but not restaurant orders, and orders ingested before the
  Uber item parser existed have none either — a blank section reads as a bug
  when it is usually the receipt.

Read-only. This is what a provider sent; editing it would make provenance mean
nothing.
2026-07-27 10:55:57 +10:00
siddharthd df4b875b82 feat(orders): make an ingested order legible in the transactions view
ci / lint-test (push) Failing after 43s
Four things the view could not tell you, all from reading the rows (user,
2026-07-27).

**Which platform.** The parser has always known — it has to, to read the
template — and then discarded it. "Order - Burger Corner" gives no way to know
whether to open DoorDash or Uber Eats for the detail, and restaurants exist on
both. Now stored on expense_metadata and named in the description:
"Order - Burger Corner (Uber Eats)". Migration 0021 recovers it for the 101
backfilled rows from the order_reference shape — DoorDash receipts carry no id
of their own so ingestion synthesises `msg:<message-id>`, Uber carries a real
trip UUID, which makes the discriminator exact.

**Bank said "Manual".** That label is derived, not stored, and "Manual" reads
as "hand-entered, still awaiting a card line to match". A gift-card order has
no card line coming, ever. It now reads "Gift Card", and — the part that
actually mattered — credits joins cash in needsCardMatch(), so these stop
sitting in the pending-reconciliation queue. All 81 were queued against a match
that could not exist.

**Uber line items were never parsed.** 67 of 101 orders had none. Uber itemises
groceries but not restaurant orders, so some of that is genuine; the rest was
simply unread. Its markup is better than DoorDash's — every cell carries a
data-testid with the item's uuid, so qty/title/amount bind by id rather than by
column position. Sold-out items (0.00) are kept: they are why a total is lower
than what was ordered.

**Uber prints pick-up and delivery addresses on every receipt** and they were
thrown away. Captured as `route` [{label, time, address}], de-duplicated
because the template renders the whole block twice for narrow screens. Wording
is kept as printed ("Pick-up" on some receipts, "Pickup" on others) rather than
normalised, so a template change stays visible. This is the same block a *trip*
receipt uses for start and destination — rides are not ingested today, but the
reader will not need changing when they are.

Also stores source_email_subject/from, which order ingestion had left null on
columns that already existed.

Verified against the captured corpus: route on all 6 Uber fixtures, 5/5 items
on the GLOMARK grocery receipt including the sold-out one. Production data
updated by smarthome:docker/scripts/order-presentation-2026-07-27.sql
(81 descriptions, `backfill` tag, re-run clean). `route` and Uber line items
are parsed from here on only — recovering them for already-ingested orders
means re-reading the mail, which I7 idempotency refuses by design.
2026-07-27 10:51:30 +10:00
siddharthd ae0c34fce7 fix(orders): two defects the backfill exposed that tests could not
ci / lint-test (push) Failing after 1m26s
Both were found by looking at the data after the live backfill, not by the
suite — 105 tests were green while 85 rows were invisible and 4 were double
counted.

owner_id was NULL on every ingested order. Analytics scope on
COALESCE(t.owner_id, s.owner_id), and an ingested order carries no statement,
so the coalesce resolved to NULL and matched no owner. The rows existed in
`transactions` and appeared in no view in the app. Ingestion now sets
DEFAULT_OWNER_ID, and a regression test asserts the row survives the same
COALESCE scoping the UI uses.

[Family] orders are card-settled, not credits-funded. Their receipts name the
payer ("Payments Siddharth LKR 3,783.20") and no instrument, which an earlier
version read as credits. The card statement carries all four of them (CBA
...3893, exact foreign_currency_amount matches), so creating a transaction
duplicated spend already recorded — the double-count I5 exists to prevent.
They now record provenance only; the statement line is the transaction and is
what carries the `family` tag that keeps them out of budgets.

Production data corrected separately by
smarthome:docker/scripts/fix-order-backfill-2026-07-27.sql.

Also: reconciliation tests no longer assert global row counts.
reconcilePendingOrders() scans every pending row, so leftovers from other
files moved the totals — the source of an intermittent failure that only
appeared on the first run after a source edit.
2026-07-27 10:41:10 +10:00
siddharthd 5db42f086f fix(orders): match the card leg by masking, not by card brand
ci / lint-test (push) Failing after 45s
Backfill dry-run over 130 real messages surfaced one 422: 'payments sum to 1.17
but receipt states 16.50'. The receipt is a mixed Uber payment —
Uber Cash $1.17 + Westpac ••••8032 $15.33 — and the card regex only matched
Visa|MasterCard|American Express|Amex, so an issuer-named leg was dropped
entirely. validateOrderTotals correctly refused it rather than recording $1.17
as the cost of a $16.50 order.

Anchors on the ••••NNNN masking instead, which also covers the form already
seen in the corpus ('Mastercard ••••3893 (CBA Ultimate) CHF 51.23'). Fixture
and regression test added.

Also repoints .env.test at the current postgres-personal container IP and
documents why: the container publishes no host port, so the address changes on
every recreate and the whole integration suite fails with connection errors
until it is refreshed.
2026-07-27 01:39:42 +10:00
siddharthd 1103397397 fix(orders): three defects found reviewing my own branch
ci / lint-test (push) Failing after 1m27s
None of these were caught by 105 green tests, because the code they live in was
barely tested and the HTTP path was not tested at all.

1. reconcilePendingOrders hardcoded category 'dining', so any order resolved
   through the deferred path booked as dining regardless of merchant — a
   Woolworths grocery order that parks and later reconciles was misfiled.
   That reintroduced, through the back door, exactly the misfiling
   resolveCategory() exists to prevent. Now calls it.

2. reconcileCardLeg never marked a statement line as consumed, so two orders on
   the same card inside the +/-4 day window both bound to the same charge and
   each booked its own credits remainder — double-counting spend. At 10-15
   orders a month on one card that is not a corner case. Migration 0020 adds
   matched_transaction_id with a unique index; the matcher now excludes lines
   already claimed.

3. The ingest API returned HTTP 200 for every parse failure, and the Slack
   alert fires only on non-200. So the single most likely production failure —
   a provider template change breaking every order at once — was completely
   silent. Split into NotAReceiptError (promotions, delivery updates, refund
   and adjustment notices: 200, silent, expected traffic) and OrderParseError
   (it IS a receipt and would not parse: 422, alerts).

Also: order_reference now anchors on Uber's own tripReference cell rather than
'first UUID in the document'. I had claimed to verify that the first UUID was
always the order UUID; that check compared against zero samples and was
vacuous. tripReference is present in all 29 captured receipts and, for ue-00,
equals the UUID the PDF redirect resolves to. The positional fallback remains
but only flags when there is genuine ambiguity.

Adds the API route's first tests — auth gate and error taxonomy — plus
anchoring regressions. 63 unit + 53 integration green on five consecutive runs;
corpus holds at 63/65.
2026-07-27 01:15:39 +10:00
siddharthd a9e251d969 feat(orders): amendments, family imports, and the ingest API
Closes the three gaps left after the parser rebuild.

Refund amendments. ue-05 is a real refund: 'Previous total $49.94 / Refund
-$4.21 / New Total $45.73'. Uber reuses the order UUID across the receipt and
the amendment, so the two can be matched. The transaction is reduced in place
rather than offset with a second row — the order is one event whose cost
changed, and a compensating row would misreport both the meal count and the
merchant's spend. When the original was never ingested, nothing is invented.

[Family] orders now import instead of parking. Their payment line names the
payer, not an instrument ('Payments Siddharth LKR 3,783.20'), so no split is
recoverable and there is no card leg to reconcile against — they would have sat
pending forever, which fails the actual requirement to import and tag them.
Treated as credits, flagged as an assumption. Safe because the family tag
removes them from every budget regardless of instrument, and the LKR amount is
preserved with amount_aud left NULL rather than asserting an FX rate.

Ingest API. n8n now POSTs each message to /api/orders/ingest instead of parsing
in a Code node — the n8n sandbox has no require or fs, so a parser there cannot
be tested against the fixture corpus, which is the one thing that makes this
parser trustworthy. Auth is a shared secret, since machine callers have no
Traefik session header. Rejections return 422 and record nothing.

60 unit + 45 integration green on three consecutive runs; 63/65 corpus holds.
2026-07-27 00:48:20 +10:00
siddharthd c82a22767f feat(orders): wire the real parser in, defer card reconciliation
Ingestion now runs on the rebuilt parser. Three substantive changes.

Deferred card reconciliation. A 'MasterCard 8032 and/or credits' receipt never
states the split, but the card leg lands on the statement — Subway's $29.08
order shows $13.06 on 8032, so $16.02 was credits. For a live order that
statement is weeks away, so the split cannot be settled at ingest time. Such
orders are now parked with provenance and no transaction, and
reconcilePendingOrders() resolves them once the statement arrives. Backfill
takes the same path and resolves immediately. Migration 0019 adds the columns
that make an order resumable; applied to personal_test only, prod untouched.

Payment detection bug, found by the new tests: the old regex delimited the
'Paid with' line on a double space, which whitespace collapsing removes. Every
card and mixed receipt fell through to the credits branch — the Woolworths
receipt booked $60.93 of credits spend that never happened.

Category resolution reversed deliberately. Correction 1 said never default to
dining; the implementation of that sent everything unrecognised to 'other', and
knowing six merchants meant Carl's Jr, Taco Bell, Chilli India, Oporto, Schnitz
and Souvlaki GR all landed there. Grocers are an enumerable set and restaurants
are not, so match groceries explicitly and let the residual be dining.

Tests rebuilt on real captured receipts; the synthetic fixtures are deleted.
60 unit + 41 integration green on three consecutive runs.
2026-07-26 22:43:10 +10:00
siddharthd 33db7d05ef feat(orders): rebuild the receipt parser against real captured email
The previous parser was written against synthetic fixtures shaped to match the
code. It invented a table layout DoorDash does not send, generated
order_reference from Math.random(), read the order date from a 'Date:' string
present in no real message, and detected [Family] by searching the body for the
substring 'family'. Its tests passed because the fixtures were built to satisfy
it. Against 36 real DoorDash and 29 real Uber Eats receipts it does not work.

Rebuilt from the real corpus. 63 of 65 now parse and validate; the 2 rejected
are correctly rejected — one is an order-adjustment notice and one a refund,
neither of which is a receipt.

Corrects an inherited diagnosis: the Mad Mex 'Discounts -$24.09' was recorded
as an HTML-flattening artefact masking a 'true discount of $9.45'. Parsing the
table cells structurally returns the same figures and no $9.45 exists anywhere
in the message — DoorDash genuinely prints a Discounts line that equals subtotal
plus service fee, and the components fail to reconcile on 32 of 36 receipts. So
the breakdown is stored as provenance and never gated on; validation instead
cross-checks the two independently stated totals and the payment line, which is
the number that becomes money.

Real-world cases the corpus forced, none of which were in the spec: [Family]
orders are LKR purchases for family in Sri Lanka (reading them as dollars
inflates ~200x), Swiss orders arrive in CHF, grocery 'Final receipt' mails carry
no Total Charged row, and a declined payment is printed alongside the successful
retry and must be skipped or it records money that never moved.

order_reference now comes from the Uber order UUID embedded in the body, or the
provider message id where DoorDash supplies no order id at all — never random,
so re-ingestion is genuinely idempotent.
2026-07-26 22:25:26 +10:00
siddharthd 6d3b6e1a9d feat(orders): withdraw the ShopBack transfer guard
Tests 12 & 13 asserted that every 'ShopBack Gift Cards' row becomes a transfer.
Resolved against the ShopBack purchase emails, 3 of the 14 matching rows are
Airbnb, 1 Shell, 1 Amazon — the bank descriptor's trailing token is a sequence
counter, not a brand code, so the description cannot identify what was bought.
Only $313.66 of $3,411.16 was ever reclassifiable.

Withdrawn rather than narrowed: making it safe needs ShopBack purchase-email
ingestion, brand resolution and an approval gate, to correctly handle 2
transactions in 20 months. Those two rows get handled by hand.
2026-07-26 22:09:31 +10:00
siddharthd 9f168cf4c8 test(orders): stop integration files racing on the shared test database
The integration suite shares one personal_test database and helpers.resetDB()
TRUNCATEs it with CASCADE, which reaches expense_metadata via the transactions
FK. With file parallelism on, queries.test.ts and participants.test.ts were
truncating rows out from under order-ingestion.test.ts mid-test, so a different
set of assertions failed on every run — including I6 (credits), I7
(idempotency) and I11 ([Family]), the three invariants the suite exists to
prove. Serialise the files.

Suite was reported 31/31 green; observed 29/31 then 28/31 on consecutive runs.
Now 31/31 on three consecutive runs.
2026-07-26 19:50:21 +10:00
siddharthd 775e5cc08f feat(orders): add Uber Eats and Uber Rides support, fare parsing, and category resolution 2026-07-26 19:00:59 +10:00
siddharthd bbb90238e4 feat(orders): implement order ingestion pipeline, review ratings schema, and [Family] exclusion 2026-07-26 18:52:34 +10:00
siddharthd d06088fe34 docs: monthly expense baseline and emergency reserve analysis
ci / lint-test (push) Successful in 37s
One-off analysis, nothing built. Realistic baseline $4,140/mo -> $24,800 for
six months, against $89,770 already accessible ($81,017 loan redraw + $8,753
offset).

Records four corrections the raw data needs before any restatement:
misfiled Raiz/Vanguard/moomoo debits counted as spend, `other` credits read as
negative spend, `government` conflating ATO with rates/rego, and `fees` being
mostly annual.

CLAUDE.md gains two traps found while doing it: partial split coverage inside a
category is usually correct rather than a gap (only shared utilities and
subscriptions are split), and the loan repayment is voluntarily above contracted
($2,500 vs $1,190.54 per fortnight) with the difference recoverable via redraw.
2026-07-26 16:57:23 +10:00
siddharthd c465742635 docs: capture this session's learnings for a future pickup
ci / lint-test (push) Successful in 40s
CLAUDE.md gains the traps a new session would otherwise re-discover:

- Rules: a zero-condition rule matches everything (rule 43 would split all ~3,700
  transactions); preview-then-apply-by-id is the safe pattern and why it beats
  auto-applying on ingestion; how run provenance works.
- Shared expenses: transaction_splits.settled is dead data; getParticipantBalances
  is correct and must not be 'fixed'; settlement cannot be attributed per trip.
- The shared loan: separate ledger, fixed 50% with a tracked receivable, why the
  share must not be derived from actual payments, and why interest stays as spend.
- Extraction: balance assertions are the check that works, do not derive
  opening_balance or add a totals assertion (both would be tautological), Gemini
  invents summary fields it was not given, empty statements must not throw, FX is
  per-date, and CSV comparisons need millisecond ordering.

The design doc records Phase 0 as done - including that the original Phase 0 plan
was wrong, since reading the code first is what prevented breaking a working
balance page.

Known Gaps lists what is open: the unbuilt phases, 11 failing assertions, the
uncategorised Up rows, and the CSVs sitting in 030490e's history.
2026-07-26 16:19:02 +10:00
siddharthd 3f04cbd5e7 fix(trips): stop reporting a settlement breakdown that cannot be computed
ci / lint-test (push) Successful in 35s
The trip view showed Total Owed / Settled / Unsettled per participant, with the
last two derived from transaction_splits.settled. Nothing sets that flag - its
only writer was /api/splits/settle, which no UI calls - so it is false on all 673
splits and every trip reported 100% unsettled, including trips already paid in
full. Molina has paid $20,782.79 against $19,556.07 of splits and the Europe trip
still showed her entire share outstanding.

A correct per-trip figure is not computable either: split_payments records only
from, to, amount and date, so a payment cannot be attributed to a trip. The trip
view now shows each participant's share and points at Shared for what is actually
owed, which is where settlement genuinely lives.

Also removes /api/splits/settle. It was unreachable from the UI but live on its
URL, and a single call with participant_id would mark every one of that person's
splits settled - writing a flag nothing reads. Settlement will be reintroduced
against settlement contexts (docs/shared-expenses-design.md).

getParticipantBalances is deliberately untouched: it computes splits minus
payments, which is coherent. Excluding settled splits there while still
subtracting the payments that settled them would double-count.
2026-07-26 16:13:20 +10:00
siddharthd ba87ff86e7 docs: record loan decisions — separate ledger, 50/50 fixed, $4,000 receivable
ci / lint-test (push) Successful in 35s
The loan is a separate ledger, not a settlement context: a contribution must
never be able to settle a dinner.

The share is fixed at 50%, not derived from actual payments. During Sonu's leave
the obligation did not change, only the payment did - a percentage-of-actual
model would silently redefine her share as 30% and make the shortfall vanish. So
the model needs an expected schedule alongside actual contributions, with the
difference as a tracked receivable. Currently $4,000.00 over Jul 2025 - Jun 2026.

On interest: recorded the mechanics (it is debited to the loan and repaid as part
of the balance - the reconciliation is exact) alongside the counter-argument that
$16,523.64 left and bought nothing, which is what an expense is. Recommends
keeping it as spend with a fixed/discretionary grouping to address the real
concern, but flags it as a judgement call rather than settling it.
2026-07-26 16:03:53 +10:00
siddharthd 4b7a7e5d9c docs: design proposal for shared expenses, settlement and the shared loan
ci / lint-test (push) Successful in 35s
Three problems that look separate are one: the app records money moving, and
separately records who owes whom, and the two never meet.

Documents what is broken with evidence - two half-built settlement models,
settlements existing twice unlinked, and Sonu's $37,980 of loan contributions
sitting unrecognised as generic transfers - then proposes settlement contexts,
payments as transactions rather than a side table, and loan co-ownership.

Nothing built. Five open questions, two of which are decisions about the
arrangement rather than the software.
2026-07-26 15:57:03 +10:00
siddharthd 3778bfe836 feat(rules): show what an apply run changed, and which rule ran
ci / lint-test (push) Successful in 38s
Apply History listed only counts - '13 matches · 13 transactions' - which reads
identically whether the run renamed a merchant or split every transaction with
another participant. Revert is destructive, so that is not enough to decide on.

Two additions. Migration 0017 records rule_id, rule_name and source on each run:
rule_name is denormalised so history stays readable after a rule is edited or
deleted, and there is no FK so deleting a rule cannot cascade away the audit
trail. Both write paths now populate it - the condition-matched run and the
selection-based quick action.

And rows expand to show the run's snapshot set against current values: which
transactions were touched and what changed on each. Rows changed by something
else since the run are called out, because reverting restores the pre-run value
and would discard that later edit.

Runs recorded before this show 'Unknown rule' - the rule they came from is not
recoverable.
2026-07-26 15:20:56 +10:00
157 changed files with 27204 additions and 633 deletions
+21 -4
View File
@@ -13,10 +13,27 @@ jobs:
node-version: 22 node-version: 22
- name: Install - name: Install
run: npm ci run: npm ci
# Advisory until the pre-existing lint debt is cleared (2026-07-19: # The Prisma client is generated into src/generated/prisma, which is
# ~20 errors across budget/insights/shared pages) — then make blocking. # gitignored — so a fresh checkout has no client and every test that
- name: Lint (advisory) # reaches src/lib/db.ts dies on "Cannot find package
# '@/generated/prisma/client'". Schema-only, so it needs no database.
# Without this the pipeline had been red on every run since at least
# ae0c34f, which is how the failure stayed invisible: it looked like
# the normal colour.
- name: Generate Prisma client
run: npx prisma generate
# Blocking since #79. It was advisory from 2026-07-19 while 23 errors sat
# across the pages and the integration tests; those are now fixed, so a
# new one must not be able to slip in behind a green tick. Warnings are
# still allowed (eslint exits 0 on them) — there are 9 left, all
# `no-unused-vars` or `exhaustive-deps` advisories, none of which should
# stop a push.
- name: Lint
run: npm run lint run: npm run lint
continue-on-error: true # Typecheck is separate from `next build`: nothing else in CI compiles the
# test files, so a type error in a fixture could only ever be found on a
# developer's machine.
- name: Typecheck
run: npx tsc --noEmit
- name: Unit tests - name: Unit tests
run: npm test run: npm test
+4
View File
@@ -45,3 +45,7 @@ next-env.d.ts
# Raw statement exports — real financial data, never commit # Raw statement exports — real financial data, never commit
dump/ dump/
# Python tooling for scripts/ (split_csv_match.py)
.venv/
__pycache__/
+37
View File
@@ -0,0 +1,37 @@
# Repository Guidelines
## Project Structure & Module Organization
Application code lives in `src/`. Next.js App Router pages and API route handlers belong in `src/app/`; reusable UI components are in `src/components/`; database access, query functions, hooks, authentication, and domain helpers are in `src/lib/`. Tests are separated into `src/__tests__/unit/` and `src/__tests__/integration/`. PostgreSQL schema and numbered SQL migrations live under `prisma/`, static assets under `public/`, operational scripts under `scripts/`, and design notes under `docs/`.
Keep data flow consistent: API routes call query functions in `src/lib/queries.ts`, which use `queryRaw()` from `src/lib/db.ts`; client components access APIs through TanStack Query hooks in `src/lib/hooks.ts`.
**Task tracking is the Vikunja board at `https://tasks.bosecamp.com`** (project *Work*, saved filter **finance-app**), which replaced the smarthome repo's `ACTIONS.md` on 2026-07-30. Update the ticket in the same change as the code. See `CLAUDE.md` → "Work tracking".
## Build, Test, and Development Commands
- `npm ci` installs the locked dependency set (Node 22 is used in CI).
- `npm run dev` starts the local Next.js development server.
- `npm run build` creates a production build; `npm start` serves it.
- `npm run lint` runs the Next.js ESLint configuration. **Blocking in CI since #79** — the pre-existing debt is cleared, so it must stay at zero errors (warnings are still allowed).
- `npx tsc --noEmit` typechecks, including the test files. Also blocking in CI.
- `npm test` runs fast unit tests.
- `npm run test:setup` creates/refreshes the `personal_test` database (run it once, and after any migration).
- `npm run test:integration` runs database-backed tests. It re-resolves the DSN from the live container first (`scripts/resolve-test-db.sh`), so `.env.test` must never carry a hand-written host — see `CLAUDE.md` → Testing.
- `npm run test:all` runs both test suites.
## Coding Style & Naming Conventions
Use strict TypeScript, two-space indentation, semicolons, and double quotes, matching existing files. Name React components and types in PascalCase, functions and variables in camelCase, and files/routes in kebab-case. Use the `@/` alias for imports from `src/`. Preserve owner scoping and prefer transaction overrides with `COALESCE` in financial queries. Every API route must authenticate before accessing data.
## Testing Guidelines
Vitest is the test framework. Name tests `*.test.ts` and place pure logic tests under `unit/`; put PostgreSQL-dependent behavior under `integration/`. Add regression coverage for query, rule, reconciliation, and category changes. No numeric coverage threshold is configured; focus on meaningful edge cases and run `npm run test:all` before submitting database-related changes.
## Database, Security & Configuration
Add schema changes as the next numbered `prisma/migrations/NNNN_description/migration.sql`. Never commit `.env`, `.env.test`, raw statements in `dump/`, or other financial data. Consult `CLAUDE.md` and relevant `docs/` notes before changing splits, settlements, loans, reconciliation, or statement accounting.
## Commits & Pull Requests
History follows concise Conventional Commit-style subjects such as `feat(rules): preview rule changes`, `fix(trips): ...`, and `docs: ...`. Keep commits focused. Pull requests should explain behavior and data-model impact, link related issues, list validation commands, and include screenshots for UI changes. Ensure lint, typecheck, unit tests and the production build pass; call out any migration steps.
+1055 -6
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -282,6 +282,18 @@ All routes require authentication via `X-Forwarded-User` header (set by Traefik)
| GET / POST | `/api/tags` | List or create tags | | GET / POST | `/api/tags` | List or create tags |
| PATCH / DELETE | `/api/tags/[id]` | Update or delete a tag | | PATCH / DELETE | `/api/tags/[id]` | Update or delete a tag |
**Machine routes** — shared-secret `x-ingest-token` header instead of `X-Forwarded-User`; each fails closed when its token is unset:
| Method | Route | Token | Description |
|--------|-------|-------|-------------|
| POST / PATCH | `/api/orders/ingest` | `ORDER_INGEST_TOKEN` | n8n order receipts → transactions (see smarthome CLAUDE.md "Order Processor") |
| POST | `/api/receipts/ingest` | `RECEIPT_INGEST_TOKEN` | pantry-app confirmed grocery receipts → one transaction per tender leg (ING-10) |
| POST | `/api/intake` | `INTAKE_TOKEN` | **Intake router** (2026-08-21): multipart `source` (`uber_eats\|doordash\|coles\|woolworths\|other`), `shared_by`, `batch_id` (uuid), 1–6 `file`s (image / PDF / text). Persists `intake_batches`, extracts once via LiteLLM `cloud-budget` (PDF → text via `unpdf`), matches `(platform, merchant, date, total)` exactly, then enriches the matched order's `line_items` (food), forwards the original files to pantry `POST /api/capture` (grocery), or replies with candidates / "Groceries or a meal?" / needs-header / held / duplicate. Returns `{kind, status, transaction_id?, capture_id?, candidates?, slack_blocks, text}` |
| PUT | `/api/transactions/[id]/line-items` | `INTAKE_TOKEN` or `RECEIPT_INGEST_TOKEN` | Idempotent replace of `expense_metadata.line_items` for `transaction_id = :id OR matched_transaction_id = :id`; body `{line_items, source: 'intake'\|'pantry', batch_id?, platform?, replace: true}`; creates the metadata row if absent, never a transaction (404). Returns `{previous_line_items, line_items}` — pantry calls it on every confirm and with `previous_line_items` on undo |
| POST | `/api/slack/interactive` | `ORDER_INGEST_TOKEN` + Slack signature | Order-nudge buttons and the intake taps (`intake_use_candidate`, `intake_create`, `intake_grocery`, `intake_meal`) |
Env for the intake router: `INTAKE_TOKEN`, `LITELLM_BASE_URL` + `LITELLM_KEY` (OpenAI-compatible, alias `cloud-budget`), `PANTRY_BASE_URL` (+ the existing `RECEIPT_INGEST_TOKEN` as pantry's machine auth), optional `INTAKE_FILE_DIR` (original files by sha256, default `/app/data/intake` — a later "Groceries" tap re-sends them) and `FINANCE_PUBLIC_URL` (the card's View link, default `https://finance.bosecamp.com`). Design: smarthome `plans/intake-router-slice-plan.md` §11.
--- ---
## Ingestion Pipeline ## Ingestion Pipeline
@@ -347,6 +359,8 @@ docker exec postgres-personal psql -U personal -d personal \
| `0009_split_payments` | `split_payments` | | `0009_split_payments` | `split_payments` |
| `0010_csv_import_reconcile` | `reconciled_with_id`, CSV import support | | `0010_csv_import_reconcile` | `reconciled_with_id`, CSV import support |
| `0011_trips` | `trips`, `trip_id` on `transaction_overrides` (catch-up — was applied directly) | | `0011_trips` | `trips`, `trip_id` on `transaction_overrides` (catch-up — was applied directly) |
| … | |
| `0034_intake_batches` | `intake_batches` (one row per `/api/intake` upload, written before extraction) and `intake_merchant_kinds` (the remembered "Groceries or a meal?" answer per merchant) |
> `paperless_doc_id` on statements and the `uq_statements_paperless_doc_id` index were added directly (not tracked in a migration file). > `paperless_doc_id` on statements and the `uq_statements_paperless_doc_id` index were added directly (not tracked in a migration file).
> `owner_id` on transactions and `statement_id` made nullable were applied directly (March 2026) to support manual transaction entry without a fake statement. > `owner_id` on transactions and `statement_id` made nullable were applied directly (March 2026) to support manual transaction entry without a fake statement.
+211
View File
@@ -0,0 +1,211 @@
# Monthly expense baseline and emergency reserve
Analysis run 2026-07-26. **One-off analysis, not a feature** — nothing in the app
computes these numbers. Read "Reproducing this" before trusting a restated figure.
The question: how much should be held in reserve to cover 6–12 months of expenses?
## Answer
| Scenario | $/mo | 6 months | 12 months |
|---|---:|---:|---:|
| Survival — contracted loan repayment, essentials only | 3,040 | 18,250 | 36,500 |
| **Realistic — contracted loan, + dining and charity** | **4,140** | **24,800** | **49,600** |
| Status quo — keep overpaying the loan, normal life ex-travel | 5,557 | 33,500 | 67,000 |
Use the middle row. The survival row assumes dining is cut to zero and stays
there, which is not a plan anyone executes for six months.
Against that, liquidity already available (statements 133 and 131, 2026-06-30):
| | |
|---|---:|
| Loan redraw | 81,017.42 |
| Offset balance | 8,753.00 |
| **Accessible** | **89,770.42** |
That is 3.6× the six-month target and 1.8× the twelve-month one. Redraw grew
$62,387.17 → $81,017.42 across the last two loan statements, matching the
overpayment rate — the money spent killing the loan faster is still reachable.
**Caveat on counting redraw as the reserve.** It is available at AMP's
discretion, and lenders reduce or freeze it exactly when a borrower looks
distressed — which is when it would be needed. The rate also moved 5.54% → 6.29%
between the two statements, so redrawn funds cost more than they did. Hold some
genuine cash; it does not need to be $50,000.
## This is *your* outgoings, not household spend
The app only sees accounts that get imported. Sonu's own spending on the
household is invisible to it. Grocery spend reads as ~$300/mo gross on 4–10
transactions, which is implausible for a household and is partly explained by her
paying from her own account.
For "how much reserve do **I** need" that blind spot does not matter — your own
outgoings is the correct measure. Do not relabel these figures as household
totals; they are not, and they would be wrong by an unknown amount.
## The loan has two floors
This is the largest single lever and the reason there are three scenarios.
| | $/fortnight | $/mo annualised | Your 50% |
|---|---:|---:|---:|
| Contracted minimum | 1,190.54 | 2,579.50 | **1,290** |
| Actual direct debit | 2,500.00 | 5,416.67 | **2,708** |
| Voluntary overpayment | 1,309.46 | 2,837.17 | 1,419 |
Dropping to contracted cuts your loan cost by $1,418/mo. Sonu's obligation is a
fixed 50% of the repayment (see CLAUDE.md → "The shared loan"), so it falls with
it. Her rate returned to $1,250/fortnight in July 2026 after the reduced $750
period during her leave.
## Baseline composition
Built from **Feb–Jun 2026** — the months where split data is trustworthy — with
annual items annualised over 12 rather than divided by the 5-month sample.
| Essential | $/mo | Note |
|---|---:|---|
| Loan (contracted, your 50%) | 1,290 | 2,708 at the current actual rate |
| Transport | 326 | |
| Insurance | 280 | annualised; your 55% |
| Utilities | 278 | shared energy/water + personal mobile |
| Subscriptions | 204 | shared household + personal/AI |
| Groceries | 200 | see the blind-spot note above |
| Card + package fees | 175 | annualised — see below |
| Health | 167 | |
| Rates + rego | 122 | annualised, your share |
| **Essential** | **3,042** | |
| + dining 675, charity 422 | 4,139 | charity is a Smith Family sponsorship commitment |
| + typical shopping | ~4,800 | median 657, **not** the 1,709 mean |
**Travel is excluded throughout.** At $3,153/mo of your share even post-cutover it
would roughly double every figure, and it is the first thing that stops.
## Four corrections the raw data needed
Any restatement that skips these will be wrong. None are fixed in the data yet.
**Micro-investing counted as spend.** Raiz ($9,406 / 27 rows), Vanguard Super
($500) and moomoo ($300) sit in `other` as debits — $10,206/yr, ~$850/mo of
phantom spend. These belong in `investment`, which is already excluded.
**Incoming money counted as negative spend.** 17 rows in `other` typed `credit`
($5,622 in the window). `SPEND_SIGNED` negates credits so refunds cancel
purchases, but these are not refunds — they are money arriving. June 2025 shows
*minus* $7,814 of total spend because two Wise credits of ~$16.7k each landed in
`other`.
**`government` is two unrelated things.** $25,554 of ATO payments (one annual
bill, routed through Zen B2B and RewardPay to earn points) versus $2,054 council
rates and $875 rego. Tax is not a monthly living cost and falls with income
anyway; rates and rego are non-negotiable. Splitting them moves this line from
$2,411/mo to $244/mo.
**`fees` is mostly annual.** Of $2,599 post-cutover, $1,750 is an annual card fee
and $349 a loan package annual fee. Recurring is ~$175/mo annualised, not the
$520/mo the 5-month mean implies.
## Splits: what is trustworthy and what is not
Splits exist in this app from **2026-01-09** only; before that they were tracked
in SplitMyExpenses. So a trailing-12-month per-person series splices six months
of *gross* onto six months of *net* and is not a series at all. Use Feb–Jun 2026.
**Partial split coverage within a category is usually correct, not a gap.** This
was misdiagnosed once during the analysis. Verified composition:
| Category | Your share | Split | Unsplit |
|---|---:|---|---|
| utilities | 69% | Globird, OVO, GWW, home telecoms | Telstra, Vodafone, Optus, JB Hi-Fi Mobile |
| subscriptions | 91% | Uber One, Amazon Prime, OnePass | Claude, OpenAI, Anthropic, OpenRouter, You.com, LinkedIn, Xero, Billdu, Spotify, Patreon |
| fees | 100% | — | credit card fees are personal |
| charity | 100% | — | personal commitment |
Only *shared* utilities and subscriptions are split. Mobile bills, AI
subscriptions and card fees are personal and correctly sit at 100%. Do not
"fix" these ratios.
## Known weak spots
- **`other` is $424–773/mo of your share and unclassified.** The single biggest
lever on accuracy. Clearing it with the rule preview
(`GET /api/rules/[id]/matches`) improves every other view at the same time.
- **Five months is a short sample**, and it contains the Europe trip and the ATO
bill. Both are excluded, but they crowd out the ordinary months. The
SplitMyExpenses CSVs would stretch it to 18 months — worth having, not
blocking, since only an *aggregate ratio per category* is needed, not row
matching, so the combined-transaction problem does not bite.
- **`/api/analytics/monthly` does not filter `reconciled_with_id IS NOT NULL`.**
48 rows are double-counted app-wide. Small, but real, and unrelated to this
analysis. The queries below do filter it.
## Reproducing this
Working queries are not checked in; they were run ad hoc against
`postgres-personal`. The shape that matters:
```sql
-- Per-category monthly distribution, your share, Feb-Jun 2026.
-- Mirrors src/lib/analytics-sql.ts, plus the corrections above.
WITH s AS (
SELECT to_char(t.transaction_date,'YYYY-MM') m,
COALESCE(o.category_override, t.category, 'other') cat,
SUM((CASE WHEN t.transaction_type IN ('refund','credit') THEN -1 ELSE 1 END)
* (CASE WHEN t.interest_amount IS NOT NULL
THEN t.interest_amount ELSE COALESCE(t.amount_aud, t.amount) END)
* COALESCE(ts.share_percent, o.my_share_percent,
100 - COALESCE((SELECT SUM(x.share_percent) FROM transaction_splits x
WHERE x.transaction_id = t.id
AND x.participant_id <> 1), 0)) / 100)::numeric(12,2) amt
FROM transactions t
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = 1
LEFT JOIN statements st ON st.id = t.statement_id
WHERE COALESCE(t.owner_id, st.owner_id) = 1
AND t.reconciled_with_id IS NULL -- see weak spots
AND ((t.transaction_type IN ('debit','fee','interest')
OR t.interest_amount IS NOT NULL)
OR t.transaction_type IN ('refund','credit'))
AND COALESCE(o.category_override, t.category, 'other')
NOT IN ('transfers','investment','income')
-- correction 1: misfiled investments
AND COALESCE(t.merchant_normalized, t.merchant_name, '')
!~* '^(Raiz|Vanguard Super|moomoo)'
-- correction 2: incoming money miscategorised as 'other' credits
AND NOT (COALESCE(o.category_override, t.category, 'other') = 'other'
AND t.transaction_type = 'credit')
AND t.transaction_date >= '2026-02-01' AND t.transaction_date < '2026-07-01'
GROUP BY 1, 2
)
SELECT cat, count(*) mths, round(avg(amt),0) mean,
round(percentile_cont(0.5) WITHIN GROUP (ORDER BY amt)::numeric, 0) median
FROM s GROUP BY cat ORDER BY mean DESC;
```
Use the **median** for anything recurring monthly and the **annualised total** for
lumpy-but-certain items (insurance, rates, rego, annual card fees). Means are
badly skewed here — shopping is 1,709 mean against 657 median.
Corrections 3 and 4 (`government`, `fees`) are not expressible as filters; they
need the category split by merchant, done by hand above.
## If this becomes a feature
The blocker is `other`, not the split model — the split data held up under
scrutiny. Sequence:
1. Recategorise Raiz / Vanguard Super / moomoo to `investment` (27 rows,
unambiguous) via the rule preview.
2. Split `government` so rates and rego separate from ATO. Probably a new
category or a tag; `government` conflates a recurring obligation with an
annual tax bill.
3. Work `other` down with the rule preview.
4. Then a baseline panel on Insights, which already carries the
Regular/Occasional split this analysis is a stricter version of
(`REGULAR_CATEGORIES` in `src/lib/categories.ts`).
A **liquidity vs baseline** view would be genuinely new rather than a restatement:
`statements.redraw_available`, offset closing balance, and the
contracted-vs-actual repayment gap are all in the schema and nothing reads them.
+246
View File
@@ -0,0 +1,246 @@
# Shared expenses and settlement
Status: **built and live**, as of 2026-07-28. The loan section at the end is
still a proposal — nothing there is built.
This replaces the 2026-07-26 proposal. That document described three problems
and proposed a `settlement_contexts` table to solve them. The problems were
real; the table was not built, and the reasoning for not building it is
recorded under [What was rejected](#what-was-rejected).
---
## The one rule
**Spend and owed are different questions asked of the same table, and the line
between them is the cutover date, refined by `transaction_splits.settled`.**
`ACTIVE_OBLIGATION` is `ts.settled = false AND t.transaction_date >=
'2026-01-09'`. Nothing before the cutover can be owed, because carryover
transaction 2348 already carries the entire pre-cutover balance as one figure —
so a split on an older transaction describes only *how an expense was shared*.
That is what makes splitting history safe, and it is why the flag is a
refinement rather than the guard: any delete-and-recreate write path resets a
boolean, and one did.
| | Counts settled splits? | Why |
|---|---|---|
| **Spend** (`myShare`, `mySplitOf`) | **Yes** | Half a 2025 grocery shop was my expense whether or not the other half was ever repaid. |
| **Owed** (balances, trip figures) | **No** | A discharged obligation is not outstanding. |
Getting this backwards in either direction is the failure the model exists to
prevent. Filtering settled rows out of spend would re-inflate exactly the
figures that importing settled history exists to correct.
The predicate is `ACTIVE_OBLIGATION` in `src/lib/analytics-sql.ts`.
## Two orthogonal axes
`settled` and `trip_id` answer different questions and neither implies the
other:
- **`transaction_splits.settled`** — *is this obligation still live?*
- **`split_payments.trip_id`** — *which tab does this payment settle?*
NULL means the ongoing household tab.
A trip can be fully paid while the household tab runs a balance, and vice
versa. Before migration 0022 there was one global pool and this could not be
expressed, so every trip reported 100% unsettled forever — including trips paid
in full.
## How settling up actually works
**By recording a payment.** There is deliberately no "mark settled" action
anywhere in the app.
`settled` marks obligations discharged *outside* this app — the imported
SplitMyExpenses history, whose repayments happened on a platform we no longer
run and which therefore have no `split_payments` row here. A live obligation is
settled by recording the payment, and the balance nets to zero on its own.
Doing both would subtract the settlement twice: the splits leave the sum *and*
the payment is deducted, driving the balance negative by the amount repaid.
## What is built
| Piece | Where | Note |
|---|---|---|
| `settled` as the single balance gate | `ACTIVE_OBLIGATION` | Applied in both arms of the balances UNION and in the trip owed query |
| Payment scope | `split_payments.trip_id` (migration 0022) | Household payments do not settle a trip, and vice versa |
| Owner-scoped owed | `OWNER_SCOPE` in the trip owed query | Without it, a debt between the *other two* participants was reported as owed to the owner — $1,605.49 on Europe 2026 |
| Direction on screen | `/trips/[id]`, `/shared` | all square / owes you / ahead — you owe them |
| Historical splits | `scripts/split_csv_match.py` | 1,242 rows across 657 transactions, all `settled` |
| Duplicate suppression | `transactions.superseded_by_id` (migration 0023) | 31 rows, $42,040.68 |
| Overlap detection | `STATEMENT_OVERLAPS` → statements page | Red badge; catches the cause rather than the symptom |
## The historical import
The five SplitMyExpenses CSVs are the record of how expenses were shared before
this app existed. 676 of 1,536 shareable rows matched (44%), and 1,242 split
rows were written as `settled = true`.
**The deliverable is historical spend, not balances.** $35,259 left my spend —
$13,088 in 2024 and $22,117 in 2025 — because a $200 grocery shop that was
always half hers no longer reads as $200 of mine. Balances were byte-identical
before and after, which is the assertion that mattered.
Three things the matcher has to get right, each of which has bitten:
1. **Date format is decided per file.** The household export writes D/M/YYYY and
the four trip exports write ISO; 474 rows parse validly under both readings.
Guessing per row silently swaps January and February for some rows and not
others.
2. **A person's column is net balance impact, not their share.** The payer is
whoever is positive; the other's share is `|their negative| / cost`. So a
`+cost / -cost` row means the other party owes **100%** — not that the
expense was unshared, which is the reading that fakes an arrangement change.
3. **Matching is one-to-one, best pair first.** The NZ trip has two identical
$10.16 Uber rows against three ledger rows; without this a ledger row is
claimed repeatedly while the second CSV row looks matched and is not.
**The 44% is a coverage ceiling, not a matcher weakness.** The CSVs describe 678
shared expenses in 2024; the ledger holds 591 rows for all of 2024, 3 to 72 a
month, far less than a household actually spends. South Korea April 2024 matches
4 of 158. Chasing a higher rate is chasing transactions that were never
imported.
### A reversed recommendation
The 2026-07-26 proposal said, under *What I would not do*: "**Do not** restate
history from the SplitMyExpenses CSVs… the value is low: those balances are
settled and will not change."
That was overturned on 2026-07-28, and it was wrong in an instructive way: it
measured the value in *balances*, where it is indeed nil, and missed the value
in *spend*, where it is $35,259. Importing as `settled` gets the second without
touching the first. The "combining problem" it cited is real and is why the
match rate is capped — but a partial restatement of spend beats none, and rows
that cannot be matched simply keep their current treatment.
## What was rejected
**`settlement_contexts` as a table.** The need was real — a payment must say
what it settles. But trips already exist and already carry membership on
`transaction_overrides.trip_id`, so scope is a read of existing data rather
than a new grouping key. One nullable column on `split_payments` expressed it.
A general context table would have meant a new entity to create and maintain
before a payment could be recorded, in a two-person household with two trips.
**Deleting duplicate transactions.** Every child of `transactions` is
`ON DELETE CASCADE`, and which member of a duplicate pair holds the curation is
an accident of import order. Duplicates are superseded instead: the row stays,
keeps its children, and points at the row that replaces it.
**Reusing `reconciled_with_id` for duplicates.** Its predicate is scoped to
`statement_id IS NULL` on purpose — a statement line pointing at something else
is the survivor, not the duplicate. In the duplicate-import case both rows are
statement lines, so that predicate can never hide either.
## Scale note
This is a home app for one user, occasionally two, and the second user consumes
the splits view and little else. Reviews of this subsystem have repeatedly
proposed enterprise-grade reconciliation, lineage and audit machinery; the
*findings* are often right and the *sizing* is not. A one-column solution a
person can hold in their head beats a correct-but-unmaintainable one here.
---
## Still a proposal: the shared loan
**Nothing in this section is built.**
Sonu's contributions are in the ledger and unrecognised. All are categorised
`transfers` — correct for spend, but it makes a loan contribution and an expense
settlement indistinguishable:
| Pattern in offset credits | Rows | Total | Meaning |
|---|---:|---:|---|
| `…emi` | 39 | $37,980.24 | Sonu's loan contribution |
| `…mummy…` | 6 | $29,721.24 | Molina's money, forwarded by Sonu |
| other Meghalee | 15 | $71,130.27 | Sonu's own settlements |
### The loan is separate from shared expenses
Different obligations, different rhythms, different nature: one funds an asset,
the other funds consumption. They do not share a settlement scope, and a
contribution must never settle a dinner.
### The share is 50/50 fixed, with the shortfall tracked
Not derived from actual payments, which fluctuate. Over 2025-07-01 → 2026-06-30:
| | |
|---|---:|
| Repayments | $63,500.00 |
| Sonu's 50% obligation | $31,750.00 |
| Actually contributed (26 payments) | $27,750.00 |
| **Shortfall** | **$4,000.00** |
She never missed a fortnight; the rate changed — $1,250 × 15 (Aug 2025–Feb
2026, the correct 50%), $1,000 × 3 (Jul 2025, pre-adjustment), $750 × 8
(Mar–Jun 2026, leave).
So the model needs a **contribution schedule** (expected per period) alongside
actual contributions, with the running difference as a tracked receivable. A
flat percentage-of-actual cannot express "obligation unchanged, payment
temporarily reduced, difference owed" — it would silently redefine her share as
30% and make the shortfall disappear.
### Interest: recommended as expense, pending final call
Over 12 months $63,500 of cash left and debt fell by $44,127.36. The $16,523.64
difference bought nothing and is not recoverable — an expense by definition.
Excluding it leaves the balance sheet unable to reconcile cash out against
equity gained, and understates annual cost by ~10%.
The legitimate concern is that interest is non-discretionary. The answer is a
fixed-commitments grouping alongside rent, insurance and utilities — a
presentation change, not an exclusion.
**Do not** model the loan as a recurring split: that would put $2,500 a
fortnight of principal into spend, the error migration 0014 exists to prevent.
## Open questions
1. **Does equity need tracking per person?** If Sonu accrues a share of the
principal, that is a balance-sheet item the app has no concept of. Probably
belongs in a net-worth view rather than here.
2. **Attribution of forwarded payments.** `mummy` in the description reliably
marks Molina's money in all six known cases, but it is a description match on
a free-text field. Acceptable as a *suggestion* requiring confirmation, not
as an automatic rule.
3. **The solo leg.** A Qantas booking on 23 Apr (txn 2849, $1,366.40) is the
flight to Bangkok that begins a solo leg, and the Singapore spending
($2,242.05, 40 rows, to 9 May) is solo — not shared. It has no trip record.
Worth one if trip *cost* is wanted for it; nothing about sharing depends on
it.
## Resolved
- **Europe — Sonu + Sunny** (trip 3, 2026-04-12 → 2026-04-28, 124 rows,
$9,914.24). The leg after the group trip, previously marked only by tag 5 and
invisible to trip analytics. Includes one advance booking on 17 Mar
(Ticketmaster Nanterre) and the 12 Apr handover-day rows, which were already
held out of Europe 2026.
- **Grouped payments, split by scope.** Payments are made grouped — one transfer
covers several tabs — and that needs no schema change, because
`split_payments` has no unique constraint on `linked_transaction_id`. So one
bank transfer carries one row per scope, and the rows re-add to the transfer.
Sonu's two "transfer" payments were allocated Europe-first, remainder to
household, chronologically so each settles what was outstanding when it was
made:
| Transfer | Scope | Amount |
|---|---|---:|
| $3,779.33, 12 Apr (txn 4111) | Europe 2026 | 1,084.61 |
| | Europe — Sonu + Sunny | 2,694.72 |
| $4,794.06, 16 May (txn 4121) | Europe — Sonu + Sunny | 1,145.52 |
| | household | 3,648.54 |
Both Europe tabs now read $0.00 and her overall balance is unchanged at
$5,428.08 — allocation moves money between tabs, never between people. That
invariance is the check worth repeating on any future re-allocation.
+752
View File
@@ -0,0 +1,752 @@
# UI and information architecture review
**Date:** 2026-07-26
**Status:** Priority 0 implemented 2026-07-27 (see below). Priorities 1–4 remain
proposals, tracked on the board under epic **Analytics & Insights information
architecture** (#154) — verified 2026-08-02, nothing in 1, 2 or 4 has been
started, and 3 is partially covered by the shared-expenses rebuild rather than by
this review.
## Implementation status — Priority 0 (2026-07-27)
All six Priority 0 items landed, with three amendments found while verifying the
proposals against the code:
1. **Reconciled source rows** — the exclusion was missing from *all five*
analytics routes, not only `/monthly`. It is now one fragment
(`EXCLUDE_RECONCILED_SOURCE`) that `queries.ts` also imports, so the two
halves cannot drift apart again. Real effect: 48 rows, **$4,474.79** of
double-counted spend removed from every category total, mover, Pareto and
merchant ranking.
2. **Spend pace** — now served by `/api/analytics/daily`, built from the same
fragments as the headline. Measured on live data, the old client-side series
ended July at **$4,747.31** against a headline of **$3,597.10** — a 32%
overstatement of the number directly above it.
3. **Fees and interest** — bounded by an explicit period (default 12 months,
`months=0` for all time), with the range shown and selectable. The unbounded
figure was overstating the last 12 months by roughly **$2,700 of fees**.
4. **Split-coverage warning** — *deliberately not implemented* (user decision,
2026-07-27).
5. **Shared foreign currency** — amended. The obvious fix, reading `s.currency`,
would have mislabelled every order row as AUD, because an order receipt has
no statement and carries its own currency. Sourcing is now
`NATIVE_CURRENCY = COALESCE(s.currency, t.foreign_currency_code, 'AUD')`,
whose COALESCE order keeps two opposite denomination conventions apart. Note
this change is **latent on today's data**: no foreign transaction is
currently split, so nothing on Shared looks different yet.
6. **Partial-month comparisons** — the hero average, the top movers and the pace
baseline now exclude the in-progress month, and compare through the same day
of the month when the selected month is the current one.
Also fixed while in here, both found by checking rather than by proposal:
- **Every analytics window was a day early.** `toISOString()` on a local-midnight
`Date` converts backwards through UTC in any timezone east of Greenwich. Now
`toDateStr()`. This was pre-existing in `/monthly` and `/merchants`.
- **Rounding grain.** `/monthly` rounded per category and `/daily` per
category-day, so the pace chart ended the month a few cents off its own
headline. Both now carry 4dp and round once, at display.
Guarded in two files. `src/__tests__/integration/analytics-sql.test.ts` covers
the SQL fragments — `EXCLUDE_RECONCILED_SOURCE`, `NATIVE_CURRENCY` and
`INVESTMENT_SIGNED`. Since 2026-08-21 `analytics-priority0.test.ts` covers items
2, 3 and 6 (#155): the three where a regression is silent rather than loud — the
pace chart simply going back to disagreeing with the headline printed directly
above it, the fees figure back to growing forever, and a partial month back to
being measured against complete ones, none of which fails anything.
Two things were needed to make item 6 testable at all, and both are the point
rather than incidental. The rules lived as closures inside `budget/page.tsx`
reading `new Date()` inline, so they now live in `src/lib/partial-month.ts` with
an injectable clock; and the tests freeze that clock, because a fixture defined
against the real "now" drifts out of the routes' rolling windows and the
assertion goes vacuous — the same class of silent failure, one level up. Item
2's suite carries a placebo assertion for the same reason: parity between two
queries that are identical by construction proves nothing unless some fixture
can actually tell the old and new spend definitions apart.
### Landed after this doc, in the same family (2026-08-02)
Two changes postdate the Priority 0 pass and belong to the same
metric-integrity thread, so read them alongside it:
- **The investments line is signed** (`c70d2b1`, 2026-07-31). `INVESTMENT_SIGNED`
makes credits and refunds negative so a withdrawal nets against contributions
instead of reading as more money invested. March 2026 had shown $38,615.34 of
investing in a month that was net **−$11,384.66**. Consequence for the
Analytics section below: the "income, expenses, invested, and net-cash strip"
it describes now carries a *signed* invested figure, and a net-disinvesting
month is real data — the budget page gates on `!== 0`, not `> 0`.
- **Transfers hidden by default in the transactions view** (`f6c500b`,
2026-07-30), with an explicit category pick overriding the exclusion and the
default off when scoped to a statement. An IA change in this review's
territory that this review did not propose.
The doc's characterisation of `REGULAR_CATEGORIES` (Insights section) is also
slightly off: the set has 13 members including rent, utilities, insurance and
subscriptions, not the 8 listed. The case for replacing it stands — a flat
binary cannot express obligation — but that is the reason, not arbitrary
membership. Note too that the proposed Fixed/Essential/Lifestyle model needs a
commitment dimension that does not exist yet: `fees` cannot be split into
avoidable versus known-annual, `subscriptions` cannot be split into contractual
versus cancellable, and the contracted loan repayment is not in the spend stream
at all (`SPEND_BASE` keeps only the interest portion). That is a data-model
change, not an Insights rework.
## Executive summary
The July 19 UI refresh gave the app a cohesive and distinctive visual identity.
The ink-and-copper palette, typography, financial number treatment, month spine,
and transaction drill-downs are all strong foundations.
The larger remaining issue is not appearance. It is information hierarchy.
Analytics and Insights contain useful data, but they are reporting-heavy rather
than decision-oriented. Shared communicates the immediate running balance, but
the current settlement model prevents it from answering which expenses a payment
settled, whether a trip is closed, or how the shared loan should be represented.
The product should make four questions easy to answer:
1. Am I financially okay?
2. What changed and why?
3. What needs my attention?
4. Who owes what, and for which expenses?
Today there is no single page that answers the first three. The app opens on
Transactions and presents ten equally weighted navigation items.
The recommended direction is:
- Add an Overview as the default landing page.
- Keep Analytics focused on historical exploration: **what happened?**
- Rebuild Insights around decisions and attention: **what should I know or do?**
- Rebuild Shared around settlement contexts: **who owes what, and why?**
- Keep the shared loan as a separate ledger from shared consumption expenses.
- Fix calculation and coverage inconsistencies before adding more visualisations.
## Context reviewed
This review covered:
- The current Next.js pages and shared components.
- Analytics SQL and API calculations.
- Shared-expense balance and transaction queries.
- `CLAUDE.md`.
- `docs/shared-expenses-design.md`.
- `docs/expense-baseline.md`.
- Recent repository history.
- Recent finance-app memories retrieved from OpenViking.
The OpenViking history confirmed:
- The July 19 redesign intentionally introduced the ink-and-copper theme,
Fraunces display type, month-spine navigation, top movers, category
sparklines, and heat-tinted ledger tables.
- The user prefers a modern, high-fidelity interface and actionable analytics.
- Later July 25–26 work changed the financial meaning under those screens:
split-aware personal spend, AUD-aware settlement, refund netting, loan
principal/interest separation, rule previews, and the proposed contextual
settlement model.
- The preferred settlement model links payments to real transactions, separates
Household, Trip, and Historical contexts, and keeps the shared loan separate.
## What already works
### Visual system
- The dark ink-and-copper theme is coherent and distinctive.
- Serif headings and mono financial figures create useful hierarchy.
- The copper accent is used consistently for selection and emphasis.
- The design feels like one application rather than a collection of unrelated
pages.
### Analytics interactions
- The month spine is an effective year-at-a-glance navigation control.
- “What changed” is more useful than a generic category chart.
- Category sparklines make direction visible without creating a large
multi-series chart.
- Category rows can be expanded into their transactions.
- Inline recategorisation allows users to correct the data while investigating
it.
### Shared workflow
- “Owes you,” “you owe,” and “all square” communicate the immediate relationship
balance clearly.
- Payment history is preserved rather than reducing settlement to a boolean.
- Participant and tag filters support practical investigation.
- Split transactions can be edited without returning to the main transaction
page.
## App-wide information architecture
### Current problem
The app redirects `/` to `/transactions`. This makes the operational ledger the
default product surface. Transactions are important, but they do not tell the
user whether anything needs attention or what the current financial position
means.
The sidebar also gives equal weight to:
- operational screens such as Reconcile;
- analytical screens such as Analytics;
- configuration screens such as Rules;
- organisational screens such as Tags.
This makes the product feel like a database administration interface even when
the individual pages are well designed.
The `/budget` route is labelled Analytics in navigation. This is a leftover from
an older product concept and should become `/analytics`.
### Recommended navigation
Group navigation by intent:
**Overview**
- Overview
**Money**
- Transactions
- Statements
- Reconcile
**Understand**
- Analytics
- Insights
- Merchants
**Shared**
- Shared
- Trips
- Loan
**Organise**
- Tags
- Rules
Lower-frequency configuration items can be visually separated or collapsed.
### Recommended Overview
The default landing page should be a concise status and attention surface, not
another full analytics dashboard.
Suggested structure:
1. **This month**
- Personal spend to date
- Expected baseline at this point in the month
- Income
- Net cash
2. **Financial resilience**
- Realistic monthly baseline
- Cash coverage in months
- Redraw shown separately from cash
3. **Needs attention**
- Uncategorised or `other` transactions
- Unreconciled transactions
- Statements failing balance assertions
- New or unusual recurring charges
- Shared expenses added since the last settlement
4. **Shared**
- Current balances by person and context
- Loan contribution shortfall shown separately
5. **Recent change**
- The two or three categories that explain the largest movement
The Overview should link into Analytics, Insights, Shared, and Reconcile rather
than reproduce their complete tables.
## Analytics review
### What the current page does
The current Analytics page includes:
- selected-month spend hero;
- twelve-month month spine;
- income, expenses, invested, and net-cash strip;
- top category movers;
- eight category sparkline cards;
- spend-concentration Pareto chart;
- cumulative spend pace;
- expandable category table;
- six-month heat-tinted category ledger.
Each component is defensible in isolation. Together, they create too many
competing summaries of the same category data.
### What Analytics should answer
Analytics should answer:
> What happened during this period, how does it compare, and what explains the
> difference?
Recommended primary structure:
1. Period and comparison controls.
2. Personal spend, income, invested, and net cash.
3. Explanation of the change versus the selected comparison.
4. One main category/trend visualisation.
5. Category breakdown with transaction drill-down.
6. An optional Explore section for detailed tables.
### Recommended removals and consolidation
- Keep either category sparklines or the six-month ledger as the primary
category-trend representation, not both.
- Move the Pareto chart behind an Explore section. It describes concentration
but rarely produces an immediate decision.
- Retain “What changed,” but make each item clickable and explain which
transactions caused the movement.
- Avoid comparing a partial current month with full prior months unless values
are projected or compared through the same day.
- Add gross-versus-personal-share switching only if it is clearly labelled.
Personal share should remain the default.
### Calculation and trust issues
#### Reconciled source rows can be double-counted
`/api/analytics/monthly` does not currently exclude manual source rows where
`reconciled_with_id IS NOT NULL`. The baseline analysis identified 48
double-counted rows.
The analytics query should apply the same reconciled-row exclusion used by the
main transaction queries.
#### Spend pace compares unlike numbers
The Analytics headline uses:
- split-adjusted personal share;
- fees and interest;
- refund and credit netting;
- loan interest rather than principal;
- non-spend-category exclusions.
The cumulative spend-pace chart uses only `transaction_type === "debit"` and
adds gross `amount_aud ?? amount`. It does not use personal share and does not
apply the same refund, fee, interest, or loan semantics.
The chart can therefore disagree with the headline while both appear to
represent “spend.” The cumulative series should be produced by the same
server-side spend semantics as the monthly total.
#### Split coverage changes mid-series
Reliable in-app split data begins on 2026-01-09. A trailing twelve-month personal
series currently combines older gross spending with newer split-adjusted
spending.
Until historical splits are restored:
- default personal trend analysis to February–June 2026;
- visibly mark periods with incomplete split coverage; or
- offer gross-only twelve-month comparison separately.
Do not present the mixed series as one comparable personal-spend trend.
#### Comparison baseline is too naive
The selected month is compared against the average of all other months with
data. That average can include travel, annual fees, tax payments, incomplete
current periods, and months with incompatible split coverage.
Better comparison choices:
- previous month;
- same month last year;
- median of comparable complete months;
- recurring baseline;
- user-selected comparison.
### Data trust indicator
Analytics should include a compact methodology and coverage indicator:
> Personal share · refunds netted · investments excluded · split coverage
> reliable from Feb 2026 · 12 transactions need classification
This makes the meaning of the numbers inspectable without overwhelming the page.
## Insights review
### Current problem
The current Insights page contains:
- Regular versus occasional spending;
- another monthly category breakdown;
- recurring charges;
- fees and interest.
The monthly breakdown duplicates Analytics. The page does not yet surface the
most decision-relevant findings already known from the data: sustainable monthly
cost, liquidity, the loan-overpayment lever, data-quality weaknesses, or unusual
changes requiring attention.
### “Regular” is not the same as committed or essential
`REGULAR_CATEGORIES` includes:
- groceries;
- dining;
- transport;
- health;
- personal care;
- government;
- charity;
- pets.
These may recur, but they have very different flexibility and obligation.
“Regular” describes transaction behaviour, not financial necessity.
The current chart therefore cannot answer:
- What is the minimum monthly cost?
- What can be cut?
- What is contractually committed?
- What is lifestyle spending?
- What is a one-off?
### Recommended model
Replace Regular versus Occasional with:
1. **Fixed commitments**
- Contracted loan repayment
- Insurance
- Rates and registration
- Known annual fees
- Contractual subscriptions
2. **Essential variable spending**
- Utilities
- Groceries
- Transport
- Health
3. **Lifestyle and discretionary**
- Dining
- Shopping
- Entertainment
- Personal care
4. **One-offs and travel**
5. **Investments and transfers**
- Shown for cashflow context, excluded from spending
This should support scenario views rather than claiming there is one true
baseline.
### Recommended Insights structure
#### 1. Financial baseline
Show the scenarios already established by the expense-baseline analysis:
- Survival: contracted loan repayment and essentials only.
- Realistic: contracted loan repayment plus ordinary dining and charity.
- Status quo: current loan overpayment and normal life excluding travel.
For each scenario show:
- monthly amount;
- six-month reserve;
- twelve-month reserve.
#### 2. Liquidity and resilience
Show:
- cash available;
- redraw available separately;
- months covered under each baseline;
- a warning that redraw is lender-controlled and not equivalent to cash.
#### 3. Biggest flexible levers
Examples:
- voluntary loan overpayment;
- dining;
- shopping;
- subscriptions;
- travel.
The loan should always show both the contracted floor and actual repayment.
#### 4. Attention and anomalies
Examples:
- a new recurring charge;
- a charge larger than its prior range;
- a category materially above baseline;
- a fee increase;
- an unexpected incoming credit categorised as spend;
- a merchant still classified as `other`;
- an investment incorrectly counted as spending.
Each insight should link directly to the affected transactions.
#### 5. Data-quality work queue
The baseline analysis found that data quality is currently a larger blocker than
visualisation:
- `other` remains a large unresolved category;
- Raiz, Vanguard Super, and moomoo need investment classification;
- incoming `other` credits can make spending negative;
- `government` conflates tax with rates and registration;
- annual fees distort short-window monthly averages.
Insights should make these visible as fixable tasks.
### Recurring charges
The current detector identifies merchants with regular transaction intervals.
That does not necessarily mean a subscription or commitment. Weekly grocery
shopping can look recurring.
Recommended changes:
- Rename the section **Recurring patterns** unless contractual charges can be
distinguished.
- Show confidence and the basis for classification.
- Show the next expected charge date.
- Separate likely subscriptions from recurring merchants.
- Allow dismissing or confirming a detected pattern.
- Highlight price changes.
- Collapse inactive patterns by default.
The current eight-column table is also too wide for a primary page. Put secondary
fields such as first seen, total paid, and count into an expandable detail row.
### Fees and interest
The current fees query aggregates statement summary values across all available
statements without a date filter. The UI does not label the period, so the total
looks like a current-period figure even though it is effectively lifetime to
date.
Recommended presentation:
- Explicit date range.
- Avoidable fees.
- Known annual fees.
- Credit-card interest.
- Loan interest.
- Change versus prior comparable period.
- Drill-down transactions.
Loan interest should remain spending, but appear under fixed or
non-discretionary costs rather than being hidden.
## Shared review
### What the current page answers well
The unfiltered balance cards correctly implement a running ledger:
> splits minus payments
*(Superseded 2026-07-28: the shared-expenses rebuild made `settled`
load-bearing — it gates `ACTIVE_OBLIGATION` and survives split rewrites. See
`docs/shared-expenses-design.md` for the live model; the paragraph above
described the pre-rebuild state.)*
### What the current model cannot answer
- Which split expenses did a payment settle?
- Is a particular trip settled?
- Can a trip be closed without closing Household?
- Is an imported offset-account credit already represented by a manual payment?
- What remains open inside one settlement context?
- How should the shared-loan contribution shortfall be shown?
The page should not imply answers that the data model cannot support.
### Tag-filtered balance cards are semantically misleading
When a tag filter is active, participant balance queries intentionally stop
subtracting payments because payments are not attributable to a tag. The cards
then show raw split totals for the tag.
This behavior is explained in small text, but the card still says “owes you” or
“you owe.” That looks like a real payable balance when it is not.
When filtered, relabel the cards:
> Split total in Europe 2026
Do not show payment or settlement actions from that state.
### Recommended settlement-context design
Use explicit settlement contexts:
- Household
- Individual trips
- Historical / Pre-2026
- Closed contexts
Recommended Shared navigation:
- All
- Household
- Trips
- Closed
Within a context show:
1. Net balance and direction.
2. Expenses added since the last settlement.
3. Payments attributed to that context.
4. A chronological activity ledger combining expenses and payments.
5. Context status: running, ready to settle, or closed.
6. Settlement action.
### Payments should link to transactions
An offset-account credit and a manual `split_payments` row can represent the same
money. The page should:
- propose matching an imported credit to a settlement;
- display the linked transaction;
- prevent silent duplication;
- allow a manual payment only when no matching transaction exists.
“Record Payment” should become a context-aware settlement flow:
1. Choose what is being settled.
2. Match an existing incoming transaction where possible.
3. Confirm amount and residual balance.
4. Preserve an auditable history.
### Shared transaction table
The table currently shows raw `tx.amount` with a dollar sign and no currency
indicator. Participant balances correctly convert to AUD.
For foreign transactions, show:
- the native amount and currency;
- the AUD equivalent;
- splits based on the AUD settlement amount.
This prevents a visible mismatch between transaction rows and participant
balances.
### Shared loan
The loan is not a shared-expense settlement context. It funds an asset rather
than consumption, and a loan contribution must never settle a dinner or utility
bill.
Give it a separate page or clearly separated ledger showing:
- expected contribution by period;
- actual contribution;
- running shortfall or receivable;
- principal reduction;
- interest expense;
- contracted repayment;
- actual repayment;
- voluntary overpayment;
- redraw movement.
The partner obligation is a fixed 50% of the repayment schedule, not a percentage
inferred from actual contributions.
## Responsive and interaction improvements
- Replace wide eight-column primary tables with compact rows and expandable
details.
- Keep financial summaries readable at mobile widths without horizontal
scrolling.
- Add explicit loading skeletons rather than only text.
- Add error states for failed analytics requests.
- Ensure chart meaning is not conveyed by colour alone.
- Give interactive chart regions keyboard-accessible equivalents.
- Confirm material deletions, including payment-history deletion.
- Make expandable table rows use buttons with appropriate accessibility state.
- Use consistent labels for personal share, gross amount, native currency, and
AUD equivalent.
## Recommended implementation order
### Priority 0 — metric integrity *(done 2026-07-27; test gap #155)*
1. Exclude reconciled source rows from monthly analytics.
2. Make spend pace use the same spend semantics as the headline.
3. Add date ranges to fees and interest.
4. Add split-coverage warnings to historical personal-share analysis.
5. Fix Shared foreign-currency presentation.
6. Avoid partial-month versus full-month comparisons.
### Priority 1 — product hierarchy *(#156)*
1. Add Overview and make it the default route.
2. Group sidebar navigation by user intent.
3. Rename `/budget` to `/analytics`.
4. Add consistent methodology and coverage indicators.
### Priority 2 — Analytics and Insights *(#157)*
1. Simplify Analytics around period, comparison, change explanation, trend, and
drill-down.
2. Remove the duplicate monthly breakdown from Insights.
3. Add baseline scenarios and liquidity coverage.
4. Add flexible-spending levers, anomalies, and a data-quality work queue.
5. Rework recurring patterns and fees into decision-oriented summaries.
### Priority 3 — Shared *(#158; items 2 and 5 landed with the shared-expenses rebuild)*
1. Add settlement contexts.
2. Link payments to real transactions.
3. Add context activity ledgers and closeable trip contexts.
4. Introduce the separate loan contribution ledger.
5. Backfill historical closed-context splits so long-range personal analytics
become comparable.
### Priority 4 — polish *(#159)*
1. Improve mobile layouts.
2. Add accessibility semantics.
3. Add richer loading, error, and empty states.
4. Consolidate repeated card, table, filter, and page-header patterns into shared
components.
## Proposed success criteria
The redesign is successful when:
- The first page explains current status and outstanding actions without opening
multiple screens.
- Analytics can explain why one comparable period differs from another.
- Insights identifies baseline cost, financial resilience, flexible levers, and
data-quality problems.
- Every displayed total states or clearly implies its period and whether it is
gross or personal share.
- Historical charts do not silently combine incompatible split coverage.
- Shared can distinguish Household, Trip, and Historical balances.
- A settlement can be traced to both the obligation it reduces and the real
transaction representing the payment.
- Loan contributions cannot affect ordinary shared-expense balances.
+19 -1
View File
@@ -16,7 +16,8 @@
"prisma": "^7.4.2", "prisma": "^7.4.2",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"recharts": "^3.8.0" "recharts": "^3.8.0",
"unpdf": "^1.8.1"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
@@ -8494,6 +8495,23 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/unpdf": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/unpdf/-/unpdf-1.8.1.tgz",
"integrity": "sha512-xkURhy2SoGpOIH0a1gLHNkASPIQYonadDJs2AQwPEfUakafeD9EA1WTWWsaR++gfTCXJpV27W7tU1nXuk82UKQ==",
"license": "MIT",
"engines": {
"node": ">=22"
},
"peerDependencies": {
"@napi-rs/canvas": "^0.1.69 || ^1.0.0"
},
"peerDependenciesMeta": {
"@napi-rs/canvas": {
"optional": true
}
}
},
"node_modules/unrs-resolver": { "node_modules/unrs-resolver": {
"version": "1.11.1", "version": "1.11.1",
"resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz",
+3 -1
View File
@@ -10,6 +10,7 @@
"test": "vitest run --config vitest.config.ts", "test": "vitest run --config vitest.config.ts",
"test:watch": "vitest --config vitest.config.ts", "test:watch": "vitest --config vitest.config.ts",
"test:setup": "bash scripts/setup-test-db.sh", "test:setup": "bash scripts/setup-test-db.sh",
"pretest:integration": "bash scripts/resolve-test-db.sh",
"test:integration": "vitest run --config vitest.integration.config.ts", "test:integration": "vitest run --config vitest.integration.config.ts",
"test:all": "npm test && npm run test:integration" "test:all": "npm test && npm run test:integration"
}, },
@@ -22,7 +23,8 @@
"prisma": "^7.4.2", "prisma": "^7.4.2",
"react": "19.2.3", "react": "19.2.3",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"recharts": "^3.8.0" "recharts": "^3.8.0",
"unpdf": "^1.8.1"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
@@ -0,0 +1,28 @@
-- Record which rule a run came from.
--
-- rule_apply_runs stored only counts ("13 matches · 13 transactions"), which is
-- not enough to decide whether to revert: you cannot tell a merchant rename from
-- a 50/50 split of your entire history. The snapshot column already holds the
-- before-state, but nothing said what was applied or why.
--
-- rule_name is denormalised deliberately. A run must stay readable after the
-- rule it came from is edited or deleted -- the history is a record of what
-- happened, not a pointer to what the rule says today.
ALTER TABLE rule_apply_runs
ADD COLUMN IF NOT EXISTS rule_id INTEGER,
ADD COLUMN IF NOT EXISTS rule_name TEXT,
-- 'all' = bulk run over every enabled rule; 'rule' = one rule by conditions;
-- 'selection' = one rule against hand-picked transactions (preview → apply).
ADD COLUMN IF NOT EXISTS source TEXT;
ALTER TABLE rule_apply_runs DROP CONSTRAINT IF EXISTS rule_apply_runs_source_check;
ALTER TABLE rule_apply_runs ADD CONSTRAINT rule_apply_runs_source_check
CHECK (source IS NULL OR source IN ('all', 'rule', 'selection'));
-- No FK to rules: deleting a rule must not cascade away the audit trail.
CREATE INDEX IF NOT EXISTS idx_rule_apply_runs_rule
ON rule_apply_runs (rule_id) WHERE rule_id IS NOT NULL;
-- Existing rows stay NULL. There is no way to recover which rule they ran;
-- the UI shows them as "unknown rule" rather than guessing.
@@ -0,0 +1,40 @@
-- Create order_reviews table
CREATE TABLE IF NOT EXISTS order_reviews (
id SERIAL PRIMARY KEY,
transaction_id INTEGER NOT NULL UNIQUE REFERENCES transactions(id) ON DELETE CASCADE,
rating TEXT,
order_again BOOLEAN,
note TEXT,
item_verdicts JSONB NOT NULL DEFAULT '[]',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Add source_message_id to expense_metadata
ALTER TABLE expense_metadata ADD COLUMN IF NOT EXISTS source_message_id TEXT;
-- 1. Admit 'credits' as a payment method.
ALTER TABLE transactions DROP CONSTRAINT IF EXISTS transactions_payment_method_check;
ALTER TABLE transactions ADD CONSTRAINT transactions_payment_method_check
CHECK (payment_method IS NULL OR payment_method IN
('card','cash','bank_transfer','credits','other'));
-- 2. Idempotency key (I7). Partial: rows without an order_reference
-- (manual entries) are unaffected.
CREATE UNIQUE INDEX IF NOT EXISTS uq_expense_source_order
ON expense_metadata (source, order_reference)
WHERE order_reference IS NOT NULL;
-- 3. I1 as a database-level guard, not just workflow logic.
-- Scoped to pipeline-created rows so manual/statement rows are untouched.
ALTER TABLE transactions DROP CONSTRAINT IF EXISTS chk_ingested_orders_after_cutover;
ALTER TABLE transactions ADD CONSTRAINT chk_ingested_orders_after_cutover
CHECK (
payment_method IS DISTINCT FROM 'credits'
OR transaction_date >= DATE '2026-01-09'
);
-- 4. Constrain the review verdict (§6.1).
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 ('again','fine','never'));
@@ -0,0 +1,17 @@
-- Deferred card reconciliation.
--
-- An order paid "MasterCard Ending in 8032 and/or credits" does not state the
-- split. The split is recoverable from the card statement -- but for a live
-- order that statement is weeks away, so the split cannot be resolved at ingest
-- time. These columns let an order be parked and revisited.
ALTER TABLE expense_metadata ADD COLUMN IF NOT EXISTS card_last4 TEXT;
ALTER TABLE expense_metadata ADD COLUMN IF NOT EXISTS currency TEXT;
ALTER TABLE expense_metadata ADD COLUMN IF NOT EXISTS flags JSONB NOT NULL DEFAULT '[]';
ALTER TABLE expense_metadata ADD COLUMN IF NOT EXISTS reconciled_at TIMESTAMPTZ;
-- The pending set: provenance recorded, no transaction yet, still waiting on a
-- statement line. Partial so it stays small regardless of table growth.
CREATE INDEX IF NOT EXISTS idx_expense_metadata_pending
ON expense_metadata (transaction_date)
WHERE transaction_id IS NULL AND reconciled_at IS NULL;
@@ -0,0 +1,13 @@
-- Which statement line settled an order's card leg.
--
-- Without this, reconcileCardLeg has no way to know a charge has already been
-- consumed, so two orders on the same card inside the match window both bind to
-- it and each books its own credits remainder -- double-counting spend.
ALTER TABLE expense_metadata
ADD COLUMN IF NOT EXISTS matched_transaction_id INTEGER
REFERENCES transactions(id) ON DELETE SET NULL;
-- One statement line settles at most one order.
CREATE UNIQUE INDEX IF NOT EXISTS uq_expense_matched_txn
ON expense_metadata (matched_transaction_id)
WHERE matched_transaction_id IS NOT NULL;
@@ -0,0 +1,39 @@
-- Order provenance: which platform the receipt came from, and the message it
-- came from.
--
-- The parser has always known the platform (it has to, to read the template)
-- and then threw it away. Without it a transaction reads "Order - Burger
-- Corner" with no way to tell whether to look in DoorDash or Uber Eats for the
-- detail, and no way to answer "how much of this is DoorDash?" at all.
--
-- `source_email_subject` / `source_email_from` already existed for the
-- Paperless expense path and were simply never populated by order ingestion.
ALTER TABLE expense_metadata ADD COLUMN IF NOT EXISTS platform text;
COMMENT ON COLUMN expense_metadata.platform IS
'doordash | ubereats | uber — the receipt template the order was read from.';
-- Backfill the 101 rows written by the 2026-07-27 backfill. DoorDash receipts
-- carry no order id of their own, so ingestion synthesises `msg:<message-id>`;
-- Uber receipts carry a real trip UUID. That is the only surviving
-- discriminator, and it is exact.
UPDATE expense_metadata
SET platform = CASE WHEN order_reference LIKE 'msg:%' THEN 'doordash' ELSE 'ubereats' END
WHERE platform IS NULL
AND source = 'email'
AND paperless_doc_id IS NULL -- exclude the Paperless expense path
AND order_reference IS NOT NULL;
-- Pick-up / delivery stops, as the receipt prints them. Uber puts these on
-- every order under `Order details`; DoorDash prints no addresses at all, so
-- this stays '[]' there. Same block a *trip* receipt uses for start and
-- destination, so this column already fits rides when they come into scope.
ALTER TABLE expense_metadata ADD COLUMN IF NOT EXISTS route jsonb NOT NULL DEFAULT '[]'::jsonb;
COMMENT ON COLUMN expense_metadata.route IS
'Uber only: [{label, time, address}] — pick-up and delivery stops as printed.';
CREATE INDEX IF NOT EXISTS idx_expense_metadata_platform
ON expense_metadata (platform)
WHERE platform IS NOT NULL;
@@ -0,0 +1,48 @@
-- Settlement scope: which tab a payment settles.
--
-- `split_payments` has carried from/to/amount/date since it was written and
-- nothing else. That is the whole reason a per-trip balance has never been
-- computable — `getTripAnalytics` says so in a comment where the figure should
-- be: "split_payments carries no trip attribution, so a payment cannot be
-- assigned to a trip. Settlement is a property of the whole relationship."
--
-- It is also the reason the Shared page silently drops payments the moment a
-- tag filter is applied (`getParticipantBalances`): with one global payments
-- pool there is no honest way to show a filtered balance, so it showed gross
-- splits under the same label instead. A tag is a view; a scope is a ledger.
--
-- The scope is a *trip*, not a new `settlement_contexts` table. `trips` already
-- has owner_id, dates and an archived flag, and `transaction_overrides.trip_id`
-- already decides which transactions belong to it. A second grouping beside it
-- would be two unsynchronised scopes over the same rows — a trip could hold a
-- mix of contexts and a context could span trips, with no invariant saying
-- which one governs.
--
-- NULL means the ongoing household tab. That tab never closes, which is why
-- this is nullable rather than defaulted to some "general" row: absence is the
-- honest representation of "not attached to a trip", and it keeps every
-- existing payment correct without a backfill.
ALTER TABLE split_payments
ADD COLUMN IF NOT EXISTS trip_id integer REFERENCES trips(id) ON DELETE SET NULL;
COMMENT ON COLUMN split_payments.trip_id IS
'The trip this payment settles. NULL = the ongoing household tab.';
CREATE INDEX IF NOT EXISTS idx_split_payments_trip
ON split_payments (trip_id)
WHERE trip_id IS NOT NULL;
-- `settled` answers a different question and the two must not be collapsed:
-- trip_id is *which tab*, settled is *is this obligation still live*. A
-- pre-2026 historical split is settled with no tab; a Europe split becomes
-- settled when Europe's payment lands; a household split stays unsettled and
-- open indefinitely.
--
-- Nothing writes `settled` today. The comment in queries.ts claims
-- /api/splits/settle does — that route does not exist, and the column is false
-- on all 1,279 rows, which is why every trip has always reported 100%
-- unsettled including trips paid in full.
COMMENT ON COLUMN transaction_splits.settled IS
'Obligation discharged. Excluded from owed figures; still counted in spend analytics.';
@@ -0,0 +1,37 @@
-- A transaction imported twice cannot simply be deleted.
--
-- Every child of `transactions` is ON DELETE CASCADE — splits, tags, overrides,
-- expense_metadata, order_reviews. Deleting a row said to be "the duplicate"
-- therefore destroys whatever curation happens to sit on it, silently and
-- unrecoverably. The curation is not reliably on the surviving side either: of
-- the 31 known duplicate pairs, one carries splits and six carry overrides, and
-- which member holds them is an accident of import order.
--
-- So a duplicate is superseded, never removed. The row stays, keeps its
-- children, and points at the row that replaces it. Reversing a mistake is then
-- one UPDATE rather than a restore from backup.
--
-- This is the statement-vs-statement case. `reconciled_with_id` already covers
-- manual-vs-statement, and deliberately cannot be reused: the predicate that
-- hides a reconciled row is scoped to `statement_id IS NULL`, because a
-- statement line pointing at something else is the survivor, not the duplicate.
-- Both of these rows are statement lines.
ALTER TABLE transactions
ADD COLUMN IF NOT EXISTS superseded_by_id integer
REFERENCES transactions(id) ON DELETE SET NULL;
COMMENT ON COLUMN transactions.superseded_by_id IS
'This row was imported twice; the named row is the one that counts. Excluded from every figure, kept for its children and its audit trail. NULL = live.';
CREATE INDEX IF NOT EXISTS idx_transactions_superseded
ON transactions (superseded_by_id)
WHERE superseded_by_id IS NOT NULL;
-- A row cannot supersede itself, and a survivor cannot itself be superseded
-- (that would hide both members of the pair and lose the amount entirely).
ALTER TABLE transactions
DROP CONSTRAINT IF EXISTS transactions_no_self_supersede;
ALTER TABLE transactions
ADD CONSTRAINT transactions_no_self_supersede
CHECK (superseded_by_id IS NULL OR superseded_by_id <> id);
@@ -0,0 +1,52 @@
-- A four-level verdict, per-item opinions, and one verdict per PERSON.
--
-- Three levels collapsed the distinction that actually drives a re-order:
-- "loved" and "liked" are both "would order again", but only one is worth a
-- detour, and "ok" is not a recommendation. Asked for by the user 2026-07-28.
--
-- Safe as a straight swap: order_reviews had 0 rows when this was written, so
-- there are no old values to map. If that ever stops being true, map
-- again->liked, fine->ok, never->never BEFORE adding the constraint.
ALTER TABLE order_reviews DROP CONSTRAINT IF EXISTS chk_order_review_rating;
ALTER TABLE order_reviews ADD CONSTRAINT chk_order_review_rating
CHECK (rating IS NULL OR rating IN ('loved', 'liked', 'ok', 'never'));
-- ---------------------------------------------------------------------------
-- A verdict belongs to a person, not to an order.
--
-- A shared meal produces two opinions and they routinely disagree — that
-- disagreement is the useful part, and one row per transaction cannot hold it.
-- The Slack nudge asks whether the order was shared; a yes both splits the
-- expense and asks the other person for their verdict, so the second row is
-- the normal case for anything shared, not an edge case.
--
-- No DEFAULT on participant_id on purpose: a verdict silently attributed to
-- whoever happens to be id 1 is worse than an insert that fails loudly.
ALTER TABLE order_reviews
ADD COLUMN IF NOT EXISTS participant_id integer
REFERENCES participants(id) ON DELETE CASCADE;
UPDATE order_reviews SET participant_id = 1 WHERE participant_id IS NULL;
ALTER TABLE order_reviews ALTER COLUMN participant_id SET NOT NULL;
-- Replace the per-transaction uniqueness with per-transaction-per-person.
-- Dropping this is what allows the second opinion to exist at all.
ALTER TABLE order_reviews DROP CONSTRAINT IF EXISTS order_reviews_transaction_id_key;
ALTER TABLE order_reviews
ADD CONSTRAINT order_reviews_transaction_participant_key
UNIQUE (transaction_id, participant_id);
-- ---------------------------------------------------------------------------
-- item_verdicts already exists as jsonb DEFAULT '[]'. It has never been
-- written. The shape is now fixed as:
-- [{"item": "<line item description>", "verdict": "loved"|"never"}]
--
-- Keyed by description rather than by position in line_items: an index is
-- meaningless across orders, and the reusable signal is "the Pad Thai here is
-- good", which has to survive into the next order from the same merchant.
-- Only the poles are offered — a per-item "ok" is noise nobody would ever read.
ALTER TABLE order_reviews ADD CONSTRAINT chk_order_review_item_verdicts
CHECK (jsonb_typeof(item_verdicts) = 'array');
@@ -0,0 +1,14 @@
-- A fifth verdict: "bad", between "ok" and "never".
--
-- "OK" to "Never again" is a big jump and most disappointments live in the gap
-- (user, 2026-07-28). Without it, a merely poor meal either flatters itself as
-- OK or gets blacklisted, and the blacklist is the signal that has to stay
-- sharp — `warn` remains exclusive to 'never' so it is not diluted by every
-- mediocre delivery.
--
-- Safe as a straight widening: no existing row uses a value being removed,
-- because nothing is being removed.
ALTER TABLE order_reviews DROP CONSTRAINT IF EXISTS chk_order_review_rating;
ALTER TABLE order_reviews ADD CONSTRAINT chk_order_review_rating
CHECK (rating IS NULL OR rating IN ('loved', 'liked', 'ok', 'bad', 'never'));
@@ -0,0 +1,31 @@
-- Let credits-funded orders exist before the cutover.
--
-- `chk_ingested_orders_after_cutover` (migration 0018) refused any row with
-- payment_method = 'credits' dated before 2026-01-09. It was written as a
-- database-level guard for invariant I1, whose stated reason was splits:
-- before the cutover, shared expenses lived in SplitMyExpenses, and
-- re-importing them would double-charge Sonu against carryover transaction
-- 2348.
--
-- That reason no longer holds. Since finance-app 788219b, ACTIVE_OBLIGATION is
-- `ts.settled = false AND t.transaction_date >= '2026-01-09'`, so a split on a
-- pre-cutover transaction cannot assert a debt at all. The guard now blocks
-- something it was never aimed at: the orders themselves, which are ordinary
-- historical spend. 275 of them — $9,799.96 of meals and rides across
-- 2020-2025 — were invisible because the receipt was the only record and the
-- money came from a gift-card balance rather than a card.
--
-- What is NOT resolved, and is accepted deliberately (user, 2026-07-28): some
-- of those orders were funded by ShopBack gift cards that are themselves
-- recorded as expenses, so that portion is counted twice. The exposure is
-- bounded at $3,411.16 (14 loads) and is probably smaller, because the
-- descriptors name no brand — "ShopBack Gift Cards SQ" is a batch code, and
-- the card could be Amazon, Airbnb or Shell as easily as DoorDash. Six are
-- categorised `gifts` and may be real presents rather than self-funding.
-- Reclassifying them on a guess would corrupt correct data to fix a
-- double-count that cannot be demonstrated, so they are left alone; only the
-- ShopBack purchase emails can settle it, joined on total paid.
--
-- The split guard is untouched: this changes what may exist, not what may be
-- owed.
ALTER TABLE transactions DROP CONSTRAINT IF EXISTS chk_ingested_orders_after_cutover;
@@ -0,0 +1,37 @@
-- Receipt scans as a second producer on the order lane.
--
-- A grocery shop paid with a supermarket gift card settles against no statement and
-- arrives in no mail, so the pantry scan is the only touchpoint that ever sees it. Rather
-- than a second ingestion mechanism, a scan lands as a manual transaction
-- (statement_id IS NULL) and the existing pending-reconciliation queue resolves it —
-- with needsCardMatch() already excluding cash/credits rows for which no card leg is
-- ever coming.
-- The receipt as text, kept as evidence rather than parsed into a decision. The token
-- that distinguishes a gift card from a bank card was only findable by reading two
-- payments side by side on one receipt; whether it holds across merchants is answerable
-- from stored blocks and not at all from none.
ALTER TABLE expense_metadata
ADD COLUMN IF NOT EXISTS tender_raw TEXT;
-- The image the extraction came from. Not the idempotency key -- a photo and the store's
-- e-receipt PDF of one purchase hash differently -- but exact where it applies, and the
-- only thing a later cross-source dedupe against an emailed or Paperless copy could match
-- on.
ALTER TABLE expense_metadata
ADD COLUMN IF NOT EXISTS receipt_sha256 TEXT;
CREATE INDEX IF NOT EXISTS idx_expense_receipt_sha256
ON expense_metadata (receipt_sha256)
WHERE receipt_sha256 IS NOT NULL;
-- Legs of one split-tender shop, so a $40.75 gift-card row can be shown as part of a
-- $114.57 purchase instead of an orphan. The shared receipt identity is carried in
-- order_reference ('pantry:<merchant:store:register:number:date>#<leg>'); this column is
-- what makes the group queryable without parsing that string.
ALTER TABLE expense_metadata
ADD COLUMN IF NOT EXISTS receipt_group TEXT;
CREATE INDEX IF NOT EXISTS idx_expense_receipt_group
ON expense_metadata (receipt_group)
WHERE receipt_group IS NOT NULL;
@@ -0,0 +1,54 @@
-- A feed that re-sends yesterday's rows needs an identity the importer can recognise.
--
-- The CSV import that exists today is built for a one-off: `batchInsertCSVTransactions`
-- assigns `row_index` as MAX(row_index) + 1 over the owner's manual rows, so the same
-- file imported twice produces two sets of rows with different indexes. That defeats
-- `uq_transaction_identity` (statement_id, transaction_date, description, amount,
-- row_index) by construction — the constraint cannot fire, because the fifth column is
-- guaranteed fresh on every run. Nothing else stops it either.
--
-- For a hand-driven bank CSV that is tolerable; the operator sees the file once. For a
-- recurring aggregator export it is not, because the windows overlap *by design*: a
-- 12-month export pulled weekly re-states ~51 weeks of rows it has already sent. The
-- failure would be silent and cumulative, and it is not one we can clean up afterwards —
-- every child of `transactions` is ON DELETE CASCADE, so a duplicate must be superseded
-- rather than deleted (migration 0023), and ~$42k of re-imported statement rows already
-- exist as evidence of how this goes.
--
-- So the provider's own key travels with the row. Frollo issues a stable per-transaction
-- `id`, which is exactly what the wallet-capture lane lacked and could never synthesise
-- from a notification. The same two columns serve any future feed that has one.
ALTER TABLE transactions
ADD COLUMN IF NOT EXISTS source TEXT;
COMMENT ON COLUMN transactions.source IS
'Feed this row was ingested from (e.g. ''frollo''). NULL = entered by hand, imported from a statement, or predates the column.';
ALTER TABLE transactions
ADD COLUMN IF NOT EXISTS source_ref TEXT;
COMMENT ON COLUMN transactions.source_ref IS
'The provider''s own identifier for this transaction, verbatim. Idempotency key for re-imports; never generated locally.';
-- Partial, so the millions of rows with no source do not have to be unique on (NULL,
-- NULL). Enforced in the database rather than in the importer: an ON CONFLICT DO NOTHING
-- that silently depends on application-side dedupe is one refactor away from not.
CREATE UNIQUE INDEX IF NOT EXISTS uq_transaction_source_ref
ON transactions (source, source_ref)
WHERE source IS NOT NULL AND source_ref IS NOT NULL;
-- A source row is only half-identified without knowing which account it came from --
-- two accounts at the same institution can legitimately carry the same provider id
-- namespace. Kept as free text rather than a foreign key: finance-app has no account
-- entity, and inventing one to hold a label from an external system would be the tail
-- wagging the dog.
ALTER TABLE transactions
ADD COLUMN IF NOT EXISTS source_account TEXT;
COMMENT ON COLUMN transactions.source_account IS
'Account label as the source system names it, for provenance and for scoping an import to particular accounts. Not an entity reference.';
CREATE INDEX IF NOT EXISTS idx_transactions_source
ON transactions (source, transaction_date)
WHERE source IS NOT NULL;
@@ -0,0 +1,25 @@
-- 0029: let the spine bridge write one row PER SHIPMENT of a split order.
--
-- An Amazon order that ships in two boxes is charged per shipment, so its
-- order_total matches no statement line — the bridge (ingestion-engine
-- jobs/order_transaction_bridge.py) matches each shipment's own amount and
-- items instead. Those rows share (source='order-bridge', order_reference),
-- which uq_expense_source_order forbade.
--
-- The index exists as the MEAL lane's idempotency key (0018 I7), and that
-- lane's semantics are untouched: the scope simply excludes bridge rows,
-- whose idempotency key is source_message_id (<entity_key> for whole-order
-- rows, <entity_key>#f<fact_id> per shipment) — now enforced with its own
-- unique index instead of by convention. No app code does ON CONFLICT
-- against either index; ingest idempotency is SELECT-based
-- (lib/order-ingestion.ts).
DROP INDEX IF EXISTS uq_expense_source_order;
CREATE UNIQUE INDEX uq_expense_source_order
ON expense_metadata (source, order_reference)
WHERE order_reference IS NOT NULL AND source <> 'order-bridge';
CREATE UNIQUE INDEX uq_expense_bridge_message
ON expense_metadata (source, source_message_id)
WHERE source = 'order-bridge';
@@ -0,0 +1,127 @@
-- 0030 — order ↔ transaction, many-to-many (board 205, orders phase 2)
--
-- Phase 1 read linkage out of `expense_metadata`, which is shaped as ONE ROW
-- PER TRANSACTION: `transaction_id` is UNIQUE and `matched_transaction_id`
-- partial-unique. Every multiplicity it expresses today is smuggled through a
-- string key — migration 0029 keys split shipments `<entity_key>#f<fact_id>` —
-- and a BNPL plan needs four rows for one order with no such trick available.
--
-- The flagship case: order_ebay_14-11714-95953, a A$1,599 DJI drone paid in
-- four A$399.75 Afterpay legs (txns 2318, 2333, 1652, 1664). Phase 1 shows it
-- with zero linked transactions, which is correct and useless.
--
-- KEYED ON entity_key, NOT entities.id. finance-app does not model the spine
-- and must not carry an FK across an ownership boundary that a spine
-- re-extraction can decompile. The real cascade risk is NOT transaction
-- deletion (that is handled below) but spine RE-KEYING: supersede_stale()
-- decompiles an entity when a re-extraction yields a different entity_key, and
-- a TEXT key with no FK silently orphans. Bridge-sourced links are rebuildable;
-- `manual` ones are curation and are not. Hence the orphan view at the end.
CREATE TABLE IF NOT EXISTS order_transaction_links (
id SERIAL PRIMARY KEY,
entity_key TEXT NOT NULL,
transaction_id INTEGER NOT NULL REFERENCES transactions(id) ON DELETE CASCADE,
leg_kind TEXT NOT NULL CHECK (leg_kind IN ('charge','shipment','instalment','refund','fee')),
-- HUMAN position only: "2 of 4". Never an id.
--
-- The first draft stored a fact id here. Live shipment keys already carry
-- fact ids 22664 and 22711, max(extracted_facts.id) is 23,781 today, and
-- SMALLINT tops out at 32,767 — that ceiling arrives on ordinary corpus
-- growth and the insert dies with `smallint out of range`. Fact ids go in
-- source_fact_id, which is BIGINT.
leg_index SMALLINT,
leg_count SMALLINT,
source_fact_id BIGINT,
-- The LEG's own amount, signed: positive is money out, negative is a credit
-- coming back. Storing the order total on every leg would make four rows sum
-- to four times the purchase.
amount NUMERIC(12,2) NOT NULL,
currency TEXT NOT NULL DEFAULT 'AUD',
source TEXT NOT NULL CHECK (source IN ('order-bridge','instalment-matcher','manual')),
confidence TEXT NOT NULL DEFAULT 'exact' CHECK (confidence IN ('exact','derived','manual')),
evidence JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Every index needs IF NOT EXISTS, not just the table: a run that creates the
-- table and then fails during the backfill would otherwise abort on re-run
-- with "relation already exists" and need manual surgery.
--
-- uq_otl_transaction is load-bearing. Without it two matchers can each claim
-- txn 2318 and the order reads as paid twice. It is deliberately stricter than
-- reality for one case — a single card charge covering two orders — which then
-- surfaces as a NAMED REJECTED INSERT rather than as silent duplication.
CREATE UNIQUE INDEX IF NOT EXISTS uq_otl_transaction ON order_transaction_links (transaction_id);
-- WHAT IDENTIFIES A LEG DEPENDS ON ITS KIND, and the first version of this
-- index got it wrong in the most on-brand way available. It read
-- (entity_key, leg_kind, COALESCE(leg_index, 0)), so two shipment legs of the
-- same order — both with a NULL leg_index — collapsed to the same key and
-- ON CONFLICT DO NOTHING dropped one SILENTLY. Caught only because the
-- backfill reported 62 rows against 63 candidates.
--
-- The row it ate was order_amazon_249-4859367-0690246's $130.00 second
-- shipment (txn 3171) — the same order named in migration 0029's comment as
-- the reason split shipments need distinct keys at all, because Amazon mails
-- and CHARGES per shipment. A uniqueness rule that cannot hold two shipments
-- is the exact defect 0029 was written to fix.
--
-- So: an instalment leg is identified by its INDEX (1 of 4), a shipment leg by
-- its FACT (each shipment is its own extracted fact), and a whole-order charge
-- by neither — one per order, which the zeros express.
DROP INDEX IF EXISTS uq_otl_leg;
CREATE UNIQUE INDEX IF NOT EXISTS uq_otl_leg ON order_transaction_links
(entity_key, leg_kind, COALESCE(leg_index, 0), COALESCE(source_fact_id, 0));
CREATE INDEX IF NOT EXISTS idx_otl_entity ON order_transaction_links (entity_key);
-- Backfill the existing bridge rows so the table is not empty on day one and
-- the readers can switch over in the same change. `#f<fact_id>` splits into a
-- shipment leg carrying its fact id; everything else is a whole-order charge.
INSERT INTO order_transaction_links
(entity_key, transaction_id, leg_kind, leg_index, source_fact_id, amount, currency, source, confidence, evidence)
SELECT split_part(em.source_message_id, '#', 1) AS entity_key,
COALESCE(em.matched_transaction_id, em.transaction_id) AS transaction_id,
CASE WHEN em.source_message_id LIKE '%#f%' THEN 'shipment' ELSE 'charge' END,
NULL::smallint,
NULLIF(substring(em.source_message_id FROM '#f([0-9]+)$'), '')::bigint,
COALESCE(em.amount, 0),
COALESCE(em.currency, 'AUD'),
'order-bridge',
'exact',
jsonb_build_object('backfilled_from', 'expense_metadata',
'source_message_id', em.source_message_id)
FROM expense_metadata em
WHERE em.source = 'order-bridge'
AND COALESCE(em.matched_transaction_id, em.transaction_id) IS NOT NULL
ON CONFLICT DO NOTHING;
-- Spine re-keying orphans links silently. Bridge rows can be rebuilt by
-- re-running the bridge; a `manual` orphan is lost curation and is the row
-- that actually needs a human. Same idea as order_settlement_violations.
CREATE OR REPLACE VIEW order_link_orphans AS
SELECT l.id, l.entity_key, l.transaction_id, l.leg_kind, l.leg_index,
l.amount, l.currency, l.source, l.confidence, l.created_at
FROM order_transaction_links l
WHERE NOT EXISTS (SELECT 1 FROM entities e WHERE e.entity_key = l.entity_key);
-- Two stores that can disagree will disagree. The bridge now writes both in
-- ONE transaction, so a half-written pair should be impossible — this view
-- exists to prove that rather than to assume it, and it must stay empty.
--
-- The plan's suggested guard (widen the bridge's `NOT EXISTS expense_metadata`
-- to "neither store has it") was NOT taken: the expense_metadata INSERT has no
-- ON CONFLICT, so re-attempting an order that already has a receipt row would
-- duplicate it. Detecting drift is cheap; re-attempting a non-idempotent write
-- to fix a state that cannot occur is not.
CREATE OR REPLACE VIEW order_link_drift AS
SELECT em.source_message_id,
COALESCE(em.matched_transaction_id, em.transaction_id) AS transaction_id,
em.amount, em.currency, em.reconciled_at
FROM expense_metadata em
WHERE em.source = 'order-bridge'
AND COALESCE(em.matched_transaction_id, em.transaction_id) IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM order_transaction_links l
WHERE l.transaction_id = COALESCE(em.matched_transaction_id, em.transaction_id)
);
@@ -0,0 +1,148 @@
-- Normalise statements.bank_name so one institution is one name.
--
-- WHY THIS IS NOT COSMETIC: `uq_statement_identity` is
-- (bank_name, account_number, billing_end_date). The bank name is a *component
-- of the duplicate check*, so two spellings of one institution mean the same
-- statement can be ingested twice without the index ever firing. Gemini reads
-- the name off whatever the PDF happens to print, so the spelling varies per
-- document, not per account.
--
-- Zip made this visible. One account (2705256) arrived under four names in
-- sixteen minutes — 'ZipMoney Payments Pty Ltd', 'ZipMoney Payments Pty
-- Limited', 'Zip Pay', 'ZipPay' — with ten or more still in the ingestion
-- queue. It was not unique: 17 bank_name values represented 13 institutions,
-- and every split pair shared an identical account number.
--
-- American Express (14) / AMERICAN EXPRESS (1) -> XXXX-XXXXXX-01000
-- Citibank (8) / Citi (4) -> 5258073330340253
-- National Australia Bank Limited (3) / ... (NAB) (1) -> 1015001000116227
--
-- Fragmentation also splits the statements-page bank filter, the transactions
-- bank filter, and by_bank in /api/analytics/fees.
--
-- THE VOCABULARY IS OPEN, unlike statement_type in 0013. An unrecognised name
-- passes through tidied, never collapsed to a fallback: a bank this household
-- has never used must be able to arrive without a migration, and destroying its
-- name on first contact is far worse than leaving it unmapped. That is also why
-- there is deliberately NO CHECK constraint here.
--
-- Aliases for banks that only ever arrive one way (Westpac, ANZ, HSBC, ING,
-- AMP, Up, CommBank) are no-ops today and map TO the spelling already in use,
-- so they cannot rename anything. They exist to catch the legal-entity variant
-- the next PDF might print — which is exactly how Zip got here.
--
-- Wise is deliberately left alone: 'Wise Australia Pty Ltd.' is a single
-- spelling, so shortening it would be a rename nobody asked for rather than a
-- merge. Add a branch if a second spelling ever shows up.
--
-- Idempotent: safe to re-run.
CREATE OR REPLACE FUNCTION normalize_bank_name(raw TEXT)
RETURNS TEXT AS $$
DECLARE
v TEXT;
BEGIN
IF raw IS NULL THEN
RETURN NULL;
END IF;
-- Collapse whitespace before matching so 'Zip Pay' and 'Zip Pay' agree.
v := lower(regexp_replace(trim(raw), '\s+', ' ', 'g'));
IF v = '' THEN
-- An empty string is not a name. NULL is how the schema already spells
-- "unknown": bankLabel() renders it 'Manual' and uq_statement_identity
-- excludes it.
RETURN NULL;
END IF;
-- ---- Merges: institutions seen under more than one spelling ----
-- 'Zip Pay', 'ZipPay', 'ZipMoney Payments Pty Ltd', 'ZipMoney Payments Pty
-- Limited'. Anchored so a merchant that merely contains "zip" cannot match.
IF v LIKE 'zip%' THEN
RETURN 'Zip';
END IF;
IF v LIKE '%american express%' OR v LIKE 'amex%' THEN
RETURN 'American Express';
END IF;
-- Citi, Citibank, Citigroup — one card, 5258073330340253.
IF v LIKE 'citi%' THEN
RETURN 'Citibank';
END IF;
IF v LIKE '%national australia bank%' OR v = 'nab' THEN
RETURN 'NAB';
END IF;
-- ---- Defensive: map future legal-entity variants onto the name in use ----
IF v LIKE '%westpac%' THEN
RETURN 'Westpac';
END IF;
IF v = 'anz' OR v LIKE 'anz %' OR v LIKE '%australia and new zealand banking%' THEN
RETURN 'ANZ';
END IF;
IF v LIKE '%commonwealth bank%' OR v LIKE 'commbank%' OR v = 'cba' THEN
RETURN 'Commonwealth Bank';
END IF;
IF v LIKE 'hsbc%' THEN
RETURN 'HSBC';
END IF;
IF v = 'ing' OR v LIKE 'ing %' THEN
RETURN 'ING';
END IF;
IF v = 'amp' OR v LIKE 'amp %' THEN
RETURN 'AMP Bank';
END IF;
IF v = 'up' OR v LIKE 'up bank%' THEN
RETURN 'Up';
END IF;
-- Unknown institution: keep exactly what was extracted, tidied only.
RETURN regexp_replace(trim(raw), '\s+', ' ', 'g');
END;
$$ LANGUAGE plpgsql IMMUTABLE;
-- Normalise on write so the N8N workflow keeps sending whatever the PDF prints.
CREATE OR REPLACE FUNCTION statements_normalize_bank_name_trigger()
RETURNS TRIGGER AS $$
BEGIN
NEW.bank_name := normalize_bank_name(NEW.bank_name);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_statements_normalize_bank_name ON statements;
CREATE TRIGGER trg_statements_normalize_bank_name
BEFORE INSERT OR UPDATE OF bank_name ON statements
FOR EACH ROW EXECUTE FUNCTION statements_normalize_bank_name_trigger();
-- Backfill. Verified collision-free against uq_statement_identity before
-- writing this: no two statements share (normalised name, account_number,
-- billing_end_date).
UPDATE statements
SET bank_name = normalize_bank_name(bank_name)
WHERE bank_name IS DISTINCT FROM normalize_bank_name(bank_name);
-- account_owner_mappings keys on (bank_name, account_number) with its own
-- UNIQUE constraint, so a rename on `statements` would strand its rows. The
-- table is empty today; this keeps it aligned if it is ever populated before
-- this migration runs somewhere else. ON CONFLICT because two spellings of one
-- bank could otherwise merge into one key.
UPDATE account_owner_mappings
SET bank_name = normalize_bank_name(bank_name)
WHERE bank_name IS DISTINCT FROM normalize_bank_name(bank_name)
AND NOT EXISTS (
SELECT 1 FROM account_owner_mappings other
WHERE other.bank_name = normalize_bank_name(account_owner_mappings.bank_name)
AND other.account_number = account_owner_mappings.account_number
);
@@ -0,0 +1,164 @@
-- Keep one institution's national entities apart. Fixes 0031.
--
-- 0031 merged spellings by prefix — `citi%` -> 'Citibank'. Every merge it made
-- was provably one account (each split pair shared an account_number), but the
-- PREFIX generalises past that evidence: Citibank Australia and Citibank India
-- are different banks, different accounts, different currency, and `citi%`
-- flattens them into one. Older Citi India statements are queued to load, so
-- this would have merged them on arrival.
--
-- It is not a Citi problem. `hsbc%` and `%american express%` carry the identical
-- defect — all three are global banks operating as separate national entities.
--
-- WHY NOT DISCRIMINATE ON CURRENCY ALONE: Wise holds AUD, EUR and USD accounts
-- under one provider, so a blanket "non-AUD means a different bank" rule would
-- shatter it into three. Wise stays unmapped for exactly this reason — mapping
-- a genuinely multi-currency provider through this function would be wrong, and
-- that is now a documented property rather than an accident.
--
-- THE RULE: the country comes from the name when the name states it, and from
-- the currency otherwise. AUD is home and takes no suffix, so nothing renames
-- today — all 21 existing statements keep the name 0031 gave them.
--
-- 'Citibank' + AUD -> 'Citibank' (unchanged)
-- 'Citi' + AUD -> 'Citibank' (unchanged)
-- 'Citibank Australia'+ AUD -> 'Citibank'
-- 'Citibank India' + INR -> 'Citibank India'
-- 'Citibank N.A.' + INR -> 'Citibank India' (currency carries it)
-- 'HSBC' + AUD -> 'HSBC' (unchanged)
--
-- Degradation is safe but not silent: an India statement whose currency was
-- extracted as AUD and whose name omits the country WILL merge. The tell is a
-- second account_number appearing under one bank_name with a different
-- currency — see the check in CLAUDE.md.
--
-- Idempotent: safe to re-run.
-- 'N.A.' is deliberately absent: it means "National Association", a US legal
-- form that appears on Citibank letterhead worldwide, including India. Reading
-- it as a country marker is precisely the mistake this migration exists to fix.
CREATE OR REPLACE FUNCTION bank_country_suffix(v TEXT, cur TEXT)
RETURNS TEXT AS $$
DECLARE
c TEXT := upper(trim(coalesce(cur, '')));
BEGIN
-- The name wins when it states a country: it is the source's own assertion,
-- and it survives a currency that failed to extract.
IF v LIKE '%australia%' OR v LIKE '% au%' THEN RETURN ''; END IF; -- home
IF v LIKE '%india%' THEN RETURN ' India'; END IF;
IF v LIKE '%singapore%' THEN RETURN ' Singapore'; END IF;
IF v LIKE '%hong kong%' THEN RETURN ' Hong Kong'; END IF;
IF v LIKE '%new zealand%' THEN RETURN ' NZ'; END IF;
IF v LIKE '%united kingdom%' OR v LIKE '% uk%' THEN RETURN ' UK'; END IF;
IF v LIKE '%emirates%' OR v LIKE '% uae%' THEN RETURN ' UAE'; END IF;
-- Otherwise infer from the statement's own currency. AUD is home.
IF c = '' OR c = 'AUD' THEN RETURN ''; END IF;
IF c = 'INR' THEN RETURN ' India'; END IF;
IF c = 'SGD' THEN RETURN ' Singapore'; END IF;
IF c = 'HKD' THEN RETURN ' Hong Kong'; END IF;
IF c = 'NZD' THEN RETURN ' NZ'; END IF;
IF c = 'GBP' THEN RETURN ' UK'; END IF;
IF c = 'AED' THEN RETURN ' UAE'; END IF;
IF c = 'USD' THEN RETURN ' US'; END IF;
-- EUR spans countries and cannot name one, so fall back to the code itself.
-- A distinguishable name beats a wrong one.
RETURN ' ' || c;
END;
$$ LANGUAGE plpgsql IMMUTABLE;
-- `currency` is a second parameter with a default so any existing single-arg
-- call keeps working; the trigger passes NEW.currency.
CREATE OR REPLACE FUNCTION normalize_bank_name(raw TEXT, cur TEXT DEFAULT NULL)
RETURNS TEXT AS $$
DECLARE
v TEXT;
BEGIN
IF raw IS NULL THEN
RETURN NULL;
END IF;
v := lower(regexp_replace(trim(raw), '\s+', ' ', 'g'));
IF v = '' THEN
RETURN NULL;
END IF;
-- ---- Merges: institutions seen under more than one spelling ----
-- Each carries a country suffix, empty for AUD, so one bank's national
-- entities stay apart while its spellings collapse.
IF v LIKE 'zip%' THEN
RETURN 'Zip' || bank_country_suffix(v, cur);
END IF;
IF v LIKE '%american express%' OR v LIKE 'amex%' THEN
RETURN 'American Express' || bank_country_suffix(v, cur);
END IF;
IF v LIKE 'citi%' THEN
RETURN 'Citibank' || bank_country_suffix(v, cur);
END IF;
IF v LIKE '%national australia bank%' OR v = 'nab' THEN
RETURN 'NAB';
END IF;
-- ---- Defensive: map future legal-entity variants onto the name in use ----
IF v LIKE '%westpac%' THEN
RETURN 'Westpac' || bank_country_suffix(v, cur);
END IF;
IF v = 'anz' OR v LIKE 'anz %' OR v LIKE '%australia and new zealand banking%' THEN
RETURN 'ANZ' || bank_country_suffix(v, cur);
END IF;
IF v LIKE '%commonwealth bank%' OR v LIKE 'commbank%' OR v = 'cba' THEN
RETURN 'Commonwealth Bank';
END IF;
IF v LIKE 'hsbc%' THEN
RETURN 'HSBC' || bank_country_suffix(v, cur);
END IF;
IF v = 'ing' OR v LIKE 'ing %' THEN
RETURN 'ING' || bank_country_suffix(v, cur);
END IF;
IF v = 'amp' OR v LIKE 'amp %' THEN
RETURN 'AMP Bank';
END IF;
IF v = 'up' OR v LIKE 'up bank%' THEN
RETURN 'Up';
END IF;
-- Unknown institution: keep exactly what was extracted, tidied only.
-- Wise reaches here, which is what keeps its AUD/EUR/USD accounts together.
RETURN regexp_replace(trim(raw), '\s+', ' ', 'g');
END;
$$ LANGUAGE plpgsql IMMUTABLE;
CREATE OR REPLACE FUNCTION statements_normalize_bank_name_trigger()
RETURNS TRIGGER AS $$
BEGIN
NEW.bank_name := normalize_bank_name(NEW.bank_name, NEW.currency);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Re-point the trigger: it must fire when `currency` changes too, since the
-- country is now partly derived from it.
DROP TRIGGER IF EXISTS trg_statements_normalize_bank_name ON statements;
CREATE TRIGGER trg_statements_normalize_bank_name
BEFORE INSERT OR UPDATE OF bank_name, currency ON statements
FOR EACH ROW EXECUTE FUNCTION statements_normalize_bank_name_trigger();
-- Backfill. Expected to touch nothing: every existing statement is AUD except
-- Wise, which is unmapped.
UPDATE statements
SET bank_name = normalize_bank_name(bank_name, currency)
WHERE bank_name IS DISTINCT FROM normalize_bank_name(bank_name, currency);
@@ -0,0 +1,71 @@
-- One account, one identity — without destroying how the number reads.
--
-- `account_number` carries the same fragmentation `bank_name` had, from the
-- same cause: Gemini copies whatever the PDF prints, and the formatting varies
-- per document.
--
-- ANZ '4085-56264' 4 statements ANZ ACCESS ADVANTAGE
-- ANZ '408556264' 1 statement ACCESS ADVANTAGE <- same account
--
-- That matters because `uq_statement_identity` is
-- (bank_name, account_number, billing_end_date). Re-importing one period under
-- the other spelling evades the duplicate check entirely.
--
-- (It is NOT what caused the documented 31-row / $42,040.68 ANZ duplication.
-- Statements 107/142/143 overlap on *different* end dates, which that index
-- cannot catch at any spelling. Different problem, same table.)
--
-- WHY A GENERATED COLUMN RATHER THAN REWRITING THE VALUE: the raw number is the
-- readable one and some of it is structure, not noise. Up stores
-- '633-123 / 176540052' — a BSB and an account number — and flattening that to
-- '633123176540052' would lose a distinction a human reads at a glance, to fix
-- a machine problem. So the raw text stays exactly as extracted and the
-- comparison key is derived beside it. Same reason the app derives trip
-- participation instead of storing it: two records of one fact drift.
--
-- Separators only. Case and the Amex mask ('XXXX-XXXXXX-01000') are preserved,
-- because 'X' is real information about which digits were redacted, and
-- case-folding an alphanumeric account id could merge two genuinely different
-- ones.
--
-- Verified collision-free before rebuilding the index: no two statements share
-- (bank_name, stripped account_number, billing_end_date).
--
-- Idempotent: safe to re-run.
ALTER TABLE statements
ADD COLUMN IF NOT EXISTS account_number_key TEXT
GENERATED ALWAYS AS (
NULLIF(regexp_replace(coalesce(account_number, ''), '[^0-9A-Za-z]', '', 'g'), '')
) STORED;
COMMENT ON COLUMN statements.account_number_key IS
'Derived from account_number, separators stripped. The identity used by '
'uq_statement_identity so one account under two formats is one account. '
'Never write to it — it is GENERATED. Display account_number instead.';
-- Rebuild the identity index on the derived key. Dropped and recreated rather
-- than added alongside: leaving the old one in place would keep admitting the
-- duplicate it exists to stop.
DROP INDEX IF EXISTS uq_statement_identity;
CREATE UNIQUE INDEX uq_statement_identity
ON statements (bank_name, account_number_key, billing_end_date)
WHERE bank_name IS NOT NULL AND billing_end_date IS NOT NULL;
-- The fragmentation detector, now able to see both halves: one account under
-- two bank names, or one bank under two spellings of one number.
CREATE OR REPLACE VIEW statement_identity_drift AS
SELECT account_number_key,
array_agg(DISTINCT bank_name) AS bank_names,
array_agg(DISTINCT account_number) AS account_numbers,
count(*) AS statements
FROM statements
WHERE account_number_key IS NOT NULL
GROUP BY account_number_key
HAVING count(DISTINCT bank_name) > 1
OR count(DISTINCT account_number) > 1;
COMMENT ON VIEW statement_identity_drift IS
'Non-empty means an account is fragmented: normalize_bank_name() cannot know '
'that two names for a bank it has never seen are one institution, and this is '
'how that always shows up. Check it after loading a new bank.';
@@ -0,0 +1,74 @@
-- Intake router (plans/intake-router-slice-plan.md §11 C1, smarthome repo).
--
-- One upload point for receipts — a screenshot, a PDF export, a pasted body —
-- that reads what it was given and does every step that applies: fill the
-- empty `line_items` on the order the email pipeline already booked, hand a
-- grocery receipt to pantry, or ask when it cannot tell.
--
-- Every batch is persisted BEFORE anything is extracted, for two reasons:
-- 1. The Slack card it produces carries buttons ("Use this", "Create",
-- "Groceries or a meal?") that are pressed minutes later in a fresh
-- request with nothing but the batch id to go on.
-- 2. A re-share of an already-handled order — a second crop, the PDF after
-- the screenshot — must be recognised, and the only durable evidence of
-- the first share is this row (`file_sha256` overlap, or the same
-- (platform, merchant, date, total) header already resolved).
--
-- `id` is the uuid n8n mints per webhook call, so a retried delivery is the
-- same batch rather than a second one. `header` holds the extracted
-- (merchant, order_date, total, platform, ...) and `items` the verbatim item
-- list, so a later tap can enrich or create without re-reading the files.
--
-- Idempotent: safe to re-run.
CREATE TABLE IF NOT EXISTS intake_batches (
id uuid PRIMARY KEY,
source text NOT NULL,
shared_by text,
file_sha256 text[] NOT NULL DEFAULT '{}',
header jsonb,
items jsonb,
kind text,
status text NOT NULL,
transaction_id integer,
capture_id integer,
-- The undo baseline: what `expense_metadata.line_items` held before this
-- batch first replaced it. Recorded here rather than returned from the write,
-- because pantry re-calls the enrich endpoint on every confirm and after a
-- timeout — a caller that hands back whatever it just read gives a retry its
-- OWN write as the "previous" state, and undo then restores the thing it was
-- meant to reverse. Written under COALESCE, so only the first snapshot sticks.
previous_line_items jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
resolved_at timestamptz
);
-- For a database that already ran an earlier draft of this file.
ALTER TABLE intake_batches ADD COLUMN IF NOT EXISTS previous_line_items jsonb;
COMMENT ON TABLE intake_batches IS
'One row per upload batch through POST /api/intake. Persisted before '
'extraction so Slack taps can act on it later and re-shares are detected. '
'kind: grocery|food|ask|held|unsupported. status: received|enriched|created|'
'candidates|forwarded_to_pantry|needs_header|duplicate|unsupported|held|error.';
-- Overlap lookups for the re-share check (`file_sha256 && $1`).
CREATE INDEX IF NOT EXISTS intake_batches_file_sha256_gin
ON intake_batches USING gin (file_sha256);
CREATE INDEX IF NOT EXISTS intake_batches_transaction_id
ON intake_batches (transaction_id) WHERE transaction_id IS NOT NULL;
-- The "Groceries or a meal?" answer, remembered per merchant (plan §2, Codex
-- #6). A tiny answer table, not merchant master-data: the pattern list in code
-- decides the obvious cases and this catches the `Romeo's Food Hall`s. Keyed on
-- the normalised merchant name the router already computes for matching.
CREATE TABLE IF NOT EXISTS intake_merchant_kinds (
merchant_key text PRIMARY KEY,
kind text NOT NULL CHECK (kind IN ('grocery', 'food')),
created_at timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE intake_merchant_kinds IS
'Per-merchant answer to "Groceries or a meal?" from the intake Slack card. '
'Consulted after the grocery pattern list; an unknown merchant asks once.';
+78
View File
@@ -18,6 +18,7 @@ model trips {
archived Boolean @default(false) archived Boolean @default(false)
created_at DateTime @default(now()) created_at DateTime @default(now())
overrides transaction_overrides[] overrides transaction_overrides[]
payments split_payments[]
} }
model transaction_overrides { model transaction_overrides {
@@ -41,6 +42,7 @@ model participants {
account_owner_mappings account_owner_mappings[] account_owner_mappings account_owner_mappings[]
payments_sent split_payments[] @relation("payments_from") payments_sent split_payments[] @relation("payments_from")
payments_received split_payments[] @relation("payments_to") payments_received split_payments[] @relation("payments_to")
order_reviews order_reviews[]
} }
model account_owner_mappings { model account_owner_mappings {
@@ -75,9 +77,13 @@ model split_payments {
payment_date DateTime @db.Date payment_date DateTime @db.Date
notes String? notes String?
linked_transaction_id Int? linked_transaction_id Int?
trip_id Int?
created_at DateTime @default(now()) created_at DateTime @default(now())
from_participant participants @relation("payments_from", fields: [from_participant_id], references: [id]) from_participant participants @relation("payments_from", fields: [from_participant_id], references: [id])
to_participant participants @relation("payments_to", fields: [to_participant_id], references: [id]) to_participant participants @relation("payments_to", fields: [to_participant_id], references: [id])
trip trips? @relation(fields: [trip_id], references: [id], onDelete: SetNull)
@@index([trip_id])
} }
model tags { model tags {
@@ -129,6 +135,11 @@ model statements {
card_name String? card_name String?
account_type String? account_type String?
account_number String account_number String
/// GENERATED ALWAYS (migration 0033) — account_number with separators
/// stripped, so '4085-56264' and '408556264' are one account. Backs
/// uq_statement_identity. Read-only: never write to it, display
/// account_number instead.
account_number_key String?
billing_start_date DateTime? @db.Date billing_start_date DateTime? @db.Date
billing_end_date DateTime? @db.Date billing_end_date DateTime? @db.Date
total_amount_due Decimal? @db.Decimal(12, 2) total_amount_due Decimal? @db.Decimal(12, 2)
@@ -178,12 +189,19 @@ model transactions {
payment_method String? // card | cash | bank_transfer | other; NULL = unknown (migration 0016) payment_method String? // card | cash | bank_transfer | other; NULL = unknown (migration 0016)
owner_id Int? owner_id Int?
reconciled_with_id Int? reconciled_with_id Int?
superseded_by_id Int?
principal_amount Decimal? @db.Decimal(12, 2) principal_amount Decimal? @db.Decimal(12, 2)
interest_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) statement statements? @relation(fields: [statement_id], references: [id], onDelete: Cascade)
reconciled_with transactions? @relation("reconciled", fields: [reconciled_with_id], references: [id], onDelete: SetNull) reconciled_with transactions? @relation("reconciled", fields: [reconciled_with_id], references: [id], onDelete: SetNull)
reconciled_by transactions[] @relation("reconciled") 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? expense_metadata expense_metadata?
order_reviews order_reviews[]
} }
model expense_metadata { model expense_metadata {
@@ -193,6 +211,7 @@ model expense_metadata {
paperless_doc_id Int? @unique paperless_doc_id Int? @unique
source_email_subject String? source_email_subject String?
source_email_from String? source_email_from String?
source_message_id String?
payment_method String? payment_method String?
payment_method_detail String? payment_method_detail String?
order_reference String? order_reference String?
@@ -204,7 +223,39 @@ model expense_metadata {
transaction_date DateTime? @db.Date transaction_date DateTime? @db.Date
extraction_model String? @default("gemini-2.5-flash") extraction_model String? @default("gemini-2.5-flash")
created_at DateTime? @default(now()) 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) transaction transactions? @relation(fields: [transaction_id], references: [id], onDelete: Cascade)
@@unique([source, order_reference], name: "uq_expense_source_order")
}
model order_reviews {
id Int @id @default(autoincrement())
transaction_id Int
participant_id Int
rating String?
order_again Boolean?
note String?
item_verdicts Json @default("[]")
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 { model rule_apply_runs {
@@ -217,3 +268,30 @@ model rule_apply_runs {
reverted_at DateTime? reverted_at DateTime?
snapshot Json @default("[]") snapshot Json @default("[]")
} }
// 0034 — intake router. One row per upload batch, written before extraction so
// Slack taps can act on it later and a re-share is detected. Columns are the
// §11 C1 contract in plans/intake-router-slice-plan.md (smarthome repo).
model intake_batches {
id String @id @db.Uuid
source String
shared_by String?
file_sha256 String[] @default([])
header Json?
items Json?
kind String?
status String
transaction_id Int?
capture_id Int?
// The undo baseline, written once per batch (see 0034 and line-items.ts).
previous_line_items Json?
created_at DateTime @default(now()) @db.Timestamptz(6)
resolved_at DateTime? @db.Timestamptz(6)
}
// 0034 — the remembered answer to "Groceries or a meal?" per merchant.
model intake_merchant_kinds {
merchant_key String @id
kind String
created_at DateTime @default(now()) @db.Timestamptz(6)
}
+66
View File
@@ -0,0 +1,66 @@
#!/bin/bash
# Resolve the integration-test DSN at run time and (re)write .env.test.
#
# Why this exists (#161): .env.test used to be written ONCE by
# setup-test-db.sh with the Postgres container's bridge IP baked in. Container
# IPs are reassigned on recreation, so the file rotted silently — by
# 2026-08-21 its 172.22.0.18 belonged to *gitea*, and an earlier drift had the
# suite pointing its TRUNCATE at postgres-pantry. It only failed safe because
# the credentials happened not to match.
#
# The fix is to stop persisting a host at all: this runs as `pretest:integration`,
# so the DSN is re-derived from the live container immediately before every run.
#
# Resolution order:
# 1. An explicit TEST_DATABASE_URL in the environment wins (CI, remote DB).
# 2. The container's published host port (`docker port`) — postgres-personal
# publishes 127.0.0.1:5432 since the 2026-08-17 PG17 migration. Preferred
# because a loopback address cannot be reassigned to another container.
# 3. The container's bridge IP, for a host where the port is not published.
#
# helpers.ts::assertTestDatabase is the backstop: it refuses to TRUNCATE
# anything whose current_database() is not `personal_test`.
set -euo pipefail
PG_CONTAINER=${PG_CONTAINER:-postgres-personal}
PG_USER=${PG_USER:-personal}
PG_PASS=${PG_PASS:-personalpassword123}
TEST_DB=${TEST_DB:-personal_test}
ENV_FILE="$(cd "$(dirname "$0")/.." && pwd)/.env.test"
if [ -n "${TEST_DATABASE_URL:-}" ]; then
DSN="$TEST_DATABASE_URL"
SOURCE="TEST_DATABASE_URL"
else
if ! docker inspect "$PG_CONTAINER" >/dev/null 2>&1; then
echo "ERROR: container '$PG_CONTAINER' not found. Start it, or set TEST_DATABASE_URL." >&2
exit 1
fi
# `docker port` prints e.g. "127.0.0.1:5432" (possibly several lines).
PUBLISHED=$(docker port "$PG_CONTAINER" 5432/tcp 2>/dev/null | head -1 || true)
if [ -n "$PUBLISHED" ]; then
HOSTPORT="$PUBLISHED"
SOURCE="published port"
else
IP=$(docker inspect "$PG_CONTAINER" \
--format '{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}' 2>/dev/null \
| tr ' ' '\n' | grep -v '^$' | head -1)
if [ -z "$IP" ]; then
echo "ERROR: '$PG_CONTAINER' has neither a published 5432 nor a bridge IP." >&2
exit 1
fi
HOSTPORT="$IP:5432"
SOURCE="bridge IP"
fi
DSN="postgresql://$PG_USER:$PG_PASS@$HOSTPORT/$TEST_DB"
fi
printf 'DATABASE_URL=%s\n' "$DSN" > "$ENV_FILE"
# Never print the password.
echo "resolve-test-db: ${DSN%%://*}://***@${DSN#*@} (via $SOURCE)"
+14 -18
View File
@@ -1,25 +1,24 @@
#!/bin/bash #!/bin/bash
# Creates the personal_test database and writes .env.test # Creates the personal_test database and copies the production schema into it.
# Run once before integration tests: npm run test:setup # Run once (and after any migration) before integration tests: npm run test:setup
#
# It no longer writes a host into .env.test. That was the #161 defect: the file
# was written once with the container's bridge IP and then rotted as the IP was
# reassigned. scripts/resolve-test-db.sh now re-derives the DSN before every
# integration run (wired as `pretest:integration`), and this script just calls it
# at the end so a fresh setup leaves a usable .env.test behind.
set -e set -e
PG_CONTAINER=postgres-personal PG_CONTAINER=${PG_CONTAINER:-postgres-personal}
PG_USER=personal PG_USER=${PG_USER:-personal}
PG_PASS=personalpassword123 TEST_DB=${TEST_DB:-personal_test}
TEST_DB=personal_test
# Discover the container's bridge IP (accessible from the host on Linux) if ! docker inspect "$PG_CONTAINER" >/dev/null 2>&1; then
PG_HOST=$(docker inspect "$PG_CONTAINER" \
--format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' 2>/dev/null | head -1)
if [ -z "$PG_HOST" ]; then
echo "ERROR: Could not find container '$PG_CONTAINER'" echo "ERROR: Could not find container '$PG_CONTAINER'"
exit 1 exit 1
fi fi
echo "Postgres container at $PG_HOST:5432"
# Create test database (ignore error if it already exists) # Create test database (ignore error if it already exists)
docker exec "$PG_CONTAINER" psql -U "$PG_USER" -d postgres \ docker exec "$PG_CONTAINER" psql -U "$PG_USER" -d postgres \
-c "CREATE DATABASE $TEST_DB;" 2>/dev/null || true -c "CREATE DATABASE $TEST_DB;" 2>/dev/null || true
@@ -31,9 +30,6 @@ docker exec "$PG_CONTAINER" pg_dump -U "$PG_USER" --schema-only "$PG_USER" \
echo "Schema ready." echo "Schema ready."
# Write .env.test bash "$(dirname "$0")/resolve-test-db.sh"
cat > "$(dirname "$0")/../.env.test" << EOF
DATABASE_URL=postgresql://$PG_USER:$PG_PASS@$PG_HOST:5432/$TEST_DB
EOF
echo ".env.test written — integration tests can now run with: npm run test:integration" echo "Integration tests can now run with: npm run test:integration"
+376
View File
@@ -0,0 +1,376 @@
#!/usr/bin/env python3
"""Match SplitMyExpenses CSV rows to transactions already in the ledger.
Dry-run by default. It prints what it would do and writes nothing; `--write`
is a separate step (task #9) and is deliberately not implemented here.
Why this exists
---------------
The CSVs are the record of how expenses were actually shared before this app
existed. Importing them is what makes historical *spend* correct: without a
split row, a $200 grocery shop counts as $200 of my spending when half of it
was never mine. The balances are already settled by carryover transaction 2348,
so these splits are imported as `settled = true` and move no balance.
Three things make the matching harder than "same date, same amount":
1. **Dates are ambiguous across files.** The household file writes D/M/YYYY;
the four trip files write ISO. 474 rows parse validly under both readings,
so the format is decided per file, from the file, and never guessed per row.
2. **The sign convention is not "who paid".** A person's column is their net
balance impact: positive means they are owed. So the payer is whoever is
positive, and the other person's share is |their negative| / cost. A row
reading +cost / -cost therefore means the other party owes 100% -- NOT that
the expense was unshared, which is the reading that would fake an
arrangement change.
3. **A settlement is not an expense.** Rows where one person hands the other
money must not become split transactions; they are already represented by
the carryover.
Usage:
.venv/bin/python scripts/split_csv_match.py [--verbose] [--file NAME]
"""
from __future__ import annotations
import argparse
import csv
import glob
import os
import re
import sys
from collections import Counter
from dataclasses import dataclass, field
from datetime import date, datetime, timedelta
import psycopg2
import psycopg2.extras
DUMP_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "dump")
# Read from the same place the app does rather than hardcoding a container IP,
# which changes on every recreate.
def db_url() -> str:
url = os.environ.get("DATABASE_URL")
if url:
return url
for envfile in (".env", ".env.test"):
path = os.path.join(os.path.dirname(DUMP_DIR), envfile)
if not os.path.exists(path):
continue
for line in open(path):
if line.startswith("DATABASE_URL"):
return line.split("=", 1)[1].strip().strip('"').strip("'")
sys.exit("No DATABASE_URL found (env, .env, or .env.test)")
# The two people in these files. The CSV writes full names; the ledger uses
# first names.
CSV_ME = "Siddharth Bose"
CSV_THEM = "Meghalee"
PARTICIPANT_ME = 1
PARTICIPANT_THEM = 4
# A settlement transfers money; it is not a shared cost. These are the
# descriptions SplitMyExpenses uses for them.
SETTLEMENT_PAT = re.compile(
r"payment|settle|debts? remainder|reimburse|transfer to|paid back", re.I
)
@dataclass
class CsvRow:
source: str
line: int
when: date
description: str
category: str
cost: float
currency: str
net_me: float
net_them: float
# Filled in by classify()
mode: str = ""
payer: int = 0 # participant id who paid
ower: int = 0 # participant id who owes
ower_share: float = 0.0 # 0-100
def __str__(self) -> str:
return f"{self.source}:{self.line} {self.when} {self.description[:38]!r} ${self.cost:.2f} [{self.mode}]"
@dataclass
class MatchReport:
rows: list = field(default_factory=list)
matched: list = field(default_factory=list)
ambiguous: list = field(default_factory=list)
unmatched: list = field(default_factory=list)
skipped: list = field(default_factory=list)
def sniff_date_format(sample: list[str]) -> str:
"""Decide ISO vs D/M/YYYY for a whole file.
Deciding per row is what produces a ledger where January and February are
silently swapped for some rows and not others. A file is written by one
exporter in one format, so the file is the unit of decision.
"""
if not sample:
return "%Y-%m-%d"
slashes = sum(1 for s in sample if "/" in s)
return "%d/%m/%Y" if slashes > len(sample) / 2 else "%Y-%m-%d"
def parse_rows(path: str) -> list[CsvRow]:
with open(path, newline="", encoding="utf-8-sig") as fh:
reader = list(csv.DictReader(fh))
if not reader:
return []
# Header names vary in quoting between exports.
def col(row: dict, *names: str):
for n in names:
for k in row:
if k.strip().strip('"') == n:
return row[k]
return None
fmt = sniff_date_format([col(r, "Date") or "" for r in reader[:40]])
out: list[CsvRow] = []
for i, r in enumerate(reader, start=2):
raw_date = (col(r, "Date") or "").strip()
try:
when = datetime.strptime(raw_date, fmt).date()
except ValueError:
continue
try:
cost = float(col(r, "Cost") or 0)
net_me = float(col(r, CSV_ME) or 0)
net_them = float(col(r, CSV_THEM) or 0)
except ValueError:
continue
out.append(
CsvRow(
source=os.path.basename(path),
line=i,
when=when,
description=(col(r, "Description") or "").strip(),
category=(col(r, "Category") or "").strip(),
cost=cost,
currency=(col(r, "Currency") or "AUD").strip(),
net_me=net_me,
net_them=net_them,
)
)
return out
def classify(row: CsvRow) -> CsvRow:
"""Work out who paid and what share the other person owes.
A person's column is their net balance impact, not their share: positive
means they are owed money. So the payer is whoever is positive.
"""
if row.cost == 0:
row.mode = "zero-cost"
return row
if SETTLEMENT_PAT.search(row.description):
row.mode = "settlement"
return row
# Both zero against a real cost: recorded but not shared.
if abs(row.net_me) < 0.005 and abs(row.net_them) < 0.005:
row.mode = "unshared"
return row
if row.net_me > 0:
row.payer, row.ower, owed = PARTICIPANT_ME, PARTICIPANT_THEM, abs(row.net_them)
else:
row.payer, row.ower, owed = PARTICIPANT_THEM, PARTICIPANT_ME, abs(row.net_me)
row.ower_share = round(owed / row.cost * 100, 2)
if abs(row.ower_share - 50) < 0.6:
row.mode = "50/50"
elif abs(row.ower_share - 100) < 0.6:
row.mode = "other-owes-all"
else:
row.mode = f"uneven-{row.ower_share:.0f}"
return row
def norm(s: str) -> set[str]:
return {w for w in re.split(r"[^a-z0-9]+", (s or "").lower()) if len(w) > 2}
def score(row: CsvRow, tx: dict) -> float:
"""How well a ledger row matches a CSV row. Amount and date gate it;
description only ranks among survivors."""
days = abs((tx["transaction_date"] - row.when).days)
s = 100.0 - days * 4
overlap = norm(row.description) & (norm(tx["description"]) | norm(tx["merchant_normalized"]))
s += 12 * len(overlap)
return s
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--verbose", action="store_true")
ap.add_argument("--file", help="only this CSV (substring match)")
ap.add_argument("--window", type=int, default=5, help="date tolerance in days")
ap.add_argument(
"--write", action="store_true",
help="actually insert the splits (settled=true). Without this, nothing is written.",
)
args = ap.parse_args()
paths = sorted(glob.glob(os.path.join(DUMP_DIR, "*SplitMyExpenses*.csv")))
if args.file:
paths = [p for p in paths if args.file in os.path.basename(p)]
if not paths:
sys.exit("No SplitMyExpenses CSVs found in dump/")
conn = psycopg2.connect(db_url())
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
# Superseded rows are duplicates; matching against them would attach a split
# to a row nothing else counts.
cur.execute(
"""
SELECT t.id, t.transaction_date, COALESCE(t.description,'') AS description,
COALESCE(t.merchant_normalized,'') AS merchant_normalized,
COALESCE(t.amount_aud, t.amount)::float AS amount,
t.superseded_by_id,
EXISTS(SELECT 1 FROM transaction_splits x WHERE x.transaction_id=t.id) AS has_split
FROM transactions t
LEFT JOIN statements s ON s.id = t.statement_id
WHERE COALESCE(t.owner_id, s.owner_id) IN (%s, %s)
AND t.superseded_by_id IS NULL
AND NOT (t.statement_id IS NULL AND t.reconciled_with_id IS NOT NULL)
AND t.transaction_type IN ('debit','fee','interest')
""",
(PARTICIPANT_ME, PARTICIPANT_THEM),
)
txs = cur.fetchall()
# Index by rounded amount: the amount must agree, so it is the only cheap
# gate that never needs fuzzy comparison.
by_amount: dict[float, list[dict]] = {}
for t in txs:
by_amount.setdefault(round(t["amount"], 2), []).append(t)
rep = MatchReport()
modes: Counter = Counter()
candidates: list = []
for path in paths:
for row in parse_rows(path):
classify(row)
rep.rows.append(row)
modes[row.mode] += 1
if row.mode in ("settlement", "zero-cost", "unshared"):
rep.skipped.append(row)
continue
if row.currency != "AUD":
rep.skipped.append(row)
continue
cands = [
t for t in by_amount.get(round(row.cost, 2), [])
if abs((t["transaction_date"] - row.when).days) <= args.window
]
if not cands:
rep.unmatched.append(row)
else:
candidates.append((row, cands))
# Assign one-to-one, best pair first.
#
# Without this a ledger row can be claimed by several CSV rows. That is not
# hypothetical: the NZ trip has two identical $10.16 Uber trips on one day
# and three matching ledger rows, and four PayMyPark rows in the same shape.
# Attaching a split twice is harmless (the unique key absorbs it) but it
# leaves the second CSV row silently unrepresented while looking matched,
# which is a lie in the report rather than a defect in the data.
scored = sorted(
((score(row, t), row, t) for row, cands in candidates for t in cands),
key=lambda x: -x[0],
)
taken_tx: set[int] = set()
taken_row: set[int] = set()
for s, row, t in scored:
if id(row) in taken_row or t["id"] in taken_tx:
continue
taken_row.add(id(row))
taken_tx.add(t["id"])
rep.matched.append((row, t))
for row, cands in candidates:
if id(row) not in taken_row:
rep.ambiguous.append((row, cands))
total = len(rep.rows)
considered = total - len(rep.skipped)
print(f"CSV rows {total}")
print(f" skipped {len(rep.skipped)} (settlements, zero-cost, unshared, non-AUD)")
print(f" considered {considered}")
print(f" matched {len(rep.matched)} ({len(rep.matched)/max(considered,1)*100:.1f}%)")
print(f" ambiguous {len(rep.ambiguous)}")
print(f" unmatched {len(rep.unmatched)}")
print()
print("Row modes:")
for m, n in modes.most_common():
print(f" {m:<18} {n}")
already = sum(1 for _, t in rep.matched if t["has_split"])
print()
print(f"Of the matched, {already} already carry a split and would be left alone;")
print(f"{len(rep.matched) - already} would gain one.")
if args.write:
# Imported as settled: these obligations were discharged on a platform
# we no longer run, and the residual is already carried by transaction
# 2348. Writing them unsettled would re-open ~$40k of debts that were
# paid years ago. See ACTIVE_OBLIGATION in analytics-sql.ts -- settled
# rows stay in spend and leave every owed figure, which is exactly the
# point: this import exists to correct historical SPEND.
SETTLED_ON = "2026-01-09" # the carryover's date
written = 0
for row, tx in rep.matched:
if tx["has_split"]:
continue
payer_share = round(100 - row.ower_share, 2)
pairs = [(row.ower, row.ower_share)]
if payer_share > 0:
pairs.append((row.payer, payer_share))
for pid, share in pairs:
cur.execute(
"""
INSERT INTO transaction_splits
(transaction_id, participant_id, share_percent, settled, settled_at)
VALUES (%s, %s, %s, true, %s)
ON CONFLICT (transaction_id, participant_id) DO NOTHING
""",
(tx["id"], pid, share, SETTLED_ON),
)
written += cur.rowcount
conn.commit()
print(f"\nWROTE {written} split rows (settled=true, settled_at={SETTLED_ON}).")
if args.verbose:
print("\n--- ambiguous ---")
for row, cands in rep.ambiguous[:40]:
print(f" {row}")
for t in cands[:3]:
print(f" -> #{t['id']} {t['transaction_date']} {t['description'][:44]!r}")
print("\n--- unmatched ---")
for row in rep.unmatched[:60]:
print(f" {row}")
conn.close()
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,56 @@
[
{
"file": "dd-credits-zero.html",
"id": "19b692bdf1ea7a08",
"subject": "Order Confirmation for Siddharth from Chilli India",
"receivedAt": "2025-12-29T08:14:00.000Z",
"sender": "DoorDash <no-reply@doordash.com>",
"why": "DoorDash credits-funded, Total Charged $0.00",
"stated": null
},
{
"file": "ue-aud-prefix.html",
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAhmyVi0AAAA",
"subject": "Your Friday evening order with Uber Eats",
"receivedAt": "2025-10-17T09:50:39Z",
"sender": "noreply@uber.com",
"why": "Uber total written as Total A\\$",
"stated": "Total A$54.87"
},
{
"file": "ue-nzd-prefix.html",
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAf4nGLUAAAA",
"subject": "Your Saturday evening order with Uber Eats",
"receivedAt": "2025-05-03T19:26:05Z",
"sender": "noreply@uber.com",
"why": "Uber total written as Total NZ\\$",
"stated": "Total NZ$22.83"
},
{
"file": "ut-aud-prefix.html",
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAhLnaIfAAAA",
"subject": "Your Saturday morning trip with Uber",
"receivedAt": "2025-09-05T23:17:58Z",
"sender": "noreply@uber.com",
"why": "Uber total written as Total A\\$",
"stated": "Total A$14.19"
},
{
"file": "ut-nzd-prefix.html",
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAf1poEUAAAA",
"subject": "Your Sunday afternoon trip with Uber",
"receivedAt": "2025-04-27T13:27:16Z",
"sender": "noreply@uber.com",
"why": "Uber total written as Total NZ\\$",
"stated": "Total NZ$10.83"
},
{
"file": "ut-inr-symbol.html",
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAgPBHtAAAAA",
"subject": "Your Friday evening trip with Uber",
"receivedAt": "2025-06-06T18:00:51Z",
"sender": "noreply@uber.com",
"why": "Uber total written as Total \u20b9",
"stated": "Total \u20b9622.74"
}
]
@@ -0,0 +1,549 @@
<!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:o="urn:schemas-microsoft-com:office:office" xmlns:v="urn:schemas-microsoft-com:vml">
<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;
}
}
@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;
}
}
@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_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_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 bgcolor="#ffffff" class="em_body" data-gr-c-s-loaded="true" style="margin:0px auto; padding:0px;">
<span style="color:transparent;visibility:hidden;display:none;opacity:0;height:0;width:0;font-size:0;"></span> <!-- == Body Section == -->
<table bgcolor="#ffffff" border="0" cellpadding="0" cellspacing="0" class="em_full_wrap" width="100%">
<tbody>
<tr>
<td align="center" valign="top">
<table align="center" border="0" cellpadding="0" cellspacing="0" class="em_main_table" style="width:700px;" width="700">
<tbody>
<tr>
<td align="center" valign="top"><!---->
<table align="center" bgcolor="#ededed" border="0" cellpadding="0" cellspacing="0" width="100%">
<tbody>
<tr>
<td class="em_h30" height="62" style="height:62px; line-height:0px; font-size:0px;"></td>
</tr> <!-- banner Section -->
<tr>
<td align="center" valign="top">
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
<tbody>
<tr>
<td align="center" bgcolor="#ededed" class="em_hauto" valign="top"><!---->
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
<tbody>
<tr>
<td class="em_side30" style="width:60px;" width="60"></td>
<td align="center" class="em_hauto" valign="top">
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
<tbody>
<tr>
<td align="left" valign="top"><a style="text-decoration:none;" target="_blank" href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiX9NQvXQ9aE-2FeLMhxL9C-2FAEkIYH_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRygzsqDKLqVcMuBPdkhwo7LHk9TXfSbi-2B0mW4NvsDLo9Q6gWLaNP3J-2Fyzkvnw5dniDohab0lVqyyqLUGGGtDfKri8s0BPbLw2Kta64VgtBfCarELB9jSoY9mQzB-2FDOSk7-2FtMWjohoN3uf5f2rLrFhl33mtjWZ53lXvtDO4wQsmVDJSCEfVON02MQaDh-2BmTLPN5Vxq7DH8UlIbETnDVF4Df7l7njadHPDZB2aHMoBR9nS93-2B-2FrbnWmdM0cdfetMj3RGMa8Yk-2Fgyfj6ZKM2CqlUxqNZhbHVjZo1bFR4YvyxsTPc56A8ujeF0vzPIUM4yXtdY-2BtGKNvO-2F2momIcFB8TCK6k39zWYllF-2FQub6BRW64qF3OwcOA8Qi4U05aRe966x3Bpi5mkgpGuXH0X2CslH23bljSHOl2uOmwPSt23EvYS0CPbrX4YRtgLnEmHgYYbokWIuo5wjdIr3VkCFPni8ollM9GFO6AXsUeSwZAvhq6oZY-2FetynINJjWZRmZA3FqSomogDxHnQi04e6CHFKy-2BGOn5XT6stPkmBsFDgIMFmy1Jd3aVxMduJiqTN-2FrgtJfvcZfTHhj5HcBZymhPVxScgd1TCe4E2-2FYMFqXzB9Or-2FAP5yIgF2yNJhy8OA3CMlV8LdA8Rd-2Fsxt1caI8cPn9hyYypY-2FLckUNAxRzwuBCAQ1buubQlldewgQiuToSUsejOOxk-3D" universal="true"><img alt="DOORDASH" border="0" class="en_icon" style="display:block; max-width:45px;font-family:Arial, sans-serif;font-size:20px; line-height:30px; color:#ee3623; font-weight:bold;" width="45" 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 align="left" class="em_defaultlink em_font_58 em_bold" style="font-family:'TTNorms-Bold', Arial, sans-serif; color:#000000;font-size:51px; line-height:60px; font-weight: bold;" valign="top"><!---->Thanks for your<br> order, Siddharth<!----></td>
</tr>
<tr>
<td class="em_h10" height="20" style="height:20px; line-height:0px; font-size:0px;"></td>
</tr>
<tr>
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#000000;font-size:16px; line-height:20px;" valign="top"><!---->The estimated delivery time for your order<br class="em_hide"> is <strong>2:00 pm - 2:15 pm</strong>. Track your order in<br class="em_hide"> the DoorDash app or website.<!----></td>
</tr>
<tr>
<td class="em_h10" height="20" style="height:20px; line-height:0px; font-size:0px;"></td>
</tr>
<tr>
<td align="left" valign="top">
<table align="left" border="0" cellpadding="0" cellspacing="0" class="em_cta" style="width:220px; max-width:220px;" width="220">
<tbody>
<tr>
<td align="center" class="em_defaultlink em_cta em_bold" height="45" style="font-family:'TTNorms-Bold', Arial, sans-serif;color:#ffffff;font-size:18px; background-color:#eb1700; border-radius:25px; font-weight: bold; " valign="middle"><a style="text-decoration:none; display:block; color:#ffffff; line-height:45px;" target="_blank" href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiUYpjiz5uS6IJixGXj3UHbTD93pKefx1-2F0xnbLGDGm-2F-2BQiJu-2BjCmmrEKOYlPr7srvk-3D8BvI_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRygzsqDKLqVcMuBPdkhwo7LHk9TXfSbi-2B0mW4NvsDLo9Q6gWLaNP3J-2Fyzkvnw5dniDohab0lVqyyqLUGGGtDfKri8s0BPbLw2Kta64VgtBfCarELB9jSoY9mQzB-2FDOSk7-2FtMWjohoN3uf5f2rLrFhl33mtjWZ53lXvtDO4wQsmVDJSCEfVON02MQaDh-2BmTLPN5Vxq7DH8UlIbETnDVF4Df7l7njadHPDZB2aHMoBR9nS93-2B-2FrbnWmdM0cdfetMj3RGMa8Yk-2Fgyfj6ZKM2CqlUxqNZhbHVjZo1bFR4YvyxsTPc56A8ujeF0vzPIUM4yXtdY-2BtGKNvO-2F2momIcFB8TCK6k39zWYllF-2FQub6BRW64qF3OwcOA8Qi4U05aRe966x3Bpi5mkgpGuXH0X2CslH23bljSHOl2uOmwPSt23EvYS0CPbrX4YRtgLnEmHgYYbokWIuo5wjdIr3VkCFPni8ollM9GFO6AXsUeSwZAvhq6oZY-2FetynINJjWZRmZA3FqSomogDxHnQi04e6CHFKy-2BGOn5XT6stPkmBsFDgIMFmy1JaOMC6-2F6zNNGOii9obOCSUz236yIB4gCzgF-2BEE7DFN-2BzWJDOZETyK2c-2BdBr5QJMquOChmyoid171OpGSki94P-2Br-2FT5mkOXMUdSuXDA9B2zx9bfVlZsW1KYw6NBNiJ3c-2F9p-2FTFh6SVp-2Ferm15pDsGPoc-3D" universal="true">Track Your Order</a></td>
</tr>
</tbody>
</table></td>
</tr>
</tbody>
</table></td>
<td class="em_side15" style="width:20px;" width="20"></td>
</tr>
</tbody>
</table></td>
</tr>
</tbody>
</table></td>
</tr> <!----><!--Illustration 1-->
<tr>
<td align="center" class="em_full_img" valign="top"><a style="text-decoration:none;" target="_blank" href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiX9NQvXQ9aE-2FeLMhxL9C-2FAEGC0g_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRygzsqDKLqVcMuBPdkhwo7LHk9TXfSbi-2B0mW4NvsDLo9Q6gWLaNP3J-2Fyzkvnw5dniDohab0lVqyyqLUGGGtDfKri8s0BPbLw2Kta64VgtBfCarELB9jSoY9mQzB-2FDOSk7-2FtMWjohoN3uf5f2rLrFhl33mtjWZ53lXvtDO4wQsmVDJSCEfVON02MQaDh-2BmTLPN5Vxq7DH8UlIbETnDVF4Df7l7njadHPDZB2aHMoBR9nS93-2B-2FrbnWmdM0cdfetMj3RGMa8Yk-2Fgyfj6ZKM2CqlUxqNZhbHVjZo1bFR4YvyxsTPc56A8ujeF0vzPIUM4yXtdY-2BtGKNvO-2F2momIcFB8TCK6k39zWYllF-2FQub6BRW64qF3OwcOA8Qi4U05aRe966x3Bpi5mkgpGuXH0X2CslH23bljSHOl2uOmwPSt23EvYS0CPbrX4YRtgLnEmHgYYbokWIuo5wjdIr3VkCFPni8ollM9GFO6AXsUeSwZAvhq6oZY-2FetynINJjWZRmZA3FqSomogDxHnQi04e6CHFKy-2BGOn5XT6stPkmBsFDgIMFmy1JaZrmPxM5tO3MVqwpeuZtxlipSYowDluqjEhtng6OZ1tiReEtrMZSXfyizE8Z-2BoYl-2FOhh2mVbKh8Ag8sj-2FY0Dr5fXcralZiuXdtrszdPSlbYpbbdCWJgW2Mvx4JTup-2Be7s2F323Q0hdUfWlDu1kXlV0-3D" universal="true"><img alt="" border="0" class="em_full_img" style="display:block; max-width:700px; font-family:Arial, sans-serif; font-size:22px; line-height:25px; color:#ffffff; font-weight:bold;" width="700" src="https://assets.doordash.team/m/2f9c7fde7cfed840/original/-template-OrderConfirmation-foodbag.png"></a></td>
</tr> <!--//Illustration 1--><!----><!-- //banner Section -->
<tr>
<td align="center" valign="top">
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
<tbody>
<tr>
<td style="width:6%;" width="6%"></td>
<td align="center" valign="top">
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
<tbody>
<tr>
<td align="center" class="em_rounded" style="border-top-left-radius: 40px; border-top-right-radius: 40px; background-color: #ffffff;" valign="top">
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
<tbody>
<tr>
<td class="em_side15" style="width: 40px;" width="40"></td>
<td align="center" valign="top">
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
<tbody>
<tr>
<td class="em_h20" height="50" style="height:50px; line-height:0px; font-size:0px;"></td>
</tr>
<tr>
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:17px; line-height:24px;" valign="top">Paid with credits<br> Mad Mex</td>
</tr>
<tr>
<td align="left" class="em_defaultlink em_bold" style="font-family:'TTNorms-Bold', Arial, sans-serif; color:#767676;font-size:17px; line-height:24px; font-weight:bold; color:#000000;" valign="top">Total: $14.64</td>
</tr>
<tr>
<td class="em_h20" height="35" style="height:35px; line-height:0px; font-size:0px;"></td>
</tr>
<tr>
<td align="left" class="em_defaultlink em_bold" style="font-family:'TTNorms-Bold', Arial, sans-serif; color:#000000;font-size:26px; line-height:36px; font-weight: bold;" valign="top">Your receipt</td>
</tr>
<tr>
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:21px;" valign="top">19 Lady Penrhyn Dr, 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 align="center" valign="top">
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
<tbody>
<tr>
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:17px; line-height:24px;" valign="top"><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>Burrito</b> (Mains)<br><font color="dimgrey">• Slow Cooked Beef (GF)</font><br><font color="dimgrey">• Fresh Guacamole (GF, VG)</font><br><font color="dimgrey">• Spicy Salsa</font><br><font color="dimgrey">• No Beans (GF,V)</font><br><br></td>
<td valign="top" width="15%" style="color: #666666; font-size: 18px; line-height: 24px text-align: right">$22.10</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" style="width: 40px;" width="40"></td>
</tr>
</tbody>
</table></td>
</tr>
<tr>
<td align="center" bgcolor="#ffffff" class="em_pad1" valign="top">
<table align="center" border="0" cellpadding="0" cellspacing="0" class="em_wrapper" style="width:530px;" width="530">
<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 align="center" style="background-color: #ffffff;" valign="top">
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
<tbody>
<tr>
<td class="em_side15" style="width: 40px;" width="40"></td>
<td align="center" valign="top">
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
<tbody>
<tr>
<td class="em_h20" height="10" style="height:10px; line-height:0px; font-size:0px;"></td>
</tr>
<tr>
<td align="center" valign="top">
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
<tbody>
<tr><!---->
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">Subtotal</td> <!---->
<td align="right" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">$22.10</td>
</tr>
</tbody>
</table></td>
</tr>
<tr>
<td align="center" valign="top">
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
<tbody>
<tr><!---->
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">Taxes</td> <!---->
<td align="right" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">$0.00</td>
</tr>
</tbody>
</table></td>
</tr> <!----><!---->
<tr>
<td align="center" valign="top">
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
<tbody>
<tr>
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">Delivery Fee</td>
<td align="right" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">$0.00</td>
</tr>
</tbody>
</table></td>
</tr>
<tr>
<td align="center" valign="top">
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
<tbody>
<tr>
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">Service Fee</td>
<td align="right" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">$1.99</td>
</tr>
</tbody>
</table></td>
</tr>
<tr>
<td align="center" valign="top">
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
<tbody>
<tr>
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">Tip</td>
<td align="right" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">$0.00</td>
</tr>
</tbody>
</table></td>
</tr> <!---->
<tr>
<td align="center" valign="top">
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
<tbody>
<tr>
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">Discounts</td>
<td align="right" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">-$24.09</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" style="width: 40px;" width="40"></td>
</tr>
</tbody>
</table></td>
</tr>
<tr>
<td align="center" bgcolor="#ffffff" class="em_pad1" valign="top">
<table align="center" border="0" cellpadding="0" cellspacing="0" class="em_wrapper" style="width:530px;" width="530">
<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 align="center" style="background-color: #ffffff;" valign="top">
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
<tbody>
<tr>
<td class="em_side15" style="width: 40px;" width="40"></td>
<td align="center" valign="top">
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
<tbody>
<tr>
<td class="em_h20" height="12" style="height:12px; line-height:0px; font-size:0px;"></td>
</tr>
<tr>
<td align="center" valign="top">
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
<tbody>
<tr><!---->
<td align="left" class="em_defaultlink em_bold" style="font-family:'TTNorms-Bold', Arial, sans-serif; color:#000000;font-size:17px; line-height:24px; font-weight: bold;" valign="top">Total Charged</td> <!---->
<td align="right" class="em_defaultlink em_bold" style="font-family:'TTNorms-Bold', Arial, sans-serif; color:#000000;font-size:17px; line-height:24px; font-weight: bold;" valign="top">$14.64</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 align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#ff2f07;font-size:14px; line-height:21px; font-weight: bold;" valign="top"><a style="color:#ff2f07; text-decoration:none;" target="_blank" href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiUYpjiz5uS6IJixGXj3UHbTD93pKefx1-2F0xnbLGDGm-2F-2BQiJu-2BjCmmrEKOYlPr7srvk-3DZM8O_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRygzsqDKLqVcMuBPdkhwo7LHk9TXfSbi-2B0mW4NvsDLo9Q6gWLaNP3J-2Fyzkvnw5dniDohab0lVqyyqLUGGGtDfKri8s0BPbLw2Kta64VgtBfCarELB9jSoY9mQzB-2FDOSk7-2FtMWjohoN3uf5f2rLrFhl33mtjWZ53lXvtDO4wQsmVDJSCEfVON02MQaDh-2BmTLPN5Vxq7DH8UlIbETnDVF4Df7l7njadHPDZB2aHMoBR9nS93-2B-2FrbnWmdM0cdfetMj3RGMa8Yk-2Fgyfj6ZKM2CqlUxqNZhbHVjZo1bFR4YvyxsTPc56A8ujeF0vzPIUM4yXtdY-2BtGKNvO-2F2momIcFB8TCK6k39zWYllF-2FQub6BRW64qF3OwcOA8Qi4U05aRe966x3Bpi5mkgpGuXH0X2CslH23bljSHOl2uOmwPSt23EvYS0CPbrX4YRtgLnEmHgYYbokWIuo5wjdIr3VkCFPni8ollM9GFO6AXsUeSwZAvhq6oZY-2FetynINJjWZRmZA3FqSomogDxHnQi04e6CHFKy-2BGOn5XT6stPkmBsFDgIMFmy1JaVyVX-2BN0rfrZb4gqsyh-2FPhwDCNS7-2Fr19T2GWsMfiRQ3UNw3buP-2FdHaE5XAo30Zx73XG0x2-2Bj9CkYTdbTkYtPKndOo8C-2Frr-2BJqyRFJZMfQUP1ZV-2F2Ey3WNBzbwVr6SxafeujsUnxXrbM6VJ3DMYac3E-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" style="width: 40px;" width="40"></td>
</tr>
</tbody>
</table></td>
</tr>
</tbody>
</table></td>
<td style="width:6%;" width="6%"></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:0 0 48px 0;border-bottom: 1px solid #E7E7E7;"></td>
</tr>
<tr>
<td align="left" style="color: #191919; padding: 32px 0 16px 0;"><p style="font-family:'TTNorms',system-ui,sans-serif;font-size:14px;line-height:18px;font-weight:400;margin:0;color:#9A9A9A;">©2026 <a style="color: #9A9A9A; text-decoration: none;">DoorDash Technologies Australia Pty Ltd <br>401 Collins St. <br>Melbourne, VIC 3000 Australia</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-2FDD8FcMNUAvAdfcDQmgdYCzx-2Br1lrYaof6JiBiFLOVfEOgDjXh7OOs7yReUnPIfAQpor-2FGsJy8J81hADh-2FLb3M-3Dcz1D_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRygzsqDKLqVcMuBPdkhwo7LHk9TXfSbi-2B0mW4NvsDLo9Q6gWLaNP3J-2Fyzkvnw5dniDohab0lVqyyqLUGGGtDfKri8s0BPbLw2Kta64VgtBfCarELB9jSoY9mQzB-2FDOSk7-2FtMWjohoN3uf5f2rLrFhl33mtjWZ53lXvtDO4wQsmVDJSCEfVON02MQaDh-2BmTLPN5Vxq7DH8UlIbETnDVF4Df7l7njadHPDZB2aHMoBR9nS93-2B-2FrbnWmdM0cdfetMj3RGMa8Yk-2Fgyfj6ZKM2CqlUxqNZhbHVjZo1bFR4YvyxsTPc56A8ujeF0vzPIUM4yXtdY-2BtGKNvO-2F2momIcFB8TCK6k39zWYllF-2FQub6BRW64qF3OwcOA8Qi4U05aRe966x3Bpi5mkgpGuXH0X2CslH23bljSHOl2uOmwPSt23EvYS0CPbrX4YRtgLnEmHgYYbokWIuo5wjdIr3VkCFPni8ollM9GFO6AXsUeSwZAvhq6oZY-2FetynINJjWZRmZA3FqSomogDxHnQi04e6CHFKy-2BGOn5XT6stPkmBsFDgIMFmy1JRYFdI4rWGqZbwcwPjA-2FfGSpVNEJe6-2BhKqFbWrIPgphG2UaqzMu-2FrPVVEEnOy-2F7K7l-2Fiy9DJuVosOT1N6tV6enGQ9g8qH2bVpDkJgmaqxnNTS4xf9s7LwM3H-2FEquZm-2By-2FmUkLGnDPlpUJFzu1om-2F4mY-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 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-2FDD8FcMNUAvAdfcDQmgdYC9Fbd7OtaSznXr4XXA8cVP-2BJHCjKWkCwgvETCnlfYzgA-3D-3D9VgN_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRygzsqDKLqVcMuBPdkhwo7LHk9TXfSbi-2B0mW4NvsDLo9Q6gWLaNP3J-2Fyzkvnw5dniDohab0lVqyyqLUGGGtDfKri8s0BPbLw2Kta64VgtBfCarELB9jSoY9mQzB-2FDOSk7-2FtMWjohoN3uf5f2rLrFhl33mtjWZ53lXvtDO4wQsmVDJSCEfVON02MQaDh-2BmTLPN5Vxq7DH8UlIbETnDVF4Df7l7njadHPDZB2aHMoBR9nS93-2B-2FrbnWmdM0cdfetMj3RGMa8Yk-2Fgyfj6ZKM2CqlUxqNZhbHVjZo1bFR4YvyxsTPc56A8ujeF0vzPIUM4yXtdY-2BtGKNvO-2F2momIcFB8TCK6k39zWYllF-2FQub6BRW64qF3OwcOA8Qi4U05aRe966x3Bpi5mkgpGuXH0X2CslH23bljSHOl2uOmwPSt23EvYS0CPbrX4YRtgLnEmHgYYbokWIuo5wjdIr3VkCFPni8ollM9GFO6AXsUeSwZAvhq6oZY-2FetynINJjWZRmZA3FqSomogDxHnQi04e6CHFKy-2BGOn5XT6stPkmBsFDgIMFmy1JePrOHq5c5aW6evBo1DEdQJNXu-2BX-2FLP1H7ZXkSsghQ0EUu5Q6HYBtkMij3hyHvfxKdfDvfPKun4porDdzU27HkbjUwffVpDDk5SZimtlGs-2B8re-2FwuFPMI9TFYPc8JMXjGAWJTpoqf-2BcFT1RgPBXiW4Y-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 class="em_hide" style="line-height:1px;min-width:700px;background-color:#f4f4f4;"><img alt="" border="0" height="1" style="max-height:1px; min-height:1px; display:block; width:700px; min-width:700px;" width="700" 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.EBL2ug8kstebd25Xirrl3olMckTI261ldPjJ39bNHcC7U6EKGAOA4dSVB97lcv3b8qsQRq6LwkHED6F3X4gqaODB2b1wZFk4or4JbrThItr6lxxhe0ghxdjbmj40V9NR2q6sVlDPfArvtaJbtCejz-2BJxmNDpj6cBZjf16jHN3-2B2-2FBNoONAVXEROMy-2FebOh-2BiIcflH-2B33fKp22eXYo6U8vHb6sK-2FhKm1eTZq8fP2ZliSW-2FDJ4KPJHZKnoxb-2FEyRvVNj8NRt3Z7VhrxSD83L-2BD-2Fg-2BYQJkcfBPsJzteuA7jtfPftcKLG1twN4g1gQQZHw9OGtsD1rLq9iXJ6ZzaVdWLEzmjDcZ7g3fTMixrMzH4oIIOEair3v0qOMD-2BV-2BHU7ncYtO-2FnD0bk1O9XI94PBy9e9JXZDgi2OxIT84h7X7b83MwQfHv6Y0eFjKVrvu0wzSlch7hpGzIv6v9RQGcCePKn-2BQ-2B85mf3KcKtUiyQ-2Bcvh-2BNe6ZHrHZAucV3pF0Fdo4Yn1-2Fie3uN1peu4VputGOf9gYSRqzfDIxJ-2BpD9KcAQghrRgZ08n7rFpdtIeHrI0J-2BsvQ0YqnprMwzKBgarmyWY4Ll6vo-2B-2FQLHQVXWlGWqomUtaBIY1wIPtH3sUn48aYCsuzz9tPifFXGPqgruVpD2mpi3C41MG8rFgUFQwfp4xV7SLCp8axKXY3t7WsYtRFw04WcnX-2FJMSRUrn8bq8PbEHq3Hjikf10MnkiyW7GobtuKhHdBL4F6ntalNVUHk3neSbhFBHGlJJQUA2Kfxt1bsI5eAQoX-2FRsU2BYtChgxaMUD53E-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>
@@ -0,0 +1,817 @@
<!doctype html>
<html lang="en" dir="ltr" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1 user-scalable=yes">
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no, url=no">
<meta name="x-apple-disable-message-reformatting">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>DoorDash</title> <!-- WEB FONTS --> <!--[if !mso]>-->
<style type="text/css">
@font-face{font-family:'TTNorms';font-style:normal;font-weight:700;src:url('https://typography.doordash.com/TTNorms-Pro/TTNormsPro-Bold.woff2')format('woff2');}
@font-face{font-family:'TTNorms';font-style:normal;font-weight:600;src:url('https://typography.doordash.com/TTNorms-Pro/TTNormsPro-DemiBold.woff2')format('woff2');}
@font-face{font-family:'TTNorms';font-style:normal;font-weight:500;src:url('https://typography.doordash.com/TTNorms-Pro/TTNormsPro-Medium.woff2')format('woff2');}
@font-face{font-family:'TTNorms';font-style:normal;font-weight:450;src:url('https://typography.doordash.com/TTNorms-Pro/TTNormsPro-Normal.woff2')format('woff2');}
@font-face{font-family:'TTNorms';font-style:normal;font-weight:400;src:url('https://typography.doordash.com/TTNorms-Pro/TTNormsPro-Regular.woff2')format('woff2');}
</style> <!--<![endif]--> <!-- STYLE RESETS -->
<style type="text/css">a[href^="tel"],a[href^="sms"]{color:inherit;cursor:default;font-weight:inherit;text-decoration:none}body{-ms-text-size-adjust:100%;-webkit-font-smoothing:antialiased;-webkit-text-size-adjust:100%;mso-line-height-rule:exactly;}html,body{width:100%;margin:0;padding:0}img{border:0;display:block;height:auto;line-height:100%;outline:none;text-decoration:none}table{border:0 !important;padding:0 !important; border-collapse:collapse !important;mso-table-lspace:0pt;mso-table-rspace:0pt;}u + .body a{color:inherit;font-family:inherit;font-size:inherit;font-weight:inherit;line-height:inherit}u ~ img + div > div{display:none;}u + .body{width:100%;}.body a[x-apple-data-detectors=true]{color:inherit!important;text-decoration:inherit!important}span.MsoHyperlink{color:inherit !important;mso-style-priority:99 !important}span.MsoHyperlinkFollowed{color:inherit !important;mso-style-priority:99 !important}
</style> <!-- BACKGROUND COLORS -->
<style type="text/css">
body,#MainTable{background-color:#F4F4F4;}
#Basic000{background-color:#FEFFFF;}
#Sky200{background-color:#DDF4F4;}
</style> <!-- FONT STYLES -->
<style type="text/css">
h1,h2,h3,h4,h5,h6{font-family:'TTNorms',system-ui,sans-serif;font-weight:700;margin:0 0 8px 0;}
p,ol,ul{font-family:'TTNorms',system-ui,sans-serif;font-weight:450;margin:0 0 8px 0;}
ul,ol{padding:0 0 0 20px;}
li{font-weight:450;margin:0 0 8px 0;}
h1{font-size:50px;line-height:50px;letter-spacing:-0.03em;}
h2{font-size:40px;line-height:40px;letter-spacing:-0.02em;}
h3{font-size:32px;line-height:32px;letter-spacing:-0.02em;}
h4{font-size:24px;line-height:24px;letter-spacing:-0.01em;}
h5{font-size:20px;line-height:22px;letter-spacing:-0.01em;}
h6{font-size:16px;line-height:18px;}
p.p1{font-size:20px;line-height:26px;}
p.p2{font-size:16px;line-height:22px;}
p.p4{font-size:12px;line-height:14px;}
sup{font-size:11px;line-height:11px;}
</style> <!-- FONT COLORS -->
<style type="text/css">
#MainTable table td{color:#191919;}
#MainTable table td a{color:inherit;}
#MainTable table td p a{text-decoration:underline;}
.Red200{color:#EB1700 !important;}
</style> <!-- CTAs --> <!-- MOBILE STYLES -->
<style type="text/css">
@media only screen and (max-width:699px){
#MainTable > table {max-width:410px!important;}
.full{width:100%!important;height:auto!important;}
.pad0{padding-left:0!important;padding-right:0!important;}
.pad8{padding-left:8px!important;padding-right:8px!important;}
.pad24{padding-left:24px!important;padding-right:24px!important;}
.logo{padding-top:40px!important;padding-bottom:40px!important;}
h1{font-size:40px!important;line-height:40px!important;letter-spacing:-0.02em!important;}
h2{font-size:32px!important;line-height:32px!important;}
h3{font-size:24px!important;line-height:24px!important;letter-spacing:-0.01em!important;}
}
</style> <!-- DARK MODE STYLES -->
<style type="text/css">
@media (prefers-color-scheme:dark){
body,#MainTable,#Footer{background-color:#000000!important;background-image:linear-gradient(#000000,#000000)!important;}
table[id^="Basic"]{background-color:#191919!important;background-image:linear-gradient(#191919,#191919)!important;}
table[id^="Sky"],table[id^="Blue"]{background-color:#002629!important;background-image:linear-gradient(#002629,#002629)!important;}
#MainTable table td{color:#FFFFFF!important;}
#MainTable #Footer table td a{color:#FFFFFF!important;}
#MainTable .Red200{color:#FF3008!important;}
#MainTable .label span{color:#494949!important;background-color:#FEFFFF!important;}
#MainTable .grayCopy p{color:#A6A6A6!important;}
}
</style>
<style type="text/css">
:root{color-scheme:light dark;supported-color-schemes:light dark;}
</style> <!-- GMAIL APP DARK MODE FIX --> <!-- OUTLOOK SPECIFIC CSS --> <!--[if gte mso 9]>
<style type="text/css">
#MainTable td a{color:#191919;}
ol,ul{margin-left:20px !important;}
li{text-indent:-1em;}
</style>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
</head>
<body class="body" style="width:100%;margin:0;padding:0;">
<span style="color:transparent;visibility:hidden;display:none;opacity:0;height:0;width:0;font-size:0;"></span>
<div role="article" aria-roledescription="email" aria-label="DoorDash Email" lang="en" dir="ltr" style="font-size:medium; font-size:max(16px, 1rem);">
<div style="display: none; max-height: 0px; overflow: hidden;">
 ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏
</div>
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td align="center" id="MainTable" style="background-color:#F4F4F4;">
<table align="center" border="0" cellpadding="0" cellspacing="0" class="full" id="Sky200" role="presentation" style="width:700px;">
<tbody>
<tr>
<td align="left" class="pad24" style="padding: 0 64px;">
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td align="left" class="logo" style="padding: 48px 0;"><a href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiX9NQvXQ9aE-2FeLMhxL9C-2FAEerk2_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDdm5muv43mKBZj0FtslvRhwurT1HU7WzBFk0uKPD12dT8hiOQ5EDRx3fHM-2BeYkU4qHqDyPFxUwZuSfyRj0SIOTkg-2B18lVSaIyNV-2FYADATgkv5vXH4igP0Invu4xOPQBpgg16ckbsIS8IRV3mGaj1X5KhMT-2FuLlWkyJ0vYYtn7DRdGkRIV8GNdbKAWhFWQmTP4s1sfO-2BmfVk1PxO2rjtA0ZfcEcAKhCAzKFEEbQ9BWgK39Qvkt47rpQjykYZ9iiQAGWQLAjcs6P4DIPoAa8QmGrMb0TkMbrHUuWzG3I6o6QTpKFhzWbXT1TXbl0tj9rUZeGPY7Gxx-2FjDpfuEqSvOXz4YCMEoeSAPy7cIWp2wjaTin6ux0N-2Fsxe4v-2FsCd7o-2F1uCwW4EEDetpthuIuYYkeIXQlzypqy8rrJ5Czz-2F9QxvlX7Rurh5UPO-2FSnnEWnQNj-2Bby-2BmuZiFMapcD3VfIVucgG0YgY0q8krauzK38sC-2Bo3p3uvmDObIONy9moqGQopWgFpL5Oy8bXmdxo1-2FRjNsrDbsLgM1km0RQLa4WV7Y99oaIhdT35ahysXpZwozejK1qEgOqrm8WZjgms1TsVagTaVDuAieGpWb-2FQlcU9t3Vh8TCZEmOz2Loi3CvHDdReh6E5oaG4UkH-2BbB80dFEw5AwJF-2B0-3D" target="_blank" universal="true"><img alt="DoorDash" border="0" src="https://assets.doordash.team/m/5e68fa5cbbc50c32/original/DoorDash-Logo-Red100.png" style="color: #FF3008;font-family:'TTNorms',system-ui,sans-serif;font-weight:bold;font-size:18px;text-decoration:none;" width="50"></a></td>
</tr>
<tr>
<td align="left" class="pad0" style="padding:0 64px 0 0;"><h1 style="margin:0 0 16px 0;">There are adjustments to your order.</h1></td>
</tr>
<tr>
<td align="right" style="padding: 0 0 40px 0;"><img alt="" src="https://img.cdn4dd.com/s/convenience/images/adjustments_eml_grocery.png" width="380" style="width:100%;max-width:380px;"></td>
</tr>
</tbody>
</table></td>
</tr>
<tr>
<td align="left" class="pad8" style="padding: 0 40px 40px;">
<table align="left" border="0" cellpadding="0" cellspacing="0" id="Basic000" role="presentation" style="width:100%;border-radius:25px;">
<tbody>
<tr>
<td align="left" class="pad24" style="padding:40px 40px 0;">
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td align="left" style="padding:0 0 32px 0;">
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td align="left" style="padding:0 0 24px 0;"><!----> <p class="p2" style="margin:0 0 4px 0;">Paid with credits</p> <p class="p2" style="margin:0 0 4px 0;">ALDI</p> <p class="p2" style="margin:0 0 4px 0;"></p> <p class="p2"><strong>Total: $0.00</strong></p></td>
</tr>
<tr>
<td align="left" style="padding:0 0 24px 0;border-bottom:1px solid #C4C4C4;"><h4>Your receipt</h4> <!----> <p class="p2" style="margin:0 0 4px 0;"></p> <!----> <p class="p2"><a href="" style="color:#191919;text-decoration:none;">19 Lady Penrhyn Dr, Wyndham Vale VIC 3024, Australia</a></p></td>
</tr>
</tbody>
</table></td>
</tr>
</tbody>
</table> <!---->
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td align="left" style="padding:0 0 16px 0;">
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td align="left"><h4>Items that were adjusted</h4></td>
</tr>
<tr>
<td align="left"><!----> <!-- -->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<tbody>
<tr>
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<!-- -->
<tbody>
<tr>
<td align="left" class="label" style="padding: 0 0 8px 0;"><!-- --> <!-- --> <!-- --> <!-- --> <!-- --> <!-- --> <!-- --> <span style="font-family:'TTNorms',system-ui,sans-serif;font-size:12px;line-height:18px;color:#FEFFFF;background-color:#494949;display:inline-block;padding:1px 4px;border-radius:4px;font-weight:700;white-space:nowrap;">Out of Stock</span></td>
</tr> <!-- --> <!-- -->
<tr>
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<!--
-->
<tbody>
<tr>
<td align="left" valign="top" class="grayCopy"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:#767676;"><strong>5x</strong> Coca-Cola Coke Zero Sugar Soft Drink (1.5 L)</p></td>
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: line-through; margin: 0 0 4px 0;color:#767676;white-space:nowrap;">&nbsp;$16.45&nbsp;</p></td>
</tr> <!-- -->
</tbody>
</table></td>
</tr> <!-- --> <!-- -->
</tbody>
</table></td>
</tr>
</tbody>
</table> <!----> <!-- -->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<tbody>
<tr>
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<!-- -->
<tbody>
<tr>
<td align="left" class="label" style="padding: 0 0 8px 0;"><!-- --> <!-- --> <!-- --> <!-- --> <!-- --> <!-- --> <!-- --> <span style="font-family:'TTNorms',system-ui,sans-serif;font-size:12px;line-height:18px;color:#FEFFFF;background-color:#494949;display:inline-block;padding:1px 4px;border-radius:4px;font-weight:700;white-space:nowrap;">Substituted</span></td>
</tr> <!-- --> <!-- --> <!-- -->
<tr>
<td align="left"><!-- --> <!-- --> <!-- -->
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<tbody>
<tr>
<td align="left" style="padding: 0 0 12px 0;">
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<tbody>
<tr>
<td align="left" valign="top" class="grayCopy"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color: #767676;"><strong>2x</strong> Specially Selected Beef Wagyu Burger (150 g)</p></td>
<td align="right" valign="top" class="grayCopy" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: line-through; margin: 0 0 4px 0;color: #767676;white-space:nowrap;">&nbsp;$8.58&nbsp;</p></td>
</tr> <!-- -->
</tbody>
</table></td>
</tr>
<tr>
<td align="left" style="color: #191919;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; margin: 0 0 8px 0; font-size: 16px; line-height: 20px; font-weight: 700;">Substituted with:</p></td>
</tr>
<tr>
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<tbody>
<tr>
<td align="left" valign="top" style="color: #191919;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;"><strong>1x</strong> Ready, Set...Cook! Wagyu Beef Burgers (400 g)</p></td>
<td align="right" valign="top" style="color: #191919; padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;white-space:nowrap;">$9.39</p></td>
</tr> <!-- -->
</tbody>
</table></td>
</tr>
</tbody>
</table></td>
</tr> <!-- -->
</tbody>
</table></td>
</tr>
</tbody>
</table> <!----> <!-- -->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<tbody>
<tr>
<td align="left" style="padding: 16px 0 0 0; border-bottom: none;">
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<!-- -->
<tbody>
<tr>
<td align="left" class="label" style="padding: 0 0 8px 0;"><!-- --> <!-- --> <!-- --> <!-- --> <!-- --> <!-- --> <!-- --> <span style="font-family:'TTNorms',system-ui,sans-serif;font-size:12px;line-height:18px;color:#FEFFFF;background-color:#494949;display:inline-block;padding:1px 4px;border-radius:4px;font-weight:700;white-space:nowrap;">Substituted</span></td>
</tr> <!-- --> <!-- --> <!-- -->
<tr>
<td align="left"><!-- --> <!-- --> <!-- -->
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<tbody>
<tr>
<td align="left" style="padding: 0 0 12px 0;">
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<tbody>
<tr>
<td align="left" valign="top" class="grayCopy"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color: #767676;"><strong>1x</strong> Bon Appetit Sliced Brioche Burger Buns with Sesame Seeds (200 g)</p></td>
<td align="right" valign="top" class="grayCopy" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: line-through; margin: 0 0 4px 0;color: #767676;white-space:nowrap;">&nbsp;$3.89&nbsp;</p></td>
</tr> <!-- -->
</tbody>
</table></td>
</tr>
<tr>
<td align="left" style="color: #191919;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; margin: 0 0 8px 0; font-size: 16px; line-height: 20px; font-weight: 700;">Substituted with:</p></td>
</tr>
<tr>
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<tbody>
<tr>
<td align="left" valign="top" style="color: #191919;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;"><strong>1x</strong> Bon Appetit Sliced Brioche Burger Buns (200 g)</p></td>
<td align="right" valign="top" style="color: #191919; padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;white-space:nowrap;">$3.89</p></td>
</tr> <!-- -->
</tbody>
</table></td>
</tr>
</tbody>
</table></td>
</tr> <!-- -->
</tbody>
</table></td>
</tr>
</tbody>
</table> <!----></td>
</tr>
</tbody>
</table></td>
</tr>
</tbody>
</table> <!----> <!---->
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td align="left" style="padding:0 0 32px 0;">
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td align="left"><h4>Items you ordered</h4></td>
</tr>
<tr>
<td align="left" style="padding:0 0 16px 0;border-bottom:1px solid #C4C4C4;"><!----> <!-- -->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<tbody>
<tr>
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<!-- --> <!-- -->
<tbody>
<tr>
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<!--
-->
<tbody>
<tr>
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> Ironbark Pork Belly Pack</p></td>
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;">&nbsp;$24.21&nbsp;</p></td>
</tr> <!-- -->
<tr>
<td colspan="2" align="left" class="grayCopy"><p class="para-md" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 14px; line-height: 18px; margin: 0 0 4px 0;color: #767676;">$18.99/kg • Purchased 1.275 kg</p></td>
</tr> <!-- -->
</tbody>
</table></td>
</tr> <!-- --> <!-- -->
</tbody>
</table></td>
</tr>
</tbody>
</table> <!----> <!-- -->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<tbody>
<tr>
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<!-- --> <!-- -->
<tbody>
<tr>
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<!--
-->
<tbody>
<tr>
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> Green Capsicum Loose (each) (each)</p></td>
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;">&nbsp;$1.54&nbsp;</p></td>
</tr> <!-- -->
<tr>
<td colspan="2" align="left" class="grayCopy"><p class="para-md" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 14px; line-height: 18px; margin: 0 0 4px 0;color: #767676;">$5.99/kg • Purchased 0.257 kg</p></td>
</tr> <!-- -->
</tbody>
</table></td>
</tr> <!-- --> <!-- -->
</tbody>
</table></td>
</tr>
</tbody>
</table> <!----> <!-- -->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<tbody>
<tr>
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<!-- --> <!-- -->
<tbody>
<tr>
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<!--
-->
<tbody>
<tr>
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>2x</strong> Broccoli Loose (each)</p></td>
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;">&nbsp;$3.08&nbsp;</p></td>
</tr> <!-- -->
<tr>
<td colspan="2" align="left" class="grayCopy"><p class="para-md" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 14px; line-height: 18px; margin: 0 0 4px 0;color: #767676;">$4.49/kg • Purchased 0.685 kg</p></td>
</tr> <!-- -->
</tbody>
</table></td>
</tr> <!-- --> <!-- -->
</tbody>
</table></td>
</tr>
</tbody>
</table> <!----> <!-- -->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<tbody>
<tr>
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<!-- --> <!-- -->
<tbody>
<tr>
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<!--
-->
<tbody>
<tr>
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> Ginger Loose (each)</p></td>
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;">&nbsp;$4.47&nbsp;</p></td>
</tr> <!-- -->
<tr>
<td colspan="2" align="left" class="grayCopy"><p class="para-md" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 14px; line-height: 18px; margin: 0 0 4px 0;color: #767676;">$29.99/kg • Purchased 0.149 kg</p></td>
</tr> <!-- -->
</tbody>
</table></td>
</tr> <!-- --> <!-- -->
</tbody>
</table></td>
</tr>
</tbody>
</table> <!----> <!-- -->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<tbody>
<tr>
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<!-- --> <!-- -->
<tbody>
<tr>
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<!--
-->
<tbody>
<tr>
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> Berg Streaky Bacon (200 g)</p></td>
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;">&nbsp;$4.69&nbsp;</p></td>
</tr> <!-- -->
</tbody>
</table></td>
</tr> <!-- --> <!-- -->
</tbody>
</table></td>
</tr>
</tbody>
</table> <!----> <!-- -->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<tbody>
<tr>
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<!-- --> <!-- -->
<tbody>
<tr>
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<!--
-->
<tbody>
<tr>
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> Emporium Selection Burrata (150 g)</p></td>
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;">&nbsp;$7.09&nbsp;</p></td>
</tr> <!-- -->
</tbody>
</table></td>
</tr> <!-- --> <!-- -->
</tbody>
</table></td>
</tr>
</tbody>
</table> <!----> <!-- -->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<tbody>
<tr>
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<!-- --> <!-- -->
<tbody>
<tr>
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<!--
-->
<tbody>
<tr>
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> Anco Soft Anco Soft Fabric Softener Concentrate - Cashmere Touch (1 L)</p></td>
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;">&nbsp;$3.99&nbsp;</p></td>
</tr> <!-- -->
</tbody>
</table></td>
</tr> <!-- --> <!-- -->
</tbody>
</table></td>
</tr>
</tbody>
</table> <!----> <!-- -->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<tbody>
<tr>
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<!-- --> <!-- -->
<tbody>
<tr>
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<!--
-->
<tbody>
<tr>
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> Pure Vita Canola Oil (2 L)</p></td>
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;">&nbsp;$6.49&nbsp;</p></td>
</tr> <!-- -->
</tbody>
</table></td>
</tr> <!-- --> <!-- -->
</tbody>
</table></td>
</tr>
</tbody>
</table> <!----> <!-- -->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<tbody>
<tr>
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<!-- --> <!-- -->
<tbody>
<tr>
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<!--
-->
<tbody>
<tr>
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> The Herb Garden Jalapeno Chillies (80 g)</p></td>
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;">&nbsp;$3.49&nbsp;</p></td>
</tr> <!-- -->
</tbody>
</table></td>
</tr> <!-- --> <!-- -->
</tbody>
</table></td>
</tr>
</tbody>
</table> <!----> <!-- -->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<tbody>
<tr>
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<!-- --> <!-- -->
<tbody>
<tr>
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<!--
-->
<tbody>
<tr>
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> Farmdale Thickened Cream (300 ml)</p></td>
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;">&nbsp;$3.69&nbsp;</p></td>
</tr> <!-- -->
</tbody>
</table></td>
</tr> <!-- --> <!-- -->
</tbody>
</table></td>
</tr>
</tbody>
</table> <!----> <!-- -->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<tbody>
<tr>
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<!-- --> <!-- -->
<tbody>
<tr>
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<!--
-->
<tbody>
<tr>
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> Mandarins (1 kg)</p></td>
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;">&nbsp;$3.49&nbsp;</p></td>
</tr> <!-- -->
</tbody>
</table></td>
</tr> <!-- --> <!-- -->
</tbody>
</table></td>
</tr>
</tbody>
</table> <!----> <!-- -->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<tbody>
<tr>
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<!-- --> <!-- -->
<tbody>
<tr>
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<!--
-->
<tbody>
<tr>
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> Sweet Valley Fruit Salad in Syrup (825 g)</p></td>
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;">&nbsp;$3.89&nbsp;</p></td>
</tr> <!-- -->
</tbody>
</table></td>
</tr> <!-- --> <!-- -->
</tbody>
</table></td>
</tr>
</tbody>
</table> <!----> <!-- -->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<tbody>
<tr>
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<!-- --> <!-- -->
<tbody>
<tr>
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<!--
-->
<tbody>
<tr>
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>3x</strong> Hass Avocado</p></td>
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;">&nbsp;$5.37&nbsp;</p></td>
</tr> <!-- -->
</tbody>
</table></td>
</tr> <!-- --> <!-- -->
</tbody>
</table></td>
</tr>
</tbody>
</table> <!----> <!-- -->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<tbody>
<tr>
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<!-- --> <!-- -->
<tbody>
<tr>
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<!--
-->
<tbody>
<tr>
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> Spring Onion Bunch</p></td>
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;">&nbsp;$2.69&nbsp;</p></td>
</tr> <!-- -->
</tbody>
</table></td>
</tr> <!-- --> <!-- -->
</tbody>
</table></td>
</tr>
</tbody>
</table> <!----> <!-- -->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<tbody>
<tr>
<td align="left" style="padding: 16px 0 0 0; border-bottom: none;">
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<!-- --> <!-- -->
<tbody>
<tr>
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<!--
-->
<tbody>
<tr>
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> The Herb Garden Coriander Bunch</p></td>
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;">&nbsp;$2.99&nbsp;</p></td>
</tr> <!-- -->
</tbody>
</table></td>
</tr> <!-- --> <!-- -->
</tbody>
</table></td>
</tr>
</tbody>
</table> <!----></td>
</tr>
</tbody>
</table></td>
</tr>
</tbody>
</table> <!---->
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td align="left" style="padding:0 0 32px 0;">
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td align="left" style="padding:0 0 24px 0;border-bottom:1px solid #C4C4C4;">
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td align="left"><p class="p2">Subtotal</p></td>
<td align="right" valign="bottom"><p class="p2">$94.45</p></td>
</tr> <!----> <!---->
<tr>
<td align="left"><p class="p2">Bag Fee</p></td>
<td align="right" valign="top"><p class="p2">$0.75</p></td>
</tr> <!----> <!----> <!---->
<tr>
<td align="left"><p class="p2">Tax</p></td>
<td align="right" valign="top"><p class="p2">$0.00</p></td>
</tr> <!----> <!----> <!---->
<tr>
<td align="left"><p class="p2">Delivery fee</p></td>
<td align="right" valign="top"><p class="p2">$0.00</p></td>
</tr> <!----> <!---->
<tr>
<td align="left"><p class="p2">Service&nbsp;fee</p></td>
<td align="right" valign="top"><p class="p2">$8.95</p></td>
</tr> <!----> <!---->
<tr>
<td align="left"><p class="p2">Dasher&nbsp;tip</p></td>
<td align="right" valign="top"><p class="p2">$0.00</p></td>
</tr> <!----> <!---->
<tr>
<td align="left"><p class="p2">Discount</p></td>
<td align="right" valign="top"><p class="p2">-$25.00</p></td>
</tr> <!----> <!----> <!----> <!---->
</tbody>
</table></td>
</tr>
</tbody>
</table></td>
</tr>
</tbody>
</table>
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td align="left">
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td align="left"><p class="p1" style="font-weight:700;">Final total charged</p></td>
<td align="right" valign="bottom"><p class="p1" style="font-weight:700;">$0.00</p></td>
</tr>
</tbody>
</table></td>
</tr>
<tr>
<td align="left" style="padding:16px 0 24px 0;">
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td align="left"><!----> <!----> <!----> <p class="p4" style="margin:0 0 16px;font-weight:400;">This email confirms revisions made to your original DoorDash order and reflects the final amount charged. The new total cost of your order is above and includes all taxes and fees. Payment processing adjustments to the original charge may take up to 5-7 business days to process.</p> <!----> <p class="p2" style="margin:0 0 16px;"><a class="Red200" href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiUYUIJyXMOWcKUkQoS6w1cidTUBltD9gsdDsG54KhCqi0TpTxXlRftLSD-2F0zchmY726xMeqb-2BYjhquRL7EwEUa-2B-C1a_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDdm5muv43mKBZj0FtslvRhwurT1HU7WzBFk0uKPD12dT8hiOQ5EDRx3fHM-2BeYkU4qHqDyPFxUwZuSfyRj0SIOTkg-2B18lVSaIyNV-2FYADATgkv5vXH4igP0Invu4xOPQBpgg16ckbsIS8IRV3mGaj1X5KhMT-2FuLlWkyJ0vYYtn7DRdGkRIV8GNdbKAWhFWQmTP4s1sfO-2BmfVk1PxO2rjtA0ZfcEcAKhCAzKFEEbQ9BWgK39Qvkt47rpQjykYZ9iiQAGWQLAjcs6P4DIPoAa8QmGrMb0TkMbrHUuWzG3I6o6QTpKFhzWbXT1TXbl0tj9rUZeGPY7Gxx-2FjDpfuEqSvOXz4YCMEoeSAPy7cIWp2wjaTin6ux0N-2Fsxe4v-2FsCd7o-2F1uCwW4EEDetpthuIuYYkeIXQlzypqy8rrJ5Czz-2F9QxvlX7Rurh5UPO-2FSnnEWnQNj-2Bby-2BmuZiFMapcD3VfIVucgG0YgY0q8krauzK38sC-2Bo3p3uvmDObIONy9moqGQopWgFpL5Oy8bXmdxo1-2FRjNsrDbsIzcz91FpNoAfjDMq4Z1wpXHGTMPHfVoKcReXA-2FJ6jaKo-2B6xGAG-2Bp5h6Him0z2xNQVc2hNO8YatcJsnFfudME5OtlTe61-2Bq439PkYJf0JqaFj1iwRjZNlhThyTmY7ccTBU-3D" target="_blank" style="font-weight:700;text-decoration:none;" universal="true">Get Order Help</a></p></td>
</tr>
</tbody>
</table></td>
</tr>
</tbody>
</table></td>
</tr>
</tbody>
</table></td>
</tr>
</tbody>
</table>
<table align="center" border="0" cellpadding="0" cellspacing="0" class="full" id="Footer" role="presentation" style="width:700px;">
<tbody>
<tr>
<td valign="top" align="center" style="padding:0 0 24px 0;"><!--[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:0 0 48px 0;border-bottom: 1px solid #E7E7E7;"></td>
</tr>
<tr>
<td align="left" style="color: #191919; padding: 32px 0 16px 0;"><p style="font-family:'TTNorms',system-ui,sans-serif;font-size:14px;line-height:18px;font-weight:400;margin:0;color:#9A9A9A;">©2026 <a style="color: #9A9A9A; text-decoration: none;">DoorDash Technologies Australia Pty Ltd <br>401 Collins St. <br>Melbourne, VIC 3000 Australia</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-2FDD8FcMNUAvAdfcDQmgdYAqF1KGCKTTTznL6MvOfulOsmFa2kqsuG7LjgY0AllZKBKWegcAPOx5sr25l15pnWbLZL6VnVCDFc3hEnP4sDnrjJH9_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDdm5muv43mKBZj0FtslvRhwurT1HU7WzBFk0uKPD12dT8hiOQ5EDRx3fHM-2BeYkU4qHqDyPFxUwZuSfyRj0SIOTkg-2B18lVSaIyNV-2FYADATgkv5vXH4igP0Invu4xOPQBpgg16ckbsIS8IRV3mGaj1X5KhMT-2FuLlWkyJ0vYYtn7DRdGkRIV8GNdbKAWhFWQmTP4s1sfO-2BmfVk1PxO2rjtA0ZfcEcAKhCAzKFEEbQ9BWgK39Qvkt47rpQjykYZ9iiQAGWQLAjcs6P4DIPoAa8QmGrMb0TkMbrHUuWzG3I6o6QTpKFhzWbXT1TXbl0tj9rUZeGPY7Gxx-2FjDpfuEqSvOXz4YCMEoeSAPy7cIWp2wjaTin6ux0N-2Fsxe4v-2FsCd7o-2F1uCwW4EEDetpthuIuYYkeIXQlzypqy8rrJ5Czz-2F9QxvlX7Rurh5UPO-2FSnnEWnQNj-2Bby-2BmuZiFMapcD3VfIVucgG0YgY0q8krauzK38sC-2Bo3p3uvmDObIONy9moqGQopWgFpL5Oy8bXmdxo1-2FRjNsrDbsIjwoUCSEKvO5n-2Bj16PIPm9vylVUKXBAg-2Fy62EoW7wAeLOkv7ljadn2lJdO1Z8YyTZVsg50XPfub07LBUsUSDmpWsvorrlalMazpGr6pIsnVp6l5lDVPbZ82aaASmCqGBc-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 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-2FDD8FcMNUAvAdfcDQmgdYABnncKv1afIihr5Gt6bkMj2LIo_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDdm5muv43mKBZj0FtslvRhwurT1HU7WzBFk0uKPD12dT8hiOQ5EDRx3fHM-2BeYkU4qHqDyPFxUwZuSfyRj0SIOTkg-2B18lVSaIyNV-2FYADATgkv5vXH4igP0Invu4xOPQBpgg16ckbsIS8IRV3mGaj1X5KhMT-2FuLlWkyJ0vYYtn7DRdGkRIV8GNdbKAWhFWQmTP4s1sfO-2BmfVk1PxO2rjtA0ZfcEcAKhCAzKFEEbQ9BWgK39Qvkt47rpQjykYZ9iiQAGWQLAjcs6P4DIPoAa8QmGrMb0TkMbrHUuWzG3I6o6QTpKFhzWbXT1TXbl0tj9rUZeGPY7Gxx-2FjDpfuEqSvOXz4YCMEoeSAPy7cIWp2wjaTin6ux0N-2Fsxe4v-2FsCd7o-2F1uCwW4EEDetpthuIuYYkeIXQlzypqy8rrJ5Czz-2F9QxvlX7Rurh5UPO-2FSnnEWnQNj-2Bby-2BmuZiFMapcD3VfIVucgG0YgY0q8krauzK38sC-2Bo3p3uvmDObIONy9moqGQopWgFpL5Oy8bXmdxo1-2FRjNsrDbsIrFQJW5Yztgt2Jh362zEdlWVE33xdgG5SV98hQgmujo0E4JvhCRaOchZGxbbbqba5s49iSoOH1uA1snY7iHXvSUNUHWVknLjLn6FaOweBoSS4nrQXEl0UQpbIiLKqwLeM-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>
</tbody>
</table></td>
</tr>
</tbody>
</table>
</div>
<img src="https://tracksg.doordash.com/wf/open?upn=u001.EBL2ug8kstebd25Xirrl3olMckTI261ldPjJ39bNHcC7U6EKGAOA4dSVB97lcv3b8qsQRq6LwkHED6F3X4gqaFe59-2BHz9QfrDL7jp-2BN3jX4WgiXvlTBIakpalIO3JdserrDEfSsXb7VYNi8XmPfj32D9Xcsg-2BKnH54BOn83XeKSYsMOiCB2O7NlyiRLKTOp-2FBccW4vEHOV-2FHaLf-2FAjylTtey6rU4sqCD635svGzB8KNIeJlLUHQHFm36Lriv1GKTHI0TRmME0InLq-2F8D2jWE2PvL5vl2AuTCSyofMbb8v6F0-2Fp2tKFfRS7SLHGABa-2BiIEO0Cj-2B51-2BCj8vI17Ej73lSLlLpOQjZQ5w6Xjpy94GXTJoyFOgOCkMe1dYoozgKu053kwmU-2FQgzvTvZvWljLEAh8AKZgpttD0qf9fwmzT-2FfbmEACAx1lYnUbMQSEnKVrq-2BN93H5GudoxrcNAH5xj7CeRBdn-2B-2Fm2uq78KkVh9j7d2poYBFM-2F0phg4yfzMJ8HenyMNPoDsxTYF2uwr9FPYHeHB92FdN-2FyMNtbhWu3f2Bj2Jp32hssVlE8ypjMbIy3F8Z4K8hxnzYgtXhQn9f7NJRAIR6ZxzgEYOW9gIQ1FDvzuTPT-2BOgXirIYJd7VNW1ls-2F33I09NUJXDzuQt6FpCbkzrw5rMFL9uAjGdsCzWAwAdtI0Vpj9QHVbFrQ7V0Km2o1xng4pDAf2zzOoVcOfy6Llv7NL5VR958SiCNPk-2FivSDJKGbqADXQIiUAHJEWxjJh9" 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>
@@ -0,0 +1,592 @@
<!doctype html>
<html lang="en" dir="ltr" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1 user-scalable=yes">
<meta name="format-detection" content="telephone=no, date=no, address=no, email=no, url=no">
<meta name="x-apple-disable-message-reformatting">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>DoorDash</title> <!-- WEB FONTS --> <!--[if !mso]>-->
<style type="text/css">
@font-face{font-family:'TTNorms';font-style:normal;font-weight:700;src:url('https://typography.doordash.com/TTNorms-Pro/TTNormsPro-Bold.woff2')format('woff2');}
@font-face{font-family:'TTNorms';font-style:normal;font-weight:600;src:url('https://typography.doordash.com/TTNorms-Pro/TTNormsPro-DemiBold.woff2')format('woff2');}
@font-face{font-family:'TTNorms';font-style:normal;font-weight:500;src:url('https://typography.doordash.com/TTNorms-Pro/TTNormsPro-Medium.woff2')format('woff2');}
@font-face{font-family:'TTNorms';font-style:normal;font-weight:450;src:url('https://typography.doordash.com/TTNorms-Pro/TTNormsPro-Normal.woff2')format('woff2');}
@font-face{font-family:'TTNorms';font-style:normal;font-weight:400;src:url('https://typography.doordash.com/TTNorms-Pro/TTNormsPro-Regular.woff2')format('woff2');}
</style> <!--<![endif]--> <!-- STYLE RESETS -->
<style type="text/css">a[href^="tel"],a[href^="sms"]{color:inherit;cursor:default;font-weight:inherit;text-decoration:none}body{-ms-text-size-adjust:100%;-webkit-font-smoothing:antialiased;-webkit-text-size-adjust:100%;mso-line-height-rule:exactly;}html,body{width:100%;margin:0;padding:0}img{border:0;display:block;height:auto;line-height:100%;outline:none;text-decoration:none}table{border:0 !important;padding:0 !important; border-collapse:collapse !important;mso-table-lspace:0pt;mso-table-rspace:0pt;}u + .body a{color:inherit;font-family:inherit;font-size:inherit;font-weight:inherit;line-height:inherit}u ~ img + div > div{display:none;}u + .body{width:100%;}.body a[x-apple-data-detectors=true]{color:inherit!important;text-decoration:inherit!important}span.MsoHyperlink{color:inherit !important;mso-style-priority:99 !important}span.MsoHyperlinkFollowed{color:inherit !important;mso-style-priority:99 !important}
</style> <!-- BACKGROUND COLORS -->
<style type="text/css">
body,#MainTable{background-color:#F4F4F4;}
#Basic000{background-color:#FEFFFF;}
#Sky200{background-color:#DDF4F4;}
</style> <!-- FONT STYLES -->
<style type="text/css">
h1,h2,h3,h4,h5,h6{font-family:'TTNorms',system-ui,sans-serif;font-weight:700;margin:0 0 8px 0;}
p,ol,ul{font-family:'TTNorms',system-ui,sans-serif;font-weight:450;margin:0 0 8px 0;}
ul,ol{padding:0 0 0 20px;}
li{font-weight:450;margin:0 0 8px 0;}
h1{font-size:50px;line-height:50px;letter-spacing:-0.03em;}
h2{font-size:40px;line-height:40px;letter-spacing:-0.02em;}
h3{font-size:32px;line-height:32px;letter-spacing:-0.02em;}
h4{font-size:24px;line-height:24px;letter-spacing:-0.01em;}
h5{font-size:20px;line-height:22px;letter-spacing:-0.01em;}
h6{font-size:16px;line-height:18px;}
p.p1{font-size:20px;line-height:26px;}
p.p2{font-size:16px;line-height:22px;}
p.p4{font-size:12px;line-height:14px;}
sup{font-size:11px;line-height:11px;}
</style> <!-- FONT COLORS -->
<style type="text/css">
#MainTable table td{color:#191919;}
#MainTable table td a{color:inherit;}
#MainTable table td p a{text-decoration:underline;}
.Red200{color:#EB1700 !important;}
</style> <!-- CTAs --> <!-- MOBILE STYLES -->
<style type="text/css">
@media only screen and (max-width:699px){
#MainTable > table {max-width:410px!important;}
.full{width:100%!important;height:auto!important;}
.pad0{padding-left:0!important;padding-right:0!important;}
.pad8{padding-left:8px!important;padding-right:8px!important;}
.pad24{padding-left:24px!important;padding-right:24px!important;}
.logo{padding-top:40px!important;padding-bottom:40px!important;}
h1{font-size:40px!important;line-height:40px!important;letter-spacing:-0.02em!important;}
h2{font-size:32px!important;line-height:32px!important;}
h3{font-size:24px!important;line-height:24px!important;letter-spacing:-0.01em!important;}
}
</style> <!-- DARK MODE STYLES -->
<style type="text/css">
@media (prefers-color-scheme:dark){
body,#MainTable,#Footer{background-color:#000000!important;background-image:linear-gradient(#000000,#000000)!important;}
table[id^="Basic"]{background-color:#191919!important;background-image:linear-gradient(#191919,#191919)!important;}
table[id^="Sky"],table[id^="Blue"]{background-color:#002629!important;background-image:linear-gradient(#002629,#002629)!important;}
#MainTable table td{color:#FFFFFF!important;}
#MainTable #Footer table td a{color:#FFFFFF!important;}
#MainTable .Red200{color:#FF3008!important;}
#MainTable .label span{color:#494949!important;background-color:#FEFFFF!important;}
#MainTable .grayCopy p{color:#A6A6A6!important;}
}
</style>
<style type="text/css">
:root{color-scheme:light dark;supported-color-schemes:light dark;}
</style> <!-- GMAIL APP DARK MODE FIX --> <!-- OUTLOOK SPECIFIC CSS --> <!--[if gte mso 9]>
<style type="text/css">
#MainTable td a{color:#191919;}
ol,ul{margin-left:20px !important;}
li{text-indent:-1em;}
</style>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
</head>
<body class="body" style="width:100%;margin:0;padding:0;">
<span style="color:transparent;visibility:hidden;display:none;opacity:0;height:0;width:0;font-size:0;"></span>
<div role="article" aria-roledescription="email" aria-label="DoorDash Email" lang="en" dir="ltr" style="font-size:medium; font-size:max(16px, 1rem);">
<div style="display: none; max-height: 0px; overflow: hidden;">
 ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏
</div>
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td align="center" id="MainTable" style="background-color:#F4F4F4;">
<table align="center" border="0" cellpadding="0" cellspacing="0" class="full" id="Sky200" role="presentation" style="width:700px;">
<tbody>
<tr>
<td align="left" class="pad24" style="padding: 0 64px;">
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td align="left" class="logo" style="padding: 48px 0;"><a href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiX9NQvXQ9aE-2FeLMhxL9C-2FAEa1Qb_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDdm5muv43mKBZj0FtslvRhwurT1HU7WzBFk0uKPD12dTef2rlCVIu36es-2FxRXYDPOLHXG7qitIbeuwICQOtiAiJyF0XW5mYCg4EenXQa5FiPOrlqnzXCXafNC5mlJGvxOlPnGz4KG6-2FibY4F4HrGTkxRoVpyf9CEBWQB3h4B6-2F1vV34umJj7-2FLlLpcjkRso-2FZrw9zD-2FDCCnEzAJ9b7RCYUIm4jyKucNAH6Fj2kRAnALL6-2FL0QY3vFFztXGwYqrzLSreyQBjHZ9aQwUSam7UJYCAaq48Y50TUgS9EuM1bSw4Twf5p6fp3H4FoRGPMueME-2FSTdpWyR6zn70BPVqAFezHiorOMilHHsOq6nk1-2FqORI-2F0veiu3RW2zrCwnVrabBGigtiGnhurr7p9nOs1AjdyZa-2Fy5Uh8bX3CPB4wzCMXE7O-2FOmAxPQkFoB0-2BgXl8YJlxRNDOaueR-2BgqPA2uYZxr4-2BQtI4L0YynseA3NMscHGJNGN88RJHrH4CqPbPBkbpXelO-2Ftz4qkPn2adhNtmCgMrl4mfF5lxevUpoJQJ7UmMxollJfoLEGLjLhrl7sdZrHH5YHZ8O6My471AKWPRD9-2Fi1Gj5kDczANohQUl0hqEmYQa58AnEUM4SbTghHAzOx2-2FdIewmuvSy5w0lyALMIuqj5OPNSb0LQsZWNIEFVG8SDXp" target="_blank" universal="true"><img alt="DoorDash" border="0" src="https://assets.doordash.team/m/5e68fa5cbbc50c32/original/DoorDash-Logo-Red100.png" style="color: #FF3008;font-family:'TTNorms',system-ui,sans-serif;font-weight:bold;font-size:18px;text-decoration:none;" width="50"></a></td>
</tr>
<tr>
<td align="left" class="pad0" style="padding:0 64px 0 0;"><h1 style="margin:0 0 16px 0;">Final receipt.</h1></td>
</tr>
<tr>
<td align="right" style="padding: 0 0 40px 0;"><img alt="" src="https://img.cdn4dd.com/s/convenience/images/adjustments_eml_grocery.png" width="380" style="width:100%;max-width:380px;"></td>
</tr>
</tbody>
</table></td>
</tr>
<tr>
<td align="left" class="pad8" style="padding: 0 40px 40px;">
<table align="left" border="0" cellpadding="0" cellspacing="0" id="Basic000" role="presentation" style="width:100%;border-radius:25px;">
<tbody>
<tr>
<td align="left" class="pad24" style="padding:40px 40px 0;">
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td align="left" style="padding:0 0 32px 0;">
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td align="left" style="padding:0 0 24px 0;"><!--
--> <p class="p2" style="margin:0 0 4px 0;">Paid with MasterCard Ending in 8032&nbsp;and/or&nbsp;credits</p> <p class="p2" style="margin:0 0 4px 0;">Woolworths</p> <p class="p2" style="margin:0 0 4px 0;"></p> <p class="p2"><strong>Total: $60.93</strong></p></td>
</tr>
<tr>
<td align="left" style="padding:0 0 24px 0;border-bottom:1px solid #C4C4C4;"><h4>Your receipt</h4> <!----> <p class="p2" style="margin:0 0 4px 0;"></p> <!----> <p class="p2"><a href="" style="color:#191919;text-decoration:none;">19 Lady Penrhyn Dr, Wyndham Vale VIC 3024, Australia</a></p></td>
</tr>
</tbody>
</table></td>
</tr>
</tbody>
</table> <!----> <!---->
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td align="left" style="padding:0 0 32px 0;">
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td align="left"><h4>Items you ordered</h4></td>
</tr>
<tr>
<td align="left" style="padding:0 0 16px 0;border-bottom:1px solid #C4C4C4;"><!----> <!-- -->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<tbody>
<tr>
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<!-- --> <!-- -->
<tbody>
<tr>
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<!--
-->
<tbody>
<tr>
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> Woolworths Corned Beef Silverside (each)</p></td>
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;">&nbsp;$15.54&nbsp;</p></td>
</tr> <!-- -->
<tr>
<td colspan="2" align="left" class="grayCopy"><p class="para-md" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 14px; line-height: 18px; margin: 0 0 4px 0;color: #767676;">$11.50/kg • Purchased 1.351 kg</p></td>
</tr> <!-- -->
</tbody>
</table></td>
</tr> <!-- --> <!-- -->
</tbody>
</table></td>
</tr>
</tbody>
</table> <!----> <!-- -->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<tbody>
<tr>
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<!-- --> <!-- -->
<tbody>
<tr>
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<!--
-->
<tbody>
<tr>
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>2x</strong> Jalapeno Chilli (1 ea)</p></td>
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;">&nbsp;$2.30&nbsp;</p></td>
</tr> <!-- -->
</tbody>
</table></td>
</tr> <!-- --> <!-- -->
</tbody>
</table></td>
</tr>
</tbody>
</table> <!----> <!-- -->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<tbody>
<tr>
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<!-- --> <!-- -->
<tbody>
<tr>
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<!--
-->
<tbody>
<tr>
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>2x</strong> Coca-Cola Zero Sugar Soft Drink Bottle (2 L)</p></td>
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;">&nbsp;$9.00&nbsp;</p></td>
</tr> <!-- -->
</tbody>
</table></td>
</tr> <!-- --> <!-- -->
</tbody>
</table></td>
</tr>
</tbody>
</table> <!----> <!-- -->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<tbody>
<tr>
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<!-- --> <!-- -->
<tbody>
<tr>
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<!--
-->
<tbody>
<tr>
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> Kewpie Sriracha Mayonnaise (300 g)</p></td>
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;">&nbsp;$6.11&nbsp;</p></td>
</tr> <!-- -->
</tbody>
</table></td>
</tr> <!-- --> <!-- -->
</tbody>
</table></td>
</tr>
</tbody>
</table> <!----> <!-- -->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<tbody>
<tr>
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<!-- --> <!-- -->
<tbody>
<tr>
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<!--
-->
<tbody>
<tr>
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> Woolworths Angus Quarter Pound Beef Burgers (454 g × 4 pk)</p></td>
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;">&nbsp;$9.00&nbsp;</p></td>
</tr> <!-- -->
</tbody>
</table></td>
</tr> <!-- --> <!-- -->
</tbody>
</table></td>
</tr>
</tbody>
</table> <!----> <!-- -->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<tbody>
<tr>
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<!-- --> <!-- -->
<tbody>
<tr>
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<!--
-->
<tbody>
<tr>
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> Green Capsicum (1 ea)</p></td>
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;">&nbsp;$1.40&nbsp;</p></td>
</tr> <!-- -->
</tbody>
</table></td>
</tr> <!-- --> <!-- -->
</tbody>
</table></td>
</tr>
</tbody>
</table> <!----> <!-- -->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<tbody>
<tr>
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<!-- --> <!-- -->
<tbody>
<tr>
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<!--
-->
<tbody>
<tr>
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> Brioche Gourmet Sesame Brioche Burger Buns (4 pk)</p></td>
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;">&nbsp;$7.30&nbsp;</p></td>
</tr> <!-- -->
</tbody>
</table></td>
</tr> <!-- --> <!-- -->
</tbody>
</table></td>
</tr>
</tbody>
</table> <!----> <!-- -->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<tbody>
<tr>
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<!-- --> <!-- -->
<tbody>
<tr>
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<!--
-->
<tbody>
<tr>
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>2x</strong> Hass Avocado</p></td>
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;">&nbsp;$4.10&nbsp;</p></td>
</tr> <!-- -->
</tbody>
</table></td>
</tr> <!-- --> <!-- -->
</tbody>
</table></td>
</tr>
</tbody>
</table> <!----> <!-- -->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<tbody>
<tr>
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<!-- --> <!-- -->
<tbody>
<tr>
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<!--
-->
<tbody>
<tr>
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> Helga's Light Rye Bread (680 g)</p></td>
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;">&nbsp;$5.95&nbsp;</p></td>
</tr> <!-- -->
</tbody>
</table></td>
</tr> <!-- --> <!-- -->
</tbody>
</table></td>
</tr>
</tbody>
</table> <!----> <!-- -->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<tbody>
<tr>
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<!-- --> <!-- -->
<tbody>
<tr>
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<!--
-->
<tbody>
<tr>
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> Red Capsicum (1 ea)</p></td>
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;">&nbsp;$1.40&nbsp;</p></td>
</tr> <!-- -->
</tbody>
</table></td>
</tr> <!-- --> <!-- -->
</tbody>
</table></td>
</tr>
</tbody>
</table> <!----> <!-- -->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<tbody>
<tr>
<td align="left" style="padding: 16px 0 0 0; border-bottom: 1px solid #C4C4C4;">
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<!-- --> <!-- -->
<tbody>
<tr>
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<!--
-->
<tbody>
<tr>
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> Garlic Head</p></td>
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;">&nbsp;$2.15&nbsp;</p></td>
</tr> <!-- -->
</tbody>
</table></td>
</tr> <!-- --> <!-- -->
</tbody>
</table></td>
</tr>
</tbody>
</table> <!----> <!-- -->
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<tbody>
<tr>
<td align="left" style="padding: 16px 0 0 0; border-bottom: none;">
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<!-- --> <!-- -->
<tbody>
<tr>
<td align="left" style="padding: 0 0 12px 0;"><!-- --> <!-- --> <!-- -->
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width: 100%; border: 0; padding: 0; border-collapse: collapse;" width="100%">
<!--
-->
<tbody>
<tr>
<td align="left" valign="top" class=""><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; margin: 0 0 4px 0;color:inherit;"><strong>1x</strong> Lee Kum Kee Premium Dark Soy Sauce (250 ml)</p></td>
<td align="right" valign="top" style="padding: 0 0 0 24px;"><p class="para-rg" style="font-family: 'TTNorms',system-ui,sans-serif; font-weight: 500; font-size: 16px; line-height: 20px; text-decoration: none; margin: 0 0 4px 0;color:inherit;white-space:nowrap;">&nbsp;$3.65&nbsp;</p></td>
</tr> <!-- -->
</tbody>
</table></td>
</tr> <!-- --> <!-- -->
</tbody>
</table></td>
</tr>
</tbody>
</table> <!----></td>
</tr>
</tbody>
</table></td>
</tr>
</tbody>
</table> <!---->
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td align="left" style="padding:0 0 32px 0;">
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td align="left" style="padding:0 0 24px 0;border-bottom:1px solid #C4C4C4;">
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td align="left"><p class="p2">Subtotal</p></td>
<td align="right" valign="bottom"><p class="p2">$67.90</p></td>
</tr> <!----> <!---->
<tr>
<td align="left"><p class="p2">Bag Fee</p></td>
<td align="right" valign="top"><p class="p2">$0.50</p></td>
</tr> <!----> <!----> <!---->
<tr>
<td align="left"><p class="p2">Tax</p></td>
<td align="right" valign="top"><p class="p2">$0.00</p></td>
</tr> <!----> <!----> <!---->
<tr>
<td align="left"><p class="p2">Delivery fee</p></td>
<td align="right" valign="top"><p class="p2">$0.00</p></td>
</tr> <!----> <!---->
<tr>
<td align="left"><p class="p2">Service&nbsp;fee</p></td>
<td align="right" valign="top"><p class="p2">$6.11</p></td>
</tr> <!----> <!---->
<tr>
<td align="left"><p class="p2">Dasher&nbsp;tip</p></td>
<td align="right" valign="top"><p class="p2">$0.00</p></td>
</tr> <!----> <!---->
<tr>
<td align="left"><p class="p2">Discount</p></td>
<td align="right" valign="top"><p class="p2">-$13.58</p></td>
</tr> <!----> <!----> <!----> <!---->
</tbody>
</table></td>
</tr>
</tbody>
</table></td>
</tr>
</tbody>
</table>
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td align="left">
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td align="left"><p class="p1" style="font-weight:700;">Final total charged</p></td>
<td align="right" valign="bottom"><p class="p1" style="font-weight:700;">$60.93</p></td>
</tr>
</tbody>
</table></td>
</tr>
<tr>
<td align="left" style="padding:16px 0 24px 0;">
<table align="left" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
<tbody>
<tr>
<td align="left"><!----> <!----> <!----> <p class="p4" style="margin:0 0 16px;font-weight:400;">This email confirms revisions made to your original DoorDash order and reflects the final amount charged. The new total cost of your order is above and includes all taxes and fees. Payment processing adjustments to the original charge may take up to 5-7 business days to process.</p> <!----> <p class="p2" style="margin:0 0 16px;"><a class="Red200" href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiXhYhQ4ra2libvRVipt1sfv4E-2FKwlbgukGbMndo1ZsJhMUiuAxeGb86p06pmk6V-2FQsCkNG42P38jfRm6zgoux4YpHAP_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDdm5muv43mKBZj0FtslvRhwurT1HU7WzBFk0uKPD12dTef2rlCVIu36es-2FxRXYDPOLHXG7qitIbeuwICQOtiAiJyF0XW5mYCg4EenXQa5FiPOrlqnzXCXafNC5mlJGvxOlPnGz4KG6-2FibY4F4HrGTkxRoVpyf9CEBWQB3h4B6-2F1vV34umJj7-2FLlLpcjkRso-2FZrw9zD-2FDCCnEzAJ9b7RCYUIm4jyKucNAH6Fj2kRAnALL6-2FL0QY3vFFztXGwYqrzLSreyQBjHZ9aQwUSam7UJYCAaq48Y50TUgS9EuM1bSw4Twf5p6fp3H4FoRGPMueME-2FSTdpWyR6zn70BPVqAFezHiorOMilHHsOq6nk1-2FqORI-2F0veiu3RW2zrCwnVrabBGigtiGnhurr7p9nOs1AjdyZa-2Fy5Uh8bX3CPB4wzCMXE7O-2FOmAxPQkFoB0-2BgXl8YJlxRNDOaueR-2BgqPA2uYZxr4-2BQtI4L0YynseA3NMscHGJNGN88RJHrH4CqPbPBkbpXelO-2Ftz4qkPn2adhNtmCgMrl40rFinK7NkUBQ-2Fl8tQtwvq8JTmf6gxu6dY07cNv4deM6rPu-2BQ-2FvHH4q00LtMFjDuuQ3oUFkbnbQHKhXkIUv3eSN4o7Kl6vrZHuHXulUntzFpaf0-2BlkucXK2YwHzC7HliOAyEF6-2FBa1FHUj7-2B2nt5St" target="_blank" style="font-weight:700;text-decoration:none;" universal="true">Get Order Help</a></p></td>
</tr>
</tbody>
</table></td>
</tr>
</tbody>
</table></td>
</tr>
</tbody>
</table></td>
</tr>
</tbody>
</table>
<table align="center" border="0" cellpadding="0" cellspacing="0" class="full" id="Footer" role="presentation" style="width:700px;">
<tbody>
<tr>
<td valign="top" align="center" style="padding:0 0 24px 0;"><!--[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:0 0 48px 0;border-bottom: 1px solid #E7E7E7;"></td>
</tr>
<tr>
<td align="left" style="color: #191919; padding: 32px 0 16px 0;"><p style="font-family:'TTNorms',system-ui,sans-serif;font-size:14px;line-height:18px;font-weight:400;margin:0;color:#9A9A9A;">©2026 <a style="color: #9A9A9A; text-decoration: none;">DoorDash Technologies Australia Pty Ltd <br>401 Collins St. <br>Melbourne, VIC 3000 Australia</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-2FDD8FcMNUAvAdfcDQmgdYAqF1KGCKTTTznL6MvOfulOsmFa2kqsuG7LjgY0AllZKBKWegcAPOx5sr25l15pnWbLZL6VnVCDFc3hEnP4sDnr_qFX_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDdm5muv43mKBZj0FtslvRhwurT1HU7WzBFk0uKPD12dTef2rlCVIu36es-2FxRXYDPOLHXG7qitIbeuwICQOtiAiJyF0XW5mYCg4EenXQa5FiPOrlqnzXCXafNC5mlJGvxOlPnGz4KG6-2FibY4F4HrGTkxRoVpyf9CEBWQB3h4B6-2F1vV34umJj7-2FLlLpcjkRso-2FZrw9zD-2FDCCnEzAJ9b7RCYUIm4jyKucNAH6Fj2kRAnALL6-2FL0QY3vFFztXGwYqrzLSreyQBjHZ9aQwUSam7UJYCAaq48Y50TUgS9EuM1bSw4Twf5p6fp3H4FoRGPMueME-2FSTdpWyR6zn70BPVqAFezHiorOMilHHsOq6nk1-2FqORI-2F0veiu3RW2zrCwnVrabBGigtiGnhurr7p9nOs1AjdyZa-2Fy5Uh8bX3CPB4wzCMXE7O-2FOmAxPQkFoB0-2BgXl8YJlxRNDOaueR-2BgqPA2uYZxr4-2BQtI4L0YynseA3NMscHGJNGN88RJHrH4CqPbPBkbpXelO-2Ftz4qkPn2adhNtmCgMrl47hiO5jAJKuIZrUFfimDnpntNrXHRtriBc5B6j34phPs9t-2FTsNtprBO5gereYjazskrJGz4kTq383Phk6ev5l-2FwkKCZAvVCUn589YJbi-2FoyqrxbStev5iE7PJ2Us6bPgQpdvcUQSEkpYDzIyI-2Blcws" target="_blank" style="color: #9A9A9A; text-decoration: none;">Privacy Policy</a></p></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-2FDD8FcMNUAvAdfcDQmgdYABnncKv1afIihr5Gt6bkMjesVR_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDdm5muv43mKBZj0FtslvRhwurT1HU7WzBFk0uKPD12dTef2rlCVIu36es-2FxRXYDPOLHXG7qitIbeuwICQOtiAiJyF0XW5mYCg4EenXQa5FiPOrlqnzXCXafNC5mlJGvxOlPnGz4KG6-2FibY4F4HrGTkxRoVpyf9CEBWQB3h4B6-2F1vV34umJj7-2FLlLpcjkRso-2FZrw9zD-2FDCCnEzAJ9b7RCYUIm4jyKucNAH6Fj2kRAnALL6-2FL0QY3vFFztXGwYqrzLSreyQBjHZ9aQwUSam7UJYCAaq48Y50TUgS9EuM1bSw4Twf5p6fp3H4FoRGPMueME-2FSTdpWyR6zn70BPVqAFezHiorOMilHHsOq6nk1-2FqORI-2F0veiu3RW2zrCwnVrabBGigtiGnhurr7p9nOs1AjdyZa-2Fy5Uh8bX3CPB4wzCMXE7O-2FOmAxPQkFoB0-2BgXl8YJlxRNDOaueR-2BgqPA2uYZxr4-2BQtI4L0YynseA3NMscHGJNGN88RJHrH4CqPbPBkbpXelO-2Ftz4qkPn2adhNtmCgMrl4Xvmy9Ho7hVCvflBaTaT7GIPK-2Bx-2FVY0eWPUYOIl9ze-2FigbiVg7zeZtEGGvUnrFsPyYMZvNqAmnc5awn0lwnuo6T2MwPVn7itLe2lk679GxtX4NcwYfkPFJXWYwEqreqmpCX0cU2u5EQKvkeFL5tyNJ" 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>
</tbody>
</table></td>
</tr>
</tbody>
</table>
</div>
<img src="https://tracksg.doordash.com/wf/open?upn=u001.EBL2ug8kstebd25Xirrl3olMckTI261ldPjJ39bNHcC7U6EKGAOA4dSVB97lcv3b8qsQRq6LwkHED6F3X4gqaFe59-2BHz9QfrDL7jp-2BN3jX4WgiXvlTBIakpalIO3Jdse0BvY-2BpzLRd3B7D76PRNnOdtT8H1IzuhLRJrs8Ejc-2BQ4LU1ObPOGMj6kj4yuf9wvF-2BNU8yHnNyO0aMIUyf7Vw-2FvxkWQyIhy6jsNc64aO9QhWMEzgvfqcwerkGC0infSMLleveIQg1DJBFCa1l1sbtqm8v4JdVcD3tIswbkSwUBel2KQ6xwvitDjmSW4JqAVQjU2bnGfsVtQIUlNm-2FVkiKuTOkUSpSv-2BlmuRSRwJBxspIW-2FHL-2Fizzna0UNWeoxCQEouEyMXVglI9wvbdjxbE2fJhnbSO5BQgiIaJi6Z1Agc9UNZnRJQfmuKx-2BvkOjjEvTyMkvt-2F1GvutzvpeXgVGLKtJPwmIIEYfS4bk0NKmMQHCG0VBFaOwtyWNr32tHHB6clMeOlpiIJ9B-2FV6NK2NOKOn7nF2XPwImzV33xN-2B0bIZZeeDZf0piG4s3LMX9IoAqChWag-2BAHkcHaYx0ULMfGzIdlJCvGzkUFqfvXEkO7EloGLJ7Ir77VX8C-2BK1PF-2Bev7l0-2F1IBaXJUUw185DM9Yga0FDlihJgZGc0DBp8ml0RmuDjeqZPfpMHZgCXSm-2BXpoAOZg8Jjnj0I8Xr3SrabOHhH5IyYeOz-2FFmDwDDEXGh7pvKTOmsNosAQAHnmTCzcE1ABT" 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>
@@ -0,0 +1,554 @@
<!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:o="urn:schemas-microsoft-com:office:office" xmlns:v="urn:schemas-microsoft-com:vml">
<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;
}
}
@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;
}
}
@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_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_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 bgcolor="#ffffff" class="em_body" data-gr-c-s-loaded="true" style="margin:0px auto; padding:0px;">
<span style="color:transparent;visibility:hidden;display:none;opacity:0;height:0;width:0;font-size:0;"></span> <!-- == Body Section == -->
<table bgcolor="#ffffff" border="0" cellpadding="0" cellspacing="0" class="em_full_wrap" width="100%">
<tbody>
<tr>
<td align="center" valign="top">
<table align="center" border="0" cellpadding="0" cellspacing="0" class="em_main_table" style="width:700px;" width="700">
<tbody>
<tr>
<td align="center" valign="top"><!---->
<table align="center" bgcolor="#ededed" border="0" cellpadding="0" cellspacing="0" width="100%">
<tbody>
<tr>
<td class="em_h30" height="62" style="height:62px; line-height:0px; font-size:0px;"></td>
</tr> <!-- banner Section -->
<tr>
<td align="center" valign="top">
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
<tbody>
<tr>
<td align="center" bgcolor="#ededed" class="em_hauto" valign="top"><!---->
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
<tbody>
<tr>
<td class="em_side30" style="width:60px;" width="60"></td>
<td align="center" class="em_hauto" valign="top">
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
<tbody>
<tr>
<td align="left" valign="top"><a style="text-decoration:none;" target="_blank" href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiX9NQvXQ9aE-2FeLMhxL9C-2FAEYEgU_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRygzsqDKLqVcMuBPdkhwo7LHk9TXfSbi-2B0mW4NvsDLo9Q6gWLaNP3J-2Fyzkvnw5dniCObtF-2F77Rftwzxtvch3D6ixsFu8SYfu5xBceQTH8-2FBodTZxiewjgPzbEdFl3FPmC8yd8up2svP69bkXzPP1EgX9eD-2FyUn-2FEnfP2x2wq7ZOBUoARCeqqlFkP6W7Y5dGla8nNiQlX7AucOtqmpWXoN748jOggntsFT6RKIRx0tqjjl8YZ9wNtw1lwTOb8kFmOgsOcVVtLje8EbuOzP3zqIlcuQWgqqYb-2BV3lOeDIxWBtKbxqWC5a-2FCRgVj5lY1Bv7imoZhcA0AGsBJcII2E70CeIU8WfbTYM4M37ZPgsh2cLUb3V4Aejx-2FWVUWAWH2KDEs5dv-2B4PKgCJ8Acse3h-2BQekWDD3i77HE0-2BDrWvpiQ0PqKJUExxn5P-2FL0klhkr7BaNj8Rk-2F1Chu5ZAE5-2BwUiki7JUuLsWW9OOERb4e0qR3cSg4LijaO28HYiLjXFPAYOjdZeQOd-2FBnPtUPMDVpU9gYUKfSgsagIJguyAqpdjLfY6G8VVr86jIoxtluUxhWGTXIjaVcoCCZMHwuVy8TqEvwJR2SqSwWKk68wYn83-2BGVzp0Q96Il8ghSJk4VqSWMBaTqTd0KosKTV3a2OQBYPn6Cz3rrGJtTstNW0ohwZ27PImy8rSHU9BerWxzrGv35FfsQbw-3D" universal="true"><img alt="DOORDASH" border="0" class="en_icon" style="display:block; max-width:45px;font-family:Arial, sans-serif;font-size:20px; line-height:30px; color:#ee3623; font-weight:bold;" width="45" 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 align="left" class="em_defaultlink em_font_58 em_bold" style="font-family:'TTNorms-Bold', Arial, sans-serif; color:#000000;font-size:51px; line-height:60px; font-weight: bold;" valign="top"><!---->Thanks for your<br> order, Siddharth<!----></td>
</tr>
<tr>
<td class="em_h10" height="20" style="height:20px; line-height:0px; font-size:0px;"></td>
</tr>
<tr>
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#000000;font-size:16px; line-height:20px;" valign="top"><!---->The estimated delivery time for your order<br class="em_hide"> is <strong>12:56 pm - 1:06 pm</strong>. Track your order in<br class="em_hide"> the DoorDash app or website.<!----></td>
</tr>
<tr>
<td class="em_h10" height="20" style="height:20px; line-height:0px; font-size:0px;"></td>
</tr>
<tr>
<td align="left" valign="top">
<table align="left" border="0" cellpadding="0" cellspacing="0" class="em_cta" style="width:220px; max-width:220px;" width="220">
<tbody>
<tr>
<td align="center" class="em_defaultlink em_cta em_bold" height="45" style="font-family:'TTNorms-Bold', Arial, sans-serif;color:#ffffff;font-size:18px; background-color:#eb1700; border-radius:25px; font-weight: bold; " valign="middle"><a style="text-decoration:none; display:block; color:#ffffff; line-height:45px;" target="_blank" href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiVnjyPy8cqQ7jo-2BNF2Wc6ScBomnSDcwnk7ZiV0pRP-2F7pg2J7U-2BGioomJQf-2FXVlW43g-3Dvyw__gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRygzsqDKLqVcMuBPdkhwo7LHk9TXfSbi-2B0mW4NvsDLo9Q6gWLaNP3J-2Fyzkvnw5dniCObtF-2F77Rftwzxtvch3D6ixsFu8SYfu5xBceQTH8-2FBodTZxiewjgPzbEdFl3FPmC8yd8up2svP69bkXzPP1EgX9eD-2FyUn-2FEnfP2x2wq7ZOBUoARCeqqlFkP6W7Y5dGla8nNiQlX7AucOtqmpWXoN748jOggntsFT6RKIRx0tqjjl8YZ9wNtw1lwTOb8kFmOgsOcVVtLje8EbuOzP3zqIlcuQWgqqYb-2BV3lOeDIxWBtKbxqWC5a-2FCRgVj5lY1Bv7imoZhcA0AGsBJcII2E70CeIU8WfbTYM4M37ZPgsh2cLUb3V4Aejx-2FWVUWAWH2KDEs5dv-2B4PKgCJ8Acse3h-2BQekWDD3i77HE0-2BDrWvpiQ0PqKJUExxn5P-2FL0klhkr7BaNj8Rk-2F1Chu5ZAE5-2BwUiki7JUuLsWW9OOERb4e0qR3cSg4LijaO28HYiLjXFPAYOjdZeQOd-2FBnPtUPMDVpU9gYUKfSgsagIJguyAqpdjLfY6G8c11Xrs5mrwxYvXOjM-2B31YOhRV-2FVn8biClWfjAQE-2FFKhuO4Omylt1Br9cDjiGoEk5b7gncfkgZItoZ0uVSEVcQFQl4dKCCcWTsNznKpdwNr7hzrx4kK9eLK5xE8TC9mvmLuZcj8FiuGDz2TIKo2aVMk-3D" universal="true">Track Your Order</a></td>
</tr>
</tbody>
</table></td>
</tr>
</tbody>
</table></td>
<td class="em_side15" style="width:20px;" width="20"></td>
</tr>
</tbody>
</table></td>
</tr>
</tbody>
</table></td>
</tr> <!----><!----><!--Illustration 2-->
<tr>
<td align="center" class="em_full_img" valign="top"><a style="text-decoration:none;" target="_blank" href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiX9NQvXQ9aE-2FeLMhxL9C-2FAEvLxo_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRygzsqDKLqVcMuBPdkhwo7LHk9TXfSbi-2B0mW4NvsDLo9Q6gWLaNP3J-2Fyzkvnw5dniCObtF-2F77Rftwzxtvch3D6ixsFu8SYfu5xBceQTH8-2FBodTZxiewjgPzbEdFl3FPmC8yd8up2svP69bkXzPP1EgX9eD-2FyUn-2FEnfP2x2wq7ZOBUoARCeqqlFkP6W7Y5dGla8nNiQlX7AucOtqmpWXoN748jOggntsFT6RKIRx0tqjjl8YZ9wNtw1lwTOb8kFmOgsOcVVtLje8EbuOzP3zqIlcuQWgqqYb-2BV3lOeDIxWBtKbxqWC5a-2FCRgVj5lY1Bv7imoZhcA0AGsBJcII2E70CeIU8WfbTYM4M37ZPgsh2cLUb3V4Aejx-2FWVUWAWH2KDEs5dv-2B4PKgCJ8Acse3h-2BQekWDD3i77HE0-2BDrWvpiQ0PqKJUExxn5P-2FL0klhkr7BaNj8Rk-2F1Chu5ZAE5-2BwUiki7JUuLsWW9OOERb4e0qR3cSg4LijaO28HYiLjXFPAYOjdZeQOd-2FBnPtUPMDVpU9gYUKfSgsagIJguyAqpdjLfY6G8WRCD-2FqRcT3qYaTZhzIDIiyJa5PtObUnCjY4SczlOveif0uLWX8gst2iXvDBVuJze7WmSiLEzmN-2Bdnt8ToATmeJperQEu3d9WnvS8-2BknB4MEeK-2BwIQhdsw6X9lhJzohufq-2BYaSEg2vrnOSJed7mMlcQ-3D" universal="true"><img alt="" border="0" class="em_full_img" style="display:block; max-width:700px; font-family:Arial, sans-serif; font-size:22px; line-height:25px; color:#ffffff; font-weight:bold;" width="700" src="https://assets.doordash.team/m/188a590491f6c3c9/original/-template-OrderConfirmation-dancingfood.png"></a></td>
</tr> <!--//Illustration 2--><!----><!----><!-- //banner Section -->
<tr>
<td align="center" valign="top">
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
<tbody>
<tr>
<td style="width:6%;" width="6%"></td>
<td align="center" valign="top">
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
<tbody>
<tr>
<td align="center" class="em_rounded" style="border-top-left-radius: 40px; border-top-right-radius: 40px; background-color: #ffffff;" valign="top">
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
<tbody>
<tr>
<td class="em_side15" style="width: 40px;" width="40"></td>
<td align="center" valign="top">
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
<tbody>
<tr>
<td class="em_h20" height="50" style="height:50px; line-height:0px; font-size:0px;"></td>
</tr>
<tr>
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:17px; line-height:24px;" valign="top">Paid with MasterCard Ending in 8032<br> Subway</td>
</tr>
<tr>
<td align="left" class="em_defaultlink em_bold" style="font-family:'TTNorms-Bold', Arial, sans-serif; color:#767676;font-size:17px; line-height:24px; font-weight:bold; color:#000000;" valign="top">Total: $29.08</td>
</tr>
<tr>
<td class="em_h20" height="35" style="height:35px; line-height:0px; font-size:0px;"></td>
</tr>
<tr>
<td align="left" class="em_defaultlink em_bold" style="font-family:'TTNorms-Bold', Arial, sans-serif; color:#000000;font-size:26px; line-height:36px; font-weight: bold;" valign="top">Your receipt</td>
</tr>
<tr>
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:21px;" valign="top">19 Lady Penrhyn Dr, 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 align="center" valign="top">
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
<tbody>
<tr>
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:17px; line-height:24px;" valign="top"><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>Italian B.M.T.®</b> (All Subs)<br><font color="dimgrey">• Subway Footlong ®</font><br><font color="dimgrey">• Italian Herb &amp; Cheese Bread</font><br><font color="dimgrey">• Toasted</font><br><font color="dimgrey">• Old English Style Cheese</font><br><font color="dimgrey">• Double Cheese</font><br><font color="dimgrey">• Spinach</font><br><font color="dimgrey">• Tomato</font><br><font color="dimgrey">• Cucumber</font><br><font color="dimgrey">• Capsicum</font><br><font color="dimgrey">• Onions</font><br><font color="dimgrey">• Jalapenos</font><br><font color="dimgrey">• Carrots</font><br><font color="dimgrey">• Honey Mustard Sauce</font><br><font color="dimgrey">• Sweet Onion Dressing</font><br><font color="dimgrey">• Pepper</font><br><font color="dimgrey">• Sea Salt</font><br><br></td>
<td valign="top" width="15%" style="color: #666666; font-size: 18px; line-height: 24px text-align: right">$19.85</td>
</tr>
<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>Italian Meatball</b> (All Subs)<br><font color="dimgrey">• Subway 6-Inch ®</font><br><font color="dimgrey">• Italian Herb &amp; Cheese Bread</font><br><font color="dimgrey">• Toasted</font><br><font color="dimgrey">• Mozzarella</font><br><font color="dimgrey">• Double Meat (Selected Meat only)</font><br><font color="dimgrey">• Double Cheese</font><br><font color="dimgrey">• Cucumber</font><br><font color="dimgrey">• Pickles</font><br><font color="dimgrey">• Capsicum</font><br><font color="dimgrey">• Onions</font><br><font color="dimgrey">• Ranch Dressing</font><br><font color="dimgrey">• Garlic Aioli</font><br><font color="dimgrey">• Pepper</font><br><font color="dimgrey">• Sea Salt</font><br><br></td>
<td valign="top" width="15%" style="color: #666666; font-size: 18px; line-height: 24px text-align: right">$16.00</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" style="width: 40px;" width="40"></td>
</tr>
</tbody>
</table></td>
</tr>
<tr>
<td align="center" bgcolor="#ffffff" class="em_pad1" valign="top">
<table align="center" border="0" cellpadding="0" cellspacing="0" class="em_wrapper" style="width:530px;" width="530">
<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 align="center" style="background-color: #ffffff;" valign="top">
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
<tbody>
<tr>
<td class="em_side15" style="width: 40px;" width="40"></td>
<td align="center" valign="top">
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
<tbody>
<tr>
<td class="em_h20" height="10" style="height:10px; line-height:0px; font-size:0px;"></td>
</tr>
<tr>
<td align="center" valign="top">
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
<tbody>
<tr><!---->
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">Subtotal</td> <!---->
<td align="right" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">$35.85</td>
</tr>
</tbody>
</table></td>
</tr>
<tr>
<td align="center" valign="top">
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
<tbody>
<tr><!---->
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">Taxes</td> <!---->
<td align="right" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">$0.00</td>
</tr>
</tbody>
</table></td>
</tr> <!----><!---->
<tr>
<td align="center" valign="top">
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
<tbody>
<tr>
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">Delivery Fee</td>
<td align="right" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">$0.00</td>
</tr>
</tbody>
</table></td>
</tr>
<tr>
<td align="center" valign="top">
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
<tbody>
<tr>
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">Service Fee</td>
<td align="right" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">$3.23</td>
</tr>
</tbody>
</table></td>
</tr>
<tr>
<td align="center" valign="top">
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
<tbody>
<tr>
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">Tip</td>
<td align="right" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">$0.00</td>
</tr>
</tbody>
</table></td>
</tr> <!---->
<tr>
<td align="center" valign="top">
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
<tbody>
<tr>
<td align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">Discounts</td>
<td align="right" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#767676;font-size:14px; line-height:22px;" valign="top">-$26.02</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" style="width: 40px;" width="40"></td>
</tr>
</tbody>
</table></td>
</tr>
<tr>
<td align="center" bgcolor="#ffffff" class="em_pad1" valign="top">
<table align="center" border="0" cellpadding="0" cellspacing="0" class="em_wrapper" style="width:530px;" width="530">
<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 align="center" style="background-color: #ffffff;" valign="top">
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
<tbody>
<tr>
<td class="em_side15" style="width: 40px;" width="40"></td>
<td align="center" valign="top">
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
<tbody>
<tr>
<td class="em_h20" height="12" style="height:12px; line-height:0px; font-size:0px;"></td>
</tr>
<tr>
<td align="center" valign="top">
<table align="center" border="0" cellpadding="0" cellspacing="0" width="100%">
<tbody>
<tr><!---->
<td align="left" class="em_defaultlink em_bold" style="font-family:'TTNorms-Bold', Arial, sans-serif; color:#000000;font-size:17px; line-height:24px; font-weight: bold;" valign="top">Total Charged</td> <!---->
<td align="right" class="em_defaultlink em_bold" style="font-family:'TTNorms-Bold', Arial, sans-serif; color:#000000;font-size:17px; line-height:24px; font-weight: bold;" valign="top">$29.08</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 align="left" class="em_defaultlink" style="font-family:'TTNorms-Regular', Arial, sans-serif; color:#ff2f07;font-size:14px; line-height:21px; font-weight: bold;" valign="top"><a style="color:#ff2f07; text-decoration:none;" target="_blank" href="https://tracksg.doordash.com/uni/ls/click?upn=u001.2WPFTROKt87XSwhhpTWl-2B-2FBg5Q-2B-2F17CbuWT8XZi-2BuiVnjyPy8cqQ7jo-2BNF2Wc6ScBomnSDcwnk7ZiV0pRP-2F7pg2J7U-2BGioomJQf-2FXVlW43g-3D6wBj_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRygzsqDKLqVcMuBPdkhwo7LHk9TXfSbi-2B0mW4NvsDLo9Q6gWLaNP3J-2Fyzkvnw5dniCObtF-2F77Rftwzxtvch3D6ixsFu8SYfu5xBceQTH8-2FBodTZxiewjgPzbEdFl3FPmC8yd8up2svP69bkXzPP1EgX9eD-2FyUn-2FEnfP2x2wq7ZOBUoARCeqqlFkP6W7Y5dGla8nNiQlX7AucOtqmpWXoN748jOggntsFT6RKIRx0tqjjl8YZ9wNtw1lwTOb8kFmOgsOcVVtLje8EbuOzP3zqIlcuQWgqqYb-2BV3lOeDIxWBtKbxqWC5a-2FCRgVj5lY1Bv7imoZhcA0AGsBJcII2E70CeIU8WfbTYM4M37ZPgsh2cLUb3V4Aejx-2FWVUWAWH2KDEs5dv-2B4PKgCJ8Acse3h-2BQekWDD3i77HE0-2BDrWvpiQ0PqKJUExxn5P-2FL0klhkr7BaNj8Rk-2F1Chu5ZAE5-2BwUiki7JUuLsWW9OOERb4e0qR3cSg4LijaO28HYiLjXFPAYOjdZeQOd-2FBnPtUPMDVpU9gYUKfSgsagIJguyAqpdjLfY6G8bOKlnu7vSFB-2BIQ-2FSa0IlYx-2F9RNR0nsTokQzCNSqlpkomb9IWoemjq9we5J1OThMwqfb3jx6ATLRRe0hZPiX7urz31ND8Ku6OkcqBpF9xCGYZ-2B8b-2FruvvcHPSbTGjapxW5zZugpMbHsegIXXCMVBkNg-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" style="width: 40px;" width="40"></td>
</tr>
</tbody>
</table></td>
</tr>
</tbody>
</table></td>
<td style="width:6%;" width="6%"></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:0 0 48px 0;border-bottom: 1px solid #E7E7E7;"></td>
</tr>
<tr>
<td align="left" style="color: #191919; padding: 32px 0 16px 0;"><p style="font-family:'TTNorms',system-ui,sans-serif;font-size:14px;line-height:18px;font-weight:400;margin:0;color:#9A9A9A;">©2026 <a style="color: #9A9A9A; text-decoration: none;">DoorDash Technologies Australia Pty Ltd <br>401 Collins St. <br>Melbourne, VIC 3000 Australia</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-2FDD8FcMNUAvAdfcDQmgdYAqF1KGCKTTTznL6MvOfulOsmFa2kqsuG7LjgY0AllZKBKWegcAPOx5sr25l15pnWbLZL6VnVCDFc3hEnP4sDnrBpZV_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRygzsqDKLqVcMuBPdkhwo7LHk9TXfSbi-2B0mW4NvsDLo9Q6gWLaNP3J-2Fyzkvnw5dniCObtF-2F77Rftwzxtvch3D6ixsFu8SYfu5xBceQTH8-2FBodTZxiewjgPzbEdFl3FPmC8yd8up2svP69bkXzPP1EgX9eD-2FyUn-2FEnfP2x2wq7ZOBUoARCeqqlFkP6W7Y5dGla8nNiQlX7AucOtqmpWXoN748jOggntsFT6RKIRx0tqjjl8YZ9wNtw1lwTOb8kFmOgsOcVVtLje8EbuOzP3zqIlcuQWgqqYb-2BV3lOeDIxWBtKbxqWC5a-2FCRgVj5lY1Bv7imoZhcA0AGsBJcII2E70CeIU8WfbTYM4M37ZPgsh2cLUb3V4Aejx-2FWVUWAWH2KDEs5dv-2B4PKgCJ8Acse3h-2BQekWDD3i77HE0-2BDrWvpiQ0PqKJUExxn5P-2FL0klhkr7BaNj8Rk-2F1Chu5ZAE5-2BwUiki7JUuLsWW9OOERb4e0qR3cSg4LijaO28HYiLjXFPAYOjdZeQOd-2FBnPtUPMDVpU9gYUKfSgsagIJguyAqpdjLfY6G8Q8l21R-2BUnujFyqLh7dgRALyW1uXR-2FN8S0g00dvJLLfcmlzcQZxW44cVyDjeLcQdT7oXKMmfaQcqjpUqJASXPTpcyojzmAR7AJNx0XL1wVst-2FulVzDY1gZlfxA5V-2BU-2F-2B4tKqztf0gKzb-2BVk9xtu1bac-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 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-2FDD8FcMNUAvAdfcDQmgdYABnncKv1afIihr5Gt6bkMjMbSD_gLgx7IOQjj9tjcBPTdxDOJz1JqFsZO0kLUqSc8MGlKXluNU-2FF7J47geTAg8w6cqXb3B-2FMFQSFRN7JikLQZlFDU38dwgb-2BDbpbbf0hwXhFRygzsqDKLqVcMuBPdkhwo7LHk9TXfSbi-2B0mW4NvsDLo9Q6gWLaNP3J-2Fyzkvnw5dniCObtF-2F77Rftwzxtvch3D6ixsFu8SYfu5xBceQTH8-2FBodTZxiewjgPzbEdFl3FPmC8yd8up2svP69bkXzPP1EgX9eD-2FyUn-2FEnfP2x2wq7ZOBUoARCeqqlFkP6W7Y5dGla8nNiQlX7AucOtqmpWXoN748jOggntsFT6RKIRx0tqjjl8YZ9wNtw1lwTOb8kFmOgsOcVVtLje8EbuOzP3zqIlcuQWgqqYb-2BV3lOeDIxWBtKbxqWC5a-2FCRgVj5lY1Bv7imoZhcA0AGsBJcII2E70CeIU8WfbTYM4M37ZPgsh2cLUb3V4Aejx-2FWVUWAWH2KDEs5dv-2B4PKgCJ8Acse3h-2BQekWDD3i77HE0-2BDrWvpiQ0PqKJUExxn5P-2FL0klhkr7BaNj8Rk-2F1Chu5ZAE5-2BwUiki7JUuLsWW9OOERb4e0qR3cSg4LijaO28HYiLjXFPAYOjdZeQOd-2FBnPtUPMDVpU9gYUKfSgsagIJguyAqpdjLfY6G8aanhk3H7M0AmyPIeD-2FSytmK5nE5elBgAZcjX3JsIKz28vuZG4HM2bl-2FxI9FZWifPR-2FNBQ-2FOmTQqLo0YK3jcUZRxpOUh8EYeWnkDHAf9CUnJYhfBCXrPHy8-2BIouWsFADv6rViHCrwIlJq7PKIdjhsQU-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 class="em_hide" style="line-height:1px;min-width:700px;background-color:#f4f4f4;"><img alt="" border="0" height="1" style="max-height:1px; min-height:1px; display:block; width:700px; min-width:700px;" width="700" 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.EBL2ug8kstebd25Xirrl3olMckTI261ldPjJ39bNHcC7U6EKGAOA4dSVB97lcv3b8qsQRq6LwkHED6F3X4gqaODB2b1wZFk4or4JbrThItr6lxxhe0ghxdjbmj40V9NR2q6sVlDPfArvtaJbtCejz-2BJxmNDpj6cBZjf16jHN3-2B0iC1V4tFBTMLc-2BJwfmofzVTZ93Zr38yBFeE9QeTx2HIPl8aahBdGSAhck5E0FopTVrs3ftPac-2BjBZQ-2F-2BOtO9AAatRdgybtYfdgGvULGP9PgScKi7bqhiEsMHtYK5BiFCtgHDd3wSmmSQEmLY5CxOdBaxC6hncHaAEfQICV5PV5RYoWJUn59AMV6iCj-2FAzsFmZDmbIos3SveyBNTIRxoQ9cPRYnGUJvcAO2C-2FN1QxbjuL3Y8ELK5gWiOUqwAeJaoIqwImrrWGzkrtF6Iu6lpAYw2yIml9u8FnfV-2BNo6dypylz5AbWbO4wm5AnTe6e7WmglLSPh-2FBr7RLUmgVl05JkvNjJmFrJUL-2FOA3VD8xCUTqdI8CiSkwhJgeP4PUUSKU6CPRXiEhpXbnj4JBARqbiZEvMCv3cChoIFQg20r6bMddokffgXp-2BiFWCvNIrp4WNUFzUegA1y-2FApKsMQemjXy5KM5DhVuyriQDcI1uKZbNH2L4o9QR7SSsEbRXbj-2F5-2FuRPshsLYfjHnGoPt11ACguPrpPaB5Kmke1gWd0UvceKwobel8MH3YRSgU2gvLJEJ000ZGfHr9Mj0j30BzHFnFaYfPzegN2Y2l5rXh6a3zR9Dsv0mUWU0mjRXrYJif2o5NEVM-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
@@ -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 restaurant’s 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 you’ve 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>
@@ -0,0 +1,254 @@
[
{
"id": "19f89664ea7b3aeb",
"date": "Date: Wed, 22 Jul 2026 10:36:50 +0000 (UTC)",
"from": "From: DoorDash Order <no-reply@doordash.com>",
"subject": "Subject: Order Confirmation for Siddharth from Homey Meals",
"len": 38394
},
{
"id": "19f68fd09cd2c3f1",
"date": "Date: Thu, 16 Jul 2026 03:34:00 +0000 (UTC)",
"from": "From: DoorDash Order <no-reply@doordash.com>",
"subject": "Subject: Order Confirmation for Siddharth from Mad Mex",
"len": 37448
},
{
"id": "19f656a298f42858",
"date": "Date: Wed, 15 Jul 2026 10:54:42 +0000 (UTC)",
"from": "From: DoorDash Order <no-reply@doordash.com>",
"subject": "Subject: Order Confirmation for Siddharth from Carl's Jr.",
"len": 37970
},
{
"id": "19f605de9ab82216",
"date": "Date: Tue, 14 Jul 2026 11:23:14 +0000 (UTC)",
"from": "From: DoorDash Order <no-reply@doordash.com>",
"subject": "Subject: Order Confirmation for Siddharth from Delhi Nights - Sweets & Indian\r\n Cuisine",
"len": 38707
},
{
"id": "19f2b6390a95b665",
"date": "Date: Sat, 04 Jul 2026 04:29:32 +0000 (UTC)",
"from": "From: DoorDash Order <no-reply@doordash.com>",
"subject": "Subject: Order Confirmation for Siddharth from TEG KEBABS & BIRYANI",
"len": 38005
},
{
"id": "19f2256c51714b9c",
"date": "Date: Thu, 02 Jul 2026 10:18:58 +0000 (UTC)",
"from": "From: DoorDash Order <no-reply@doordash.com>",
"subject": "Subject: Order Confirmation for Siddharth from Sri Dwaraka",
"len": 37817
},
{
"id": "19f1d0b003834626",
"date": "Date: Wed, 01 Jul 2026 09:38:07 +0000 (UTC)",
"from": "From: DoorDash Order <no-reply@doordash.com>",
"subject": "Subject: Order Confirmation for Siddharth from Taco Bell",
"len": 38019
},
{
"id": "19f1c301dda7332c",
"date": "Date: Wed, 01 Jul 2026 05:39:01 +0000 (UTC)",
"from": "From: DoorDash Order <no-reply@doordash.com>",
"subject": "Subject: Order Confirmation for Siddharth from Woolworths",
"len": 38753
},
{
"id": "19f0c5624707ae34",
"date": "Date: Sun, 28 Jun 2026 03:46:39 +0000 (UTC)",
"from": "From: DoorDash Order <no-reply@doordash.com>",
"subject": "Subject: Order Confirmation for Siddharth from ALDI",
"len": 68206
},
{
"id": "19f0c0d0c33bf5c9",
"date": "Date: Sun, 28 Jun 2026 02:26:49 +0000 (UTC)",
"from": "From: DoorDash Order <no-reply@doordash.com>",
"subject": "Subject: Order Confirmation for Siddharth from ALDI",
"len": 47419
},
{
"id": "19eed89eee79b624",
"date": "Date: Mon, 22 Jun 2026 04:14:59 +0000 (UTC)",
"from": "From: DoorDash Order <no-reply@doordash.com>",
"subject": "Subject: Order Confirmation for Siddharth from Woolworths",
"len": 48653
},
{
"id": "19eed498e3fb7fce",
"date": "Date: Mon, 22 Jun 2026 03:04:39 +0000 (UTC)",
"from": "From: DoorDash Order <no-reply@doordash.com>",
"subject": "Subject: Order Confirmation for Siddharth from Woolworths",
"len": 44104
},
{
"id": "19eed38ca77047a1",
"date": "Date: Mon, 22 Jun 2026 02:46:21 +0000 (UTC)",
"from": "From: DoorDash Order <no-reply@doordash.com>",
"subject": "Subject: Order Confirmation for Siddharth from Oporto",
"len": 38675
},
{
"id": "19ee9b9b1b7073c9",
"date": "Date: Sun, 21 Jun 2026 10:28:39 +0000 (UTC)",
"from": "From: DoorDash Order <no-reply@doordash.com>",
"subject": "Subject: Order Confirmation for Siddharth from Chilli India",
"len": 37871
},
{
"id": "19e95be74e216594",
"date": "Date: Fri, 05 Jun 2026 03:05:46 +0000 (UTC)",
"from": "From: DoorDash Order <no-reply@doordash.com>",
"subject": "Subject: Order Confirmation for Siddharth from Chilli India",
"len": 37817
},
{
"id": "19e854cdbf552dcf",
"date": "Date: Mon, 01 Jun 2026 22:27:45 +0000 (UTC)",
"from": "From: DoorDash Order <no-reply@doordash.com>",
"subject": "Subject: Order Confirmation for Siddharth from TOMKINS BAKERY",
"len": 37741
},
{
"id": "19e8020eb0027109",
"date": "Date: Sun, 31 May 2026 22:21:39 +0000 (UTC)",
"from": "From: DoorDash Order <no-reply@doordash.com>",
"subject": "Subject: Order Confirmation for Siddharth from Tonmax Bakery",
"len": 37439
},
{
"id": "19e16b64b3f6dbad",
"date": "Date: Mon, 11 May 2026 11:05:04 +0000 (UTC)",
"from": "From: DoorDash Order <no-reply@doordash.com>",
"subject": "Subject: Order Confirmation for Siddharth from Woolworths",
"len": 39750
},
{
"id": "19e16b1a2d9526f8",
"date": "Date: Mon, 11 May 2026 11:00:00 +0000 (UTC)",
"from": "From: DoorDash Order <no-reply@doordash.com>",
"subject": "Subject: Order Confirmation for Siddharth from Burger Road",
"len": 38230
},
{
"id": "19e11390ea456222",
"date": "Date: Sun, 10 May 2026 09:30:11 +0000 (UTC)",
"from": "From: DoorDash Order <no-reply@doordash.com>",
"subject": "Subject: Order Confirmation for Siddharth from Chilli India",
"len": 37847
},
{
"id": "19cdfa1d25f194a8",
"date": "Date: Thu, 12 Mar 2026 01:20:48 +0000 (UTC)",
"from": "From: DoorDash Order <no-reply@doordash.com>",
"subject": "Subject: Order Confirmation for Siddharth from Kesari Indian Kitchen",
"len": 37922
},
{
"id": "19cc62db0460b8a0",
"date": "Date: Sat, 07 Mar 2026 02:43:28 +0000 (UTC)",
"from": "From: DoorDash Order <no-reply@doordash.com>",
"subject": "Subject: Order Confirmation for Siddharth from Chilli India",
"len": 37884
},
{
"id": "19ca1f0fa493f0ca",
"date": "Date: Sat, 28 Feb 2026 01:50:49 +0000 (UTC)",
"from": "From: DoorDash Order <no-reply@doordash.com>",
"subject": "Subject: Order Confirmation for Siddharth from Chilli India",
"len": 37862
},
{
"id": "19c9c18ad4bf22e1",
"date": "Date: Thu, 26 Feb 2026 22:36:27 +0000 (UTC)",
"from": "From: DoorDash Order <no-reply@doordash.com>",
"subject": "Subject: Order Confirmation for Siddharth from Hungry Jacks",
"len": 37596
},
{
"id": "19c8ef02c0a0f590",
"date": "Date: Tue, 24 Feb 2026 09:17:09 +0000 (UTC)",
"from": "From: DoorDash Order <no-reply@doordash.com>",
"subject": "Subject: Order Confirmation for Siddharth from Schnitz",
"len": 38107
},
{
"id": "19c6e7cb5565afd5",
"date": "Date: Wed, 18 Feb 2026 02:03:12 +0000 (UTC)",
"from": "From: DoorDash Order <no-reply@doordash.com>",
"subject": "Subject: Order Confirmation for Siddharth from Chilli India",
"len": 37418
},
{
"id": "19c4c4b1a0efa1e0",
"date": "Date: Wed, 11 Feb 2026 10:41:56 +0000 (UTC)",
"from": "From: DoorDash Order <no-reply@doordash.com>",
"subject": "Subject: Order Confirmation for Siddharth from lahori fish n tikka",
"len": 37166
},
{
"id": "19c4013c5a7fac93",
"date": "Date: Mon, 09 Feb 2026 01:46:02 +0000 (UTC)",
"from": "From: DoorDash Order <no-reply@doordash.com>",
"subject": "Subject: Order Confirmation for Siddharth from Subway",
"len": 39212
},
{
"id": "19c30b95bf183a2b",
"date": "Date: Fri, 06 Feb 2026 02:12:59 +0000 (UTC)",
"from": "From: DoorDash Order <no-reply@doordash.com>",
"subject": "Subject: Order Confirmation for Siddharth from Souvlaki GR",
"len": 37776
},
{
"id": "19c30b773199c2fb",
"date": "Date: Fri, 06 Feb 2026 02:10:54 +0000 (UTC)",
"from": "From: DoorDash Order <no-reply@doordash.com>",
"subject": "Subject: Order Confirmation for Siddharth from Souvlaki GR",
"len": 37309
},
{
"id": "19c27f770fae04f2",
"date": "Date: Wed, 04 Feb 2026 09:24:13 +0000 (UTC)",
"from": "From: DoorDash Order <no-reply@doordash.com>",
"subject": "Subject: Order Confirmation for Siddharth from Chilli India",
"len": 37440
},
{
"id": "19c21316b26e066f",
"date": "Date: Tue, 03 Feb 2026 01:50:11 +0000 (UTC)",
"from": "From: DoorDash Order <no-reply@doordash.com>",
"subject": "Subject: Order Confirmation for Siddharth from Punjabi bai sweets & Indian\r\n cuisine",
"len": 39289
},
{
"id": "19bf2e0fd25facda",
"date": "Date: Sun, 25 Jan 2026 01:59:48 +0000 (UTC)",
"from": "From: DoorDash Order <no-reply@doordash.com>",
"subject": "Subject: Order Confirmation for Siddharth from Chilli India",
"len": 38432
},
{
"id": "19bb18096ebd9bf5",
"date": "Date: Mon, 12 Jan 2026 09:19:12 +0000 (UTC)",
"from": "From: DoorDash Order <no-reply@doordash.com>",
"subject": "Subject: Order Confirmation for Siddharth from Woolworths",
"len": 42464
},
{
"id": "19bac527ef5fb1d4",
"date": "Date: Sun, 11 Jan 2026 09:10:45 +0000 (UTC)",
"from": "From: DoorDash Order <no-reply@doordash.com>",
"subject": "Subject: Order Confirmation for Siddharth from Chilli India",
"len": 37833
},
{
"id": "19baad6dd7f80a9f",
"date": "Date: Sun, 11 Jan 2026 02:16:04 +0000 (UTC)",
"from": "From: DoorDash Order <no-reply@doordash.com>",
"subject": "Subject: Order Confirmation for Siddharth from Chilli India",
"len": 37334
}
]
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,205 @@
[
{
"i": 0,
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkeckxXAAAA",
"date": "2026-07-22T03:41:19Z",
"subject": "Your Wednesday afternoon order with Uber Eats",
"len": 57778
},
{
"i": 1,
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkd32TzAAAA",
"date": "2026-07-21T04:25:09Z",
"subject": "Your Tuesday afternoon order with Uber Eats",
"len": 61532
},
{
"i": 2,
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkd32TsAAAA",
"date": "2026-07-20T23:17:05Z",
"subject": "Your Tuesday morning order with Uber Eats",
"len": 59110
},
{
"i": 3,
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkdK0voAAAA",
"date": "2026-07-20T04:23:40Z",
"subject": "Your Monday afternoon order with Uber Eats",
"len": 66211
},
{
"i": 4,
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkR0aigAAAA",
"date": "2026-07-07T15:31:27Z",
"subject": "[Family] Your Tuesday evening order with Uber Eats",
"len": 80279
},
{
"i": 5,
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkR0aicAAAA",
"date": "2026-07-07T08:58:43Z",
"subject": "Your Tuesday evening order with Uber Eats",
"len": 73592
},
{
"i": 6,
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkR0aibAAAA",
"date": "2026-07-07T08:50:06Z",
"subject": "[Family] Your Tuesday afternoon order with Uber Eats",
"len": 78782
},
{
"i": 7,
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkR0aiaAAAA",
"date": "2026-07-07T08:46:17Z",
"subject": "Your Tuesday evening order with Uber Eats",
"len": 65886
},
{
"i": 8,
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkR0ah6AAAA",
"date": "2026-07-05T15:51:11Z",
"subject": "[Family] Your Sunday evening order with Uber Eats",
"len": 82383
},
{
"i": 9,
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkR0ah5AAAA",
"date": "2026-07-05T15:33:01Z",
"subject": "[Family] Your Sunday evening order with Uber Eats",
"len": 88958
},
{
"i": 10,
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkR0ahTAAAA",
"date": "2026-07-03T10:04:15Z",
"subject": "Your Friday evening order with Uber Eats",
"len": 61485
},
{
"i": 11,
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkR0ahFAAAA",
"date": "2026-07-02T22:57:18Z",
"subject": "Your Friday morning order with Uber Eats",
"len": 60751
},
{
"i": 12,
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkOIIcBAAAA",
"date": "2026-06-27T04:21:08Z",
"subject": "Your Saturday afternoon order with Uber Eats",
"len": 60720
},
{
"i": 13,
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkH8zVTAAAA",
"date": "2026-06-22T10:19:49Z",
"subject": "Your Monday evening order with Uber Eats",
"len": 58303
},
{
"i": 14,
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkH8zUwAAAA",
"date": "2026-06-20T12:28:26Z",
"subject": "Your Saturday afternoon order with Uber Eats",
"len": 60856
},
{
"i": 15,
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkH8zURAAAA",
"date": "2026-06-18T18:43:06Z",
"subject": "Your Thursday evening order with Uber Eats",
"len": 57881
},
{
"i": 16,
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkGJuBoAAAA",
"date": "2026-06-15T19:32:17Z",
"subject": "Your Monday evening order with Uber Eats",
"len": 60838
},
{
"i": 17,
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAj9_fg8AAAA",
"date": "2026-06-02T11:27:03Z",
"subject": "Your Tuesday evening order with Uber Eats",
"len": 55147
},
{
"i": 18,
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAj7_L67AAAA",
"date": "2026-06-02T03:11:33Z",
"subject": "Your Tuesday afternoon order with Uber Eats",
"len": 60716
},
{
"i": 19,
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAj66TeuAAAA",
"date": "2026-05-29T02:47:06Z",
"subject": "Your Friday afternoon order with Uber Eats",
"len": 55127
},
{
"i": 20,
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAj19QCWNwAAAA==",
"date": "2026-05-23T03:00:14Z",
"subject": "Your Saturday afternoon order with Uber Eats",
"len": 65562
},
{
"i": 21,
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAjzoSJtAAAA",
"date": "2026-05-18T10:50:25Z",
"subject": "Your Monday evening order with Uber Eats",
"len": 68144
},
{
"i": 22,
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAjy8e6sAAAA",
"date": "2026-05-17T03:13:05Z",
"subject": "Your Sunday afternoon order with Uber Eats",
"len": 62439
},
{
"i": 23,
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAjw8ZyAAAAA",
"date": "2026-05-15T09:45:15Z",
"subject": "Your Friday evening order with Uber Eats",
"len": 55113
},
{
"i": 24,
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAjw8ZxwAAAA",
"date": "2026-05-14T15:02:24Z",
"subject": "Your Thursday afternoon order with Uber Eats",
"len": 58231
},
{
"i": 25,
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAjw8ZxtAAAA",
"date": "2026-05-14T11:24:43Z",
"subject": "Your Thursday evening order with Uber Eats",
"len": 60745
},
{
"i": 26,
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAjV0eF4AAAA",
"date": "2026-04-08T05:54:03Z",
"subject": "Your Tuesday evening order with Uber Eats",
"len": 68338
},
{
"i": 27,
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAjV0eFyAAAA",
"date": "2026-04-07T22:40:52Z",
"subject": "Your Tuesday afternoon order with Uber Eats",
"len": 52600
},
{
"i": 28,
"id": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAjV0eFcAAAA",
"date": "2026-04-06T05:23:40Z",
"subject": "Your Sunday evening order with Uber Eats",
"len": 59440
}
]
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,20 @@
{
"ut-00": {
"messageId": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkH8zVAAAAA",
"subject": "Your Sunday evening trip with Uber",
"receivedAt": "2026-06-21T10:11:16Z",
"sender": "noreply@uber.com"
},
"ut-01": {
"messageId": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkH8zU-AAAA",
"subject": "Your Sunday morning trip with Uber",
"receivedAt": "2026-06-21T09:02:09Z",
"sender": "noreply@uber.com"
},
"ut-summary": {
"messageId": "AQMkADAwATM0MDAAMS1hYzQyLTVjZDktMDACLTAwCgBGAAADS6kDcxBxKkKwbCK1wU1QlwcA7Ex_hlaTTU0AgHQ16optl_AAAAIBDAAAAOxMfoZWk01NAIB0NeqKbZfgAAkH8zU3AAAA",
"subject": "Your Sunday morning trip with Uber",
"receivedAt": "2026-06-20T22:27:54Z",
"sender": "noreply@uber.com"
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,531 @@
import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from "vitest";
import type { NextRequest } from "next/server";
import { createPool, mockDbWithPool, resetDB } from "./helpers";
import {
comparisonBasis,
baselineMonths,
baselineAverage,
spendThrough,
prevMonth,
} from "../../lib/partial-month";
/**
* The three Priority 0 behaviours that shipped without a test (#155).
*
* `docs/ui-information-architecture-review.md` claimed Priority 0 was guarded;
* `analytics-sql.test.ts` only ever covered the three SQL *fragments*
* (EXCLUDE_RECONCILED_SOURCE, NATIVE_CURRENCY, INVESTMENT_SIGNED). Items 2, 3
* and 6 of that list had nothing:
*
* 2. the spend-pace series must use the same spend semantics as the headline
* printed directly above it;
* 3. fees and interest must be bounded by an explicit, reported period;
* 6. a month in progress must never be compared against complete ones.
*
* All three fail *silently* when they regress — no error, no empty screen, just
* a number that quietly disagrees with the one beside it. That is precisely the
* class the review flagged and the reason these are worth the fixture cost.
*
* The clock is frozen for every test in this file. Two of the three behaviours
* are defined relative to "now" (which month is in progress, how far back the
* default fees window reaches), so against the real clock the fixtures would
* drift out of the routes' windows and the assertions would go vacuous — which
* is the failure mode this file exists to prevent, one level up. Only `Date` is
* faked: `pg` uses real timers for its connection handling.
*/
const pool = createPool();
mockDbWithPool(pool);
/** Frozen wall clock: the 15th, so the current month is genuinely half-done. */
const NOW = new Date(2026, 7, 15, 10, 0, 0); // 2026-08-15, local time
const CURRENT_MONTH = "2026-08";
const ELAPSED = 15;
let monthlyGET: (req: NextRequest) => Promise<Response>;
let dailyGET: (req: NextRequest) => Promise<Response>;
let feesGET: (req: NextRequest) => Promise<Response>;
let ownerId: number;
// One cast at the boundary: these are the two properties the analytics routes
// read (`url` for the query string, `headers` for the auth header, which is
// mocked out anyway), not a whole NextRequest.
const req = (path: string) =>
({ url: `http://localhost${path}`, headers: { get: () => null } }) as unknown as NextRequest;
const json = async (res: Response) => (await res.json()) as Record<string, unknown>;
beforeAll(async () => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(NOW);
vi.doMock("@/lib/auth", () => ({
getCurrentUser: async () => ({ id: ownerId, name: "Owner", email: "o@x" }),
}));
({ GET: monthlyGET } = await import("../../app/api/analytics/monthly/route"));
({ GET: dailyGET } = await import("../../app/api/analytics/daily/route"));
({ GET: feesGET } = await import("../../app/api/analytics/fees/route"));
});
afterAll(() => {
vi.useRealTimers();
});
beforeEach(async () => {
await resetDB(pool);
const a = await pool.query(`INSERT INTO participants (name) VALUES ('Owner') RETURNING id`);
const b = await pool.query(`INSERT INTO participants (name) VALUES ('Other') RETURNING id`);
ownerId = a.rows[0].id;
otherId = b.rows[0].id;
});
let otherId: number;
/** A manual transaction owned by `owner`. Returns its id. */
async function txn(over: {
date: string;
amount: number;
type?: string;
category?: string;
owner?: number;
description?: string;
interest_amount?: number;
reconciled_with_id?: number;
}): Promise<number> {
const r = await pool.query(
`INSERT INTO transactions
(owner_id, statement_id, transaction_date, description, amount,
transaction_type, category, interest_amount, reconciled_with_id, row_index)
VALUES ($1, NULL, $2, $3, $4, $5, $6, $7, $8, 0) RETURNING id`,
[
over.owner ?? ownerId,
over.date,
over.description ?? `fixture ${over.date} ${over.amount}`,
over.amount,
over.type ?? "debit",
over.category ?? "groceries",
over.interest_amount ?? null,
over.reconciled_with_id ?? null,
]
);
return r.rows[0].id as number;
}
async function split(txnId: number, participantId: number, pct: number) {
await pool.query(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
VALUES ($1, $2, $3)`,
[txnId, participantId, pct]
);
}
async function tag(txnId: number, name: string) {
const t = await pool.query(
`INSERT INTO tags (name) VALUES ($1) ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name RETURNING id`,
[name]
);
await pool.query(
`INSERT INTO transaction_tags (transaction_id, tag_id) VALUES ($1, $2)`,
[txnId, t.rows[0].id]
);
}
// ───────────────────────────────────────────────────────────────────────────
// Item 2 — the pace chart and the headline must agree
// ───────────────────────────────────────────────────────────────────────────
/**
* The pace chart used to sum gross `amount_aud ?? amount` over
* `transaction_type = 'debit'` in the browser. Measured on live data it ended
* July at $4,747.31 against a headline of $3,597.10 — a 32% overstatement of
* the number printed directly above it, with both labelled "spend".
*
* `/api/analytics/daily` fixed that by building the series from the same
* fragments as `/monthly`. Nothing has been asserting it since, and the two
* routes are separate files: the drift they were merged to prevent can
* reappear the moment one is edited alone.
*
* Every fixture below is a shape where the naive gross-debit sum and the real
* spend definition disagree. The last test proves that — without it, parity
* could hold simply because the fixtures were too plain to discriminate, and
* the test would pass against a route that had regressed.
*/
describe("Priority 0 item 2 — spend pace matches the headline", () => {
/** Everything the two routes are supposed to treat identically. */
async function seedDivergentMonth(month: string) {
const d = (day: number) => `${month}-${String(day).padStart(2, "0")}`;
// Plain debit — both definitions agree on this one.
await txn({ date: d(2), amount: 100, category: "groceries" });
// Half mine. Gross says 200; my share is 100.
const shared = await txn({ date: d(3), amount: 200, category: "groceries" });
await split(shared, ownerId, 50);
await split(shared, otherId, 50);
// A refund. Ignored entirely by a debit-only sum; must cancel spend here.
await txn({ date: d(5), amount: 40, type: "refund", category: "groceries" });
// Fee and interest rows: spend, but not type 'debit'.
await txn({ date: d(6), amount: 15, type: "fee", category: "fees" });
await txn({ date: d(7), amount: 25, type: "interest", category: "fees" });
// An itemised loan repayment: type 'payment', so a debit-only sum skips it,
// but its interest portion is real spend and its principal is not.
await txn({
date: d(8),
amount: 1200,
type: "payment",
category: "loan",
interest_amount: 300,
});
// The manual row a statement line superseded — double count if counted.
const survivor = await txn({ date: d(9), amount: 60, category: "dining" });
await txn({ date: d(9), amount: 60, category: "dining", reconciled_with_id: survivor });
// Categories that are not spend at all.
await txn({ date: d(10), amount: 5000, category: "transfers" });
await txn({ date: d(11), amount: 2000, category: "investment" });
await txn({ date: d(12), amount: 3000, type: "credit", category: "income" });
// A `family`-tagged row: excluded from budget spend by EXCLUDE_NON_BUDGET_TAGS.
const family = await txn({ date: d(13), amount: 500, category: "groceries" });
await tag(family, "family");
// Someone else's row I hold a split on — mine only to the extent of the split.
const theirs = await txn({ date: d(14), amount: 80, category: "dining", owner: otherId });
await split(theirs, ownerId, 25);
await split(theirs, otherId, 75);
}
/** Sum the sparse daily map for one month. */
const sumDaily = (daily: Record<string, Record<number, number>>, month: string) =>
Object.values(daily[month] ?? {}).reduce((s, v) => s + v, 0);
it("the daily series sums to the monthly headline, to the cent", async () => {
await seedDivergentMonth("2026-06");
const monthly = await json(await monthlyGET(req("/api/analytics/monthly?months=6")));
const daily = await json(await dailyGET(req("/api/analytics/daily?months=6")));
const headline = (monthly.totals as Record<string, { spent: number }>)["2026-06"].spent;
const pace = sumDaily(daily.daily as Record<string, Record<number, number>>, "2026-06");
// Cents, not exact float equality: the two routes group at different grains
// (month+category vs month+day+category) and both carry 4dp, so they can
// differ below a cent by construction. The displayed grain is what has to
// agree — that IS the bug, the chart ending the month a few cents off the
// headline it sits under.
expect(Math.round(pace * 100) / 100).toBe(headline);
});
it("agrees per category, not just in total", async () => {
// A total can match while two categories are wrong in opposite directions,
// and the category drill-down is a real surface on this page.
await seedDivergentMonth("2026-06");
const monthly = await json(await monthlyGET(req("/api/analytics/monthly?months=6")));
const daily = await json(await dailyGET(req("/api/analytics/daily?months=6")));
const byCategory = (daily.byCategory as Record<string, Record<string, Record<number, number>>>)["2026-06"];
const rows = monthly.rows as { category: string; spent: Record<string, number> }[];
expect(Object.keys(byCategory).sort()).toEqual(
rows.filter((r) => r.spent["2026-06"] !== undefined).map((r) => r.category).sort()
);
for (const r of rows) {
const expected = r.spent["2026-06"];
if (expected === undefined) continue;
const got = Object.values(byCategory[r.category] ?? {}).reduce((s, v) => s + v, 0);
expect([r.category, Math.round(got * 100) / 100]).toEqual([
r.category,
Math.round(expected * 100) / 100,
]);
}
});
it("holds across every month in the window, not only the seeded one", async () => {
await seedDivergentMonth("2026-05");
await seedDivergentMonth("2026-06");
await seedDivergentMonth("2026-07");
const monthly = await json(await monthlyGET(req("/api/analytics/monthly?months=6")));
const daily = await json(await dailyGET(req("/api/analytics/daily?months=6")));
const totals = monthly.totals as Record<string, { spent: number }>;
const dailyMap = daily.daily as Record<string, Record<number, number>>;
for (const m of monthly.months as string[]) {
expect([m, Math.round(sumDaily(dailyMap, m) * 100) / 100]).toEqual([m, totals[m].spent]);
}
});
it("the fixtures actually discriminate — a gross debit sum does NOT agree", async () => {
// The placebo. Parity between two identical-by-construction queries is easy
// to assert and worthless if no fixture can tell the definitions apart.
// This pins the gap the old client-side series had.
await seedDivergentMonth("2026-06");
const monthly = await json(await monthlyGET(req("/api/analytics/monthly?months=6")));
const headline = (monthly.totals as Record<string, { spent: number }>)["2026-06"].spent;
const naive = await pool.query<{ total: string }>(
`SELECT COALESCE(SUM(COALESCE(amount_aud, amount)), 0)::numeric(14,2) AS total
FROM transactions
WHERE transaction_type = 'debit'
AND transaction_date >= '2026-06-01' AND transaction_date < '2026-07-01'`
);
expect(Number(naive.rows[0].total)).toBeGreaterThan(headline);
});
});
// ───────────────────────────────────────────────────────────────────────────
// Item 3 — fees and interest are bounded by an explicit, reported period
// ───────────────────────────────────────────────────────────────────────────
/**
* `/api/analytics/fees` used to aggregate every statement ever imported with no
* date filter, and the UI printed the result with no period label — so a
* lifetime-to-date total read as a current-period one and grew forever. It was
* overstating the last 12 months by roughly $2,700 of fees.
*
* The regression is silent in the worst way: the number is always plausible and
* always too big, and it gets worse the longer the app is used.
*/
describe("Priority 0 item 3 — fees and interest carry a date range", () => {
/** A statement dated by the period it covers, carrying fees and interest. */
async function statement(billingEnd: string, fees: number, interest: number, bank = "Fixture Bank") {
const r = await pool.query(
`INSERT INTO statements
(filename, bank_name, account_number, billing_end_date, currency,
fees_charged, interest_charged, owner_id)
VALUES ($1, $2, '0000', $3, 'AUD', $4, $5, $6) RETURNING id`,
[`fees-${billingEnd}-${bank}.pdf`, bank, billingEnd, fees, interest, ownerId]
);
return r.rows[0].id as number;
}
beforeEach(async () => {
// Inside the default 12-month window (opens 2025-09-01 from a 2026-08-15 now).
await statement("2026-07-31", 10, 5);
await statement("2025-10-31", 20, 7);
// Outside it — the tail that used to be silently included.
await statement("2024-06-30", 1000, 500);
await statement("2019-01-31", 2500, 900);
});
it("defaults to the last 12 months and says so", async () => {
const body = await json(await feesGET(req("/api/analytics/fees")));
const period = body.period as { months: number; from: string; to: string; all_time: boolean };
expect(period.months).toBe(12);
expect(period.all_time).toBe(false);
// The window opens on the 1st of the month 11 months back, and toDateStr()
// must not shift it a day earlier — `toISOString()` on a local-midnight
// Date does exactly that in any timezone east of Greenwich, which is how
// every analytics window used to be a day early.
expect(period.from).toBe("2025-09-01");
expect(period.to).toBe("2026-09-01");
expect(body.total_fees).toBe(30);
expect(body.total_interest).toBe(12);
});
it("excludes the older tail — the $2,700-of-fees overstatement", async () => {
const body = await json(await feesGET(req("/api/analytics/fees")));
// 3530 of fees exist; only 30 are in the last 12 months.
expect(body.total_fees).toBe(30);
expect(body.total_fees).not.toBe(3530);
});
it("months=0 asks for all time deliberately, and is labelled as such", async () => {
const body = await json(await feesGET(req("/api/analytics/fees?months=0")));
const period = body.period as { months: number; from: null; to: null; all_time: boolean };
expect(period.all_time).toBe(true);
// A null range is the honest answer for an unbounded total; a from/to here
// would be a period claim the number does not actually make.
expect(period.from).toBeNull();
expect(period.to).toBeNull();
expect(body.total_fees).toBe(3530);
expect(body.total_interest).toBe(1412);
});
it("honours a narrower explicit window", async () => {
const body = await json(await feesGET(req("/api/analytics/fees?months=3")));
const period = body.period as { months: number; from: string };
expect(period.months).toBe(3);
expect(period.from).toBe("2026-06-01");
// Only the 2026-07-31 statement falls inside three months.
expect(body.total_fees).toBe(10);
expect(body.total_interest).toBe(5);
});
it("bounds the transaction-level fee lines by the same window", async () => {
// Two halves of the same answer, and only the statement half was ever
// date-filtered in the original. A bounded total beside an unbounded list
// is the same defect wearing the fix.
await txn({ date: "2026-07-10", amount: 12, type: "fee", category: "fees" });
await txn({ date: "2020-02-10", amount: 99, type: "fee", category: "fees" });
const bounded = await json(await feesGET(req("/api/analytics/fees")));
const all = await json(await feesGET(req("/api/analytics/fees?months=0")));
expect((bounded.transactions as unknown[]).length).toBe(1);
expect((all.transactions as unknown[]).length).toBe(2);
});
it("clamps a nonsense months parameter instead of unbounding the total", async () => {
// A NaN reaching `months` would make `new Date(y, NaN, 1)` an Invalid Date
// and the window meaningless. Falling back to 12 keeps the default claim.
const body = await json(await feesGET(req("/api/analytics/fees?months=banana")));
expect((body.period as { months: number }).months).toBe(12);
expect(body.total_fees).toBe(30);
});
});
// ───────────────────────────────────────────────────────────────────────────
// Item 6 — a month in progress is never compared against complete ones
// ───────────────────────────────────────────────────────────────────────────
/**
* Two rules, both silent when broken (see src/lib/partial-month.ts):
*
* 1. the in-progress month never enters a baseline;
* 2. when it is the month selected, both sides of every comparison are
* truncated to the same day.
*
* These are driven off real `/api/analytics/daily` output rather than a
* hand-built map, because rule 2 depends on that route's day-of-month
* granularity — a route returning month totals only would make the rule
* unimplementable while every pure-function test still passed.
*/
describe("Priority 0 item 6 — partial-month comparisons", () => {
/** `perDay` dollars on each of days 1..n of `month`. */
async function evenMonth(month: string, perDay: number, days: number) {
for (let d = 1; d <= days; d++) {
await txn({ date: `${month}-${String(d).padStart(2, "0")}`, amount: perDay, category: "groceries" });
}
}
async function dailyMap() {
const body = await json(await dailyGET(req("/api/analytics/daily?months=12")));
return body.daily as Record<string, Record<number, number>>;
}
it("recognises the in-progress month and the day to compare through", () => {
expect(comparisonBasis(CURRENT_MONTH, NOW)).toEqual({ isPartial: true, compareDay: ELAPSED });
// A complete month compares through its last day, so the same code path
// serves both cases and `spendThrough` becomes a no-op.
expect(comparisonBasis("2026-06", NOW)).toEqual({ isPartial: false, compareDay: 30 });
expect(comparisonBasis("2026-02", NOW)).toEqual({ isPartial: false, compareDay: 28 });
});
it("keeps the in-progress month out of the baseline", () => {
const totals: Record<string, { spent: number }> = {
"2026-05": { spent: 100 },
"2026-06": { spent: 100 },
"2026-07": { spent: 100 },
[CURRENT_MONTH]: { spent: 40 }, // half a month of spend
};
const months = ["2026-05", "2026-06", "2026-07", CURRENT_MONTH];
// Baseline for a complete month: every other complete month with data.
expect(baselineMonths(months, "2026-07", totals, NOW)).toEqual(["2026-05", "2026-06"]);
// Baseline for the month in progress: itself excluded twice over.
expect(baselineMonths(months, CURRENT_MONTH, totals, NOW)).toEqual([
"2026-05",
"2026-06",
"2026-07",
]);
// The partial month is never in it. Averaging its 40 in would drag a
// baseline of 100 down to 85 and make an ordinary month look extravagant.
expect(baselineMonths(months, "2026-07", totals, NOW)).not.toContain(CURRENT_MONTH);
});
it("drops a month with no data rather than averaging a zero into the baseline", () => {
const totals: Record<string, { spent: number }> = { "2026-05": { spent: 100 }, "2026-06": { spent: 0 } };
expect(baselineMonths(["2026-05", "2026-06"], "2026-07", totals, NOW)).toEqual(["2026-05"]);
});
it("compares the month in progress through the same day of each prior month", async () => {
// $10/day everywhere. Prior months ran the full month; the current one has
// only reached the 15th, so it holds $150 against their $300-odd.
await evenMonth("2026-06", 10, 30);
await evenMonth("2026-07", 10, 31);
await evenMonth(CURRENT_MONTH, 10, ELAPSED);
const daily = await dailyMap();
const totals: Record<string, { spent: number }> = {
"2026-06": { spent: 300 },
"2026-07": { spent: 310 },
[CURRENT_MONTH]: { spent: 150 },
};
const months = ["2026-06", "2026-07", CURRENT_MONTH];
const { isPartial, compareDay } = comparisonBasis(CURRENT_MONTH, NOW);
const baseline = baselineMonths(months, CURRENT_MONTH, totals, NOW);
const mine = spendThrough(daily[CURRENT_MONTH], compareDay);
const avg = baselineAverage(baseline, { isPartial, compareDay, daily, totals });
// Truncated on both sides, this month is exactly typical.
expect(mine).toBe(150);
expect(avg).toBe(150);
expect(Math.round(((mine - avg) / avg) * 100)).toBe(0);
// Untruncated — the bug — it reads as 51% below average on identical
// spending, purely because half the month has not happened yet.
const naiveAvg = baseline.reduce((s, m) => s + totals[m].spent, 0) / baseline.length;
expect(Math.round(((mine - naiveAvg) / naiveAvg) * 100)).toBe(-51);
});
it("leaves a complete month compared on full totals", async () => {
await evenMonth("2026-06", 10, 30);
await evenMonth("2026-07", 20, 31);
const daily = await dailyMap();
const totals: Record<string, { spent: number }> = { "2026-06": { spent: 300 }, "2026-07": { spent: 620 } };
const months = ["2026-06", "2026-07"];
const { isPartial, compareDay } = comparisonBasis("2026-07", NOW);
const baseline = baselineMonths(months, "2026-07", totals, NOW);
expect(isPartial).toBe(false);
// Truncation must not quietly apply to a finished month: June's full 30
// days are the right comparison for July's full 31.
expect(baselineAverage(baseline, { isPartial, compareDay, daily, totals })).toBe(300);
});
it("truncates the top-movers comparison on both sides", async () => {
// The movers row asks "biggest category moves vs last month". With one side
// full and the other half-done, every category reads as a large decrease.
await evenMonth("2026-07", 10, 31); // $310 in July
await evenMonth(CURRENT_MONTH, 10, ELAPSED); // $150 so far in August
const body = await json(await dailyGET(req("/api/analytics/daily?months=12")));
const byCategory = body.byCategory as Record<string, Record<string, Record<number, number>>>;
const { compareDay } = comparisonBasis(CURRENT_MONTH, NOW);
const pm = prevMonth(CURRENT_MONTH);
expect(pm).toBe("2026-07");
const now = spendThrough(byCategory[CURRENT_MONTH]?.groceries, compareDay);
const before = spendThrough(byCategory[pm]?.groceries, compareDay);
expect(now - before).toBe(0);
// Against July's full month it would report a $160 fall that never happened.
const fullBefore = Object.values(byCategory[pm].groceries).reduce((s, v) => s + v, 0);
expect(now - fullBefore).toBe(-160);
});
it("crosses a year boundary correctly", () => {
expect(prevMonth("2026-01")).toBe("2025-12");
expect(comparisonBasis("2025-12", NOW)).toEqual({ isPartial: false, compareDay: 31 });
});
it("handles February in a leap year", () => {
// 2028 is a leap year; a hardcoded 28 would silently drop the 29th.
expect(comparisonBasis("2028-02", new Date(2028, 5, 1)).compareDay).toBe(29);
});
});
@@ -0,0 +1,403 @@
import { describe, it, expect, beforeAll } from "vitest";
import { queryRaw, queryRow } from "../../lib/db";
import {
EXCLUDE_RECONCILED_SOURCE,
NATIVE_CURRENCY,
AMOUNT_UNCONVERTED,
INVESTMENT_SIGNED,
MY_SPEND_SCOPE,
NET_SPEND_ROWS,
SPEND_SIGNED,
myShare,
mySplitOf,
} from "../../lib/analytics-sql";
import { MY_SHARE_PCT } from "../../lib/queries";
/**
* These fragments are the ones that drifted.
*
* The reconciled-row exclusion lived only in `queries.ts` for months while every
* analytics route counted the superseded manual rows as spend — 48 rows, $4,474
* of double count, invisible because the transaction list (which did exclude
* them) looked right. The currency expression has the same shape of risk: it is
* read by two call sites with two different denomination conventions.
*
* Assertions are per-row against known fixtures rather than against aggregate
* totals, so a change in unrelated data cannot mask a regression.
*/
async function scratchTxn(cols: string, vals: string, params: unknown[] = []) {
const row = await queryRow<{ id: number }>(
`INSERT INTO transactions (${cols}) VALUES (${vals}) RETURNING id`,
params
);
return row!.id;
}
/** Does this row survive the predicate? */
async function passes(predicate: string, id: number): Promise<boolean> {
const rows = await queryRaw(
`SELECT t.id FROM transactions t
LEFT JOIN statements s ON s.id = t.statement_id
WHERE t.id = $1 AND (${predicate})`,
[id]
);
return rows.length === 1;
}
describe("EXCLUDE_RECONCILED_SOURCE", () => {
it("drops a manual row that a statement line has superseded", async () => {
const survivor = await scratchTxn(
"transaction_date, description, amount, transaction_type",
"'2026-03-01','Analytics fixture — survivor', 10.00, 'debit'"
);
const superseded = await scratchTxn(
"transaction_date, description, amount, transaction_type, reconciled_with_id",
"'2026-03-01','Analytics fixture — superseded', 10.00, 'debit', $1",
[survivor]
);
expect(await passes(EXCLUDE_RECONCILED_SOURCE, superseded)).toBe(false);
});
it("keeps an ordinary manual row that was never reconciled", async () => {
const id = await scratchTxn(
"transaction_date, description, amount, transaction_type",
"'2026-03-01','Analytics fixture — unreconciled', 10.00, 'debit'"
);
expect(await passes(EXCLUDE_RECONCILED_SOURCE, id)).toBe(true);
});
it("keeps a credits order row — nothing ever sets reconciled_with_id on one", async () => {
// The order slice records the card match in expense_metadata, not on the
// transaction, and needsCardMatch() holds these out of the reconcile queue.
// If that ever changes, this exclusion would start eating real spend.
const id = await scratchTxn(
"transaction_date, description, amount, transaction_type, payment_method",
"'2026-03-01','Order - Analytics fixture', 25.00, 'debit', 'credits'"
);
expect(await passes(EXCLUDE_RECONCILED_SOURCE, id)).toBe(true);
});
});
describe("NATIVE_CURRENCY", () => {
async function currencyOf(id: number) {
const row = await queryRow<{ ccy: string; unconverted: boolean }>(
`SELECT ${NATIVE_CURRENCY} AS ccy, ${AMOUNT_UNCONVERTED} AS unconverted
FROM transactions t LEFT JOIN statements s ON s.id = t.statement_id
WHERE t.id = $1`,
[id]
);
return row!;
}
it("reads a statement-less order row's own currency, not 'AUD'", async () => {
// The bug this guards: sourcing currency from s.currency alone labelled
// every foreign order row AUD, because an order has no statement.
const id = await scratchTxn(
"transaction_date, description, amount, transaction_type, payment_method, foreign_currency_amount, foreign_currency_code",
"'2026-03-01','Order - Foreign fixture', 3500.00, 'debit', 'credits', 3500.00, 'LKR'"
);
const { ccy, unconverted } = await currencyOf(id);
expect(ccy).toBe("LKR");
// No amount_aud: the ingest path refuses to assert an FX rate it lacks, so
// this row's AUD value is genuinely unknown and must be reported as such.
expect(unconverted).toBe(true);
});
it("prefers the statement's currency over the foreign-charge record", async () => {
// Opposite convention: on an AUD statement, `amount` is AUD and
// foreign_currency_code merely notes what was originally charged. Reading
// the foreign code here would mislabel an AUD row as USD.
// owner_id is NOT NULL DEFAULT 1 with an FK to participants, so this insert
// relied on a participant with id 1 already existing — true only when a
// sibling test file had seeded one and left it behind. On a freshly reset
// database it failed the FK. Own the fixture instead of inheriting it.
const owner = await queryRow<{ id: number }>(
`INSERT INTO participants (name) VALUES ('Analytics fixture — owner') RETURNING id`
);
const stmt = await queryRow<{ id: number }>(
`INSERT INTO statements (bank_name, account_number, billing_end_date, currency, filename, owner_id)
VALUES ('Analytics Fixture Bank','0000','2026-03-31','AUD','analytics-fixture.pdf', $1) RETURNING id`,
[owner!.id]
);
const id = await scratchTxn(
"transaction_date, description, amount, amount_aud, transaction_type, statement_id, foreign_currency_amount, foreign_currency_code",
"'2026-03-01','Overseas purchase fixture', 45.00, 45.00, 'debit', $1, 30.00, 'USD'",
[stmt!.id]
);
const { ccy, unconverted } = await currencyOf(id);
expect(ccy).toBe("AUD");
expect(unconverted).toBe(false);
});
it("defaults a plain manual row to AUD", async () => {
const id = await scratchTxn(
"transaction_date, description, amount, transaction_type",
"'2026-03-01','Plain manual fixture', 12.00, 'debit'"
);
expect((await currencyOf(id)).ccy).toBe("AUD");
});
});
describe("INVESTMENT_SIGNED", () => {
/** The signed value the investments line would attribute to this row. */
async function signedValue(id: number): Promise<number> {
const row = await queryRow<{ v: string }>(
`SELECT (${INVESTMENT_SIGNED})::text AS v FROM transactions t WHERE t.id = $1`,
[id]
);
return Number(row!.v);
}
it("counts a contribution positive", async () => {
const id = await scratchTxn(
"transaction_date, description, amount, transaction_type, category",
"'2026-03-01','Investment fixture — deposit', 5000.00, 'debit', 'investment'"
);
expect(await signedValue(id)).toBe(5000);
});
it("counts a withdrawal negative so it nets against contributions", async () => {
// The $25,000 Raiz withdrawal that made March 2026 read as a $38,615.34
// investing month when it was net -$11,384.66.
const id = await scratchTxn(
"transaction_date, description, amount, transaction_type, category",
"'2026-03-19','Investment fixture — withdrawal', 25000.00, 'credit', 'investment'"
);
expect(await signedValue(id)).toBe(-25000);
});
it("a deposit and an equal withdrawal net to zero", async () => {
const inId = await scratchTxn(
"transaction_date, description, amount, transaction_type, category",
"'2026-03-01','Investment fixture — net in', 1000.00, 'debit', 'investment'"
);
const outId = await scratchTxn(
"transaction_date, description, amount, transaction_type, category",
"'2026-03-02','Investment fixture — net out', 1000.00, 'credit', 'investment'"
);
expect(await signedValue(inId) + await signedValue(outId)).toBe(0);
});
it("prefers amount_aud over the native amount", async () => {
// The IBKR rows are USD; the line is denominated in AUD.
const id = await scratchTxn(
"transaction_date, description, amount, amount_aud, transaction_type, category",
"'2026-07-25','Investment fixture — foreign', 10000.00, 14310.00, 'debit', 'investment'"
);
expect(await signedValue(id)).toBe(14310);
});
it("treats a refund like a withdrawal", async () => {
const id = await scratchTxn(
"transaction_date, description, amount, transaction_type, category",
"'2026-03-01','Investment fixture — reversal', 1500.00, 'refund', 'investment'"
);
expect(await signedValue(id)).toBe(-1500);
});
});
/**
* Whose expense is it — the gate that decides, and the share that scales it.
*
* Both fragments changed together on 2026-08-15 and neither is safe alone.
* `OWNER_SCOPE = $1` was the gate on every spend analytic, with `mySplitOf`
* scaling within it, so a row someone else paid for contributed nothing to my
* spend no matter what share I held — 167 rows / $3,210.91 of my own share
* missing from Jan–Jul 2026, while the balance queries booked the debt
* correctly. Widening the gate without also fixing `myShare` is worse than
* leaving it: the old remainder fallback hands the viewer the PAYER's share on
* a row with no split of theirs, which on an unsplit row is the full amount.
*
* So the discriminating cases here are all ones where I hold NO split row on
* someone else's transaction. That is where old and new disagree, and it is the
* only place the widened gate could do damage.
*/
describe("MY_SPEND_SCOPE / myShare", () => {
let me = 0;
let them = 0;
let third = 0;
beforeAll(async () => {
const mk = async (name: string) =>
(await queryRow<{ id: number }>(
`INSERT INTO participants (name) VALUES ($1) RETURNING id`,
[name]
))!.id;
me = await mk("Share fixture — viewer");
them = await mk("Share fixture — payer");
third = await mk("Share fixture — third party");
});
/** A transaction owned by `owner`, optionally split, optionally overridden. */
async function fixture(
label: string,
owner: number,
splits: [number, number][] = [],
myShareOverride?: number
) {
const id = await scratchTxn(
"transaction_date, description, amount, transaction_type, category, owner_id",
`'2026-06-01', $1, 100.00, 'debit', 'groceries', $2`,
[`Share fixture — ${label}`, owner]
);
for (const [pid, pct] of splits) {
await queryRaw(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
VALUES ($1, $2, $3)`,
[id, pid, pct]
);
}
if (myShareOverride !== undefined) {
await queryRaw(
`INSERT INTO transaction_overrides (transaction_id, my_share_percent) VALUES ($1, $2)`,
[id, myShareOverride]
);
}
return id;
}
const JOINS = `
FROM transactions t
LEFT JOIN statements s ON s.id = t.statement_id
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1`;
async function inScope(id: number, viewer: number): Promise<boolean> {
const rows = await queryRaw(
`SELECT t.id ${JOINS} WHERE t.id = $2 AND ${MY_SPEND_SCOPE()}`,
[viewer, id]
);
return rows.length === 1;
}
async function share(id: number, viewer: number): Promise<number> {
const row = await queryRow<{ v: string }>(
`SELECT (${myShare()})::numeric AS v ${JOINS} WHERE t.id = $2`,
[viewer, id]
);
return Number(row!.v);
}
/** What this row contributes to the viewer's net spend. */
async function spend(id: number, viewer: number): Promise<number> {
const row = await queryRow<{ v: string | null }>(
`SELECT SUM(${mySplitOf(SPEND_SIGNED)})::numeric(14,4) AS v ${JOINS}
WHERE t.id = $2 AND ${MY_SPEND_SCOPE()} AND ${NET_SPEND_ROWS}`,
[viewer, id]
);
return Number(row?.v ?? 0);
}
describe("the gate", () => {
it("admits a row someone else paid for that I hold a split on", async () => {
const id = await fixture("their shop, my half", them, [[me, 50], [them, 50]]);
expect(await inScope(id, me)).toBe(true);
});
it("still admits a row I paid for with no splits at all", async () => {
const id = await fixture("mine alone", me);
expect(await inScope(id, me)).toBe(true);
});
it("rejects a row someone else paid for that I have no split on", async () => {
const id = await fixture("not mine", them, [[them, 60], [third, 40]]);
expect(await inScope(id, me)).toBe(false);
});
});
describe("my share of someone else's row", () => {
it("is my split percent", async () => {
const id = await fixture("their shop, my quarter", them, [[me, 25], [them, 75]]);
expect(await share(id, me)).toBe(25);
});
it("is 0 with no split of mine — NOT the payer's remainder", async () => {
// The load-bearing case. An unsplit row someone else paid for has no
// `ts.share_percent`, so the old `100 - everyone else` fallback returned
// 100 and would have moved their entire grocery bill onto my spend the
// moment the gate widened.
const id = await fixture("entirely theirs", them);
expect(await share(id, me)).toBe(0);
expect(await spend(id, me)).toBe(0);
});
it("ignores my_share_percent, which can only mean the owner's share", async () => {
// One unscoped column per transaction, writable by anyone who can see the
// row. Reading it as the viewer's share on someone else's transaction
// would be a guess.
const id = await fixture("their row, their override", them, [], 30);
expect(await share(id, me)).toBe(0);
});
});
describe("my share of my own row is unchanged", () => {
it("prefers an explicit split row for me", async () => {
const id = await fixture("mine, split explicitly", me, [[me, 35], [them, 65]]);
expect(await share(id, me)).toBe(35);
});
it("falls back to the my_share_percent override", async () => {
const id = await fixture("mine, overridden", me, [], 80);
expect(await share(id, me)).toBe(80);
});
it("falls back to whatever is left after everyone else", async () => {
const id = await fixture("mine, remainder", me, [[them, 40]]);
expect(await share(id, me)).toBe(60);
});
it("is 0 when a row I paid for is allocated entirely to someone else", async () => {
const id = await fixture("mine, all theirs", me, [[them, 100]]);
expect(await share(id, me)).toBe(0);
});
});
describe("the regression it was written for", () => {
it("counts my half of a shop the other person paid for", async () => {
const id = await fixture("the grocery shop", them, [[me, 50], [them, 50]]);
expect(await spend(id, me)).toBe(50);
});
it("still counts my half of the same shop when I paid", async () => {
const id = await fixture("the same shop, my card", me, [[me, 50], [them, 50]]);
expect(await spend(id, me)).toBe(50);
});
it("counts my share whether or not the split is settled", async () => {
// `myShare` must not filter on `settled` — half a shop was my expense
// whether or not the other half was repaid. Same rule, payer-side.
const id = await fixture("settled shop", them, [[me, 50], [them, 50]]);
await queryRaw(`UPDATE transaction_splits SET settled = true WHERE transaction_id = $1`, [id]);
expect(await spend(id, me)).toBe(50);
});
it("never counts one row for both people at full value", async () => {
const id = await fixture("one shop, two people", them, [[me, 50], [them, 50]]);
expect(await spend(id, me) + await spend(id, them)).toBe(100);
});
});
describe("the transactions-list mirror agrees", () => {
it("MY_SHARE_PCT matches myShare on every fixture shape", async () => {
const shapes: [string, number, [number, number][], number | undefined][] = [
["mirror A", them, [[me, 25], [them, 75]], undefined],
["mirror B", them, [], undefined],
["mirror C", them, [], 30],
["mirror D", me, [[me, 35], [them, 65]], undefined],
["mirror E", me, [], 80],
["mirror F", me, [[them, 40]], undefined],
["mirror G", me, [], undefined],
];
for (const [label, owner, splits, override] of shapes) {
const id = await fixture(label, owner, splits, override);
const mirrored = await queryRow<{ v: string }>(
`SELECT (${MY_SHARE_PCT})::numeric AS v ${JOINS} WHERE t.id = $2`,
[me, id]
);
expect([label, Number(mirrored!.v)]).toEqual([label, await share(id, me)]);
}
});
});
});
+65 -1
View File
@@ -16,12 +16,72 @@ export function mockDbWithPool(p: Pool) {
const result = await p.query(sql, params); const result = await p.query(sql, params);
return result.rows; 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;
},
// Mirrors withTransaction: one connection, BEGIN/COMMIT around the callback,
// so pg_advisory_xact_lock actually holds across the statements inside it.
withTransaction: async (fn: (q: unknown) => Promise<unknown>) => {
const client = await p.connect();
try {
await client.query("BEGIN");
const result = await fn({
queryRaw: async (sql: string, params: unknown[] = []) => (await client.query(sql, params)).rows,
queryRow: async (sql: string, params: unknown[] = []) => (await client.query(sql, params)).rows[0] ?? null,
});
await client.query("COMMIT");
return result;
} catch (e) {
await client.query("ROLLBACK");
throw e;
} finally {
client.release();
}
},
prisma: p, prisma: p,
})); }));
} }
/**
* Refuse to truncate anything that is not the test database.
*
* `.env.test` used to name the Postgres container by bridge IP, and Docker
* reassigns those on recreation: 172.22.0.47 stopped being `postgres-personal`
* and became `postgres-pantry`, so the suite spent a while pointing its
* TRUNCATE at another app's database. It only failed safe because the
* credentials happened not to match — had they matched, this would have wiped
* pantry-app.
*
* `scripts/resolve-test-db.sh` (wired as `pretest:integration`) now re-derives
* the DSN from the live container before every run, preferring the published
* loopback port. This check stays anyway: running vitest directly bypasses the
* pretest hook, and a stale `.env.test` would still be read.
*
* Checked once per process, before the first truncate.
*/
let targetVerified = false;
async function assertTestDatabase(pool: Pool) {
if (targetVerified) return;
const { rows } = await pool.query<{ db: string }>(`SELECT current_database() AS db`);
const db = rows[0]?.db;
if (db !== "personal_test") {
throw new Error(
`Refusing to truncate: connected to "${db}", expected "personal_test". ` +
`Re-resolve the DSN with \`bash scripts/resolve-test-db.sh\` (or just run ` +
`\`npm run test:integration\`, which does it for you), and run ` +
`\`npm run test:setup\` if personal_test does not exist yet.`
);
}
targetVerified = true;
}
/** Wipe all data tables and restart sequences between tests. */ /** Wipe all data tables and restart sequences between tests. */
export async function resetDB(pool: Pool) { export async function resetDB(pool: Pool) {
await assertTestDatabase(pool);
await pool.query(` await pool.query(`
TRUNCATE TRUNCATE
split_payments, split_payments,
@@ -35,6 +95,7 @@ export async function resetDB(pool: Pool) {
transactions, transactions,
statements, statements,
tags, tags,
trips,
participants participants
RESTART IDENTITY CASCADE RESTART IDENTITY CASCADE
`); `);
@@ -72,7 +133,10 @@ export async function insertTransaction(
VALUES ($1, NULL, $2, $3, $4, $5, $6, 0) RETURNING id`, VALUES ($1, NULL, $2, $3, $4, $5, $6, 0) RETURNING id`,
[ [
ownerId, ownerId,
overrides.transaction_date ?? "2024-06-15", // Post-cutover by default: a split on an older transaction never counts
// towards a balance (ACTIVE_OBLIGATION), so a pre-cutover default would
// make every balance fixture silently read zero.
overrides.transaction_date ?? "2026-06-15",
overrides.description ?? "Test transaction", overrides.description ?? "Test transaction",
overrides.amount ?? 100, overrides.amount ?? 100,
overrides.transaction_type ?? "debit", overrides.transaction_type ?? "debit",
+793
View File
@@ -0,0 +1,793 @@
import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from "vitest";
import { Pool } from "pg";
import { readFileSync, mkdtempSync, rmSync } from "fs";
import { tmpdir } from "os";
import { join, resolve } from "path";
import type { NextRequest } from "next/server";
import { createPool, mockDbWithPool, resetDB, seedParticipants } from "./helpers";
import type { ExtractedOrder } from "../../lib/intake-rules";
/**
* The intake router against the real schema (plan §11 C1, C2, C5).
*
* The model is stubbed at the HTTP boundary — `fetch` to LiteLLM answers with a
* canned extraction, `fetch` to pantry with a canned capture — so the PDF text
* path, the persistence, the match and the writes all run for real. The two
* PDFs are the user's own Uber Eats exports; the transactions seeded here are
* shaped like production rows 6846 and 6857 (platform `ubereats`, `line_items`
* empty, the email pipeline's booking of the same two orders).
*/
const INTAKE = "test-intake-token";
const RECEIPT = "test-receipt-token";
const fixtures = resolve(__dirname, "../fixtures/intake");
const BURGERTORY = readFileSync(resolve(fixtures, "ubereats-burgertory-2026-08-19.pdf"));
const COLES = readFileSync(resolve(fixtures, "ubereats-coles-2026-08-18.pdf"));
const burgertory: ExtractedOrder = {
merchant: "Burgertory (Werribee)", order_date: "2026-08-19", total: 22.4, subtotal: 28, currency: "AUD",
items: [
{ quantity: 1, description: "Michael", unit_price: 14, line_total: 14, options: ["Milk Bun"] },
{ quantity: 1, description: "Joel", unit_price: 14, line_total: 14, options: [] },
],
adjustments: [{ label: "Delivery Fee", amount: 5.49 }, { label: "Promotion", amount: -8 }],
};
const coles: ExtractedOrder = {
merchant: "Coles (Wyndham Vale)", order_date: "2026-08-18", total: 85.74, subtotal: 118.94, currency: "AUD",
items: [
{ quantity: 3, description: "Coca-Cola Zero Sugar Soft Drink (2L)", unit_price: 4.4, line_total: 13.2, options: [] },
{ quantity: 1, description: "Coles RSPCA Approved Chicken Thigh Cutlets approx. 1.1kg", unit_price: 11, line_total: 12.76, options: ["Final 1.16 kg $11.00/kg"] },
{ quantity: 1, description: "everything else", unit_price: 92.98, line_total: 92.98, options: [] },
],
adjustments: [],
};
let pool: Pool;
let ownerId: number;
let otherId: number;
let fileDir: string;
let intakePOST: (req: NextRequest) => Promise<Response>;
let lineItemsPUT: (req: NextRequest, ctx: { params: Promise<{ id: string }> }) => Promise<Response>;
let slackPOST: (req: NextRequest) => Promise<Response>;
let orderGET: (req: NextRequest, ctx: { params: Promise<{ id: string }> }) => Promise<Response>;
/** What the next LiteLLM call answers, and what the next pantry call answers. */
let modelReply: ExtractedOrder | Error = burgertory;
let pantryReply: { status: number; body: unknown } = { status: 200, body: { captureId: 901, items: [1, 2, 3] } };
const calls: { url: string; body: unknown }[] = [];
function stubFetch() {
vi.stubGlobal("fetch", async (url: string | URL, init?: RequestInit) => {
const u = String(url);
if (u.includes("/v1/chat/completions")) {
calls.push({ url: u, body: JSON.parse(String(init?.body)) });
if (modelReply instanceof Error) return new Response("boom", { status: 500 });
return Response.json({ choices: [{ message: { content: JSON.stringify(modelReply) }, finish_reason: "stop" }] });
}
if (u.includes("/api/capture")) {
const form = init?.body as FormData;
calls.push({ url: u, body: { intake: JSON.parse(String(form.get("intake"))), files: form.getAll("image").map((f) => (f as File).name), headers: init?.headers } });
return Response.json(pantryReply.body, { status: pantryReply.status });
}
if (u.includes("hooks.slack.com")) {
calls.push({ url: u, body: JSON.parse(String(init?.body)) });
return new Response("ok");
}
throw new Error(`unexpected fetch ${u}`);
});
}
const multipart = (fields: Record<string, string>, files: { name: string; type: string; bytes: Buffer }[], token: string | null = INTAKE) => {
const fd = new FormData();
for (const [k, v] of Object.entries(fields)) fd.append(k, v);
for (const f of files) fd.append("file", new File([new Uint8Array(f.bytes)], f.name, { type: f.type }));
return new Request("http://finance.test/api/intake", { method: "POST", headers: token ? { "x-ingest-token": token } : {}, body: fd }) as unknown as NextRequest;
};
const jsonReq = (body: unknown, token: string | null) =>
({ headers: { get: (h: string) => (h === "x-ingest-token" ? token : null) }, json: async () => body }) as unknown as NextRequest;
const put = (id: number | string, body: unknown, token: string | null = RECEIPT) => lineItemsPUT(jsonReq(body, token), { params: Promise.resolve({ id: String(id) }) });
const uuid = () => crypto.randomUUID();
/** A transaction shaped like the email pipeline's booking of an order, line_items empty. */
async function seedOrder(over: { merchant: string; date: string; amount: number; platform?: string | null; owner?: number; withMeta?: boolean }) {
const t = await pool.query(
`INSERT INTO transactions (owner_id, statement_id, transaction_date, description, amount, amount_aud, transaction_type, category, merchant_name, merchant_normalized, payment_method, row_index)
VALUES ($1, NULL, $2, $3, $4, $4, 'debit', 'dining', $5, $5, 'credits', 0) RETURNING id`,
[over.owner ?? ownerId, over.date, `Order - ${over.merchant}`, over.amount, over.merchant]
);
const id = t.rows[0].id as number;
if (over.withMeta !== false) {
await pool.query(
`INSERT INTO expense_metadata (transaction_id, source, platform, order_reference, line_items, merchant_normalized, amount, transaction_date, currency)
VALUES ($1, 'email', $2, $3, '[]'::jsonb, $4, $5, $6, 'AUD')`,
[id, over.platform === undefined ? "ubereats" : over.platform, uuid(), over.merchant, over.amount, over.date]
);
}
return id;
}
/** What `components/order-details.tsx` would actually render for a transaction. */
const orderPanel = async (txnId: number) =>
(await (await orderGET({ headers: { get: () => null } } as unknown as NextRequest, { params: Promise.resolve({ id: String(txnId) }) })).json()) as { line_items?: unknown[] } | null;
const metaRows = async () => (await pool.query(`SELECT id, platform, line_items FROM expense_metadata ORDER BY id`)).rows as { id: number; platform: string | null; line_items: { description: string }[] }[];
const lineItemsOf = async (txnId: number) =>
(await pool.query(`SELECT line_items FROM expense_metadata WHERE transaction_id = $1 OR matched_transaction_id = $1`, [txnId])).rows[0]?.line_items;
const batch = async (id: string) => (await pool.query(`SELECT * FROM intake_batches WHERE id = $1`, [id])).rows[0];
beforeAll(async () => {
process.env.INTAKE_TOKEN = INTAKE;
process.env.RECEIPT_INGEST_TOKEN = RECEIPT;
process.env.ORDER_INGEST_TOKEN = "test-order-token";
process.env.LITELLM_BASE_URL = "http://litellm.test";
process.env.LITELLM_KEY = "k";
process.env.PANTRY_BASE_URL = "http://pantry.test";
process.env.FINANCE_PUBLIC_URL = "https://finance.test";
fileDir = mkdtempSync(join(tmpdir(), "intake-test-"));
process.env.INTAKE_FILE_DIR = fileDir;
pool = createPool();
mockDbWithPool(pool);
// The Slack signature gate is exercised by slack-verify.test.ts; here it is
// bypassed so the intake branch of the route is what is under test.
vi.doMock("@/lib/slack-verify", () => ({
verifySlackSignature: () => true,
participantForSlackUser: () => 1,
slackUserForParticipant: () => null,
}));
// The order-detail panel is the surface an enrichment has to appear on, so
// the read is exercised beside the write rather than assumed.
vi.doMock("@/lib/auth", () => ({ getCurrentUser: async () => ({ id: 1, name: "Owner", email: "o@x" }) }));
vi.doMock("@/lib/queries", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
canAccessTransactions: async () => true,
}));
({ POST: intakePOST } = await import("../../app/api/intake/route"));
({ PUT: lineItemsPUT } = await import("../../app/api/transactions/[id]/line-items/route"));
({ POST: slackPOST } = await import("../../app/api/slack/interactive/route"));
({ GET: orderGET } = await import("../../app/api/transactions/[id]/order/route"));
stubFetch();
});
afterAll(() => {
vi.unstubAllGlobals();
rmSync(fileDir, { recursive: true, force: true });
});
beforeEach(async () => {
await resetDB(pool);
await pool.query(`TRUNCATE intake_batches, intake_merchant_kinds`);
({ ownerId, otherId } = await seedParticipants(pool, ["Alice", "Bob"]));
await pool.query(`UPDATE participants SET email = 'bob@example.test' WHERE id = $1`, [otherId]);
modelReply = burgertory;
pantryReply = { status: 200, body: { captureId: 901, items: [1, 2, 3] } };
calls.length = 0;
});
// ─── C2 ───────────────────────────────────────────────────────────────────────
describe("PUT /api/transactions/[id]/line-items (C2)", () => {
const items = [{ qty: 1, description: "Michael", amount: 14, options: ["Milk Bun"] }, { qty: 1, description: "Joel", amount: 14 }];
it("fails closed: no token, wrong token", async () => {
expect((await put(1, { line_items: items, source: "intake", replace: true }, null)).status).toBe(401);
expect((await put(1, { line_items: items, source: "intake", replace: true }, "nope")).status).toBe(401);
});
it("accepts either token", async () => {
const id = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
expect((await put(id, { line_items: items, source: "pantry", replace: true }, RECEIPT)).status).toBe(200);
expect((await put(id, { line_items: items, source: "intake", replace: true }, INTAKE)).status).toBe(200);
});
it("404 when the transaction does not exist — it never creates one", async () => {
const res = await put(999999, { line_items: items, source: "intake", replace: true });
expect(res.status).toBe(404);
expect((await pool.query(`SELECT count(*)::int AS n FROM transactions`)).rows[0].n).toBe(0);
});
it("rejects a body that is not a replace, a bad source, or nameless items", async () => {
const id = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
expect((await put(id, { line_items: items, source: "intake" })).status).toBe(400);
expect((await put(id, { line_items: items, source: "n8n", replace: true })).status).toBe(400);
expect((await put(id, { line_items: [{ qty: 1 }], source: "intake", replace: true })).status).toBe(400);
});
it("idempotent replace: returns previous + new, and a second identical call is a no-op", async () => {
const id = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
const first = await (await put(id, { line_items: items, source: "intake", replace: true })).json();
expect(first.previous_line_items).toEqual([]);
expect(first.line_items).toHaveLength(2);
expect(first.created_metadata).toBe(false);
expect(await lineItemsOf(id)).toHaveLength(2);
const corrected = [{ qty: 1, description: "Michael (corrected)", amount: 14 }];
const second = await (await put(id, { line_items: corrected, source: "pantry", replace: true })).json();
expect(second.previous_line_items.map((i: { description: string }) => i.description)).toEqual(["Michael", "Joel"]);
expect(await lineItemsOf(id)).toEqual(corrected);
const third = await (await put(id, { line_items: corrected, source: "pantry", replace: true })).json();
expect(third.previous_line_items).toEqual(corrected);
expect((await pool.query(`SELECT count(*)::int AS n FROM expense_metadata`)).rows[0].n).toBe(1);
});
it("undo shape: PUT previous_line_items back restores the row", async () => {
const id = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
const r = await (await put(id, { line_items: items, source: "pantry", replace: true })).json();
await put(id, { line_items: r.previous_line_items, source: "pantry", replace: true });
expect(await lineItemsOf(id)).toEqual([]);
});
it("creates the expense_metadata row when the transaction has none, with source and platform", async () => {
const id = await seedOrder({ merchant: "Coles", date: "2026-08-18", amount: 85.74, withMeta: false });
const res = await (await put(id, { line_items: items, source: "intake", platform: "ubereats", batch_id: "b1", replace: true })).json();
expect(res.created_metadata).toBe(true);
const em = (await pool.query(`SELECT source, platform, order_reference, merchant_normalized, amount::text FROM expense_metadata WHERE transaction_id = $1`, [id])).rows[0];
expect(em).toEqual({ source: "intake", platform: "ubereats", order_reference: "intake:b1", merchant_normalized: "Coles", amount: "85.74" });
});
it("the undo baseline is recorded ONCE per batch: a retry gets the original, not its own write", async () => {
const txn = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
const original = [{ qty: 1, description: "what the order already had", amount: 22.4 }];
await pool.query(`UPDATE expense_metadata SET line_items = $2::jsonb WHERE transaction_id = $1`, [txn, JSON.stringify(original)]);
const id = uuid();
await pool.query(`INSERT INTO intake_batches (id, source, file_sha256, status) VALUES ($1::uuid, 'other', '{}', 'received')`, [id]);
const first = await (await put(txn, { line_items: items, source: "pantry", batch_id: id, replace: true })).json();
expect(first.previous_line_items).toEqual(original);
// Pantry re-calls on every confirm and after a timeout. Before, the retry
// was handed its OWN write back, so undo restored the thing it undid.
const retry = await (await put(txn, { line_items: items, source: "pantry", batch_id: id, replace: true })).json();
expect(retry.previous_line_items).toEqual(original);
expect((await pool.query(`SELECT previous_line_items FROM intake_batches WHERE id = $1`, [id])).rows[0].previous_line_items).toEqual(original);
await put(txn, { line_items: retry.previous_line_items, source: "pantry", batch_id: id, replace: true });
expect(await lineItemsOf(txn)).toEqual(original);
});
it("without a batch id the baseline is still the row as read — nothing else changes", async () => {
const txn = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
const first = await (await put(txn, { line_items: items, source: "pantry", replace: true })).json();
expect(first.previous_line_items).toEqual([]);
const second = await (await put(txn, { line_items: items, source: "pantry", replace: true })).json();
expect(second.previous_line_items).toEqual(items);
});
it("a pinned row that no longer exists falls back to the live one, rather than reporting a phantom write", async () => {
const txn = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
const live = (await pool.query(`SELECT id FROM expense_metadata WHERE transaction_id = $1`, [txn])).rows[0].id as number;
const { replaceLineItems } = await import("../../lib/line-items");
const r = await replaceLineItems(txn, items, { source: "intake", metadataId: live + 999 });
expect(r.metadata_id).toBe(live);
expect(await lineItemsOf(txn)).toHaveLength(2);
});
it("two writes racing to create the metadata row both resolve to the same one", async () => {
const txn = await seedOrder({ merchant: "Coles", date: "2026-08-18", amount: 85.74, withMeta: false });
const both = await Promise.all([
put(txn, { line_items: items, source: "intake", replace: true }),
put(txn, { line_items: items, source: "intake", replace: true }),
]);
expect(both.map((r) => r.status)).toEqual([200, 200]);
expect((await pool.query(`SELECT count(*)::int AS n FROM expense_metadata WHERE transaction_id = $1`, [txn])).rows[0].n).toBe(1);
});
it("the create path records an undo baseline too, before it writes", async () => {
const txn = await seedOrder({ merchant: "Coles", date: "2026-08-18", amount: 85.74, withMeta: false });
const id = uuid();
await pool.query(`INSERT INTO intake_batches (id, source, file_sha256, status) VALUES ($1::uuid, 'other', '{}', 'received')`, [id]);
await put(txn, { line_items: items, source: "intake", batch_id: id, currency: "AUD", replace: true });
// [] rather than NULL: there was nothing there, and "nothing" is the state
// undo has to be able to restore.
expect((await pool.query(`SELECT previous_line_items FROM intake_batches WHERE id = $1`, [id])).rows[0].previous_line_items).toEqual([]);
});
it("the panel reads the row the write landed on, whichever order the rows were inserted in", async () => {
const t = await seedOrder({ merchant: "UBER *EATS", date: "2026-08-19", amount: 22.4, withMeta: false });
// Inserted first: the delivery receipt pointing at the statement line.
await pool.query(
`INSERT INTO expense_metadata (transaction_id, matched_transaction_id, source, platform, order_reference, line_items) VALUES (NULL, $1, 'email', 'ubereats', $2, '[]'::jsonb)`,
[t, uuid()]
);
// Inserted second: a row the transaction owns. An unordered LIMIT 1 reads
// the first one, the write takes the second, and the enrichment reports
// success against a row nothing renders.
await pool.query(
`INSERT INTO expense_metadata (transaction_id, source, order_reference, line_items) VALUES ($1, 'pantry', $2, '[]'::jsonb)`,
[t, uuid()]
);
const written = await (await put(t, { line_items: items, source: "intake", replace: true })).json();
const panel = await orderPanel(t);
expect(panel?.line_items).toHaveLength(2);
expect((await metaRows()).find((r) => r.id === written.metadata_id)!.line_items).toHaveLength(2);
});
it("finds the row through matched_transaction_id — a card-settled order has only that", async () => {
const statementLine = await seedOrder({ merchant: "UBER *EATS", date: "2026-08-19", amount: 22.4, withMeta: false });
await pool.query(
`INSERT INTO expense_metadata (transaction_id, matched_transaction_id, source, platform, order_reference, line_items) VALUES (NULL, $1, 'email', 'ubereats', $2, '[]'::jsonb)`,
[statementLine, uuid()]
);
const res = await (await put(statementLine, { line_items: items, source: "intake", replace: true })).json();
expect(res.created_metadata).toBe(false);
expect((await pool.query(`SELECT count(*)::int AS n FROM expense_metadata`)).rows[0].n).toBe(1);
expect(await lineItemsOf(statementLine)).toHaveLength(2);
});
});
// ─── C1 ───────────────────────────────────────────────────────────────────────
describe("POST /api/intake — gates", () => {
const pdf = { name: "Receipt_19Aug2026.pdf", type: "application/pdf", bytes: BURGERTORY };
it("fails closed without INTAKE_TOKEN match", async () => {
expect((await intakePOST(multipart({ source: "uber_eats" }, [pdf], null))).status).toBe(401);
expect((await intakePOST(multipart({ source: "uber_eats" }, [pdf], "nope"))).status).toBe(401);
});
it("400 on a bad source, no files, too many files, a non-uuid batch_id", async () => {
expect((await intakePOST(multipart({ source: "menulog" }, [pdf]))).status).toBe(400);
expect((await intakePOST(multipart({ source: "uber_eats" }, []))).status).toBe(400);
expect((await intakePOST(multipart({ source: "uber_eats" }, Array(7).fill(pdf)))).status).toBe(400);
expect((await intakePOST(multipart({ source: "uber_eats", batch_id: "not-a-uuid" }, [pdf]))).status).toBe(400);
expect((await pool.query(`SELECT count(*)::int AS n FROM intake_batches`)).rows[0].n).toBe(0);
});
it("declines a file type it cannot read, after persisting the batch", async () => {
const id = uuid();
const res = await intakePOST(multipart({ source: "uber_eats", batch_id: id }, [{ name: "clip.mp4", type: "video/mp4", bytes: Buffer.from("x") }]));
const body = await res.json();
expect(body.status).toBe("unsupported");
expect(body.text).toMatch(/not this/);
expect((await batch(id)).status).toBe("unsupported");
expect(calls).toHaveLength(0);
});
});
describe("POST /api/intake — food (enrich vs candidates)", () => {
const pdf = { name: "Receipt_19Aug2026.pdf", type: "application/pdf", bytes: BURGERTORY };
it("food + exact (platform, merchant, date, total) match → enriches the existing order, one model call, PDF as text", async () => {
const txn = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
const id = uuid();
const res = await intakePOST(multipart({ source: "uber_eats", shared_by: "bob@example.test", batch_id: id }, [pdf]));
const body = await res.json();
expect(res.status).toBe(200);
expect(body).toMatchObject({ kind: "food", status: "enriched", transaction_id: txn, batch_id: id });
expect(body.text).toBe("Added 2 items to your $22.40 Uber Eats (Burgertory (Werribee)) order ✓");
expect(JSON.stringify(body.slack_blocks)).toContain("https://finance.test/transactions?q=Burgertory");
const stored = await lineItemsOf(txn);
expect(stored.map((i: { description: string }) => i.description)).toEqual(["Michael", "Joel"]);
expect(stored[0].options).toEqual(["Milk Bun"]);
// one joint call, the PDF's text in it (not an image)
expect(calls).toHaveLength(1);
const content = calls[0].body as { messages: { content: { type: string; text?: string }[] }[]; model: string; temperature: number; response_format: { type: string } };
expect(content.model).toBe("cloud-budget");
expect(content.temperature).toBe(0);
expect(content.response_format.type).toBe("json_schema");
expect(content.messages[0].content.some((c) => c.type === "image_url")).toBe(false);
expect(content.messages[0].content.some((c) => c.text?.includes("1 Michael $14.00"))).toBe(true);
const row = await batch(id);
expect(row).toMatchObject({ source: "uber_eats", shared_by: "bob@example.test", kind: "food", status: "enriched", transaction_id: txn });
expect(row.header).toMatchObject({ merchant: "Burgertory (Werribee)", order_date: "2026-08-19", total: 22.4, platform: "ubereats" });
expect(row.resolved_at).not.toBeNull();
expect((await pool.query(`SELECT count(*)::int AS n FROM transactions`)).rows[0].n).toBe(1);
});
it("the platform is part of the key: the same merchant/date/total on DoorDash is not the match — but it IS offered", async () => {
const doordash = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4, platform: "doordash" });
const body = await (await intakePOST(multipart({ source: "uber_eats" }, [pdf]))).json();
expect(body.status).toBe("candidates");
// The candidates card is a human choice, not a decision. The platform is one
// of the things that may be wrong, so it must not narrow the list — a
// platform-filtered card showed nothing at all here.
expect(body.candidates.map((c: { transaction_id: number }) => c.transaction_id)).toEqual([doordash]);
});
it("a NULL platform is 'no platform', not a wildcard — an in-store share does not overwrite the delivery order", async () => {
await pool.query(`INSERT INTO intake_merchant_kinds (merchant_key, kind) VALUES ('burgertory werribee', 'food')`);
const delivery = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4, platform: "ubereats" });
const already = [{ qty: 1, description: "what the delivery order already had", amount: 22.4 }];
await pool.query(`UPDATE expense_metadata SET line_items = $2::jsonb WHERE transaction_id = $1`, [delivery, JSON.stringify(already)]);
// `other` implies no platform; the delivery row carries one, so it is not
// the match — it is only a candidate.
const body = await (await intakePOST(multipart({ source: "other" }, [pdf]))).json();
expect(body.status).toBe("candidates");
expect(body.candidates.map((c: { transaction_id: number }) => c.transaction_id)).toEqual([delivery]);
expect(await lineItemsOf(delivery)).toEqual(already);
});
it("a reconciled manual row is neither matched nor offered — EXCLUDE_RECONCILED_SOURCE, as everywhere else", async () => {
const live = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
const reconciled = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
await pool.query(`UPDATE transactions SET reconciled_with_id = $1 WHERE id = $2`, [live, reconciled]);
const near = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-17", amount: 19.9 });
await pool.query(`UPDATE transactions SET reconciled_with_id = $1 WHERE id = $2`, [live, near]);
// Two rows at the exact key would have read as an ambiguity; one of them is
// the same spend counted twice.
const body = await (await intakePOST(multipart({ source: "uber_eats" }, [pdf]))).json();
expect(body).toMatchObject({ status: "enriched", transaction_id: live });
});
it("the write lands on the row the MATCH resolved through, not on whichever row sorts first", async () => {
const t = await seedOrder({ merchant: "UBER *EATS", date: "2026-08-19", amount: 22.4, withMeta: false });
// The statement line owns a receipt row of its own (a pantry confirm) …
const grocery = [{ qty: 1, description: "a grocery shop", amount: 22.4 }];
const own = (await pool.query(
`INSERT INTO expense_metadata (transaction_id, source, order_reference, merchant_normalized, line_items) VALUES ($1, 'pantry', $2, 'Woolworths', $3::jsonb) RETURNING id`,
[t, uuid(), JSON.stringify(grocery)]
)).rows[0].id as number;
// … and is also the card leg of the Uber Eats order being enriched.
const delivery = (await pool.query(
`INSERT INTO expense_metadata (transaction_id, matched_transaction_id, source, platform, order_reference, merchant_normalized, line_items) VALUES (NULL, $1, 'email', 'ubereats', $2, 'Burgertory (Werribee)', '[]'::jsonb) RETURNING id`,
[t, uuid()]
)).rows[0].id as number;
const body = await (await intakePOST(multipart({ source: "uber_eats" }, [pdf]))).json();
expect(body).toMatchObject({ status: "enriched", transaction_id: t });
const rows = await metaRows();
expect(rows.find((r) => r.id === delivery)!.line_items.map((i) => i.description)).toEqual(["Michael", "Joel"]);
expect(rows.find((r) => r.id === own)!.line_items).toEqual(grocery);
});
it("an upload with no prices never replaces a receipt that already has items", async () => {
const priceless = { ...burgertory, subtotal: null, total: 22.4, items: burgertory.items.map((i) => ({ ...i, unit_price: null, line_total: null })) };
const txn = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
const already = [{ qty: 1, description: "the itemised receipt already there", amount: 22.4 }];
await pool.query(`UPDATE expense_metadata SET line_items = $2::jsonb WHERE transaction_id = $1`, [txn, JSON.stringify(already)]);
modelReply = priceless;
const body = await (await intakePOST(multipart({ source: "uber_eats" }, [pdf]))).json();
expect(body).toMatchObject({ kind: "held", status: "held" });
expect(body.text).toMatch(/left the existing items alone/);
expect(await lineItemsOf(txn)).toEqual(already);
// …but an EMPTY receipt is exactly what this slice exists to fill.
const empty = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-20", amount: 22.4 });
modelReply = { ...priceless, order_date: "2026-08-20" };
const second = await (await intakePOST(multipart({ source: "uber_eats" }, [pdf]))).json();
expect(second).toMatchObject({ status: "enriched", transaction_id: empty });
expect((await lineItemsOf(empty)).map((i: { description: string }) => i.description)).toEqual(["Michael", "Joel"]);
});
it("food + no match → candidates (same merchant ±3 days, any amount) with Use-this / Create buttons; NEVER auto-creates", async () => {
const near = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-17", amount: 19.9 });
await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-10", amount: 22.4 }); // too far
await seedOrder({ merchant: "Mad Mex", date: "2026-08-19", amount: 22.4 }); // wrong merchant
const id = uuid();
const body = await (await intakePOST(multipart({ source: "uber_eats", batch_id: id }, [pdf]))).json();
expect(body.status).toBe("candidates");
expect(body.candidates.map((c: { transaction_id: number }) => c.transaction_id)).toEqual([near]);
const blocks = JSON.stringify(body.slack_blocks);
expect(blocks).toContain('"action_id":"intake_use_candidate"');
expect(blocks).toContain('"action_id":"intake_create"');
expect(blocks).toContain(JSON.stringify(JSON.stringify({ batch_id: id, transaction_id: near })));
expect((await pool.query(`SELECT count(*)::int AS n FROM transactions`)).rows[0].n).toBe(3);
expect(await lineItemsOf(near)).toEqual([]);
expect((await batch(id)).items).toHaveLength(2);
});
it("two exact hits is an ambiguity, not a coin toss", async () => {
const a = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
const b = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
const body = await (await intakePOST(multipart({ source: "uber_eats" }, [pdf]))).json();
expect(body.status).toBe("candidates");
expect(body.candidates.map((c: { transaction_id: number }) => c.transaction_id).sort()).toEqual([a, b].sort());
});
it("missing header (no total) → needs_header, nothing written but the batch row", async () => {
const txn = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
modelReply = { ...burgertory, total: null };
const id = uuid();
const body = await (await intakePOST(multipart({ source: "uber_eats", batch_id: id }, [pdf]))).json();
expect(body.status).toBe("needs_header");
expect(body.text).toMatch(/share the top of the screen/);
expect(await lineItemsOf(txn)).toEqual([]);
expect((await batch(id)).status).toBe("needs_header");
});
it("items that do not add up → held, nothing written", async () => {
const txn = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
modelReply = { ...burgertory, items: [burgertory.items[0]] };
const body = await (await intakePOST(multipart({ source: "uber_eats" }, [pdf]))).json();
expect(body).toMatchObject({ kind: "held", status: "held" });
expect(body.text).toMatch(/is a shot missing/);
expect(await lineItemsOf(txn)).toEqual([]);
});
it("a model failure is a 502 with the batch marked error, not a silent 200", async () => {
modelReply = new Error("down");
const res = await intakePOST(multipart({ source: "uber_eats" }, [pdf]));
expect(res.status).toBe(502);
expect((await res.json()).status).toBe("error");
});
it("re-share of the same file after it was handled → duplicate with a pointer", async () => {
const txn = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
const first = uuid();
await intakePOST(multipart({ source: "uber_eats", batch_id: first }, [pdf]));
calls.length = 0;
const body = await (await intakePOST(multipart({ source: "uber_eats" }, [pdf]))).json();
expect(body.status).toBe("duplicate");
expect(body.duplicate_of).toEqual({ batch_id: first, transaction_id: txn, capture_id: null });
expect(body.text).toMatch(/Already handled/);
expect(calls).toHaveLength(0); // no model call spent on a known file
});
it("re-share of the same order under a different file (screenshot after PDF) → duplicate by header", async () => {
const txn = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
const first = uuid();
await intakePOST(multipart({ source: "uber_eats", batch_id: first }, [pdf]));
const body = await (await intakePOST(multipart({ source: "uber_eats" }, [{ name: "IMG_1.png", type: "image/png", bytes: Buffer.from("fake-png") }]))).json();
expect(body.status).toBe("duplicate");
expect(body.duplicate_of.transaction_id).toBe(txn);
});
it("a repeated batch_id is refused, not re-run", async () => {
const id = uuid();
await intakePOST(multipart({ source: "uber_eats", batch_id: id }, [pdf]));
expect((await intakePOST(multipart({ source: "uber_eats", batch_id: id }, [pdf]))).status).toBe(409);
});
});
describe("POST /api/intake — grocery → pantry", () => {
const pdf = { name: "Receipt_18Aug2026.pdf", type: "application/pdf", bytes: COLES };
it("grocery + match → the ORIGINAL file goes to pantry with the target transaction and action enrich; finance is NOT touched", async () => {
modelReply = coles;
const txn = await seedOrder({ merchant: "Coles (Wyndham Vale)", date: "2026-08-18", amount: 85.74 });
const id = uuid();
const body = await (await intakePOST(multipart({ source: "uber_eats", shared_by: "bob@example.test", batch_id: id }, [pdf]))).json();
expect(body).toMatchObject({ kind: "grocery", status: "forwarded_to_pantry", capture_id: 901, transaction_id: txn });
expect(body.text).toBe("Coles (Wyndham Vale) $85.74 → pantry board (3 items) — finance updates on confirm");
const pantryCall = calls.find((c) => c.url === "http://pantry.test/api/capture")!.body as { intake: Record<string, unknown>; files: string[]; headers: Record<string, string> };
expect(pantryCall.headers["x-ingest-token"]).toBe(RECEIPT);
expect(pantryCall.files).toEqual(["Receipt_18Aug2026.pdf"]);
expect(pantryCall.intake).toEqual({ batch_id: id, platform: "ubereats", origin: "intake", shared_by: "bob@example.test", finance_target_transaction_id: txn, finance_action: "enrich" });
expect(await lineItemsOf(txn)).toEqual([]);
expect((await batch(id))).toMatchObject({ kind: "grocery", status: "forwarded_to_pantry", capture_id: 901, transaction_id: txn });
});
it("an in-store tap does not hand pantry the delivery order as its finance target", async () => {
modelReply = coles;
// The Uber Eats Coles order the email pipeline already booked.
const delivery = await seedOrder({ merchant: "Coles (Wyndham Vale)", date: "2026-08-18", amount: 85.74, platform: "ubereats" });
// The same shop, same day, same money — but shared from the Coles app, so
// it carries no platform and is a different purchase.
const body = await (await intakePOST(multipart({ source: "coles" }, [pdf]))).json();
expect(body.status).toBe("forwarded_to_pantry");
const pantryCall = calls.find((c) => c.url === "http://pantry.test/api/capture")!.body as { intake: Record<string, unknown> };
expect(pantryCall.intake).toMatchObject({ finance_action: "create", finance_target_transaction_id: null, platform: null });
expect(await lineItemsOf(delivery)).toEqual([]);
});
it("grocery + no match → pantry with finance_action create (ING-10 as today)", async () => {
modelReply = coles;
const body = await (await intakePOST(multipart({ source: "coles" }, [pdf]))).json();
expect(body.status).toBe("forwarded_to_pantry");
expect(body.text).toMatch(/finance books it on confirm/);
const pantryCall = calls.find((c) => c.url.includes("/api/capture"))!.body as { intake: Record<string, unknown> };
expect(pantryCall.intake).toMatchObject({ platform: null, finance_target_transaction_id: null, finance_action: "create" });
});
it("pantry 409 → duplicate", async () => {
modelReply = coles;
pantryReply = { status: 409, body: { error: "This receipt has already been added to the pantry.", captureId: 77 } };
const body = await (await intakePOST(multipart({ source: "coles" }, [pdf]))).json();
expect(body).toMatchObject({ kind: "grocery", status: "duplicate", capture_id: 77 });
});
it("pantry down → 502 error, batch marked error", async () => {
modelReply = coles;
pantryReply = { status: 500, body: { error: "nope" } };
const res = await intakePOST(multipart({ source: "coles" }, [pdf]));
expect(res.status).toBe(502);
expect((await res.json()).status).toBe("error");
});
});
// ─── C5 ───────────────────────────────────────────────────────────────────────
const slackPress = (action_id: string, value: unknown) => {
const payload = { type: "block_actions", user: { id: "U1", name: "alice" }, response_url: "https://hooks.slack.com/r/1", actions: [{ action_id, value: JSON.stringify(value) }] };
const raw = new URLSearchParams({ payload: JSON.stringify(payload) }).toString();
return { headers: { get: (h: string) => (h === "x-ingest-token" ? "test-order-token" : "x") }, text: async () => raw } as unknown as NextRequest;
};
const lastSlackUpdate = () => calls.filter((c) => c.url.includes("hooks.slack.com")).at(-1)?.body as { replace_original?: boolean; text: string; blocks?: unknown[] };
describe("Slack taps (C5)", () => {
const pdf = { name: "Receipt_19Aug2026.pdf", type: "application/pdf", bytes: BURGERTORY };
it("intake_use_candidate enriches the chosen transaction and replaces the card", async () => {
const near = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-17", amount: 19.9 });
const id = uuid();
await intakePOST(multipart({ source: "uber_eats", batch_id: id }, [pdf]));
const res = await slackPOST(slackPress("intake_use_candidate", { batch_id: id, transaction_id: near }));
expect(res.status).toBe(200);
expect((await lineItemsOf(near)).map((i: { description: string }) => i.description)).toEqual(["Michael", "Joel"]);
expect(await batch(id)).toMatchObject({ status: "enriched", transaction_id: near });
expect(lastSlackUpdate()).toMatchObject({ replace_original: true, text: "Added 2 items to your $22.40 Uber Eats (Burgertory (Werribee)) order ✓" });
});
it("intake_create creates the transaction ING-10-style: manual, owner from shared_by, items attached; a second press is a no-op", async () => {
const id = uuid();
await intakePOST(multipart({ source: "uber_eats", shared_by: "bob@example.test", batch_id: id }, [pdf]));
expect((await pool.query(`SELECT count(*)::int AS n FROM transactions`)).rows[0].n).toBe(0);
await slackPOST(slackPress("intake_create", { batch_id: id }));
const t = (await pool.query(`SELECT statement_id, owner_id, description, amount::text, category, transaction_date::text, source, source_ref FROM transactions`)).rows;
expect(t).toEqual([{ statement_id: null, owner_id: otherId, description: "Order - Burgertory (Werribee)", amount: "22.40", category: "dining", transaction_date: "2026-08-19", source: "intake", source_ref: id }]);
const em = (await pool.query(`SELECT source, platform, line_items FROM expense_metadata`)).rows[0];
expect(em.source).toBe("intake");
expect(em.platform).toBe("ubereats");
expect(em.line_items.map((i: { description: string }) => i.description)).toEqual(["Michael", "Joel"]);
expect(lastSlackUpdate().text).toMatch(/^Created a \$22\.40 Burgertory \(Werribee\) transaction with 2 items/);
await slackPOST(slackPress("intake_create", { batch_id: id }));
expect((await pool.query(`SELECT count(*)::int AS n FROM transactions`)).rows[0].n).toBe(1);
});
it("intake_create re-runs the match: an order booked between the card and the tap is enriched, not duplicated", async () => {
const id = uuid();
const body = await (await intakePOST(multipart({ source: "uber_eats", batch_id: id }, [pdf]))).json();
expect(body.status).toBe("candidates");
// The email/order bridge books the same purchase while the card sits in
// Slack. The card's "no match" is minutes stale by the time it is pressed.
const txn = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
await slackPOST(slackPress("intake_create", { batch_id: id }));
expect((await pool.query(`SELECT count(*)::int AS n FROM transactions`)).rows[0].n).toBe(1);
expect(await batch(id)).toMatchObject({ status: "enriched", transaction_id: txn });
expect((await lineItemsOf(txn)).map((i: { description: string }) => i.description)).toEqual(["Michael", "Joel"]);
expect(lastSlackUpdate().text).toMatch(/^Added 2 items/);
});
it("two pending cards for one order book it ONCE", async () => {
const a = uuid();
const b = uuid();
await intakePOST(multipart({ source: "uber_eats", batch_id: a }, [pdf]));
await intakePOST(multipart({ source: "uber_eats", batch_id: b }, [{ name: "shot.png", type: "image/png", bytes: Buffer.from("shot") }]));
expect((await batch(a)).status).toBe("candidates");
expect((await batch(b)).status).toBe("candidates");
await slackPOST(slackPress("intake_create", { batch_id: a }));
await slackPOST(slackPress("intake_create", { batch_id: b }));
expect((await pool.query(`SELECT count(*)::int AS n FROM transactions`)).rows[0].n).toBe(1);
expect((await batch(b)).transaction_id).toBe((await batch(a)).transaction_id);
});
it("a sibling already resolved to the pantry board makes Create a duplicate, not a second row", async () => {
// Two shares of one receipt, resolved out of order: nothing the second card
// can match on, because the first one never produced a transaction.
modelReply = { ...coles, merchant: "Romeo's Food Hall" };
const a = uuid();
const b = uuid();
await intakePOST(multipart({ source: "other", batch_id: b }, [{ name: "shot.png", type: "image/png", bytes: Buffer.from("shot") }]));
await intakePOST(multipart({ source: "other", batch_id: a }, [{ name: "Receipt_18Aug2026.pdf", type: "application/pdf", bytes: COLES }]));
expect((await batch(a)).status).toBe("ask");
await slackPOST(slackPress("intake_grocery", { batch_id: a }));
expect((await batch(a)).status).toBe("forwarded_to_pantry");
await slackPOST(slackPress("intake_meal", { batch_id: b }));
expect((await batch(b)).status).toBe("candidates");
await slackPOST(slackPress("intake_create", { batch_id: b }));
expect((await pool.query(`SELECT count(*)::int AS n FROM transactions`)).rows[0].n).toBe(0);
expect(await batch(b)).toMatchObject({ status: "duplicate", capture_id: 901 });
expect(lastSlackUpdate().text).toMatch(/^Already handled/);
});
it("two taps that land together still book one transaction, and neither reports an error", async () => {
const id = uuid();
await intakePOST(multipart({ source: "uber_eats", batch_id: id }, [pdf]));
await Promise.all([slackPOST(slackPress("intake_create", { batch_id: id })), slackPOST(slackPress("intake_create", { batch_id: id }))]);
expect((await pool.query(`SELECT count(*)::int AS n FROM transactions`)).rows[0].n).toBe(1);
const texts = calls.filter((c) => c.url.includes("hooks.slack.com")).map((c) => (c.body as { text: string }).text);
expect(texts.every((t) => !/Couldn't process/.test(t))).toBe(true);
});
it("a foreign receipt is booked in its own currency, with no invented AUD figure", async () => {
modelReply = { ...burgertory, currency: "USD" };
const id = uuid();
await intakePOST(multipart({ source: "uber_eats", batch_id: id }, [pdf]));
await slackPOST(slackPress("intake_create", { batch_id: id }));
const t = (await pool.query(`SELECT amount::text, amount_aud, foreign_currency_amount::text, foreign_currency_code FROM transactions`)).rows[0];
expect(t).toEqual({ amount: "22.40", amount_aud: null, foreign_currency_amount: "22.40", foreign_currency_code: "USD" });
expect(lastSlackUpdate().text).toMatch(/USD 22\.40/);
// OrderDetails prefers expense_metadata.currency, so a hard-coded 'AUD'
// here printed "$" on a USD order's receipt panel.
expect((await pool.query(`SELECT currency FROM expense_metadata`)).rows[0].currency).toBe("USD");
});
it("the hold applies to the Create tap's re-match, not only to the upload path", async () => {
modelReply = { ...burgertory, subtotal: null, items: burgertory.items.map((i) => ({ ...i, unit_price: null, line_total: null })) };
const id = uuid();
const body = await (await intakePOST(multipart({ source: "uber_eats", batch_id: id }, [pdf]))).json();
expect(body.status).toBe("candidates");
// Booked WITH its items between the card and the tap. The re-match walked
// past the guard routeFood applies, so the same upload destroyed the same
// receipt depending on which button reached it.
const txn = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
const already = [{ qty: 1, description: "the itemised receipt already there", amount: 22.4 }];
await pool.query(`UPDATE expense_metadata SET line_items = $2::jsonb WHERE transaction_id = $1`, [txn, JSON.stringify(already)]);
await slackPOST(slackPress("intake_create", { batch_id: id }));
expect(await lineItemsOf(txn)).toEqual(already);
expect(await batch(id)).toMatchObject({ status: "held" });
expect((await pool.query(`SELECT count(*)::int AS n FROM transactions`)).rows[0].n).toBe(1);
});
it("two DIFFERENT batches for one purchase, tapped together, book it once", async () => {
const a = uuid();
const b = uuid();
await intakePOST(multipart({ source: "uber_eats", batch_id: a }, [pdf]));
await intakePOST(multipart({ source: "uber_eats", batch_id: b }, [{ name: "shot.png", type: "image/png", bytes: Buffer.from("shot") }]));
// Their source_refs differ, so ON CONFLICT cannot save this; both taps
// clear their match and duplicate checks before either inserts.
await Promise.all([slackPOST(slackPress("intake_create", { batch_id: a })), slackPOST(slackPress("intake_create", { batch_id: b }))]);
const rows = (await pool.query(`SELECT id FROM transactions`)).rows;
expect(rows).toHaveLength(1);
expect((await batch(a)).transaction_id).toBe(rows[0].id);
expect((await batch(b)).transaction_id).toBe(rows[0].id);
});
it("unknown shared_by falls back to the default owner — never a NULL owner", async () => {
const id = uuid();
await intakePOST(multipart({ source: "uber_eats", shared_by: "nobody@example.test", batch_id: id }, [pdf]));
await slackPOST(slackPress("intake_create", { batch_id: id }));
expect((await pool.query(`SELECT owner_id FROM transactions`)).rows[0].owner_id).toBe(1);
});
it("`other` + unrecognised merchant asks; intake_meal routes it as food and remembers the merchant", async () => {
modelReply = { ...burgertory, merchant: "Romeo's Food Hall" };
const txn = await seedOrder({ merchant: "Romeo's Food Hall", date: "2026-08-19", amount: 22.4, platform: null });
const id = uuid();
const body = await (await intakePOST(multipart({ source: "other", batch_id: id }, [pdf]))).json();
expect(body).toMatchObject({ kind: "ask", status: "ask" });
expect(JSON.stringify(body.slack_blocks)).toContain("intake_grocery");
await slackPOST(slackPress("intake_meal", { batch_id: id }));
expect(await batch(id)).toMatchObject({ status: "enriched", transaction_id: txn });
expect(await lineItemsOf(txn)).toHaveLength(2);
expect((await pool.query(`SELECT kind FROM intake_merchant_kinds WHERE merchant_key = 'romeo s food hall'`)).rows[0].kind).toBe("food");
// The answer sticks: the next share of that merchant does not ask.
const again = await (await intakePOST(multipart({ source: "other" }, [{ name: "IMG.png", type: "image/png", bytes: Buffer.from("png2") }]))).json();
expect(again.status).toBe("duplicate"); // same order — but it was routed, not asked
modelReply = { ...burgertory, merchant: "Romeo's Food Hall", order_date: "2026-08-20" };
const next = await (await intakePOST(multipart({ source: "other" }, [{ name: "IMG3.png", type: "image/png", bytes: Buffer.from("png3") }]))).json();
expect(next).toMatchObject({ kind: "food", status: "candidates" });
});
it("intake_grocery forwards the stored original files to pantry", async () => {
modelReply = { ...coles, merchant: "Romeo's Food Hall" };
const id = uuid();
await intakePOST(multipart({ source: "other", batch_id: id }, [{ name: "Receipt_18Aug2026.pdf", type: "application/pdf", bytes: COLES }]));
expect((await batch(id)).status).toBe("ask");
await slackPOST(slackPress("intake_grocery", { batch_id: id }));
const pantryCall = calls.find((c) => c.url.includes("/api/capture"))!.body as { intake: Record<string, unknown>; files: string[] };
expect(pantryCall.files).toEqual(["Receipt_18Aug2026.pdf"]);
expect(pantryCall.intake).toMatchObject({ batch_id: id, finance_action: "create" });
expect(await batch(id)).toMatchObject({ kind: "grocery", status: "forwarded_to_pantry", capture_id: 901 });
expect((await pool.query(`SELECT kind FROM intake_merchant_kinds`)).rows[0].kind).toBe("grocery");
});
it("a tap on a resolved batch returns its card and writes nothing; an unknown batch says so", async () => {
const txn = await seedOrder({ merchant: "Burgertory (Werribee)", date: "2026-08-19", amount: 22.4 });
const id = uuid();
await intakePOST(multipart({ source: "uber_eats", batch_id: id }, [pdf]));
await slackPOST(slackPress("intake_create", { batch_id: id }));
expect((await pool.query(`SELECT count(*)::int AS n FROM transactions`)).rows[0].n).toBe(1);
expect(lastSlackUpdate().text).toMatch(/^Added 2 items/);
expect(await batch(id)).toMatchObject({ status: "enriched", transaction_id: txn });
await slackPOST(slackPress("intake_create", { batch_id: uuid() }));
expect(lastSlackUpdate()).toMatchObject({ replace_original: false, text: "I can't find that upload any more." });
});
});
@@ -0,0 +1,116 @@
import { describe, it, expect, beforeAll } from "vitest";
import { readFileSync } from "fs";
import { resolve } from "path";
import type { NextRequest } from "next/server";
import { queryRaw } from "../../lib/db";
/**
* The HTTP path had no tests at all — which is how three defects reached the
* branch through a suite of 105 green ones. These exercise the route handler
* directly (no server needed) so the auth gate and the error taxonomy are
* actually covered.
*/
const dir = resolve(__dirname, "../fixtures/orders/real");
const html = (f: string) => readFileSync(resolve(dir, `${f}.html`), "utf-8");
const TOKEN = "test-ingest-token";
// The handler is imported dynamically (after the env var is set), so its type
// is declared here rather than inferred. `unknown` for the request is
// deliberate: these fixtures are the two properties the route actually reads,
// not a whole NextRequest, and widening to `any` would stop the compiler
// checking the call sites too.
let POST: (req: NextRequest) => Promise<Response>;
// One cast, at the boundary, instead of `any` everywhere downstream: the
// fixture is the two properties the route actually reads, and everything past
// this line stays type-checked.
const req = (body: unknown, token: string | null = TOKEN) =>
({
headers: { get: (h: string) => (h === "x-ingest-token" ? token : null) },
json: async () => body,
}) as unknown as NextRequest;
beforeAll(async () => {
process.env.ORDER_INGEST_TOKEN = TOKEN;
({ POST } = await import("../../app/api/orders/ingest/route"));
});
describe("ingest API — auth", () => {
it("rejects a missing token", async () => {
const res = await POST(req({ html: "x", meta: {} }, null));
expect(res.status).toBe(401);
});
it("rejects a wrong token", async () => {
const res = await POST(req({ html: "x", meta: {} }, "nope"));
expect(res.status).toBe(401);
});
it("rejects a malformed body", async () => {
const res = await POST(req({ html: "only html" }));
expect(res.status).toBe(400);
});
});
describe("ingest API — error taxonomy", () => {
const meta = (over = {}) => ({
messageId: `api-${Math.random().toString(36).slice(2)}`,
subject: "Order Confirmation for Siddharth from Mad Mex",
receivedAt: "2026-07-16T03:34:00Z",
sender: "DoorDash Order <no-reply@doordash.com>",
...over,
});
it("a non-receipt is 200 and silent — it must not alert", async () => {
// A newsletter: real traffic, correctly ignored.
const res = await POST(req({ html: html("dd-01"), meta: meta({ subject: "Newsletter", sender: "promo@example.com" }) }));
expect(res.status).toBe(200);
expect((await res.json()).kind).toBe("skipped");
});
it("an adjustment notice is 200 and silent", async () => {
const res = await POST(req({
html: html("dd-08"),
meta: meta({ subject: "Order Confirmation for Siddharth from ALDI" }),
}));
expect(res.status).toBe(200);
expect((await res.json()).kind).toBe("skipped");
});
it("a receipt that cannot be parsed is 422 so it ALERTS", async () => {
// A DoorDash receipt with its totals stripped out — i.e. what a provider
// template change looks like. Previously this returned 200 and vanished.
const broken = html("dd-01")
.replace(/Total Charged/g, "Gesamtbetrag")
.replace(/Total:/g, "Summe:");
const res = await POST(req({ html: broken, meta: meta() }));
expect(res.status).toBe(422);
const body = await res.json();
expect(body.kind).toBe("parse_failed");
expect(body.reason).toMatch(/total/i);
});
it("a refund is routed to the amendment path, not ingestion", async () => {
const res = await POST(req({
html: html("ue-05"),
meta: meta({ subject: "Your Tuesday evening order with Uber Eats", sender: "uber.com" }),
dryRun: true,
}));
expect(res.status).toBe(200);
const body = await res.json();
expect(body.kind).toBe("amendment");
expect(body.amendment.new_total).toBeCloseTo(45.73, 2);
});
it("a good receipt ingests", async () => {
await queryRaw(`DELETE FROM expense_metadata WHERE source = 'email'`);
await queryRaw(`DELETE FROM transactions WHERE description LIKE 'Order - %'`);
const res = await POST(req({ html: html("dd-01"), meta: meta({ messageId: "api-ok-1" }) }));
expect(res.status).toBe(200);
const body = await res.json();
expect(body.kind).toBe("order");
expect(body.total).toBe(14.64);
expect(body.transactionId).not.toBeNull();
});
});
@@ -0,0 +1,532 @@
import { describe, it, expect, beforeEach } from "vitest";
import { readFileSync } from "fs";
import { resolve } from "path";
import { queryRaw, queryRow } from "../../lib/db";
import {
parseOrderHTML,
validateOrderTotals,
processOrderIngestion,
reconcilePendingOrders,
parseOrderAmendment,
applyOrderAmendment,
NotAReceiptError,
type MessageMeta,
} from "../../lib/order-ingestion";
import { EXCLUDE_NON_SPEND } from "../../lib/analytics-sql";
import { bankLabel, needsCardMatch } from "../../lib/queries";
/**
* These run against REAL captured receipts, not synthetic fixtures. The earlier
* suite passed 31/31 against fixtures written to satisfy the parser, while the
* parser could not read a single real email. Fixtures live in
* __tests__/fixtures/orders/real/ and are unmodified message bodies.
*/
const dir = resolve(__dirname, "../fixtures/orders/real");
const html = (f: string) => readFileSync(resolve(dir, `${f}.html`), "utf-8");
const meta = (over: Partial<MessageMeta> = {}): MessageMeta => ({
messageId: `test-${Math.random().toString(36).slice(2)}`,
subject: "Order Confirmation for Siddharth from Mad Mex",
receivedAt: "2026-07-16T03:34:00Z",
sender: "DoorDash Order <no-reply@doordash.com>",
...over,
});
describe("Order parsing — real receipts", () => {
it("reads DoorDash totals structurally, not by flattening (I9)", () => {
const p = parseOrderHTML(html("dd-01"), meta());
expect(p.merchant_name).toBe("Mad Mex");
expect(p.totals.total_charged).toBe(14.64);
expect(p.payment.credits_amount).toBe(14.64);
expect(p.line_items).toHaveLength(1);
expect(p.line_items[0].description).toBe("Burrito (Mains)");
expect(p.line_items[0].options).toContain("Slow Cooked Beef (GF)");
});
it("does NOT gate on DoorDash's fee breakdown, which genuinely does not reconcile", () => {
// Real receipt: subtotal 22.10 + service 1.99 - Discounts 24.09 = 0.00,
// against a stated total of 14.64. DoorDash prints this; it is not a parse
// artefact. Recorded here so nobody "fixes" the parser to force it to sum.
const p = parseOrderHTML(html("dd-01"), meta());
expect(p.totals.subtotal).toBe(22.10);
expect(p.totals.discounts).toBe(24.09);
expect(validateOrderTotals(p, html("dd-01")).ok).toBe(true);
});
it("derives order_reference from the message, never randomly (I7)", () => {
const m = meta({ messageId: "abc123" });
const a = parseOrderHTML(html("dd-01"), m);
const b = parseOrderHTML(html("dd-01"), m);
expect(a.order_reference).toBe(b.order_reference);
expect(a.order_reference).toBe("msg:abc123");
});
it("uses Uber's embedded order UUID as the reference", () => {
const p = parseOrderHTML(
html("ue-00"),
meta({ subject: "Your Wednesday afternoon order with Uber Eats", sender: "uber.com" })
);
expect(p.order_reference).toMatch(/^[0-9a-f-]{36}$/);
expect(p.platform).toBe("ubereats");
});
it("takes the order date from the message, not a body string", () => {
const p = parseOrderHTML(html("dd-01"), meta({ receivedAt: "2026-07-16T03:34:00Z" }));
expect(p.order_datetime.slice(0, 10)).toBe("2026-07-16");
});
it("detects [Family] from the subject prefix, not a body substring (I11)", () => {
const fam = parseOrderHTML(
html("ue-04"),
meta({ subject: "[Family] Your Tuesday evening order with Uber Eats", sender: "uber.com" })
);
expect(fam.is_family).toBe(true);
const notFam = parseOrderHTML(html("dd-01"), meta());
expect(notFam.is_family).toBe(false);
});
it("reads [Family] orders as LKR, not dollars", () => {
const p = parseOrderHTML(
html("ue-04"),
meta({ subject: "[Family] Your Tuesday evening order with Uber Eats", sender: "uber.com" })
);
expect(p.currency).toBe("LKR");
expect(p.totals.total_charged).toBeCloseTo(3783.20, 2);
});
it("reads Swiss orders as CHF", () => {
const p = parseOrderHTML(
html("ue-26"),
meta({ subject: "Your Tuesday evening order with Uber Eats", sender: "uber.com" })
);
expect(p.currency).toBe("CHF");
expect(p.totals.total_charged).toBeCloseTo(51.23, 2);
});
it("skips a failed payment attempt and takes the successful one", () => {
// ue-09: "Visa ••••8841 LKR 4,267.01 ... Failed" then "LKR 3,757.01".
const p = parseOrderHTML(
html("ue-09"),
meta({ subject: "[Family] Your Sunday evening order with Uber Eats", sender: "uber.com" })
);
expect(p.totals.total_charged).toBeCloseTo(3757.01, 2);
expect(validateOrderTotals(p, html("ue-09")).ok).toBe(true);
});
it("rejects an order-adjustment notice rather than booking $0.00", () => {
// NotAReceiptError, not OrderParseError: this is expected traffic, so it
// must be skipped silently. Only a receipt that fails to parse should
// alert — see the ingest API's error taxonomy.
expect(() =>
parseOrderHTML(html("dd-08"), meta({ subject: "Order Confirmation for Siddharth from ALDI" }))
).toThrow(NotAReceiptError);
});
it("rejects a refund notice rather than inserting a duplicate order", () => {
expect(() =>
parseOrderHTML(html("ue-05"), meta({ subject: "Your Tuesday evening order with Uber Eats", sender: "uber.com" }))
).toThrow(/refund/i);
});
it("reads a grocery Final receipt that has no Total Charged row", () => {
const p = parseOrderHTML(
html("dd-10"),
meta({ subject: "Order Confirmation for Siddharth from Woolworths" })
);
expect(p.totals.total_charged).toBeCloseTo(60.93, 2);
expect(p.payment.ambiguous).toBe(true); // "8032 and/or credits"
});
});
describe("Order ingestion — invariants", () => {
beforeEach(async () => {
await queryRaw(`DELETE FROM expense_metadata WHERE source = 'email'`);
await queryRaw(`DELETE FROM transactions WHERE description LIKE 'Order - %'`);
// The statement fixtures these tests insert survived into the next run, and
// reconcileCardLeg matched a leftover charge at ingest time — so an order
// meant to park "awaiting_card_statement" resolved immediately instead.
// That is the whole story behind the intermittent failure in "parks an
// unresolvable split": not a race, just fixtures that were never cleaned.
// Must happen BEFORE ingest, which is why cleaning up at the end of the
// test was not enough.
await queryRaw(
`DELETE FROM statements WHERE filename IN ('test-westpac-2026-03.pdf', 'panel-cba.pdf', 'panel-plain.pdf')`
);
await queryRaw(
`DELETE FROM transactions WHERE description IN ('DD *DOORDASH WOOLWORTHS MELBOURNE AUS', 'UBER *EATS ZURICH')`
);
});
it("I6: a credits order creates one transaction at face value", async () => {
const p = parseOrderHTML(html("dd-01"), meta());
const res = await processOrderIngestion(p);
expect(res.transactionId).not.toBeNull();
const txn = await queryRow<{ amount: string; payment_method: string; category: string }>(
`SELECT amount::text, payment_method, category FROM transactions WHERE id = $1`,
[res.transactionId]
);
expect(Number(txn!.amount)).toBe(14.64);
expect(txn!.payment_method).toBe("credits");
expect(txn!.category).toBe("dining");
});
it("I7: re-ingesting the same receipt creates nothing new", async () => {
const p = parseOrderHTML(html("dd-01"), meta({ messageId: "dedupe-1" }));
const a = await processOrderIngestion(p);
const b = await processOrderIngestion(parseOrderHTML(html("dd-01"), meta({ messageId: "dedupe-1" })));
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'`
);
expect(Number(n!.c)).toBe(1);
});
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).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 () => {
// Requirement was "import them but tag so they're excluded from budgets".
// Correct mechanism: the CARD statement line is the transaction and carries
// the family tag. Creating a second, credits-flavoured row duplicated it.
const p = parseOrderHTML(
html("ue-04"),
meta({ subject: "[Family] Your Tuesday evening order with Uber Eats", sender: "uber.com", receivedAt: "2026-07-07T10:08:00Z" })
);
const res = await processOrderIngestion(p);
expect(res.transactionId).toBeNull();
expect(res.flags).toContain("family_card_settled_no_transaction");
const meta_ = await queryRow<{ currency: string }>(
`SELECT currency FROM expense_metadata WHERE id = $1`, [res.metadataId]
);
expect(meta_!.currency).toBe("LKR");
});
it("a foreign-currency order records the original amount and code", async () => {
const p = parseOrderHTML(
html("ue-26"),
meta({ subject: "Your Tuesday evening order with Uber Eats", sender: "uber.com", receivedAt: "2026-04-07T08:53:00Z" })
);
// Card-settled Swiss order: no credits leg, so no transaction (I5).
const res = await processOrderIngestion(p);
expect(res.transactionId).toBeNull();
const meta_ = await queryRow<{ currency: string }>(
`SELECT currency FROM expense_metadata WHERE id = $1`,
[res.metadataId]
);
expect(meta_!.currency).toBe("CHF");
});
it("parks an unresolvable split instead of guessing, then resolves it once the statement lands", async () => {
const p = parseOrderHTML(
html("dd-10"),
meta({ subject: "Order Confirmation for Siddharth from Woolworths", receivedAt: "2026-03-02T12:00:00Z" })
);
const res = await processOrderIngestion(p);
expect(res.transactionId).toBeNull();
expect(res.flags).toContain("awaiting_card_statement");
// Statement arrives: card 8032 took 40.93 of the 60.93 order.
const st = await queryRow<{ id: number }>(
`INSERT INTO statements (bank_name, account_number, filename)
VALUES ('Westpac','5163103015778032','test-westpac-2026-03.pdf') RETURNING id`
);
await queryRaw(
`INSERT INTO transactions (statement_id, transaction_date, description, amount, transaction_type)
VALUES ($1, '2026-03-02', 'DD *DOORDASH WOOLWORTHS MELBOURNE AUS', 40.93, 'debit')`,
[st!.id]
);
const out = await reconcilePendingOrders();
// Deliberately not asserting global counts: reconcilePendingOrders() scans
// every pending row in the database, so another test's leftovers change the
// totals. Assert on THIS order's outcome instead — that is what the test is
// actually about, and it does not depend on what else is in the table.
expect(out.examined).toBeGreaterThanOrEqual(1);
const credits = await queryRow<{ amount: string }>(
`SELECT t.amount::text FROM transactions t
JOIN expense_metadata em ON em.transaction_id = t.id
WHERE em.id = $1`,
[res.metadataId]
);
expect(Number(credits!.amount)).toBeCloseTo(20.00, 2); // 60.93 - 40.93
});
it("reconciliation is idempotent — a second pass creates nothing", async () => {
// A first pass has already run above; a second must add nothing. Scoped to
// 'Order - %' rows so unrelated fixtures cannot move the number.
const q = `SELECT count(*)::text c FROM transactions WHERE description LIKE 'Order - %'`;
const before = await queryRow<{ c: string }>(q);
const out = await reconcilePendingOrders();
const after = await queryRow<{ c: string }>(q);
expect(out.created).toBe(0);
expect(after!.c).toBe(before!.c);
});
it("EXCLUDE_NON_SPEND removes family-tagged rows", async () => {
const txn = await queryRow<{ id: number }>(
`INSERT INTO transactions (transaction_date, description, amount, category, transaction_type)
VALUES ('2026-03-01','Order - Family Test', 50.00, 'dining', 'debit') RETURNING id`
);
const tag = await queryRow<{ id: number }>(
`INSERT INTO tags (name, color) VALUES ('family','#ef4444')
ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name RETURNING id`
);
await queryRaw(`INSERT INTO transaction_tags (transaction_id, tag_id) VALUES ($1,$2)`, [txn!.id, tag!.id]);
const visible = await queryRaw(
`SELECT t.id FROM transactions t
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
WHERE t.id = $1 AND (${EXCLUDE_NON_SPEND})`,
[txn!.id]
);
expect(visible).toHaveLength(0);
});
});
describe("Refund amendments", () => {
const ueMeta = (over = {}) => meta({
subject: "Your Tuesday evening order with Uber Eats",
sender: "uber.com",
receivedAt: "2026-07-07T08:17:00Z",
...over,
});
it("parses a refund notice into an amendment, not an order", () => {
const a = parseOrderAmendment(html("ue-05"), ueMeta());
expect(a.previous_total).toBeCloseTo(49.94, 2);
expect(a.refund_amount).toBeCloseTo(4.21, 2);
expect(a.new_total).toBeCloseTo(45.73, 2);
expect(a.order_reference).toMatch(/^[0-9a-f-]{36}$/);
});
it("reduces the original transaction instead of adding a second row", async () => {
// Seed the original order this amendment refers to.
const a = parseOrderAmendment(html("ue-05"), ueMeta());
const txn = await queryRow<{ id: number }>(
`INSERT INTO transactions (transaction_date, description, amount, amount_aud, category, payment_method, transaction_type)
VALUES ('2026-07-07','Order - Coles (Wyndham Vale)', 49.94, 49.94, 'groceries', 'credits', 'debit')
RETURNING id`
);
await queryRaw(
`INSERT INTO expense_metadata (transaction_id, source, order_reference, amount, transaction_date, flags)
VALUES ($1,'email',$2, 49.94, '2026-07-07', '[]'::jsonb)`,
[txn!.id, a.order_reference]
);
const before = await queryRow<{ c: string }>(`SELECT count(*)::text c FROM transactions`);
const res = await applyOrderAmendment(a);
const after = await queryRow<{ c: string }>(`SELECT count(*)::text c FROM transactions`);
expect(res.matched).toBe(true);
expect(after!.c).toBe(before!.c); // amended in place, no second row
const updated = await queryRow<{ amount: string }>(
`SELECT amount::text FROM transactions WHERE id = $1`,
[txn!.id]
);
expect(Number(updated!.amount)).toBeCloseTo(45.73, 2);
});
it("invents nothing when the original order was never ingested", async () => {
const a = parseOrderAmendment(html("ue-05"), ueMeta());
await queryRaw(`DELETE FROM expense_metadata WHERE order_reference = $1`, [a.order_reference]);
const res = await applyOrderAmendment(a);
expect(res.matched).toBe(false);
expect(res.transactionId).toBeNull();
});
});
describe("[Family] orders are card-settled, not credits", () => {
it("creates provenance but NO transaction — the statement line is the transaction", async () => {
// Regression: these were assumed credits-funded because the receipt names
// the payer and no instrument. The card statement carries all four (CBA
// ...3893, exact LKR matches), so creating a transaction double-counted
// spend already recorded.
const p = parseOrderHTML(
html("ue-04"),
meta({ subject: "[Family] Your Tuesday evening order with Uber Eats", sender: "uber.com", receivedAt: "2026-07-07T10:08:00Z" })
);
expect(p.flags).toContain("family_card_settled_no_transaction");
expect(p.payment.credits_amount).toBeNull();
const res = await processOrderIngestion(p);
expect(res.transactionId).toBeNull();
expect(res.metadataId).not.toBeNull();
});
});
describe("owner scoping", () => {
it("sets owner_id so the row is visible to the app", async () => {
// Regression: analytics scope on COALESCE(t.owner_id, s.owner_id). An
// ingested order has no statement, so a NULL owner_id made all 85
// backfilled rows invisible in every view while sitting in the table.
const p = parseOrderHTML(html("dd-01"), meta({ messageId: `owner-${Date.now()}` }));
const res = await processOrderIngestion(p);
const row = await queryRow<{ owner_id: number | null }>(
`SELECT owner_id FROM transactions WHERE id = $1`,
[res.transactionId]
);
expect(row!.owner_id).not.toBeNull();
const visible = await queryRaw(
`SELECT t.id FROM transactions t LEFT JOIN statements s ON s.id = t.statement_id
WHERE t.id = $1 AND COALESCE(t.owner_id, s.owner_id) = $2`,
[res.transactionId, row!.owner_id]
);
expect(visible).toHaveLength(1);
});
});
describe("how an ingested order presents in the app", () => {
it("names the restaurant, not the courier", async () => {
// Reversed 2026-07-28. The platform was in the headline on request, but it
// fragmented the merchant — the same restaurant read differently depending
// on who delivered — and it already has a home: the Order details panel
// renders expense_metadata.platform next to its heading.
const p = parseOrderHTML(html("dd-01"), meta({ messageId: `desc-${Date.now()}` }));
const res = await processOrderIngestion(p);
const row = await queryRow<{ description: string }>(
`SELECT description FROM transactions WHERE id = $1`, [res.transactionId]
);
expect(row!.description).toBe('Order - Mad Mex');
});
it("reads as 'Gift Card', not 'Manual', and stays out of the reconcile queue", async () => {
// bank_name is derived — no statement means "Manual", which reads as
// "hand-entered, awaiting a card line". A credits order has no card line
// coming, ever; 81 of them sat in the queue waiting for one.
const p = parseOrderHTML(html("dd-01"), meta({ messageId: `bank-${Date.now()}` }));
const res = await processOrderIngestion(p);
const row = await queryRow<{ bank_name: string }>(
`SELECT ${bankLabel()} as bank_name
FROM transactions t LEFT JOIN statements s ON s.id = t.statement_id
WHERE t.id = $1`,
[res.transactionId]
);
expect(row!.bank_name).toBe("Gift Card");
const queued = await queryRaw(
`SELECT t.id FROM transactions t
WHERE t.id = $1 AND t.statement_id IS NULL AND ${needsCardMatch("t")}`,
[res.transactionId]
);
expect(queued).toHaveLength(0);
});
it("records the platform and the message it came from", async () => {
const p = parseOrderHTML(
html("ue-00"),
meta({ messageId: `prov-${Date.now()}`, subject: "Your Wednesday order with Uber Eats", sender: "uber.com" })
);
const res = await processOrderIngestion(p, {
messageId: `prov-${Date.now()}`,
subject: "Your Wednesday order with Uber Eats",
sender: "uber.com",
});
const row = await queryRow<{
platform: string; source_email_from: string; route: { label: string }[];
}>(
`SELECT platform, source_email_from, route FROM expense_metadata WHERE id = $1`,
[res.metadataId]
);
expect(row!.platform).toBe("ubereats");
expect(row!.source_email_from).toBe("uber.com");
expect(row!.route.map((r) => r.label)).toEqual(["Pick-up", "Delivery"]);
});
});
describe("receipt lookup for the transaction detail panel", () => {
// Mirrors /api/transactions/[id]/order — the panel resolves a receipt from
// either side, and a card-settled order only has the matched_transaction_id
// side, which is exactly where the detail would otherwise go missing.
const receiptFor = (txnId: number) =>
queryRow<{ platform: string; route: { label: string; address: string }[] }>(
`SELECT platform, route FROM expense_metadata
WHERE transaction_id = $1 OR matched_transaction_id = $1 LIMIT 1`,
[txnId]
);
it("finds the receipt for a credits order", async () => {
// ue-00 is Uber Cash — credits, so it creates a transaction. ue-09 names a
// payer with no instrument and correctly parks awaiting a card statement,
// which would leave nothing to look the receipt up by.
const p = parseOrderHTML(
html("ue-00"),
meta({ messageId: `panel-${Date.now()}`, subject: "Your Wednesday order with Uber Eats", sender: "uber.com" })
);
const res = await processOrderIngestion(p);
const r = await receiptFor(res.transactionId!);
expect(r!.platform).toBe("ubereats");
expect(r!.route.map((x) => x.label)).toEqual(["Pick-up", "Delivery"]);
});
it("finds it from the statement line for a card-settled order", async () => {
const st = await queryRow<{ id: number }>(
`INSERT INTO statements (bank_name, account_number, filename)
VALUES ('CBA', '5523504401723893', 'panel-cba.pdf') RETURNING id`
);
const card = await queryRow<{ id: number }>(
`INSERT INTO transactions (statement_id, transaction_date, description, amount, transaction_type)
VALUES ($1, '2026-04-07', 'UBER *EATS ZURICH', 51.23, 'debit') RETURNING id`,
[st!.id]
);
const m = await queryRow<{ id: number }>(
`INSERT INTO expense_metadata (source, order_reference, platform, route, matched_transaction_id)
VALUES ('email', $1, 'ubereats', '[{"label":"Pick-up","time":null,"address":"Ebikon"}]'::jsonb, $2)
RETURNING id`,
[`panel-card-${Date.now()}`, card!.id]
);
expect(m).not.toBeNull();
const r = await receiptFor(card!.id);
expect(r!.platform).toBe("ubereats");
expect(r!.route[0].address).toBe("Ebikon");
});
it("returns nothing for an ordinary transaction", async () => {
const st = await queryRow<{ id: number }>(
`INSERT INTO statements (bank_name, account_number, filename)
VALUES ('CBA', '1111', 'panel-plain.pdf') RETURNING id`
);
const t = await queryRow<{ id: number }>(
`INSERT INTO transactions (statement_id, transaction_date, description, amount, transaction_type)
VALUES ($1, '2026-04-07', 'COLES 1234', 12.00, 'debit') RETURNING id`,
[st!.id]
);
expect(await receiptFor(t!.id)).toBeNull();
});
});
@@ -0,0 +1,267 @@
import { describe, it, expect, beforeAll, beforeEach, vi } from "vitest";
import type { NextRequest } from "next/server";
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);
/** What the review route returns, to the extent these tests read it. */
type ItemVerdict = { item: string; verdict: string };
type Review = {
participant_id: number;
rating: string | null;
note: string | null;
order_again: boolean | null;
item_verdicts: ItemVerdict[];
};
type ReviewBody = {
reviews: Review[];
splits: { participant_id: number; share_percent: number }[];
merchant: {
warn: boolean;
note: string | null;
counts: Record<string, number>;
history: { transaction_id: number }[];
items: { item: string; loved: number; never: number }[];
} | null;
};
type Ctx = { params: Promise<{ id: string }> };
// Declared rather than inferred because the route is imported dynamically,
// after vi.doMock. `unknown` for the request keeps the fixture honest — it is
// the two properties the handler reads, not a NextRequest.
let GET: (req: NextRequest, ctx: Ctx) => Promise<Response>;
let PUT: (req: NextRequest, ctx: Ctx) => Promise<Response>;
let ownerId: number;
let otherId: number;
let txnId: number;
const req = (body?: unknown) =>
({ headers: { get: () => null }, json: async () => body }) as unknown as NextRequest;
const params = (id: number): Ctx => ({ params: Promise.resolve({ id: String(id) }) });
const bodyOf = async (res: Response): Promise<ReviewBody> => (await res.json()) as ReviewBody;
beforeAll(async () => {
vi.doMock("@/lib/auth", () => ({
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 bodyOf(res);
// The bug this guards: a UNIQUE on transaction_id alone made the second
// save overwrite the first, and the disagreement is the useful part.
expect(body.reviews).toHaveLength(2);
expect(body.reviews.find((r) => r.participant_id === ownerId)?.rating).toBe("loved");
expect(body.reviews.find((r) => r.participant_id === otherId)?.rating).toBe("never");
});
it("defaults the verdict to the signed-in user, not the owner", async () => {
const res = await PUT(req({ rating: "ok" }), params(txnId));
const body = await bodyOf(res);
expect(body.reviews[0].participant_id).toBe(ownerId);
});
it("revising a verdict updates rather than duplicating", async () => {
await PUT(req({ participant_id: ownerId, rating: "loved" }), params(txnId));
const res = await PUT(req({ participant_id: ownerId, rating: "never" }), params(txnId));
const body = await bodyOf(res);
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 bodyOf(await PUT(req({ rating: "never" }), params(txnId)));
expect(body.reviews[0].order_again).toBe(false);
body = await bodyOf(await PUT(req({ rating: "ok" }), params(txnId)));
expect(body.reviews[0].order_again).toBe(true);
// "bad" is also a no — you would not choose it again.
body = await bodyOf(await PUT(req({ rating: "bad" }), params(txnId)));
expect(body.reviews[0].order_again).toBe(false);
});
it("only `never` raises the warning, not `bad`", async () => {
// The boundary the five-level scale exists for. A blacklist that fires for
// every mediocre meal is one nobody reads, so "bad" records the
// disappointment without triggering the alarm.
const older = await seedOrder("Thai Palace", "2026-06-01");
await PUT(req({ participant_id: ownerId, rating: "bad" }), params(older));
let body = await bodyOf(await GET(req(), params(txnId)));
expect(body.merchant!.counts.bad).toBe(1);
expect(body.merchant!.warn).toBe(false);
await PUT(req({ participant_id: ownerId, rating: "never" }), params(older));
body = await bodyOf(await GET(req(), params(txnId)));
expect(body.merchant!.warn).toBe(true);
});
});
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 bodyOf(res);
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 bodyOf(res);
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 bodyOf(res);
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 bodyOf(await GET(req(), params(txnId)));
expect(body.merchant!.warn).toBe(true);
expect(body.merchant!.counts.never).toBe(1);
expect(body.merchant!.history.map((h) => h.transaction_id)).toEqual([older]);
});
it("treats the same restaurant as one merchant across platforms", async () => {
// DoorDash and Uber Eats capitalise differently — "TEG Kebabs & Biryani"
// vs "TEG KEBABS & BIRYANI". An exact match split one restaurant's history
// in two, so a "never again" recorded through one app never warned in the
// other, silently defeating the point of the memory.
const shouty = await seedOrder("THAI PALACE", "2026-06-01");
await PUT(req({ participant_id: ownerId, rating: "never" }), params(shouty));
const body = await bodyOf(await GET(req(), params(txnId)));
expect(body.merchant!.warn).toBe(true);
expect(body.merchant!.history.map((h) => h.transaction_id)).toContain(shouty);
});
it("does not carry a verdict across different merchants", async () => {
const other = await seedOrder("Pizza Place", "2026-06-01");
await PUT(req({ participant_id: ownerId, rating: "never" }), params(other));
const body = await bodyOf(await GET(req(), params(txnId)));
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 bodyOf(await GET(req(), params(txnId)));
// Case-folded: the same dish comes back capitalised differently between
// receipts, and two entries for one dish is not a track record.
expect(body.merchant!.items).toEqual([{ item: "Pad Thai", loved: 2, never: 0 }]);
});
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 bodyOf(await GET(req(), params(txnId)));
expect(body.merchant!.items).toEqual([{ item: "Satay", loved: 0, never: 1 }]);
});
});
describe("share state", () => {
it("reports splits so the panel can show shared vs just me", async () => {
let body = await bodyOf(await GET(req(), params(txnId)));
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 bodyOf(await GET(req(), params(txnId)));
expect(body.splits).toHaveLength(1);
expect(body.splits[0].participant_id).toBe(otherId);
});
});
@@ -0,0 +1,63 @@
import { describe, it, expect } from "vitest";
import { queryRaw, queryRow } from "../../lib/db";
import { needsCardMatch } from "../../lib/queries";
import {
PAYMENT_METHODS,
NON_RECONCILING,
reconcilesAgainstStatement,
} from "../../lib/payment-methods";
/**
* These two lists drifted, and the drift was silent for two months.
*
* Migration 0018 admitted 'credits' to `transactions_payment_method_check` so
* the order pipeline could file gift-card-funded orders. Nothing propagated it
* to the hand-written allowlist in `PATCH /api/transactions/[id]` or to the
* add-transaction dropdown, so the database held 372 rows carrying a value the
* UI could not enter and the API refused to set.
*
* The failure mode is invisible on screen — a gift-card purchase entered by
* hand takes the NULL default, and NULL is *reconcilable*, so it joins the
* pending queue and is offered card matches that cannot exist. Accepting one is
* destructive: reconciled manual rows are filtered out of every query, so the
* spend vanishes while an unrelated card row claims to be it.
*
* So: assert against the database, not against a second copy of the list.
*/
describe("payment methods mirror the database", () => {
it("PAYMENT_METHODS equals the CHECK constraint's vocabulary", async () => {
const row = await queryRow<{ def: string }>(
`SELECT pg_get_constraintdef(oid) AS def FROM pg_constraint
WHERE conname = 'transactions_payment_method_check'`
);
expect(row, "constraint transactions_payment_method_check is missing").not.toBeNull();
const inConstraint = [...row!.def.matchAll(/'([a-z_]+)'::text/g)].map((m) => m[1]);
expect(inConstraint.length).toBeGreaterThan(0); // guard against the regex silently matching nothing
expect([...inConstraint].sort()).toEqual([...PAYMENT_METHODS].sort());
});
it("reconcilesAgainstStatement() agrees with needsCardMatch() for every method", async () => {
// Evaluate the real SQL predicate rather than re-implementing it: this is
// the TypeScript mirror, so the SQL is the thing being mirrored.
const rows = await queryRaw<{ pm: string | null; sql_reconciles: boolean }>(
`SELECT v.payment_method AS pm, ${needsCardMatch("v")} AS sql_reconciles
FROM (SELECT unnest($1::text[]) AS payment_method) v`,
[[...PAYMENT_METHODS, null]]
);
expect(rows).toHaveLength(PAYMENT_METHODS.length + 1);
for (const { pm, sql_reconciles } of rows) {
expect(reconcilesAgainstStatement(pm), `payment_method = ${pm ?? "NULL"}`)
.toBe(sql_reconciles);
}
});
it("holds cash and credits out of the queue, and nothing else", () => {
expect([...NON_RECONCILING].sort()).toEqual(["cash", "credits"]);
// NULL means *unknown*, never "no card leg is coming" — it must stay a
// candidate or every pre-existing row would drop out of reconciliation.
expect(reconcilesAgainstStatement(null)).toBe(true);
expect(reconcilesAgainstStatement(undefined)).toBe(true);
});
});
+961 -1
View File
@@ -8,7 +8,10 @@ mockDbWithPool(pool);
// Dynamic import AFTER the mock ensures getTransactions / getParticipantBalances // Dynamic import AFTER the mock ensures getTransactions / getParticipantBalances
// use the test pool rather than Prisma's singleton. // use the test pool rather than Prisma's singleton.
const { getTransactions, getParticipantBalances } = await import("@/lib/queries"); const {
getTransactions, getParticipantBalances, getTripAnalytics, getTripById, getStatements,
getTrips, isTripParticipant, assignTransactionsToTrip, deleteTrip,
} = await import("@/lib/queries");
beforeEach(async () => { beforeEach(async () => {
await resetDB(pool); 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", () => { describe("getTransactions — search filter", () => {
it("searches description case-insensitively", async () => { it("searches description case-insensitively", async () => {
const { ownerId } = await seedParticipants(pool); const { ownerId } = await seedParticipants(pool);
@@ -253,3 +313,903 @@ describe("getParticipantBalances", () => {
expect(bobBalance!.unsettled_count).toBe(2); expect(bobBalance!.unsettled_count).toBe(2);
}); });
}); });
describe("getTransactions — order provenance for the description sub-line", () => {
it("carries the route and platform of an order-derived row", async () => {
// Five rows all reading "Order - Uber Trip" are indistinguishable in the
// list; where the trip went is the only thing that separates them, and it
// was already stored.
const { ownerId } = await seedParticipants(pool);
const txId = await insertTransaction(pool, ownerId, { description: "Order - Uber Trip" });
await pool.query(
`INSERT INTO expense_metadata (source, order_reference, platform, route, transaction_id)
VALUES ('email', $1, 'uber',
'[{"label":"Pick-up","time":"7:32 pm","address":"Terminal 2, Melbourne Airport (MEL), Tullamarine VIC 3045, Australia"},
{"label":"Drop-off","time":"8:10 pm","address":"19 Lady Penrhyn Dr, Wyndham Vale VIC 3024, Australia"}]'::jsonb,
$2)`,
[`route-${Date.now()}`, txId]
);
const { data } = await getTransactions(ownerId, { limit: 50, offset: 0 });
const row = data.find((r) => r.id === txId)!;
expect(row.order_platform).toBe("uber");
expect(row.order_route).toHaveLength(2);
expect(row.order_route![0].address).toContain("Melbourne Airport");
});
it("resolves from the statement line for a card-settled order", async () => {
// A card-settled order creates no transaction of its own (I5) — the
// receipt points at the statement line through matched_transaction_id.
const { ownerId } = await seedParticipants(pool);
const txId = await insertTransaction(pool, ownerId, { description: "UBER *TRIP AUCKLAND" });
await pool.query(
`INSERT INTO expense_metadata (source, order_reference, platform, route, matched_transaction_id)
VALUES ('email', $1, 'uber',
'[{"label":"Pick-up","time":null,"address":"64 Federal Street, Auckland 1010, NZ"},
{"label":"Drop-off","time":null,"address":"International Terminal, Auckland 2022, New Zealand"}]'::jsonb,
$2)`,
[`route-card-${Date.now()}`, txId]
);
const { data } = await getTransactions(ownerId, { limit: 50, offset: 0 });
const row = data.find((r) => r.id === txId)!;
expect(row.order_route).toHaveLength(2);
});
it("leaves an ordinary transaction with no route", async () => {
const { ownerId } = await seedParticipants(pool);
const txId = await insertTransaction(pool, ownerId, { description: "COLES 1234" });
const { data } = await getTransactions(ownerId, { limit: 50, offset: 0 });
const row = data.find((r) => r.id === txId)!;
expect(row.order_route).toBeNull();
expect(row.order_platform).toBeNull();
});
});
// ── settlement scope: settled + split_payments.trip_id (migration 0022) ───────
describe("getParticipantBalances — settled", () => {
it("excludes a settled split from what is owed", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const txId = await insertTransaction(pool, ownerId, { amount: 100 });
await pool.query(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent, settled)
VALUES ($1, $2, 50, true)`,
[txId, otherId]
);
const balances = await getParticipantBalances(ownerId);
const bob = balances.find((b) => b.id === otherId);
expect(Number(bob!.total_owed)).toBeCloseTo(0);
});
it("still counts an unsettled split alongside a settled one", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const settledTx = await insertTransaction(pool, ownerId, { amount: 100 });
const liveTx = await insertTransaction(pool, ownerId, { amount: 40 });
await pool.query(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent, settled)
VALUES ($1, $2, 50, true), ($3, $2, 50, false)`,
[settledTx, otherId, liveTx]
);
const balances = await getParticipantBalances(ownerId);
const bob = balances.find((b) => b.id === otherId);
// Only the live split counts: 50% of 40.
expect(Number(bob!.total_owed)).toBeCloseTo(20);
});
});
describe("getTripAnalytics — per-trip settlement", () => {
async function seedTrip(ownerId: number, otherId: number) {
const trip = await pool.query(
`INSERT INTO trips (owner_id, name, start_date, end_date)
VALUES ($1, 'Test Trip', '2026-03-01', '2026-03-10') RETURNING id`,
[ownerId]
);
const tripId = trip.rows[0].id as number;
const txId = await insertTransaction(pool, ownerId, { amount: 200, category: "travel" });
await pool.query(
`INSERT INTO transaction_overrides (transaction_id, trip_id) VALUES ($1, $2)`,
[txId, tripId]
);
await pool.query(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
VALUES ($1, $2, 50)`,
[txId, otherId]
);
return tripId;
}
it("reports the gross share before any payment", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const tripId = await seedTrip(ownerId, otherId);
const { participant_splits } = await getTripAnalytics(tripId, ownerId);
const bob = participant_splits.find((r) => r.participant_id === otherId);
expect(Number(bob!.owed)).toBeCloseTo(100);
});
it("nets off a payment scoped to that trip", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const tripId = await seedTrip(ownerId, otherId);
await pool.query(
`INSERT INTO split_payments (from_participant_id, to_participant_id, amount, payment_date, trip_id)
VALUES ($1, $2, 60, '2026-03-15', $3)`,
[otherId, ownerId, tripId]
);
const { participant_splits } = await getTripAnalytics(tripId, ownerId);
const bob = participant_splits.find((r) => r.participant_id === otherId);
expect(Number(bob!.owed)).toBeCloseTo(40);
});
// The point of the whole scope column: settling the household tab must not
// make a trip look paid. Before trip_id existed there was one global pool and
// this distinction could not be expressed.
it("ignores a household payment when reporting the trip", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const tripId = await seedTrip(ownerId, otherId);
await pool.query(
`INSERT INTO split_payments (from_participant_id, to_participant_id, amount, payment_date, trip_id)
VALUES ($1, $2, 60, '2026-03-15', NULL)`,
[otherId, ownerId]
);
const { participant_splits } = await getTripAnalytics(tripId, ownerId);
const bob = participant_splits.find((r) => r.participant_id === otherId);
expect(Number(bob!.owed)).toBeCloseTo(100);
});
it("drops a settled split from the trip figure", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const tripId = await seedTrip(ownerId, otherId);
await pool.query(`UPDATE transaction_splits SET settled = true WHERE participant_id = $1`, [otherId]);
const { participant_splits } = await getTripAnalytics(tripId, ownerId);
const bob = participant_splits.find((r) => r.participant_id === otherId);
expect(bob === undefined || Number(bob.owed) === 0).toBe(true);
});
});
describe("getTripAnalytics — owner scoping", () => {
it("ignores a trip expense someone else paid for", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const trip = await pool.query(
`INSERT INTO trips (owner_id, name) VALUES ($1, 'Owner Scope Trip') RETURNING id`,
[ownerId]
);
const tripId = trip.rows[0].id as number;
// Bob paid this one. Alice's share of it is a debt Alice owes Bob — it is
// not something Bob owes Alice, so it must not appear on Alice's trip view.
const bobPaid = await insertTransaction(pool, otherId, { amount: 500, category: "travel" });
await pool.query(`INSERT INTO transaction_overrides (transaction_id, trip_id) VALUES ($1, $2)`, [bobPaid, tripId]);
await pool.query(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
VALUES ($1, $2, 50), ($1, $3, 50)`,
[bobPaid, ownerId, otherId]
);
const { participant_splits } = await getTripAnalytics(tripId, ownerId);
const bob = participant_splits.find((r) => r.participant_id === otherId);
expect(bob === undefined || Number(bob.owed) === 0).toBe(true);
});
it("ignores a payment settled between the other two participants", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const third = await pool.query(
`INSERT INTO participants (name, email) VALUES ('Carol', 'carol@example.com') RETURNING id`
);
const carolId = third.rows[0].id as number;
const trip = await pool.query(
`INSERT INTO trips (owner_id, name) VALUES ($1, 'Third Party Trip') RETURNING id`,
[ownerId]
);
const tripId = trip.rows[0].id as number;
const txId = await insertTransaction(pool, ownerId, { amount: 300, category: "travel" });
await pool.query(`INSERT INTO transaction_overrides (transaction_id, trip_id) VALUES ($1, $2)`, [txId, tripId]);
await pool.query(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent) VALUES ($1, $2, 50)`,
[txId, carolId]
);
// Carol pays Bob, not the owner. Carol still owes the owner $150.
await pool.query(
`INSERT INTO split_payments (from_participant_id, to_participant_id, amount, payment_date, trip_id)
VALUES ($1, $2, 150, '2026-03-15', $3)`,
[carolId, otherId, tripId]
);
const { participant_splits } = await getTripAnalytics(tripId, ownerId);
const carol = participant_splits.find((r) => r.participant_id === carolId);
expect(Number(carol!.owed)).toBeCloseTo(150);
});
});
// A refunded trip expense must not still read as trip cost. These queries
// filtered on debit/fee/interest, so a refund was dropped entirely and the
// original purchase stood at full value.
describe("getTripAnalytics — refunds reduce trip cost", () => {
async function seedTripWithRefund(ownerId: number) {
const trip = await pool.query(
`INSERT INTO trips (owner_id, name, start_date, end_date)
VALUES ($1, 'Refund Trip', '2026-03-01', '2026-03-10') RETURNING id`,
[ownerId]
);
const tripId = trip.rows[0].id as number;
const spend = await insertTransaction(pool, ownerId, {
amount: 200, category: "travel", description: "Hotel booking", transaction_date: "2026-03-02",
});
const refund = await insertTransaction(pool, ownerId, {
amount: 50, category: "travel", description: "Hotel partial refund",
transaction_type: "refund", transaction_date: "2026-03-05",
});
await pool.query(
`INSERT INTO transaction_overrides (transaction_id, trip_id) VALUES ($1, $3), ($2, $3)`,
[spend, refund, tripId]
);
return tripId;
}
it("nets the refund out of the category total", async () => {
const { ownerId } = await seedParticipants(pool);
const tripId = await seedTripWithRefund(ownerId);
const { category_breakdown } = await getTripAnalytics(tripId, ownerId);
const travel = category_breakdown.find((c) => c.category === "travel");
expect(Number(travel!.amount)).toBeCloseTo(150);
});
it("nets the refund out of the trip's headline total_spend", async () => {
const { ownerId } = await seedParticipants(pool);
const tripId = await seedTripWithRefund(ownerId);
const trip = await getTripById(tripId, ownerId);
expect(Number(trip!.total_spend)).toBeCloseTo(150);
});
it("shows the refund as a negative on its own day", async () => {
const { ownerId } = await seedParticipants(pool);
const tripId = await seedTripWithRefund(ownerId);
const { daily_spend } = await getTripAnalytics(tripId, ownerId);
const refundDay = daily_spend.find((d) => d.date === "2026-03-05");
expect(Number(refundDay!.amount)).toBeCloseTo(-50);
});
// The owed side must be untouched: a refund carries no split, and the owed
// query deliberately excludes credits. Netting cost must not move a balance.
it("leaves what the other participant owes unchanged", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const tripId = await seedTripWithRefund(ownerId);
const rows = await pool.query(
`SELECT transaction_id FROM transaction_overrides WHERE trip_id = $1 ORDER BY transaction_id`,
[tripId]
);
await pool.query(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent) VALUES ($1, $2, 50)`,
[rows.rows[0].transaction_id, otherId]
);
const { participant_splits } = await getTripAnalytics(tripId, ownerId);
const bob = participant_splits.find((r) => r.participant_id === otherId);
expect(Number(bob!.owed)).toBeCloseTo(100);
});
});
// An account cannot be billed twice for the same day. The boundary handling is
// the whole difficulty: these statements are issued back-to-back with one
// period ending the day the next begins, so naive inclusive ranges flag every
// consecutive pair.
describe("getStatements — overlapping billing periods", () => {
async function addStatement(
ownerId: number, account: string, start: string | null, end: string | null
): Promise<number> {
const r = await pool.query(
`INSERT INTO statements (filename, bank_name, account_number, owner_id,
billing_start_date, billing_end_date)
VALUES ($1, 'ANZ', $2, $3, $4, $5) RETURNING id`,
[`stmt-${account}-${start}.pdf`, account, ownerId, start, end]
);
return r.rows[0].id as number;
}
it("does not flag statements that merely touch at a boundary", async () => {
const { ownerId } = await seedParticipants(pool);
await addStatement(ownerId, "4085-56264", "2025-05-16", "2025-11-14");
await addStatement(ownerId, "4085-56264", "2025-11-14", "2026-05-15");
const rows = await getStatements(ownerId);
expect(rows.every((r) => r.overlaps.length === 0)).toBe(true);
});
it("flags a genuine overlap on both statements, with the day count", async () => {
const { ownerId } = await seedParticipants(pool);
const a = await addStatement(ownerId, "4085-56264", "2025-11-12", "2026-03-12");
const b = await addStatement(ownerId, "4085-56264", "2025-11-14", "2026-05-15");
const rows = await getStatements(ownerId);
const rowA = rows.find((r) => r.id === a)!;
const rowB = rows.find((r) => r.id === b)!;
expect(rowA.overlaps).toEqual([{ id: b, days: 118 }]);
expect(rowB.overlaps).toEqual([{ id: a, days: 118 }]);
});
// The real duplicate got in because the existing key compared raw text and
// ANZ wrote the same account both ways.
it("matches the same account written with and without punctuation", async () => {
const { ownerId } = await seedParticipants(pool);
const a = await addStatement(ownerId, "408556264", "2025-11-12", "2026-03-12");
const b = await addStatement(ownerId, "4085-56264", "2025-11-14", "2026-05-15");
const rows = await getStatements(ownerId);
expect(rows.find((r) => r.id === a)!.overlaps).toEqual([{ id: b, days: 118 }]);
});
it("ignores a different account billing the same days", async () => {
const { ownerId } = await seedParticipants(pool);
await addStatement(ownerId, "4085-56264", "2025-11-12", "2026-03-12");
await addStatement(ownerId, "9999-11111", "2025-11-12", "2026-03-12");
const rows = await getStatements(ownerId);
expect(rows.every((r) => r.overlaps.length === 0)).toBe(true);
});
// NULL is unbounded to daterange, which would make an undated statement
// overlap the entire history.
it("does not treat an undated statement as overlapping everything", async () => {
const { ownerId } = await seedParticipants(pool);
await addStatement(ownerId, "4085-56264", "2025-11-12", "2026-03-12");
await addStatement(ownerId, "4085-56264", null, null);
const rows = await getStatements(ownerId);
expect(rows.every((r) => r.overlaps.length === 0)).toBe(true);
});
});
// A statement imported twice puts every transaction in the overlap in the
// ledger twice. The duplicate is superseded rather than deleted, because every
// child of `transactions` cascades on delete.
describe("superseded duplicates are excluded but kept", () => {
it("hides a superseded row from the transaction list", async () => {
const { ownerId } = await seedParticipants(pool);
const keep = await insertTransaction(pool, ownerId, { description: "RAIZ INVESTMENT", amount: 1500 });
const dup = await insertTransaction(pool, ownerId, { description: "RAIZ INVESTMENT", amount: 1500 });
await pool.query(`UPDATE transactions SET superseded_by_id = $1 WHERE id = $2`, [keep, dup]);
const { data, total } = await getTransactions(ownerId, { limit: 50, offset: 0 });
expect(total).toBe(1);
expect(data.map((t) => t.id)).toEqual([keep]);
});
it("keeps the superseded row and its children in the database", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const keep = await insertTransaction(pool, ownerId, { amount: 100 });
const dup = await insertTransaction(pool, ownerId, { amount: 100 });
await pool.query(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent) VALUES ($1, $2, 50)`,
[dup, otherId]
);
await pool.query(`UPDATE transactions SET superseded_by_id = $1 WHERE id = $2`, [keep, dup]);
const rows = await pool.query(`SELECT superseded_by_id FROM transactions WHERE id = $1`, [dup]);
expect(rows.rows[0].superseded_by_id).toBe(keep);
const kids = await pool.query(`SELECT count(*)::int AS n FROM transaction_splits WHERE transaction_id = $1`, [dup]);
expect(kids.rows[0].n).toBe(1);
});
// The point of excluding it: a split on a duplicate must not be owed twice.
it("does not count a superseded row towards what someone owes", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const keep = await insertTransaction(pool, ownerId, { amount: 100 });
const dup = await insertTransaction(pool, ownerId, { amount: 100 });
for (const id of [keep, dup]) {
await pool.query(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent) VALUES ($1, $2, 50)`,
[id, otherId]
);
}
await pool.query(`UPDATE transactions SET superseded_by_id = $1 WHERE id = $2`, [keep, dup]);
const balances = await getParticipantBalances(ownerId);
const bob = balances.find((b) => b.id === otherId);
expect(Number(bob!.total_owed)).toBeCloseTo(50);
});
it("refuses to let a row supersede itself", async () => {
const { ownerId } = await seedParticipants(pool);
const id = await insertTransaction(pool, ownerId);
await expect(
pool.query(`UPDATE transactions SET superseded_by_id = $1 WHERE id = $1`, [id])
).rejects.toThrow();
});
});
// Nothing before the cutover can be owed: carryover transaction 2348 already
// carries the entire pre-cutover balance as one figure. Splits on older
// transactions exist to describe how an expense was shared -- which keeps it
// out of spend -- without asserting a debt.
describe("the split cutover gates every balance", () => {
it("ignores a split on a transaction before the cutover", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const txId = await insertTransaction(pool, ownerId, {
amount: 100, transaction_date: "2026-01-08",
});
await pool.query(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent) VALUES ($1, $2, 50)`,
[txId, otherId]
);
const balances = await getParticipantBalances(ownerId);
const bob = balances.find((b) => b.id === otherId);
expect(Number(bob?.total_owed ?? 0)).toBeCloseTo(0);
});
// Inclusive: transaction 2348, which carries the whole pre-cutover balance,
// is itself dated 2026-01-09. An exclusive bound would drop it.
it("counts a split dated exactly on the cutover", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const txId = await insertTransaction(pool, ownerId, {
amount: 100, transaction_date: "2026-01-09",
});
await pool.query(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent) VALUES ($1, $2, 50)`,
[txId, otherId]
);
const balances = await getParticipantBalances(ownerId);
const bob = balances.find((b) => b.id === otherId);
expect(Number(bob!.total_owed)).toBeCloseTo(50);
});
// The point of the date guard: it does not depend on `settled` surviving.
it("still ignores a pre-cutover split whose settled flag was lost", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const txId = await insertTransaction(pool, ownerId, {
amount: 200, transaction_date: "2025-06-01",
});
await pool.query(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent, settled)
VALUES ($1, $2, 50, false)`,
[txId, otherId]
);
const balances = await getParticipantBalances(ownerId);
const bob = balances.find((b) => b.id === otherId);
expect(Number(bob?.total_owed ?? 0)).toBeCloseTo(0);
});
});
// ── Trip participation ────────────────────────────────────────────────────────
//
// Trips were scoped to `trips.owner_id`, so a co-traveller saw nothing: Sonu
// could not open a single trip despite paying for 104 of the tagged rows
// herself. Participation is DERIVED from the expenses rather than stored as a
// membership list, because a trip is all the expenses on one trip — and two
// records of one fact drift apart.
describe("trip participation — visibility", () => {
/** A trip owned by `ownerId` with one row `ownerId` paid for. */
async function tripWithOwnerRow(ownerId: number, name = "Owned Trip") {
const trip = await pool.query(
`INSERT INTO trips (owner_id, name) VALUES ($1, $2) RETURNING id`,
[ownerId, name]
);
const tripId = trip.rows[0].id as number;
const txId = await insertTransaction(pool, ownerId, { amount: 200, category: "travel" });
await pool.query(
`INSERT INTO transaction_overrides (transaction_id, trip_id) VALUES ($1, $2)`,
[txId, tripId]
);
return { tripId, txId };
}
it("shows a trip to its owner", async () => {
const { ownerId } = await seedParticipants(pool);
const { tripId } = await tripWithOwnerRow(ownerId);
const trips = await getTrips(ownerId);
expect(trips.map((t) => t.id)).toContain(tripId);
});
it("shows a trip to someone holding a split on one of its rows", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const { tripId, txId } = await tripWithOwnerRow(ownerId);
await pool.query(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
VALUES ($1, $2, 50)`,
[txId, otherId]
);
const trips = await getTrips(otherId);
expect(trips.map((t) => t.id)).toContain(tripId);
expect(await isTripParticipant(tripId, otherId)).toBe(true);
});
it("shows a trip to someone who paid for one of its rows but holds no split", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const { tripId } = await tripWithOwnerRow(ownerId);
const theirTx = await insertTransaction(pool, otherId, { amount: 80, category: "travel" });
await pool.query(
`INSERT INTO transaction_overrides (transaction_id, trip_id) VALUES ($1, $2)`,
[theirTx, tripId]
);
expect((await getTrips(otherId)).map((t) => t.id)).toContain(tripId);
});
it("shows a trip to someone whose payment is scoped to it", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const { tripId } = await tripWithOwnerRow(ownerId);
await pool.query(
`INSERT INTO split_payments (from_participant_id, to_participant_id, amount, payment_date, trip_id)
VALUES ($1, $2, 50, '2026-06-20', $3)`,
[otherId, ownerId, tripId]
);
expect((await getTrips(otherId)).map((t) => t.id)).toContain(tripId);
});
// The Singapore + Bangkok 2026 case. A trip nobody else took must not appear
// just because trips became shareable — this is the whole reason
// participation is derived from the expenses rather than granted.
it("HIDES a trip from someone with no split, no row and no payment on it", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const { tripId } = await tripWithOwnerRow(ownerId, "Solo Trip");
expect((await getTrips(otherId)).map((t) => t.id)).not.toContain(tripId);
expect(await isTripParticipant(tripId, otherId)).toBe(false);
expect(await getTripById(tripId, otherId)).toBeNull();
});
});
describe("trip owed — both directions, never netted", () => {
/** `payerId` paid a $200 travel row on the trip; `splitId` holds 50% of it. */
async function seed(payerId: number, splitId: number, ownerId: number) {
const trip = await pool.query(
`INSERT INTO trips (owner_id, name) VALUES ($1, 'Pair Trip') RETURNING id`,
[ownerId]
);
const tripId = trip.rows[0].id as number;
const txId = await insertTransaction(pool, payerId, { amount: 200, category: "travel" });
await pool.query(
`INSERT INTO transaction_overrides (transaction_id, trip_id) VALUES ($1, $2)`,
[txId, tripId]
);
await pool.query(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
VALUES ($1, $2, 50)`,
[txId, splitId]
);
return tripId;
}
it("the payer sees it as owed to them, with nothing on the mirror", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const tripId = await seed(ownerId, otherId, ownerId);
const { participant_splits } = await getTripAnalytics(tripId, ownerId);
const bob = participant_splits.find((r) => r.participant_id === otherId)!;
expect(Number(bob.owed)).toBeCloseTo(100);
expect(Number(bob.i_owe)).toBeCloseTo(0);
expect(Number(bob.i_owe_gross)).toBeCloseTo(0);
});
// The figure that could not exist before. An obligation lives on a row someone
// ELSE paid for, so a viewer-as-payer query can never contain it — which is
// why Sonu's Europe page read "you are owed $2,408.24" while omitting the
// $8,004.04 she owed.
it("the split holder sees the same figure as owed BY them", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const tripId = await seed(ownerId, otherId, ownerId);
const { participant_splits } = await getTripAnalytics(tripId, otherId);
const alice = participant_splits.find((r) => r.participant_id === ownerId)!;
expect(Number(alice.i_owe)).toBeCloseTo(100);
expect(Number(alice.owed)).toBeCloseTo(0);
});
// Europe 2026: paid in full, so the net is zero but the gross is not — the UI
// needs both to say "settled" rather than a bare "0.00".
it("keeps gross and paid alongside the net so a paid-up trip reads as settled", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const tripId = await seed(ownerId, otherId, ownerId);
await pool.query(
`INSERT INTO split_payments (from_participant_id, to_participant_id, amount, payment_date, trip_id)
VALUES ($1, $2, 100, '2026-06-20', $3)`,
[otherId, ownerId, tripId]
);
const asPayer = await getTripAnalytics(tripId, ownerId);
const bob = asPayer.participant_splits.find((r) => r.participant_id === otherId)!;
expect(Number(bob.owed)).toBeCloseTo(0);
expect(Number(bob.owed_gross)).toBeCloseTo(100);
expect(Number(bob.paid_to_me)).toBeCloseTo(100);
const asDebtor = await getTripAnalytics(tripId, otherId);
const alice = asDebtor.participant_splits.find((r) => r.participant_id === ownerId)!;
expect(Number(alice.i_owe)).toBeCloseTo(0);
expect(Number(alice.i_owe_gross)).toBeCloseTo(100);
expect(Number(alice.paid_by_me)).toBeCloseTo(100);
});
// The API returns both halves whole; the trip page nets them for display. The
// halves must stay separately available so that net is decomposable — a net
// nobody can audit is how a wrong figure survives, and it is what let Europe
// read "settled" while concealing 56 rows Sonu had paid.
it("returns each direction whole rather than pre-netted", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const tripId = await seed(ownerId, otherId, ownerId);
// A second row, paid the other way, so both directions are live at once.
const theirTx = await insertTransaction(pool, otherId, { amount: 60, category: "travel" });
await pool.query(
`INSERT INTO transaction_overrides (transaction_id, trip_id) VALUES ($1, $2)`,
[theirTx, tripId]
);
await pool.query(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
VALUES ($1, $2, 50)`,
[theirTx, ownerId]
);
const { participant_splits } = await getTripAnalytics(tripId, ownerId);
const bob = participant_splits.find((r) => r.participant_id === otherId)!;
expect(Number(bob.owed)).toBeCloseTo(100);
expect(Number(bob.i_owe)).toBeCloseTo(30);
// What the page displays as the single settle-up figure.
expect(Number(bob.owed) - Number(bob.i_owe)).toBeCloseTo(70);
});
// Europe 2026's shape exactly, and the case the UI must NOT call a debt.
//
// A payment is allocated to a trip as a lump sum covering the payer's GROSS
// share, so netting the other side off leaves the trip negative by whatever
// the payment over-covered. That surplus is carried in the overall balance,
// not owed to them — which is why the page distinguishes a negative net WITH a
// payment into the scope (over-covered) from one WITHOUT (genuinely owed).
// `paid_to_me` is what makes the two separable, so it must stay non-zero here.
it("goes negative by the over-covered amount when a payment clears the gross", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const tripId = await seed(ownerId, otherId, ownerId);
// Bob pays his $100 share in full, scoped to the trip.
await pool.query(
`INSERT INTO split_payments (from_participant_id, to_participant_id, amount, payment_date, trip_id)
VALUES ($1, $2, 100, '2026-06-20', $3)`,
[otherId, ownerId, tripId]
);
// But Alice holds a share of something Bob paid for, and never settled it.
const bobsTx = await insertTransaction(pool, otherId, { amount: 40, category: "travel" });
await pool.query(
`INSERT INTO transaction_overrides (transaction_id, trip_id) VALUES ($1, $2)`,
[bobsTx, tripId]
);
await pool.query(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
VALUES ($1, $2, 50)`,
[bobsTx, ownerId]
);
const { participant_splits } = await getTripAnalytics(tripId, ownerId);
const bob = participant_splits.find((r) => r.participant_id === otherId)!;
expect(Number(bob.owed)).toBeCloseTo(0); // his gross, fully paid
expect(Number(bob.i_owe)).toBeCloseTo(20); // her share of his spending
expect(Number(bob.owed) - Number(bob.i_owe)).toBeCloseTo(-20);
// The discriminator: he paid into this scope, so the -20 is over-coverage
// rather than a bill. Without paid_to_me the page cannot tell the two apart.
expect(Number(bob.paid_to_me)).toBeGreaterThan(0);
});
it("goes negative with no payment when the viewer's share simply exceeds theirs", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const trip = await pool.query(
`INSERT INTO trips (owner_id, name) VALUES ($1, 'Unpaid Trip') RETURNING id`,
[ownerId]
);
const tripId = trip.rows[0].id as number;
// Only one row, paid by Bob, with Alice holding half. Nobody has paid anyone.
const bobsTx = await insertTransaction(pool, otherId, { amount: 90, category: "travel" });
await pool.query(
`INSERT INTO transaction_overrides (transaction_id, trip_id) VALUES ($1, $2)`,
[bobsTx, tripId]
);
await pool.query(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
VALUES ($1, $2, 50)`,
[bobsTx, ownerId]
);
const { participant_splits } = await getTripAnalytics(tripId, ownerId);
const bob = participant_splits.find((r) => r.participant_id === otherId)!;
expect(Number(bob.owed) - Number(bob.i_owe)).toBeCloseTo(-45);
// No payment into the scope, so this negative IS Alice's to settle.
expect(Number(bob.paid_to_me)).toBeCloseTo(0);
});
it("reports whether the viewer owns the trip", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const tripId = await seed(ownerId, otherId, ownerId);
expect((await getTripAnalytics(tripId, ownerId)).viewer_is_owner).toBe(true);
expect((await getTripAnalytics(tripId, otherId)).viewer_is_owner).toBe(false);
});
});
describe("getTransactions — trip_all_rows", () => {
async function seedSharedTrip() {
const { ownerId, otherId } = await seedParticipants(pool);
const trip = await pool.query(
`INSERT INTO trips (owner_id, name) VALUES ($1, 'Shared Trip') RETURNING id`,
[ownerId]
);
const tripId = trip.rows[0].id as number;
// One row Bob holds a split on — this is what makes him a participant.
const shared = await insertTransaction(pool, ownerId, { description: "Shared hotel", category: "travel" });
// One row Bob has no stake in whatsoever.
const solo = await insertTransaction(pool, ownerId, { description: "Alice solo museum", category: "travel" });
await pool.query(
`INSERT INTO transaction_overrides (transaction_id, trip_id) VALUES ($1, $2), ($3, $2)`,
[shared, tripId, solo]
);
await pool.query(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
VALUES ($1, $2, 50)`,
[shared, otherId]
);
return { ownerId, otherId, tripId };
}
it("gives a participant every row on the trip", async () => {
const { otherId, tripId } = await seedSharedTrip();
const { data } = await getTransactions(otherId, {
trip_id: String(tripId), trip_all_rows: true, limit: 50, offset: 0,
});
expect(data.map((r) => r.description).sort()).toEqual(["Alice solo museum", "Shared hotel"]);
});
// The main transactions page filters by trip through this same endpoint. If
// the widening were implied by trip_id, filtering your own ledger by a trip
// would silently fill it with someone else's rows and skew its totals.
it("keeps owner scoping when the flag is absent", async () => {
const { otherId, tripId } = await seedSharedTrip();
const { data } = await getTransactions(otherId, {
trip_id: String(tripId), limit: 50, offset: 0,
});
expect(data.map((r) => r.description)).toEqual(["Shared hotel"]);
});
it("returns nothing to a non-participant who passes the flag", async () => {
const { ownerId } = await seedSharedTrip();
const stranger = await pool.query(
`INSERT INTO participants (name) VALUES ('Carol') RETURNING id`
);
const carolId = stranger.rows[0].id as number;
const trip = await pool.query(`SELECT id FROM trips LIMIT 1`);
const { data } = await getTransactions(carolId, {
trip_id: String(trip.rows[0].id), trip_all_rows: true, limit: 50, offset: 0,
});
expect(data).toHaveLength(0);
expect(ownerId).toBeGreaterThan(0);
});
it("does not widen anything when trip_id is 'unassigned'", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
await insertTransaction(pool, ownerId, { description: "Alice untripped" });
await insertTransaction(pool, otherId, { description: "Bob untripped" });
const { data } = await getTransactions(otherId, {
trip_id: "unassigned", trip_all_rows: true, limit: 50, offset: 0,
});
expect(data.map((r) => r.description)).toEqual(["Bob untripped"]);
});
});
describe("assignTransactionsToTrip — authorisation", () => {
it("refuses a trip the caller does not participate in", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const trip = await pool.query(
`INSERT INTO trips (owner_id, name) VALUES ($1, 'Private Trip') RETURNING id`,
[ownerId]
);
const tripId = trip.rows[0].id as number;
const bobsTx = await insertTransaction(pool, otherId, { description: "Bob lunch" });
await expect(assignTransactionsToTrip(tripId, [bobsTx], otherId)).rejects.toThrow(/participant/i);
});
// The hole this closed: the function took no caller at all, so any
// authenticated participant could move any transaction id into any trip.
it("silently skips transactions the caller cannot see", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const trip = await pool.query(
`INSERT INTO trips (owner_id, name) VALUES ($1, 'Alice Trip') RETURNING id`,
[ownerId]
);
const tripId = trip.rows[0].id as number;
const mine = await insertTransaction(pool, ownerId, { description: "Alice flight" });
const theirs = await insertTransaction(pool, otherId, { description: "Bob private" });
const moved = await assignTransactionsToTrip(tripId, [mine, theirs], ownerId);
expect(moved).toBe(1);
const rows = await pool.query(
`SELECT transaction_id FROM transaction_overrides WHERE trip_id = $1`, [tripId]
);
expect(rows.rows.map((r) => r.transaction_id)).toEqual([mine]);
});
it("lets a participant assign their own transaction to the trip", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const trip = await pool.query(
`INSERT INTO trips (owner_id, name) VALUES ($1, 'Joint Trip') RETURNING id`,
[ownerId]
);
const tripId = trip.rows[0].id as number;
// Make Bob a participant first.
const seedTx = await insertTransaction(pool, ownerId, { category: "travel" });
await pool.query(
`INSERT INTO transaction_overrides (transaction_id, trip_id) VALUES ($1, $2)`,
[seedTx, tripId]
);
await pool.query(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
VALUES ($1, $2, 50)`,
[seedTx, otherId]
);
const bobsTx = await insertTransaction(pool, otherId, { description: "Bob taxi" });
expect(await assignTransactionsToTrip(tripId, [bobsTx], otherId)).toBe(1);
});
});
// Delete is the one thing that stayed owner-only. Both trip foreign keys are
// ON DELETE SET NULL, so deleting a trip untags every transaction on it and
// drops the trip scope from its payments — including a hand-derived allocation
// that nothing recomputes.
describe("deleteTrip — owner only", () => {
it("does not delete when a non-owner participant asks", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const trip = await pool.query(
`INSERT INTO trips (owner_id, name) VALUES ($1, 'Precious Trip') RETURNING id`,
[ownerId]
);
const tripId = trip.rows[0].id as number;
const txId = await insertTransaction(pool, ownerId, { category: "travel" });
await pool.query(
`INSERT INTO transaction_overrides (transaction_id, trip_id) VALUES ($1, $2)`,
[txId, tripId]
);
await pool.query(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
VALUES ($1, $2, 50)`,
[txId, otherId]
);
// Bob can see it...
expect(await getTripById(tripId, otherId)).not.toBeNull();
await deleteTrip(tripId, otherId);
// ...and still cannot remove it, nor untag its transaction.
expect(await getTripById(tripId, ownerId)).not.toBeNull();
const still = await pool.query(
`SELECT trip_id FROM transaction_overrides WHERE transaction_id = $1`, [txId]
);
expect(still.rows[0].trip_id).toBe(tripId);
});
it("deletes when the owner asks", async () => {
const { ownerId } = await seedParticipants(pool);
const trip = await pool.query(
`INSERT INTO trips (owner_id, name) VALUES ($1, 'Doomed Trip') RETURNING id`,
[ownerId]
);
const tripId = trip.rows[0].id as number;
await deleteTrip(tripId, ownerId);
expect(await getTripById(tripId, ownerId)).toBeNull();
});
});
@@ -0,0 +1,231 @@
import { describe, it, expect, beforeAll, beforeEach } from "vitest";
import { Pool } from "pg";
import { createPool, resetDB, seedParticipants } from "./helpers";
/**
* The receipt lane, exercised against the real schema.
*
* Every fixture here is a receipt that actually exists: a Coles split-tender e-receipt
* (7777 Werribee, 11/07/2026, $114.57 settled $40.75 gift card + $73.82 Mastercard), a
* Woolworths e-receipt (3345 Wyndham Vale, 29/06/2026, $40.75, single gift card) and a
* Coles photo (556 Manor Lakes, 29/07/2026, $23.18). The split one is the reason the lane
* writes one transaction per tender leg rather than one per receipt, so it is the case
* these tests are built around.
*/
const TOKEN = "test-receipt-token";
let pool: Pool;
let POST: typeof import("../../app/api/receipts/ingest/route").POST;
const req = (body: unknown, token: string | null = TOKEN) =>
({
headers: { get: (h: string) => (h === "x-ingest-token" ? token : null) },
json: async () => body,
}) as never;
const colesSplit = () => ({
receipt_uid: "coles:7777:114:2153:2026-07-11",
capture_event_id: 412,
image_sha256: "sha-coles-split",
merchant_name: "Coles",
store_detail: "7777",
transaction_date: "2026-07-11",
total: 114.57,
tender_raw: "EFT $40.75\nEFT $73.82\n***** 0443 MASTERCARD\nCREDIT ACCOUNT STORE CARD",
tender_legs: [
{ leg_index: 1, amount: 40.75, card_last4: "0443", card_product: "STORE CARD", class: "gift_card" as const },
{ leg_index: 2, amount: 73.82, card_last4: "3302", card_product: "MASTERCARD", class: "card" as const },
],
line_items: [
{ description: "Pork Loin Roast", qty: 1, unit: "ea", amount: 15.86, category: "meat" },
{ description: "Tomatoes", qty: 1, unit: "ea", amount: 98.71, category: "produce" },
],
});
const woolworthsGiftCard = () => ({
receipt_uid: "woolworths:3345:62:148:2026-06-29",
capture_event_id: 500,
merchant_name: "Woolworths",
store_detail: "3345",
transaction_date: "2026-06-29",
total: 40.75,
tender_legs: [{ leg_index: 1, amount: 40.75, card_last4: "0443", card_product: "STORE CARD", class: "gift_card" as const }],
line_items: [{ description: "Oat Milk", qty: 2, unit: "ea", amount: 40.75, category: "dairy" }],
});
beforeAll(async () => {
process.env.RECEIPT_INGEST_TOKEN = TOKEN;
pool = createPool();
({ POST } = await import("../../app/api/receipts/ingest/route"));
});
beforeEach(async () => {
await resetDB(pool);
await pool.query("DELETE FROM expense_metadata");
// transactions.owner_id references participants, and resetDB truncates it with RESTART
// IDENTITY — so DEFAULT_OWNER_ID (1) has to be re-seeded or every insert here fails the
// foreign key. Same dependency the order lane has; it just never had to say so.
await seedParticipants(pool);
});
const legsOf = async (group: string) =>
(await pool.query(
`SELECT t.id, t.amount::text, t.payment_method, t.statement_id, em.order_reference, em.line_items, em.flags
FROM expense_metadata em JOIN transactions t ON t.id = em.transaction_id
WHERE em.receipt_group = $1 ORDER BY em.order_reference`,
[group]
)).rows;
describe("auth", () => {
it("rejects a missing or wrong token", async () => {
expect((await POST(req(colesSplit(), null))).status).toBe(401);
expect((await POST(req(colesSplit(), "nope"))).status).toBe(401);
});
it("rejects a body missing what it needs to book money", async () => {
expect((await POST(req({ merchant_name: "Coles" }))).status).toBe(400);
});
});
describe("a split-tender receipt", () => {
it("books one transaction per leg, not one for the total", async () => {
const res = await POST(req(colesSplit()));
expect(res.status).toBe(200);
const body = await res.json();
expect(body.legs).toHaveLength(2);
const rows = await legsOf("pantry:coles:7777:114:2153:2026-07-11");
expect(rows.map((r) => r.amount)).toEqual(["40.75", "73.82"]);
// Not one $114.57 row: marked credits it would let the $73.82 statement line
// double-count; marked card it would be searched for at ±1% of $114.57 and never match.
expect(rows.some((r) => r.amount === "114.57")).toBe(false);
});
it("sums the legs back to the printed total", async () => {
await POST(req(colesSplit()));
const rows = await legsOf("pantry:coles:7777:114:2153:2026-07-11");
const total = rows.reduce((sum, r) => sum + Number(r.amount), 0);
expect(Number(total.toFixed(2))).toBe(114.57);
});
it("marks the gift-card leg credits and the card leg card", async () => {
await POST(req(colesSplit()));
const rows = await legsOf("pantry:coles:7777:114:2153:2026-07-11");
expect(rows.find((r) => r.amount === "40.75")!.payment_method).toBe("credits");
expect(rows.find((r) => r.amount === "73.82")!.payment_method).toBe("card");
});
it("leaves every leg as a manual row for the reconciliation queue", async () => {
await POST(req(colesSplit()));
const rows = await legsOf("pantry:coles:7777:114:2153:2026-07-11");
expect(rows.every((r) => r.statement_id === null)).toBe(true);
});
it("puts the line items on the card leg only", async () => {
// They describe the whole shop but transaction_id is UNIQUE on expense_metadata, so
// they can attach to one row — and the card leg is the one that reconciles onto the
// statement line, which is where an unreadable descriptor gets its contents.
await POST(req(colesSplit()));
const rows = await legsOf("pantry:coles:7777:114:2153:2026-07-11");
expect(rows.find((r) => r.amount === "73.82")!.line_items).toHaveLength(2);
expect(rows.find((r) => r.amount === "40.75")!.line_items).toHaveLength(0);
});
it("flags the split so it can be shown as one purchase", async () => {
await POST(req(colesSplit()));
const rows = await legsOf("pantry:coles:7777:114:2153:2026-07-11");
expect(rows[0].flags).toContain("split_tender");
});
});
describe("a gift-card receipt", () => {
it("becomes a visible transaction on the day it is scanned", async () => {
const res = await POST(req(woolworthsGiftCard()));
expect(res.status).toBe(200);
const rows = await legsOf("pantry:woolworths:3345:62:148:2026-06-29");
expect(rows).toHaveLength(1);
expect(rows[0].amount).toBe("40.75");
expect(rows[0].payment_method).toBe("credits");
});
it("is owned, so it is not invisible in every view", async () => {
// Owner scoping is COALESCE(t.owner_id, s.owner_id) and these rows carry no statement;
// a NULL owner is how 85 backfilled order rows ended up in the table and on no screen.
await POST(req(woolworthsGiftCard()));
const { rows } = await pool.query("SELECT owner_id FROM transactions WHERE merchant_name = 'Woolworths'");
expect(rows[0].owner_id).not.toBeNull();
});
});
describe("idempotency", () => {
it("keys on the receipt, so the same shop from two files lands once", async () => {
// A photo and the store's e-receipt PDF are different files with different hashes.
// Keying on the capture would let one purchase arrive twice.
await POST(req(colesSplit()));
await POST(req({ ...colesSplit(), capture_event_id: 999, image_sha256: "sha-different-file" }));
const rows = await legsOf("pantry:coles:7777:114:2153:2026-07-11");
expect(rows).toHaveLength(2); // still just the two legs
});
it("reports the replay rather than silently doing nothing", async () => {
await POST(req(colesSplit()));
const body = await (await POST(req(colesSplit()))).json();
expect(body.legs.every((l: { skipped?: string }) => l.skipped === "already_ingested")).toBe(true);
});
it("falls back to the capture when the receipt did not identify itself", async () => {
// A crumpled photo can lose the header entirely. That receipt still becomes spend; it
// just cannot be recognised if the same shop is scanned again from another file.
await POST(req({ ...colesSplit(), receipt_uid: null }));
expect(await legsOf("pantry:capture:412")).toHaveLength(2);
});
});
describe("validation — what becomes money", () => {
it("refuses a receipt whose tender does not add up to its total", async () => {
// A missed leg is spend that never appears; booking the rest would put a number in the
// ledger nobody can stand behind.
const broken = { ...colesSplit(), tender_legs: [colesSplit().tender_legs[0]] };
const res = await POST(req(broken));
expect(res.status).toBe(422);
expect((await res.json()).reason).toContain("tender legs sum to 40.75");
expect(await legsOf("pantry:coles:7777:114:2153:2026-07-11")).toHaveLength(0);
});
it("refuses a receipt with no tender at all", async () => {
expect((await POST(req({ ...colesSplit(), tender_legs: [] }))).status).toBe(422);
});
it("flags but accepts lines that do not sum, because promo rows are skipped by design", async () => {
const res = await POST(req({ ...colesSplit(), line_items: [{ description: "One line", amount: 10 }] }));
expect(res.status).toBe(200);
expect((await res.json()).flags.some((f: string) => f.startsWith("line_items_sum_"))).toBe(true);
});
it("writes nothing at all when validation fails", async () => {
await POST(req({ ...colesSplit(), tender_legs: [] }));
const { rows } = await pool.query("SELECT count(*)::int AS n FROM transactions");
expect(rows[0].n).toBe(0);
});
it("marks a leg nobody could classify as reconcilable rather than deciding for them", async () => {
const unknown = { ...colesSplit(), tender_legs: [{ leg_index: 1, amount: 114.57, card_last4: "9999", card_product: null, class: null }] };
const res = await POST(req(unknown));
expect(res.status).toBe(200);
const rows = await legsOf("pantry:coles:7777:114:2153:2026-07-11");
// NULL is what needsCardMatch() treats as still needing a card match, so it surfaces in
// the queue instead of being silently excluded from it.
expect(rows[0].payment_method).toBeNull();
expect(rows[0].flags).toContain("unclassified_tender");
});
});
describe("dry run", () => {
it("validates without writing", async () => {
const res = await POST(req({ ...colesSplit(), dryRun: true }));
expect(res.status).toBe(200);
expect((await res.json()).dryRun).toBe(true);
const { rows } = await pool.query("SELECT count(*)::int AS n FROM transactions");
expect(rows[0].n).toBe(0);
});
});
@@ -0,0 +1,166 @@
import { describe, it, expect, beforeAll, beforeEach, vi } from "vitest";
import { Pool } from "pg";
import { createPool, resetDB, seedParticipants } from "./helpers";
/**
* What happens to a scanned receipt when its statement finally arrives.
*
* This is the case the first design of this lane got wrong, so it is tested first-class.
* Reconciliation moves a manual row's overrides, tags and splits onto the statement row and
* then hides the manual row from every figure. `expense_metadata` was the one child it left
* behind — which did not matter while metadata only ever came from an email that had made
* its own transaction, and matters completely now that it carries a shop's line items. Left
* unmoved, the contents of the shop vanish at exactly the moment the statement line shows
* up, and `COLES 0556 MANOR LAKES` stays as unreadable as it was before anything was
* scanned.
*/
const TOKEN = "test-receipt-token";
let pool: Pool;
let ownerId: number;
let ingest: typeof import("../../app/api/receipts/ingest/route").POST;
let reconcile: typeof import("../../app/api/transactions/reconcile/route").POST;
const ingestReq = (body: unknown) =>
({ headers: { get: (h: string) => (h === "x-ingest-token" ? TOKEN : null) }, json: async () => body }) as never;
// The reconcile route authenticates a browser session rather than a shared secret.
const userReq = (body: unknown) => ({ json: async () => body }) as never;
const colesSplit = {
receipt_uid: "coles:7777:114:2153:2026-07-11",
capture_event_id: 412,
merchant_name: "Coles",
transaction_date: "2026-07-11",
total: 114.57,
tender_legs: [
{ leg_index: 1, amount: 40.75, card_last4: "0443", card_product: "STORE CARD", class: "gift_card" },
{ leg_index: 2, amount: 73.82, card_last4: "3302", card_product: "MASTERCARD", class: "card" },
],
line_items: [
{ description: "Pork Loin Roast", qty: 1, unit: "ea", amount: 15.86, category: "meat" },
{ description: "Jasmine Rice 1kg", qty: 1, unit: "ea", amount: 98.71, category: "pantry_dry" },
],
};
async function statementLine(amount: number, date: string, description: string) {
const statement = await pool.query(
`INSERT INTO statements (filename, account_number, bank_name, billing_start_date, billing_end_date, owner_id)
VALUES ('sept.pdf', '1234-5678-9012-3302', 'Test Bank', $1::date - 20, $1::date + 10, $2) RETURNING id`,
[date, ownerId]
);
const txn = await pool.query(
`INSERT INTO transactions (statement_id, transaction_date, description, amount, transaction_type, owner_id)
VALUES ($1, $2, $3, $4, 'debit', $5) RETURNING id`,
[statement.rows[0].id, date, description, amount, ownerId]
);
return txn.rows[0].id as number;
}
beforeAll(async () => {
process.env.RECEIPT_INGEST_TOKEN = TOKEN;
pool = createPool();
// The reconcile route scopes every query to the signed-in user. Mocked before the import
// (doMock is not hoisted, so it can close over `ownerId`, which resetDB reassigns on each
// run) because an ES module binding cannot be reassigned afterwards.
vi.doMock("@/lib/auth", () => ({
getCurrentUser: async () => ({ id: ownerId, name: "Alice", email: "alice@example.com" }),
}));
({ POST: ingest } = await import("../../app/api/receipts/ingest/route"));
({ POST: reconcile } = await import("../../app/api/transactions/reconcile/route"));
});
beforeEach(async () => {
await resetDB(pool);
await pool.query("DELETE FROM expense_metadata");
({ ownerId } = await seedParticipants(pool));
});
const reconcileAs = (manualId: number, statementId: number) =>
reconcile(userReq({ matches: [{ manual_id: manualId, statement_tx_id: statementId }] }));
describe("a card leg meeting its statement line", () => {
it("carries the shop's line items onto the statement row", async () => {
await ingest(ingestReq(colesSplit));
const manual = await pool.query(
`SELECT t.id FROM transactions t JOIN expense_metadata em ON em.transaction_id = t.id
WHERE em.order_reference = 'pantry:coles:7777:114:2153:2026-07-11#2'`
);
const manualId = manual.rows[0].id as number;
const statementId = await statementLine(73.82, "2026-07-12", "COLES 7777 WERRIBEE");
const res = await reconcileAs(manualId, statementId);
expect(res.status ?? 200).toBe(200);
const moved = await pool.query(`SELECT transaction_id, line_items FROM expense_metadata WHERE order_reference = $1`, [
"pantry:coles:7777:114:2153:2026-07-11#2",
]);
// The whole point: the items are now on the row that survives, not the one that got hidden.
expect(moved.rows[0].transaction_id).toBe(statementId);
expect(moved.rows[0].line_items).toHaveLength(2);
});
it("counts the shop once, not twice", async () => {
await ingest(ingestReq(colesSplit));
const manual = await pool.query(
`SELECT t.id FROM transactions t JOIN expense_metadata em ON em.transaction_id = t.id
WHERE em.order_reference = 'pantry:coles:7777:114:2153:2026-07-11#2'`
);
const statementId = await statementLine(73.82, "2026-07-12", "COLES 7777 WERRIBEE");
await reconcileAs(manual.rows[0].id, statementId);
// Reconciled manual rows are excluded from figures by reconciled_with_id; what remains
// live is the gift-card leg plus the statement line = the $114.57 that was actually spent.
const { rows } = await pool.query(
`SELECT coalesce(sum(amount), 0)::text AS total FROM transactions
WHERE reconciled_with_id IS NULL AND superseded_by_id IS NULL`
);
expect(Number(rows[0].total)).toBeCloseTo(114.57, 2);
});
it("leaves the gift-card leg alone", async () => {
await ingest(ingestReq(colesSplit));
const manual = await pool.query(
`SELECT t.id FROM transactions t JOIN expense_metadata em ON em.transaction_id = t.id
WHERE em.order_reference = 'pantry:coles:7777:114:2153:2026-07-11#2'`
);
const statementId = await statementLine(73.82, "2026-07-12", "COLES 7777 WERRIBEE");
await reconcileAs(manual.rows[0].id, statementId);
const gift = await pool.query(
`SELECT t.reconciled_with_id, t.payment_method FROM transactions t
JOIN expense_metadata em ON em.transaction_id = t.id
WHERE em.order_reference = 'pantry:coles:7777:114:2153:2026-07-11#1'`
);
expect(gift.rows[0].reconciled_with_id).toBeNull();
expect(gift.rows[0].payment_method).toBe("credits");
});
it("keeps the other source's metadata when the statement row already has some", async () => {
// An emailed or Paperless copy of the same purchase may have got there first.
// transaction_id is UNIQUE, so one of them has to lose — and it must lose visibly
// rather than by constraint violation at 11pm.
await ingest(ingestReq(colesSplit));
const manual = await pool.query(
`SELECT t.id FROM transactions t JOIN expense_metadata em ON em.transaction_id = t.id
WHERE em.order_reference = 'pantry:coles:7777:114:2153:2026-07-11#2'`
);
const statementId = await statementLine(73.82, "2026-07-12", "COLES 7777 WERRIBEE");
await pool.query(
`INSERT INTO expense_metadata (transaction_id, source, order_reference, line_items)
VALUES ($1, 'email', 'email:already-here', '[]'::jsonb)`,
[statementId]
);
const res = await reconcileAs(manual.rows[0].id, statementId);
expect(res.status ?? 200).toBe(200);
const incumbent = await pool.query(`SELECT source FROM expense_metadata WHERE transaction_id = $1`, [statementId]);
expect(incumbent.rows.map((r) => r.source)).toEqual(["email"]);
const pantryRow = await pool.query(`SELECT flags FROM expense_metadata WHERE order_reference = $1`, [
"pantry:coles:7777:114:2153:2026-07-11#2",
]);
expect(JSON.stringify(pantryRow.rows[0].flags)).toContain("metadata_collision_on_reconcile");
});
});
+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,274 @@
import { describe, it, expect, beforeAll } from "vitest";
import type { NextRequest } from "next/server";
import { queryRaw, queryRow } from "../../lib/db";
/**
* Changing who paid.
*
* Owner was write-once for every ingestion path — a pantry receipt hardcodes
* DEFAULT_OWNER_ID, and no route, modal or bulk action could correct it, so a
* shop the other person paid for was permanently filed as yours. There was not
* a single `UPDATE ... SET owner_id` anywhere in src/.
*
* Two things make this more than a field edit, and both are tested here:
*
* 1. **It is a one-way door.** Access is "owner OR holds a split"
* (canAccessTransactions), so handing a row over while holding no split on
* it removes it from your list and 404s every route that could put it back.
* Only the new owner can undo it.
* 2. **It moves money between two ledgers.** Splits record shares, not
* direction — getParticipantBalances derives who owes whom from ownership —
* so the same 50/50 rows flip sides untouched.
*/
type Ctx = { params: Promise<{ id: string }> };
type Handler = (req: NextRequest, ctx: Ctx) => Promise<Response>;
let txPATCH: Handler;
let stmtPATCH: Handler;
/** Enough of a NextRequest for these routes: the auth header and the body. */
const req = (email: string | null, body: unknown) =>
({
headers: { get: (h: string) => (h.toLowerCase() === "x-forwarded-user" ? email : null) },
json: async () => body,
}) as unknown as NextRequest;
const ctx = (id: number) => ({ params: Promise.resolve({ id: String(id) }) });
let me = 0;
let them = 0;
let meEmail = "";
let themEmail = "";
beforeAll(async () => {
({ PATCH: txPATCH } = await import("../../app/api/transactions/[id]/route"));
({ PATCH: stmtPATCH } = await import("../../app/api/statements/[id]/route"));
// Fixtures are named, not random, so a re-run is idempotent. `participants`
// has a unique email and no cascade from `transactions.owner_id`, so the
// teardown order is rows → statements → people.
await queryRaw(`DELETE FROM transactions WHERE description LIKE 'Owner fixture — %'`);
await queryRaw(`DELETE FROM statements WHERE bank_name = 'Owner Fixture Bank'`);
await queryRaw(`DELETE FROM participants WHERE email LIKE 'owner-fixture-%@example.test'`);
const mk = async (label: string) => {
const email = `owner-fixture-${label}@example.test`;
const row = await queryRow<{ id: number }>(
`INSERT INTO participants (name, email) VALUES ($1, $2) RETURNING id`,
[`Owner fixture — ${label}`, email]
);
return { id: row!.id, email };
};
const a = await mk("me");
const b = await mk("them");
me = a.id;
meEmail = a.email;
them = b.id;
themEmail = b.email;
});
/** A manual transaction owned by `owner`, with optional splits. */
async function manualTxn(label: string, owner: number, splits: [number, number][] = []) {
const row = await queryRow<{ id: number }>(
`INSERT INTO transactions (transaction_date, description, amount, transaction_type, category, owner_id)
VALUES ('2026-06-01', $1, 100.00, 'debit', 'groceries', $2) RETURNING id`,
[`Owner fixture — ${label}`, owner]
);
for (const [pid, pct] of splits) {
await queryRaw(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent) VALUES ($1,$2,$3)`,
[row!.id, pid, pct]
);
}
return row!.id;
}
const ownerOf = async (id: number) =>
(await queryRow<{ owner_id: number | null }>(`SELECT owner_id FROM transactions WHERE id = $1`, [id]))!
.owner_id;
describe("PATCH /api/transactions/[id] — owner", () => {
it("reassigns a manual row when I hold a split on it", async () => {
const id = await manualTxn("shared shop", me, [[me, 50], [them, 50]]);
const res = await txPATCH(req(meEmail, { owner_id: them }), ctx(id));
expect(res.status).toBe(200);
expect(await ownerOf(id)).toBe(them);
});
it("refuses to hand away a row I hold no split on", async () => {
const id = await manualTxn("nothing of mine", me);
const res = await txPATCH(req(meEmail, { owner_id: them }), ctx(id));
expect(res.status).toBe(409);
expect((await res.json()).code).toBe("would_lose_access");
// Unchanged — a refusal must not half-apply.
expect(await ownerOf(id)).toBe(me);
});
it("hands it away when the release is explicit", async () => {
const id = await manualTxn("given away", me);
const res = await txPATCH(req(meEmail, { owner_id: them, release: true }), ctx(id));
expect(res.status).toBe(200);
expect(await ownerOf(id)).toBe(them);
});
it("never blocks taking onto my ledger a row I can already see", async () => {
// No `release` needed: I end up the owner, so there is no door to close
// behind me. The split is what lets me see it in the first place.
const id = await manualTxn("taken on", them, [[me, 50], [them, 50]]);
const res = await txPATCH(req(meEmail, { owner_id: me }), ctx(id));
expect(res.status).toBe(200);
expect(await ownerOf(id)).toBe(me);
});
it("cannot claim a row I cannot see", async () => {
// Their transaction, no split of mine — canAccessTransactions rejects
// before any owner logic runs. Reassignment is a correction to a row you
// already have, never a way to reach one you do not.
const id = await manualTxn("out of reach", them);
const res = await txPATCH(req(meEmail, { owner_id: me }), ctx(id));
expect(res.status).toBe(404);
expect(await ownerOf(id)).toBe(them);
});
it("refuses a statement row and says where to change it", async () => {
const stmt = await queryRow<{ id: number }>(
`INSERT INTO statements (bank_name, account_number, billing_end_date, currency, filename, owner_id)
VALUES ('Owner Fixture Bank','1111','2026-06-30','AUD','owner-fixture-1.pdf',$1) RETURNING id`,
[me]
);
const row = await queryRow<{ id: number }>(
`INSERT INTO transactions (statement_id, transaction_date, description, amount, transaction_type)
VALUES ($1,'2026-06-02','Owner fixture — statement row', 40.00, 'debit') RETURNING id`,
[stmt!.id]
);
const res = await txPATCH(req(meEmail, { owner_id: them }), ctx(row!.id));
expect(res.status).toBe(400);
expect((await res.json()).code).toBe("statement_owned");
});
it("rejects a participant that does not exist", async () => {
const id = await manualTxn("bad participant", me, [[me, 100]]);
const res = await txPATCH(req(meEmail, { owner_id: 999999 }), ctx(id));
expect(res.status).toBe(400);
expect(await ownerOf(id)).toBe(me);
});
it("leaves splits untouched — they record shares, not direction", async () => {
const id = await manualTxn("shares survive", me, [[me, 50], [them, 50]]);
await txPATCH(req(meEmail, { owner_id: them }), ctx(id));
const splits = await queryRaw<{ participant_id: number; share_percent: string; settled: boolean }>(
`SELECT participant_id, share_percent, settled FROM transaction_splits
WHERE transaction_id = $1 ORDER BY participant_id`,
[id]
);
expect(splits.map((s) => [s.participant_id, Number(s.share_percent)])).toEqual([
[me, 50],
[them, 50],
]);
expect(splits.every((s) => s.settled === false)).toBe(true);
});
it("flips which side of the balance the same rows sit on", async () => {
const id = await manualTxn("balance flip", me, [[me, 50], [them, 50]]);
// "They owe me": their split on a row I own.
const owedToMe = async () =>
Number(
(await queryRow<{ v: string | null }>(
`SELECT COALESCE(SUM(t.amount * ts.share_percent / 100), 0) AS v
FROM transaction_splits ts JOIN transactions t ON t.id = ts.transaction_id
WHERE t.id = $1 AND t.owner_id = $2 AND ts.participant_id <> $2`,
[id, me]
))!.v ?? 0
);
// "I owe them": my split on a row they own.
const owedByMe = async () =>
Number(
(await queryRow<{ v: string | null }>(
`SELECT COALESCE(SUM(t.amount * ts.share_percent / 100), 0) AS v
FROM transaction_splits ts JOIN transactions t ON t.id = ts.transaction_id
WHERE t.id = $1 AND t.owner_id <> $2 AND ts.participant_id = $2`,
[id, me]
))!.v ?? 0
);
expect(await owedToMe()).toBe(50);
expect(await owedByMe()).toBe(0);
await txPATCH(req(meEmail, { owner_id: them }), ctx(id));
expect(await owedToMe()).toBe(0);
expect(await owedByMe()).toBe(50);
});
it("rejects an unauthenticated caller", async () => {
const id = await manualTxn("no auth", me, [[me, 100]]);
const res = await txPATCH(req("nobody@example.test", { owner_id: them }), ctx(id));
expect(res.status).toBe(403);
expect(await ownerOf(id)).toBe(me);
});
});
describe("PATCH /api/statements/[id] — owner", () => {
async function statementWithRows(label: string, owner: number) {
const stmt = await queryRow<{ id: number }>(
`INSERT INTO statements (bank_name, account_number, billing_end_date, currency, filename, owner_id)
VALUES ('Owner Fixture Bank', $1, '2026-06-30','AUD', $2, $3) RETURNING id`,
[label, `owner-fixture-${label}.pdf`, owner]
);
// One row inheriting the owner (owner_id NULL) and one carrying its own
// copy — the live table holds 1,803 and 2,194 of these respectively, and
// updating `statements` alone would move only the first kind.
const inheriting = await queryRow<{ id: number }>(
`INSERT INTO transactions (statement_id, transaction_date, description, amount, transaction_type)
VALUES ($1,'2026-06-03','Owner fixture — inheriting', 10.00, 'debit') RETURNING id`,
[stmt!.id]
);
const carrying = await queryRow<{ id: number }>(
`INSERT INTO transactions (statement_id, transaction_date, description, amount, transaction_type, owner_id)
VALUES ($1,'2026-06-04','Owner fixture — carrying', 20.00, 'debit', $2) RETURNING id`,
[stmt!.id, owner]
);
return { stmtId: stmt!.id, inheriting: inheriting!.id, carrying: carrying!.id };
}
/** The owner the app actually reads: COALESCE(t.owner_id, s.owner_id). */
const effectiveOwner = async (id: number) =>
(await queryRow<{ v: number }>(
`SELECT COALESCE(t.owner_id, s.owner_id) AS v FROM transactions t
LEFT JOIN statements s ON s.id = t.statement_id WHERE t.id = $1`,
[id]
))!.v;
it("moves the statement and every row on it, both kinds", async () => {
const { stmtId, inheriting, carrying } = await statementWithRows("move", me);
const res = await stmtPATCH(req(meEmail, { owner_id: them }), ctx(stmtId));
expect(res.status).toBe(200);
expect((await res.json()).rows_moved).toBe(1); // only the one carrying a copy
expect(await effectiveOwner(inheriting)).toBe(them);
expect(await effectiveOwner(carrying)).toBe(them);
});
it("refuses a caller who does not own it", async () => {
const { stmtId, carrying } = await statementWithRows("not-mine", me);
const res = await stmtPATCH(req(themEmail, { owner_id: them }), ctx(stmtId));
expect(res.status).toBe(404);
expect(await effectiveOwner(carrying)).toBe(me);
});
it("rejects a participant that does not exist", async () => {
const { stmtId, carrying } = await statementWithRows("bad-participant", me);
const res = await stmtPATCH(req(meEmail, { owner_id: 999999 }), ctx(stmtId));
expect(res.status).toBe(400);
expect(await effectiveOwner(carrying)).toBe(me);
});
it("is a no-op when the owner is unchanged", async () => {
const { stmtId } = await statementWithRows("same-owner", me);
const res = await stmtPATCH(req(meEmail, { owner_id: me }), ctx(stmtId));
expect(res.status).toBe(200);
expect((await res.json()).rows_moved).toBe(0);
});
});
@@ -0,0 +1,216 @@
import { describe, it, expect } from "vitest";
import {
applyMapping,
inFileDuplicates,
last4,
splitByCoverage,
type ColumnMapping,
type ParsedTransaction,
} from "@/lib/csv-parser";
/**
* The statement-coverage rule, which replaced a whole Frollo-specific importer.
*
* The first aggregator import wrote 422 duplicate rows out of 550 — $1,023,824
* of movement counted twice — because "these accounts issue no statements" was
* asserted rather than queried. Amount-matching could not fix it either: the two
* sources decompose the same event differently, bundling a transfer fee where
* the statement itemises it.
*
* A statement's end date answers the question that actually has an answer: up
* to what date is this account complete? On the real 2026-08-13 export it takes
* 2,607 rows down to 171, with no account allowlist and no card exclusions.
*/
const tx = (o: Partial<ParsedTransaction> & { date: string }): ParsedTransaction => ({
description: "SOMETHING",
amount: 10,
transaction_type: "debit",
...o,
});
describe("last4", () => {
it("reads the same account written four different ways", () => {
// Every one of these forms appears in the real data.
expect(last4("xxxxxxxxxxxx2176")).toBe("2176");
expect(last4("235242176")).toBe("2176");
expect(last4("xxx-xxx xx4878")).toBe("4878");
expect(last4("4085-56264")).toBe("6264");
});
it("returns empty for an unusable identifier rather than guessing", () => {
expect(last4("N/A")).toBe("");
expect(last4("12")).toBe("");
expect(last4("")).toBe("");
});
});
describe("splitByCoverage", () => {
const coverage = [{ last4: "2176", coveredTo: "2026-07-26" }];
it("excludes a row on or before the account's newest statement", () => {
const { keep, covered } = splitByCoverage(
[tx({ date: "2026-07-25", account: "xxxxxxxxxxxx2176" })],
coverage
);
expect(covered).toHaveLength(1);
expect(keep).toHaveLength(0);
});
it("treats the watermark date itself as covered", () => {
const { covered } = splitByCoverage(
[tx({ date: "2026-07-26", account: "xxxxxxxxxxxx2176" })],
coverage
);
expect(covered).toHaveLength(1);
});
it("keeps a row after the watermark — the whole point of the feed", () => {
// The 2026-08-12 salary: the statement has not arrived, so this row is the
// only record of it and must survive.
const { keep } = splitByCoverage(
[tx({ date: "2026-08-12", account: "xxxxxxxxxxxx2176", amount: 10782 })],
coverage
);
expect(keep).toHaveLength(1);
});
it("keeps everything for an account with no statements", () => {
const { keep, covered } = splitByCoverage(
[tx({ date: "2020-01-01", account: "xxx-xxx xx4878" })],
coverage
);
expect(keep).toHaveLength(1);
expect(covered).toHaveLength(0);
});
it("keeps everything when no account column was mapped", () => {
// A single-account bank export has no account column, and a row must never
// be dropped on a guess about which account it belongs to.
const { keep, covered } = splitByCoverage([tx({ date: "2020-01-01" })], coverage);
expect(keep).toHaveLength(1);
expect(covered).toHaveLength(0);
});
it("applies each account's own watermark, not a global one", () => {
// The real export spans watermarks from 2026-03-31 to 2026-07-26. A single
// date would either import duplicates or discard real rows.
const { keep, covered } = splitByCoverage(
[
tx({ date: "2026-05-01", account: "xxxx0887" }), // covered to 2026-03-31 → keep
tx({ date: "2026-05-01", account: "xxxx2176" }), // covered to 2026-07-26 → drop
],
[
{ last4: "0887", coveredTo: "2026-03-31" },
{ last4: "2176", coveredTo: "2026-07-26" },
]
);
expect(keep).toHaveLength(1);
expect(keep[0].account).toBe("xxxx0887");
expect(covered).toHaveLength(1);
});
});
describe("inFileDuplicates", () => {
it("counts twins that a row id cannot catch", () => {
// A CDR re-consent re-exports an account's whole history under fresh ids
// while the originals survive, so source_ref sees two distinct rows. 385 of
// these were in one 2,563-row export and doubled every salary payment.
const rows = [
tx({ date: "2026-07-07", amount: 10782, description: "HDR", account: "4830" }),
tx({ date: "2026-07-07", amount: 10782, description: "HDR", account: "4830" }),
];
expect(inFileDuplicates(rows)).toBe(1);
});
it("does not flag the same amount on different days", () => {
expect(
inFileDuplicates([
tx({ date: "2026-07-07", amount: 5, description: "COFFEE" }),
tx({ date: "2026-07-08", amount: 5, description: "COFFEE" }),
])
).toBe(0);
});
it("does not flag the same amount on different accounts", () => {
// Both legs of an internal transfer: same day, same amount, different
// accounts, and both are real.
expect(
inFileDuplicates([
tx({ date: "2026-05-18", amount: 3076.04, description: "Transfer", account: "6264" }),
tx({ date: "2026-05-18", amount: 3076.04, description: "Transfer", account: "9940" }),
])
).toBe(0);
});
});
describe("applyMapping with account and row-id columns", () => {
const labels = ["transaction_id", "description", "amount", "transaction_date", "account_number"];
const mapping: ColumnMapping = {
dateCol: "transaction_date",
descriptionCol: "description",
amountMode: "single",
amountCol: "amount",
accountCol: "account_number",
sourceRefCol: "transaction_id",
};
it("carries the account and row id through", () => {
const out = applyMapping(
[["1326131276", "HDR Global Services", "10782.00", "2026-08-12", "xxxxxxxxxxxx4830"]],
labels,
mapping,
"YYYY-MM-DD"
);
expect(out).toHaveLength(1);
expect(out[0].account).toBe("xxxxxxxxxxxx4830");
expect(out[0].source_ref).toBe("1326131276");
// A positive single-column amount is money in.
expect(out[0].transaction_type).toBe("credit");
});
it("marks a non-AUD row as native and unconverted", () => {
// The 2026-08-12 salary is USD. Stored without this it reads as A$10,782 —
// about a third under — and sits in a column of AUD figures looking normal.
const out = applyMapping(
[["1", "HDR Global Services", "10782.00", "2026-08-12", "xxxx4830", "USD"]],
[...labels, "currency"],
{ ...mapping, currencyCol: "currency" },
"YYYY-MM-DD"
);
expect(out[0].foreign_currency_code).toBe("USD");
expect(out[0].foreign_currency_amount).toBe(10782);
});
it("leaves an AUD row unmarked", () => {
const out = applyMapping(
[["1", "SOMETHING", "-42.50", "2026-08-12", "xxxx4830", "AUD"]],
[...labels, "currency"],
{ ...mapping, currencyCol: "currency" },
"YYYY-MM-DD"
);
expect(out[0].foreign_currency_code).toBeUndefined();
});
it("ignores a currency cell that is not a code", () => {
const out = applyMapping(
[["1", "SOMETHING", "-42.50", "2026-08-12", "xxxx4830", ""]],
[...labels, "currency"],
{ ...mapping, currencyCol: "currency" },
"YYYY-MM-DD"
);
expect(out[0].foreign_currency_code).toBeUndefined();
});
it("leaves both undefined when the columns are not mapped", () => {
const out = applyMapping(
[["1", "SOMETHING", "-42.50", "2026-08-12", "xxxx4830"]],
labels,
{ dateCol: "transaction_date", descriptionCol: "description", amountMode: "single", amountCol: "amount" },
"YYYY-MM-DD"
);
expect(out[0].account).toBeUndefined();
expect(out[0].source_ref).toBeUndefined();
expect(out[0].transaction_type).toBe("debit");
});
});
+37
View File
@@ -0,0 +1,37 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "fs";
import { resolve } from "path";
import { extractPdfText, liteLLMExtractor } from "../../lib/intake-extract";
import { reconcileItems, toLineItems, validateHeader } from "../../lib/intake-rules";
/**
* Opt-in: the real model over the real Burgertory export.
*
* INTAKE_LIVE_TEST=1 LITELLM_BASE_URL=http://localhost:4200 LITELLM_KEY=... npm test
*
* Skipped otherwise — CI has no LiteLLM, and a unit run must not depend on one.
* What it proves is the thing the mock cannot: that `cloud-budget` keeps the
* names verbatim ("Michael", "Joel", not "Burger") under the order-item prompt.
*/
const live = process.env.INTAKE_LIVE_TEST === "1" && !!process.env.LITELLM_BASE_URL && !!process.env.LITELLM_KEY;
describe.skipIf(!live)("live extraction via LiteLLM (opt-in)", () => {
it("Burgertory PDF: header exact, two items named verbatim, reconciles", async () => {
const text = await extractPdfText(readFileSync(resolve(__dirname, "../fixtures/intake/ubereats-burgertory-2026-08-19.pdf")));
const extracted = await liteLLMExtractor()([{ kind: "text", text, name: "Receipt_19Aug2026.pdf" }]);
const header = validateHeader(extracted);
expect(header.ok && header.header).toEqual({ merchant: "Burgertory (Werribee)", order_date: "2026-08-19", total: 22.4 });
const items = toLineItems(extracted.items);
expect(items.map((i) => i.description)).toEqual(["Michael", "Joel"]);
expect(reconcileItems(extracted).ok).toBe(true);
}, 120_000);
it("Coles PDF: header exact, 15 items", async () => {
const text = await extractPdfText(readFileSync(resolve(__dirname, "../fixtures/intake/ubereats-coles-2026-08-18.pdf")));
const extracted = await liteLLMExtractor()([{ kind: "text", text, name: "Receipt_18Aug2026.pdf" }]);
const header = validateHeader(extracted);
expect(header.ok && header.header).toEqual({ merchant: "Coles (Wyndham Vale)", order_date: "2026-08-18", total: 85.74 });
expect(toLineItems(extracted.items)).toHaveLength(15);
expect(reconcileItems(extracted).ok).toBe(true);
}, 120_000);
});
+300
View File
@@ -0,0 +1,300 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "fs";
import { resolve } from "path";
import {
classifyInput,
GROCERY_PATTERN,
merchantKey,
merchantMatches,
platformForSource,
reconcileItems,
resolveMerchantKind,
toLineItems,
validateHeader,
type ExtractedOrder,
} from "../../lib/intake-rules";
import { buildMessageContent, extractPdfText, parseExtraction, EXTRACT_RESPONSE_FORMAT, ExtractionError, liteLLMExtractor } from "../../lib/intake-extract";
import { askCard, candidatesCard, enrichedCard, type Block } from "../../lib/intake-blocks";
/**
* The intake router's pure rules, against the strings real receipts print.
* The two PDFs are the user's own Uber Eats exports (plan §3): a Coles grocery
* run and a Burgertory meal whose burgers are named after people.
*/
const fixtures = resolve(__dirname, "../fixtures/intake");
const pdf = (name: string) => readFileSync(resolve(fixtures, name));
describe("classifyInput — input type picks the extractor", () => {
it("PDF by MIME or by name", () => {
expect(classifyInput({ type: "application/pdf", name: "x" })).toBe("pdf");
expect(classifyInput({ type: "application/octet-stream", name: "Receipt_18Aug2026.pdf" })).toBe("pdf");
});
it("images and text", () => {
expect(classifyInput({ type: "image/png", name: "IMG_1.png" })).toBe("image");
expect(classifyInput({ type: "", name: "photo.HEIC" })).toBe("image");
expect(classifyInput({ type: "text/plain", name: "" })).toBe("text");
});
it("declines what it cannot read", () => {
expect(classifyInput({ type: "video/mp4", name: "clip.mp4" })).toBe("unsupported");
expect(classifyInput({ type: "application/zip", name: "a.zip" })).toBe("unsupported");
});
});
describe("platformForSource", () => {
it("maps the tap to expense_metadata.platform vocabulary", () => {
expect(platformForSource("uber_eats")).toBe("ubereats");
expect(platformForSource("doordash")).toBe("doordash");
expect(platformForSource("coles")).toBeNull();
expect(platformForSource("other")).toBeNull();
});
});
describe("merchant pattern — grocery | food | ask", () => {
it("the pattern list is a substring match on the normalised name", () => {
expect(resolveMerchantKind("uber_eats", "Coles (Wyndham Vale)")).toBe("grocery");
expect(resolveMerchantKind("other", "Woolworths Group Limited")).toBe("grocery");
expect(resolveMerchantKind("uber_eats", "WOOLIES METRO")).toBe("grocery");
expect(resolveMerchantKind("doordash", "ALDI Werribee")).toBe("grocery");
expect(resolveMerchantKind("uber_eats", "IGA Xpress")).toBe("grocery");
expect(resolveMerchantKind("uber_eats", "FoodWorks Manor Lakes")).toBe("grocery");
expect(resolveMerchantKind("uber_eats", "Costco Wholesale")).toBe("grocery");
});
it("a delivery order that is not a grocer is a meal", () => {
expect(resolveMerchantKind("uber_eats", "Burgertory (Werribee)")).toBe("food");
expect(resolveMerchantKind("doordash", "Mad Mex")).toBe("food");
});
it("an empty or unknown merchant asks", () => {
expect(resolveMerchantKind("uber_eats", null)).toBe("ask");
expect(resolveMerchantKind("uber_eats", " ")).toBe("ask");
expect(resolveMerchantKind("doordash", "Unknown")).toBe("ask");
});
it("the Coles/Woolworths taps are ground truth", () => {
expect(resolveMerchantKind("coles", "Romeo's Food Hall")).toBe("grocery");
expect(resolveMerchantKind("woolworths", null)).toBe("grocery");
});
it("`other` with an unrecognised merchant asks; a remembered answer sticks", () => {
expect(resolveMerchantKind("other", "Romeo's Food Hall")).toBe("ask");
expect(resolveMerchantKind("other", "Romeo's Food Hall", "grocery")).toBe("grocery");
expect(resolveMerchantKind("uber_eats", "Romeo's Food Hall", "grocery")).toBe("grocery");
});
it("merchantKey folds case and punctuation; merchantMatches allows containment only", () => {
expect(merchantKey("Coles (Wyndham Vale)")).toBe("coles wyndham vale");
expect(merchantMatches("Coles (Wyndham Vale)", "coles wyndham vale")).toBe(true);
expect(merchantMatches("Coles (Wyndham Vale)", "Coles")).toBe(true);
expect(merchantMatches("Order - Coles (Wyndham Vale)", "Coles (Wyndham Vale)")).toBe(true);
expect(merchantMatches("Burgertory (Werribee)", "Coles")).toBe(false);
expect(merchantMatches(null, "Coles")).toBe(false);
});
it("whole words, not substrings: `Bigals Pizza` contains `iga` and is neither a grocer nor an IGA", () => {
expect(GROCERY_PATTERN.test(merchantKey("Bigals Pizza"))).toBe(false);
expect(resolveMerchantKind("uber_eats", "Bigals Pizza")).toBe("food");
expect(merchantMatches("Bigals Pizza", "IGA")).toBe(false);
// and the grocers a boundary must not cost us
expect(resolveMerchantKind("uber_eats", "IGA Wyndham Vale")).toBe("grocery");
expect(resolveMerchantKind("uber_eats", "Woolworths Group Limited")).toBe("grocery");
expect(resolveMerchantKind("uber_eats", "Coles (Wyndham Vale)")).toBe("grocery");
expect(merchantMatches("IGA Wyndham Vale", "IGA")).toBe(true);
});
});
describe("validateHeader — nothing is written from a half-read upload", () => {
const base: ExtractedOrder = { merchant: "Burgertory (Werribee)", order_date: "2026-08-19", total: 22.4, items: [] };
it("accepts a complete header and rounds the total", () => {
const r = validateHeader({ ...base, total: 22.404 });
expect(r.ok && r.header.total).toBe(22.4);
});
it("missing total", () => {
const r = validateHeader({ ...base, total: null });
expect(r.ok).toBe(false);
expect(!r.ok && r.missing).toEqual(["total"]);
});
it("missing or malformed date", () => {
expect(validateHeader({ ...base, order_date: null }).ok).toBe(false);
expect(validateHeader({ ...base, order_date: "19/8/26" }).ok).toBe(false);
});
it("a missing merchant is NOT a missing header — it becomes an ask", () => {
const r = validateHeader({ ...base, merchant: null });
expect(r.ok && r.header.merchant).toBeNull();
});
});
describe("reconcileItems — the missing-screenshot check", () => {
it("reconciles to the printed item subtotal, not the total: fees and promotions are not items", () => {
// The real Coles export: 15 lines summing $118.87 against a printed $118.94 subtotal and an $85.74 total.
const r = reconcileItems({
merchant: "Coles", order_date: "2026-08-18", total: 85.74, subtotal: 118.94,
items: [{ quantity: 1, description: "a", line_total: 118.87 }],
});
expect(r.ok).toBe(true);
expect(r.basis).toBe("subtotal");
});
it("falls back to the total when no subtotal is printed", () => {
const r = reconcileItems({ merchant: "x", order_date: "2026-08-19", total: 28, items: [{ quantity: 1, description: "Michael", line_total: 14 }, { quantity: 1, description: "Joel", line_total: 14 }] });
expect(r.ok).toBe(true);
expect(r.basis).toBe("total");
});
it("holds when the lines fall short — a shot is missing", () => {
const r = reconcileItems({ merchant: "x", order_date: "2026-08-19", total: 28, subtotal: 28, items: [{ quantity: 1, description: "Michael", line_total: 14 }] });
expect(r.ok).toBe(false);
expect(r.reason).toMatch(/14\.00.*28\.00/);
});
it("tolerance is $1 or 2%, whichever is larger", () => {
expect(reconcileItems({ merchant: "x", order_date: "d", total: 10, subtotal: 10, items: [{ quantity: 1, description: "a", line_total: 9.05 }] }).ok).toBe(true);
expect(reconcileItems({ merchant: "x", order_date: "d", total: 10, subtotal: 10, items: [{ quantity: 1, description: "a", line_total: 8.9 }] }).ok).toBe(false);
expect(reconcileItems({ merchant: "x", order_date: "d", total: 200, subtotal: 200, items: [{ quantity: 1, description: "a", line_total: 196.5 }] }).ok).toBe(true);
expect(reconcileItems({ merchant: "x", order_date: "d", total: 200, subtotal: 200, items: [{ quantity: 1, description: "a", line_total: 195 }] }).ok).toBe(false);
});
it("derives a line from quantity × unit price when no line total is printed", () => {
const r = reconcileItems({ merchant: "x", order_date: "d", total: 13.2, subtotal: 13.2, items: [{ quantity: 3, description: "Coke", unit_price: 4.4 }] });
expect(r.ok).toBe(true);
expect(r.items_sum).toBe(13.2);
});
it("no items at all is held; priceless lines (pasted text) are not", () => {
expect(reconcileItems({ merchant: "x", order_date: "d", total: 10, items: [] }).ok).toBe(false);
expect(reconcileItems({ merchant: "x", order_date: "d", total: 10, items: [{ quantity: 1, description: "a" }] }).ok).toBe(true);
});
});
describe("toLineItems — names verbatim, options kept", () => {
it("keeps a burger called Michael called Michael", () => {
const out = toLineItems([{ quantity: 1, description: "Michael", unit_price: 14, line_total: 14, options: ["Milk Bun"] }]);
expect(out).toEqual([{ qty: 1, description: "Michael", amount: 14, unit_price: 14, options: ["Milk Bun"] }]);
});
it("drops nameless lines and defaults quantity to 1", () => {
const out = toLineItems([{ quantity: null, description: " ", line_total: 1 }, { quantity: null, description: "Cucumbers Continental", unit_price: 2.9 }]);
expect(out).toEqual([{ qty: 1, description: "Cucumbers Continental", amount: 2.9, unit_price: 2.9 }]);
});
});
describe("extraction request and reply", () => {
it("one call: the prompt first, then every part in the order shared", () => {
const content = buildMessageContent([
{ kind: "image", mime: "image/png", bytes: Buffer.from("a"), name: "1.png" },
{ kind: "text", text: "Total $22.40", name: "r.pdf" },
{ kind: "image", mime: "image/jpeg", bytes: Buffer.from("b"), name: "2.jpg" },
]) as { type: string; text?: string; image_url?: { url: string } }[];
expect(content[0].text).toMatch(/VERBATIM/);
const images = content.filter((c) => c.type === "image_url").map((c) => c.image_url!.url);
expect(images).toEqual(["data:image/png;base64,YQ==", "data:image/jpeg;base64,Yg=="]);
expect(content.findIndex((c) => c.text?.includes("Total $22.40"))).toBeLessThan(content.findIndex((c) => c.image_url?.url.endsWith("Yg==")));
expect(EXTRACT_RESPONSE_FORMAT.json_schema.strict).toBe(true);
});
it("parses a fenced reply and coerces numbers", () => {
const r = parseExtraction('```json\n{"merchant":"Burgertory (Werribee)","order_date":"2026-08-19","total":"22.40","subtotal":28,"currency":null,"items":[{"quantity":1,"description":"Michael","unit_price":14,"line_total":14,"options":["Milk Bun"]}],"adjustments":[{"label":"Promotion","amount":-8}]}\n```');
expect(r.total).toBe(22.4);
expect(r.currency).toBe("AUD");
expect(r.items[0]).toEqual({ quantity: 1, description: "Michael", unit_price: 14, line_total: 14, options: ["Milk Bun"] });
expect(r.adjustments).toEqual([{ label: "Promotion", amount: -8 }]);
});
});
describe("PDF text — the Uber Eats export is born-digital text", () => {
it("Coles (Wyndham Vale) · 2026-08-18 · $85.74, 15 lines, weighed and substituted", async () => {
const text = await extractPdfText(pdf("ubereats-coles-2026-08-18.pdf"));
expect(text).toContain("Here's your receipt for Coles (Wyndham Vale).");
expect(text).toContain("18 Aug, 2026");
expect(text).toContain("Total $85.74");
expect(text).toContain("Item Subtotal $118.94");
expect(text).toContain("Final 1.16 kg $11.00/kg");
expect(text).toContain("Replaced Coles Beef Slow Cook Chuck Roast");
// 15 item lines: 13 priced per piece, 2 weighed per kg.
expect(text.match(/\$\d+\.\d\d\/pc$/gm)?.length).toBe(13);
expect(text.match(/\$\d+\.\d\d\/kg$/gm)?.length).toBe(2);
});
it("Burgertory (Werribee) · 2026-08-19 · $22.40, burgers named Michael and Joel", async () => {
const text = await extractPdfText(pdf("ubereats-burgertory-2026-08-19.pdf"));
expect(text).toContain("Here's your receipt for Burgertory (Werribee).");
expect(text).toContain("Total $22.40");
expect(text).toContain("1 Michael $14.00");
expect(text).toContain("1 Joel $14.00");
expect(text).toContain("Item Subtotal $28.00");
});
});
describe("cards carry the C5 action ids and JSON values", () => {
const c = { batchId: "11111111-2222-4333-8444-555555555555", merchant: "Burgertory (Werribee)", total: 22.4, orderDate: "2026-08-19", platform: "ubereats", itemCount: 2 };
it("candidates: one Use-this per candidate plus an explicit Create", () => {
const { blocks } = candidatesCard(c, [{ transaction_id: 42, description: "Order - Burgertory (Werribee)", transaction_date: "2026-08-17", amount: "19.90" }]);
const buttons = JSON.stringify(blocks);
expect(buttons).toContain('"action_id":"intake_use_candidate"');
expect(buttons).toContain('"action_id":"intake_create"');
const use = (blocks[1] as { accessory: { value: string } }).accessory.value;
expect(JSON.parse(use)).toEqual({ batch_id: c.batchId, transaction_id: 42 });
});
it("enriched: the confirmation text and a View link", () => {
const { text, blocks } = enrichedCard({ ...c, transactionId: 7 });
expect(text).toBe("Added 2 items to your $22.40 Uber Eats (Burgertory (Werribee)) order ✓");
expect(JSON.stringify(blocks)).toContain("/transactions?q=Burgertory");
});
it("ask: Groceries / A meal", () => {
const ids = JSON.stringify(askCard({ ...c, platform: null }).blocks);
expect(ids).toContain('"action_id":"intake_grocery"');
expect(ids).toContain('"action_id":"intake_meal"');
});
it("every value the card did not write is escaped, plain_text included", () => {
const hostile = { ...c, merchant: "Ben & Jerry's <fake>" };
const { text, blocks } = candidatesCard(hostile, [
{ transaction_id: 42, description: "SQ *BEN & JERRY <AU>", transaction_date: "2026-08-17", amount: "19.90" },
]);
const sections = JSON.stringify(blocks.filter((b) => (b as Block).type === "section"));
expect(sections).toContain("Ben &amp; Jerry's &lt;fake&gt;");
expect(sections).toContain("SQ *BEN &amp; JERRY &lt;AU&gt;");
expect(text).toContain("Ben &amp; Jerry's &lt;fake&gt;");
// plain_text disables formatting, not entity decoding — &, < and > are
// control characters everywhere in a Slack payload.
const whole = JSON.stringify(blocks);
expect(whole).not.toContain("<fake>");
expect(whole).not.toContain("<AU>");
});
it("the currency is model output too, so it is escaped like any other", () => {
// parseExtraction accepts any string for `currency`; unescaped, `<@U123>`
// would be a live mention on every card the batch produces.
const { text } = enrichedCard({ ...c, currency: "<@U123>", transactionId: 7 });
expect(text).toContain("&lt;@U123&gt; 22.40");
expect(text).not.toContain("<@U123>");
});
it("a foreign receipt is not printed as dollars — header and candidate rows alike", () => {
expect(enrichedCard({ ...c, currency: "USD", transactionId: 7 }).text).toBe(
"Added 2 items to your USD 22.40 Uber Eats (Burgertory (Werribee)) order ✓"
);
expect(enrichedCard({ ...c, currency: "AUD", transactionId: 7 }).text).toContain("$22.40");
expect(enrichedCard({ ...c, transactionId: 7 }).text).toContain("$22.40");
// A candidate row is a ledger transaction and carries its own currency; it
// used to print "$" beneath a header reading "USD 22.40".
const { blocks } = candidatesCard({ ...c, currency: "USD" }, [
{ transaction_id: 42, description: "Order - Burgertory", transaction_date: "2026-08-17", amount: "19.90", currency: "USD" },
]);
expect(JSON.stringify(blocks)).toContain("USD 19.90");
});
});
describe("liteLLMExtractor — a finish_reason that is present must be `stop`", () => {
const good = { merchant: "Burgertory (Werribee)", order_date: "2026-08-19", total: 22.4, subtotal: 28, currency: "AUD", items: [], adjustments: [] };
const extractor = (finish_reason?: string) =>
liteLLMExtractor({
baseUrl: "http://litellm.test",
key: "k",
fetchImpl: (async () =>
Response.json({
choices: [{ message: { content: JSON.stringify(good) }, ...(finish_reason ? { finish_reason } : {}) }],
})) as unknown as typeof fetch,
});
it("accepts `stop`, and — on purpose — a reply that reports no reason at all", async () => {
expect((await extractor("stop")([{ kind: "text", text: "x", name: "x" }])).total).toBe(22.4);
expect((await extractor()([{ kind: "text", text: "x", name: "x" }])).total).toBe(22.4);
});
it("rejects any other reason, naming it — the strict schema only held for a reply that finished", async () => {
// An absent reason is NOT rejected: see the comment in intake-extract.ts.
await expect(extractor("length")([{ kind: "text", text: "x", name: "x" }])).rejects.toThrow(ExtractionError);
await expect(extractor("length")([{ kind: "text", text: "x", name: "x" }])).rejects.toThrow(/too long/);
await expect(extractor("content_filter")([{ kind: "text", text: "x", name: "x" }])).rejects.toThrow(/content_filter/);
await expect(extractor("tool_calls")([{ kind: "text", text: "x", name: "x" }])).rejects.toThrow(/tool_calls/);
});
});
+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));
}
});
});
+525
View File
@@ -0,0 +1,525 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "fs";
import { resolve } from "path";
import {
parseOrderHTML,
validateOrderTotals,
resolveCategory,
NotAReceiptError,
orderDescription,
type MessageMeta,
type ParsedOrder,
} from "../../lib/order-ingestion";
/**
* Rewritten 2026-07-26. The previous unit suite exercised synthetic fixtures
* built to satisfy the parser, so it passed while the parser could not read a
* real email. These run against unmodified captured receipts.
*/
const dir = resolve(__dirname, "../fixtures/orders/real");
const html = (f: string) => readFileSync(resolve(dir, `${f}.html`), "utf-8");
const meta = (over: Partial<MessageMeta> = {}): MessageMeta => ({
messageId: "unit-1",
subject: "Order Confirmation for Siddharth from Mad Mex",
receivedAt: "2026-07-16T03:34:00Z",
sender: "DoorDash Order <no-reply@doordash.com>",
...over,
});
describe("payment detection", () => {
it("credits-only", () => {
const p = parseOrderHTML(html("dd-01"), meta());
expect(p.payment.credits_amount).toBe(14.64);
expect(p.payment.card_last4).toBeNull();
expect(p.payment.ambiguous).toBe(false);
});
it("card-only produces no credits figure", () => {
const p = parseOrderHTML(
html("dd-27"),
meta({ subject: "Order Confirmation for Siddharth from Subway" })
);
expect(p.payment.card_last4).toBe("8032");
expect(p.payment.credits_amount).toBeNull();
});
it("'and/or credits' is ambiguous, not silently credits", () => {
// Regression: an earlier regex delimited the payment line on a double
// space, which whitespace collapsing removes. Every card and mixed receipt
// fell through to the credits branch — this one booked the whole $60.93 as
// credits spend that never happened.
const p = parseOrderHTML(
html("dd-10"),
meta({ subject: "Order Confirmation for Siddharth from Woolworths" })
);
expect(p.payment.ambiguous).toBe(true);
expect(p.payment.card_last4).toBe("8032");
expect(p.payment.credits_amount).toBeNull();
});
});
describe("validateOrderTotals", () => {
const base = (over: Partial<ParsedOrder> = {}): ParsedOrder => ({
order_reference: "x",
platform: "doordash",
merchant_name: "M",
order_datetime: "2026-03-01T00:00:00Z",
currency: "AUD",
payment: { credits_amount: 10, card_amount: null, card_last4: null, ambiguous: false },
totals: {
subtotal: null, taxes: null, delivery_fee: null,
service_fee: null, tip: null, discounts: null, total_charged: 10,
},
line_items: [],
route: [],
is_family: false,
flags: [],
...over,
});
it("rejects a non-positive total", () => {
const o = base();
o.totals.total_charged = 0;
expect(validateOrderTotals(o).ok).toBe(false);
});
it("rejects payments that do not account for the total", () => {
const r = validateOrderTotals(
base({ payment: { credits_amount: 5, card_amount: null, card_last4: null, ambiguous: false } })
);
expect(r.ok).toBe(false);
expect(r.reason).toMatch(/payments sum/);
});
it("accepts a matching total", () => {
expect(validateOrderTotals(base()).ok).toBe(true);
});
it("does not gate on DoorDash's non-reconciling fee breakdown", () => {
const p = parseOrderHTML(html("dd-01"), meta());
expect(p.totals.subtotal).toBe(22.10);
expect(p.totals.discounts).toBe(24.09); // 22.10 + 1.99 — genuinely printed
expect(validateOrderTotals(p, html("dd-01")).ok).toBe(true);
});
});
describe("resolveCategory", () => {
const o = (merchant: string, platform: ParsedOrder["platform"] = "doordash") =>
({ merchant_name: merchant, platform }) as ParsedOrder;
it("maps grocers to groceries", () => {
expect(resolveCategory(o("Woolworths"))).toBe("groceries");
expect(resolveCategory(o("ALDI"))).toBe("groceries");
expect(resolveCategory(o("GLOMARK Kandana", "ubereats"))).toBe("groceries");
});
it("maps restaurants to dining rather than 'other'", () => {
// The earlier six-merchant allowlist sent every one of these to `other`.
for (const m of ["Carl's Jr.", "Taco Bell", "Chilli India", "Oporto", "Schnitz", "Souvlaki GR"]) {
expect(resolveCategory(o(m))).toBe("dining");
}
});
it("maps rides to transport", () => {
expect(resolveCategory(o("Uber Trip", "uber"))).toBe("transport");
});
});
describe("parse guards", () => {
it("throws on a body too short to be a receipt", () => {
expect(() => parseOrderHTML("<html></html>", meta())).toThrow(NotAReceiptError);
});
it("throws rather than inventing a platform", () => {
expect(() =>
parseOrderHTML(html("dd-01"), meta({ subject: "Newsletter", sender: "someone@example.com" }))
).toThrow(/platform/i);
});
});
describe("order_reference anchoring", () => {
it("takes Uber's tripReference, not the first UUID in the document", () => {
const p = parseOrderHTML(
html("ue-00"),
meta({ subject: "Your Wednesday afternoon order with Uber Eats", sender: "uber.com" })
);
// The UUID the PDF redirect actually resolves to for this receipt.
expect(p.order_reference).toBe("34d6b4ee-da8f-5029-8d14-bd359617c8e9");
expect(p.flags).not.toContain("order_uuid_ambiguous");
});
it("is stable across repeated parses of the same message", () => {
const m = meta({ subject: "Your Wednesday afternoon order with Uber Eats", sender: "uber.com" });
const a = parseOrderHTML(html("ue-00"), m).order_reference;
const b = parseOrderHTML(html("ue-00"), m).order_reference;
expect(a).toBe(b);
});
it("flags ambiguity only when several UUIDs and no anchor", () => {
// Strip the anchor from a receipt that carries multiple UUIDs (ue-09 has 6).
const stripped = html("ue-09").replace(/tripReference/gi, "notTheAnchor");
const p = parseOrderHTML(
stripped,
meta({ subject: "[Family] Your Sunday evening order with Uber Eats", sender: "uber.com" })
);
expect(p.flags).toContain("order_uuid_ambiguous");
});
});
describe("mixed Uber payment (issuer-named card leg)", () => {
it("captures both legs when the card is labelled by issuer, not brand", () => {
// Real receipt: Uber Cash $1.17 + Westpac ••••8032 $15.33 = $16.50.
// A brand allowlist (Visa|MasterCard|Amex) misses "Westpac" and drops the
// card half, leaving payments that do not account for the total.
const p = parseOrderHTML(
readFileSync(resolve(dir, "ue-mixed.html"), "utf-8"),
meta({ subject: "Your Friday morning order with Uber Eats", sender: "uber.com", receivedAt: "2026-01-09T09:26:44Z" })
);
expect(p.totals.total_charged).toBeCloseTo(16.50, 2);
expect(p.payment.credits_amount).toBeCloseTo(1.17, 2);
expect(p.payment.card_amount).toBeCloseTo(15.33, 2);
expect(p.payment.card_last4).toBe("8032");
expect(validateOrderTotals(p).ok).toBe(true);
});
});
describe("Uber route (pick-up / delivery)", () => {
const uber = (f: string, subject = "Your Wednesday order with Uber Eats") =>
parseOrderHTML(html(f), meta({ subject, sender: "uber.com" }));
it("reads both stops with their times, as printed", () => {
const p = uber("ue-00");
expect(p.route).toEqual([
{ label: "Pick-up", time: "1:20 pm", address: "197 Watton St, Werribee VIC 3030, Australia" },
{ label: "Delivery", time: "1:40 pm", address: "19 Lady Penrhyn Dr, Wyndham Vale VIC 3024, Australia" },
]);
});
it("de-duplicates the block Uber renders twice", () => {
// The receipt emits the whole address section a second time for narrow
// screens. Without de-duplication every trip has four stops.
expect(uber("ue-00").route).toHaveLength(2);
expect(uber("ue-26").route).toHaveLength(2);
});
it("keeps the receipt's own wording rather than normalising it", () => {
// Uber is not internally consistent: "Pick-up" on some receipts,
// "Pickup" on others. Inventing a canonical spelling would hide that a
// template changed.
expect(uber("ue-mixed", "Your Friday morning order with Uber Eats").route[0].label).toBe("Pickup");
});
it("works on an international receipt", () => {
const p = uber("ue-26");
expect(p.route[1].address).toContain("Luzern, Switzerland");
});
it("DoorDash has no route — its receipts carry no addresses", () => {
expect(parseOrderHTML(html("dd-01"), meta()).route).toEqual([]);
});
});
describe("Uber line items", () => {
it("itemises a grocery order, binding qty/title/amount by item id", () => {
const p = parseOrderHTML(
html("ue-09"),
meta({ subject: "Your Sunday evening order with Uber Eats", sender: "uber.com" })
);
expect(p.line_items).toHaveLength(5);
expect(p.line_items[0]).toMatchObject({
qty: 1,
description: "Highland Brewing MILK FULL CREAM U H T 900ML",
amount: 440,
});
// A sold-out item prints 0.00 and is kept: it is why the total is lower
// than what was ordered, and dropping it makes the receipt unexplainable.
expect(p.line_items.map((i) => i.amount)).toContain(0);
});
it("a restaurant order legitimately has none", () => {
// Uber itemises groceries only; a restaurant receipt states a total and
// nothing else. Empty here is the receipt, not a parse failure — so it
// must not raise no_line_items_parsed either.
const p = parseOrderHTML(
html("ue-00"),
meta({ subject: "Your Wednesday order with Uber Eats", sender: "uber.com" })
);
expect(p.line_items).toEqual([]);
expect(p.flags).not.toContain("no_line_items_parsed");
});
});
/**
* Uber trips. Captured 2026-06 via a dry-run against the real mailbox after the
* user pointed out that only *overseas* rides go on a card — local rides are
* paid with credits, which puts them in the same class as delivery orders.
*/
describe("Uber trips", () => {
const utMeta: Record<string, MessageMeta> = JSON.parse(
readFileSync(resolve(dir, "ut-meta.json"), "utf-8")
);
const trip = (f: string) => parseOrderHTML(html(f), utMeta[f]);
it("a local trip is credits-funded", () => {
const p = trip("ut-00");
expect(p.platform).toBe("uber");
expect(p.currency).toBe("AUD");
expect(p.totals.total_charged).toBeCloseTo(84.78, 2);
expect(p.payment.credits_amount).toBeCloseTo(84.78, 2);
expect(p.payment.card_last4).toBeNull();
expect(validateOrderTotals(p).ok).toBe(true);
});
it("an overseas trip is card-settled", () => {
const p = trip("ut-01");
expect(p.currency).toBe("NZD");
expect(p.totals.total_charged).toBeCloseTo(55.51, 2);
expect(p.payment.card_amount).toBeCloseTo(55.51, 2);
expect(p.payment.card_last4).toBe("3893");
});
it("labels the two ends of a trip, which the receipt does not", () => {
// Delivery receipts write "1:20 pm - Pick-up"; trip receipts print the time
// alone. The naive split put the time in `label` and left `time` null.
const p = trip("ut-00");
expect(p.route).toEqual([
{
label: "Pick-up",
time: "7:32 pm",
address: "Terminal 2, Melbourne Airport (MEL), Tullamarine VIC 3045, Australia",
},
{
label: "Drop-off",
time: "8:10 pm",
address: "19 Lady Penrhyn Dr, Wyndham Vale VIC 3024, Australia",
},
]);
});
it("rejects the charge summary Uber sends before the receipt", () => {
// Uber sends two mails per trip with the same subject and the same total.
// The first says "This is not a payment receipt" and carries no
// tripReference, so order_reference would fall back to msg:<id> and I7
// could not dedupe it — every trip would be recorded twice.
expect(() => trip("ut-summary")).toThrow(NotAReceiptError);
});
});
describe("orderDescription", () => {
it("names the restaurant, not the courier", () => {
// The platform is provenance and lives in the Order details panel, which
// already renders expense_metadata.platform. Putting it here fragmented the
// merchant — the same restaurant read differently depending on who carried
// the bag, which nobody rating the food cares about.
expect(orderDescription("doordash", "Mad Mex")).toBe("Order - Mad Mex");
expect(orderDescription("ubereats", "Coles (Wyndham Vale)")).toBe(
"Order - Coles (Wyndham Vale)"
);
});
it("leaves a merchant that already names the platform alone", () => {
// A trip's merchant is literally "Uber Trip". What identifies a trip is its
// addresses, and those live in the Order details panel.
expect(orderDescription("uber", "Uber Trip")).toBe("Order - Uber Trip");
});
it("gives the same description whichever platform delivered it", () => {
// The regression this whole change exists to prevent.
expect(orderDescription("doordash", "TEG Kebabs & Biryani")).toBe(
orderDescription("ubereats", "TEG Kebabs & Biryani")
);
});
});
/**
* Two parse failures that between them accounted for 218 of the 287 unreadable
* messages in the 776-message capture set. Neither was an "old template": the
* Uber one fails on current 2024-2025 mail, and the DoorDash one fails on every
* order paid from credits, in every year.
*/
describe("currency notations Uber actually sends", () => {
const uber = (f: string, subject: string) =>
parseOrderHTML(html(f), meta({ subject, sender: "Uber Receipts <noreply@uber.com>" }));
it("reads a symbol-prefixed Australian total", () => {
// "Total A$54.87". The old pattern allowed a 3-letter ISO code or a bare
// "$", so A$ — which is what Uber sends for ordinary domestic orders —
// matched neither and 98 of 275 Uber Eats mails were unreadable.
const o = uber("ue-aud-prefix", "Your Friday evening order with Uber Eats");
expect(o.totals.total_charged).toBe(54.87);
expect(o.currency).toBe("AUD");
});
it("reads NZ$ as New Zealand dollars, not Australian", () => {
// The prefix is the only thing distinguishing them, and getting it wrong
// books a Queenstown dinner at the wrong rate rather than failing loudly.
const o = uber("ue-nzd-prefix", "Your Saturday evening order with Uber Eats");
expect(o.totals.total_charged).toBe(22.83);
expect(o.currency).toBe("NZD");
});
it("reads a bare rupee symbol on a trip", () => {
const o = uber("ut-inr-symbol", "Your Friday evening trip with Uber");
expect(o.totals.total_charged).toBe(622.74);
expect(o.currency).toBe("INR");
});
it("still reads the space-separated ISO form", () => {
// The [Family] LKR receipts depend on this and must not regress.
const o = uber("ut-nzd-prefix", "Your Sunday afternoon trip with Uber");
expect(o.totals.total_charged).toBe(10.83);
expect(o.currency).toBe("NZD");
});
});
describe("credits-funded orders are orders", () => {
const credits = () =>
parseOrderHTML(
html("dd-credits-zero"),
meta({ subject: "Order Confirmation for Siddharth from Chilli India" })
);
it("records the subtotal when the card was charged nothing", () => {
// The receipt says "Subtotal $71.86 ... Total Charged $0.00" — truthfully,
// because credits covered it. Reading that as a $0 order threw away the
// credit-funded spend this pipeline exists to surface.
const o = credits();
expect(o.totals.total_charged).toBe(71.86);
expect(o.totals.subtotal).toBe(71.86);
expect(o.flags).toContain("credits_funded_zero_charge");
});
it("books the amount as credits, not as a card charge", () => {
const o = credits();
expect(o.payment.credits_amount).toBe(71.86);
expect(o.payment.card_amount).toBeNull();
expect(o.payment.card_last4).toBeNull();
});
it("passes validation instead of being rejected as non-positive", () => {
// Both stated totals are $0.00 and agree, so the header cross-check has to
// stand down here or it rejects the very figure the parser overrode.
const o = credits();
expect(validateOrderTotals(o, html("dd-credits-zero"))).toEqual({ ok: true });
});
it("does not invent a total when the receipt never says credits", () => {
// The guard that keeps this from becoming "any zero total borrows the
// subtotal" — a genuinely empty receipt must still fail.
const notCredits = html("dd-credits-zero").replace(/Paid with/gi, "Charged to");
const o = parseOrderHTML(notCredits, meta({ subject: "Order Confirmation for Siddharth from Chilli India" }));
expect(o.totals.total_charged).toBe(0);
expect(validateOrderTotals(o, notCredits).ok).toBe(false);
});
});
describe("Uber Cash is credits, not a card", () => {
it("reads a payment line carrying a timestamp and a prefixed currency", () => {
// "Payments Uber Cash 10/17/25 8:50 PM A$54.87". The old pattern allowed
// neither the timestamp nor the A$ prefix, so credits_amount stayed null
// and the order was filed as card-settled — sent looking for a card leg
// that does not exist, and left as an orphan with nothing to match on.
const o = parseOrderHTML(
html("ue-aud-prefix"),
meta({
subject: "Your Friday evening order with Uber Eats",
sender: "Uber Receipts <noreply@uber.com>",
})
);
expect(o.payment.credits_amount).toBe(54.87);
expect(o.payment.card_last4).toBeNull();
expect(o.payment.ambiguous).toBe(false);
});
it("still reads the plain form, where the timestamp follows the amount", () => {
// "Uber Cash $25.33 22/7/26 1:41 pm" — the older layout the widened
// pattern must not break.
const o = parseOrderHTML(
html("ue-00"),
meta({
subject: "Your order with Uber Eats",
sender: "Uber Receipts <noreply@uber.com>",
})
);
expect(o.payment.credits_amount).toBe(25.33);
});
it("still reads the card leg of a mixed payment", () => {
// "Uber Cash $1.17 ... Westpac ••••8032 $15.33" — the credits half must
// not swallow the card half.
const o = parseOrderHTML(
html("ue-mixed"),
meta({ subject: "Your order with Uber Eats", sender: "Uber Receipts <noreply@uber.com>" })
);
expect(o.payment.credits_amount).toBe(1.17);
expect(o.payment.card_last4).toBe("8032");
});
});
describe("payment legs", () => {
const trip = (f: string) =>
parseOrderHTML(
html(f),
meta({
subject: "Your Wednesday afternoon trip with Uber",
sender: "Uber Receipts <noreply@uber.com>",
})
);
it("does not add a superseded authorisation to the settled charge", () => {
// "Citi Prestige ••••0253 AED 17.67" then the same card "AED 577.83",
// against a stated total of 577.83. The first is a hold, not a part
// payment; adding it overstates the trip by the held amount.
const o = trip("ut-reauth");
expect(o.totals.total_charged).toBe(577.83);
expect(o.payment.card_amount).toBe(577.83);
expect(o.payment.card_last4).toBe("0253");
expect(validateOrderTotals(o, html("ut-reauth")).ok).toBe(true);
});
it("adds the legs of a genuinely split payment", () => {
// "PayPal - <email> A$78.41" + "Uber Cash A$6.85" = 85.26. Neither leg
// equals the total, so both are real and both must be counted — and the
// PayPal leg carries no card mask to anchor on.
const o = trip("ut-paypal");
expect(o.totals.total_charged).toBe(85.26);
expect(o.payment.credits_amount).toBe(6.85);
expect(o.payment.card_amount).toBe(78.41);
expect(validateOrderTotals(o, html("ut-paypal")).ok).toBe(true);
});
});
describe("HTML entities in line items", () => {
it("splits options on an encoded bullet instead of swallowing it", () => {
// "<b>Sweet &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("•");
// `options` is optional on the parsed item; `?? 0` keeps the assertion honest
// (an absent list fails) instead of silencing the check with a non-null bang.
expect(item!.options?.length ?? 0).toBeGreaterThan(0);
});
it("leaves a literal &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();
});
});
+87
View File
@@ -0,0 +1,87 @@
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { queryRaw } from "@/lib/db";
import {
MY_SPEND_SCOPE,
STATEMENTS_JOIN,
EXCLUDE_NON_SPEND,
EXCLUDE_RECONCILED_SOURCE,
EFFECTIVE_CATEGORY,
NET_SPEND_ROWS,
SPEND_SIGNED,
mySplitOf,
toDateStr,
} from "@/lib/analytics-sql";
/**
* Daily net spend, by month, day-of-month and category.
*
* This exists so the spend-pace chart stops computing its own totals. It used to
* sum gross `amount_aud ?? amount` over `transaction_type = 'debit'` in the
* browser, which meant it ignored personal share, refunds, fees, interest, and
* itemised loan repayments — every rule the headline applies. The two numbers
* could disagree while both were labelled "spend", and the chart's own baseline
* line was drawn from the split-adjusted monthly totals, so the two series in
* one chart were on different bases.
*
* Day-of-month granularity is also what lets the page compare a partial current
* month against prior months *through the same day*, instead of against their
* full-month totals — which always made a month in progress look thrifty.
*
* Same fragments as /api/analytics/monthly. If that route's semantics change,
* this one changes with it.
*/
export async function GET(req: NextRequest) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
const { searchParams } = new URL(req.url);
const monthCount = Math.min(Math.max(Number(searchParams.get("months") || "12"), 1), 24);
const now = new Date();
const endDate = new Date(now.getFullYear(), now.getMonth() + 1, 1);
const startDate = new Date(now.getFullYear(), now.getMonth() - monthCount + 1, 1);
const rows = await queryRaw<{ month: string; day: number; category: string; spent: string }>(
`SELECT
TO_CHAR(DATE_TRUNC('month', t.transaction_date::date), 'YYYY-MM') as month,
EXTRACT(DAY FROM t.transaction_date::date)::int as day,
${EFFECTIVE_CATEGORY} as category,
-- 4dp, not 2. This is grouped finer than /monthly (by day as well as
-- category), so rounding each bucket to cents and summing accumulates a
-- different error than rounding per category does — the pace chart ended
-- the month a few cents off the headline it sits under. Round once, at
-- display time.
SUM(${mySplitOf(SPEND_SIGNED)})::numeric(14,4) as spent
FROM transactions t
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1
${STATEMENTS_JOIN}
WHERE ${MY_SPEND_SCOPE()}
AND ${NET_SPEND_ROWS}
AND ${EXCLUDE_NON_SPEND}
AND ${EXCLUDE_RECONCILED_SOURCE}
AND t.transaction_date >= $2
AND t.transaction_date < $3
GROUP BY 1, 2, 3
ORDER BY 1, 2`,
[user.id, toDateStr(startDate), toDateStr(endDate)]
);
// Sparse by design — a day with no spend has no entry, and the client treats
// a missing day as zero. Emitting 31 zeroes per month per category would
// dominate the payload.
const daily: Record<string, Record<number, number>> = {};
const byCategory: Record<string, Record<string, Record<number, number>>> = {};
for (const r of rows) {
const spent = Number(r.spent);
const m = (daily[r.month] ??= {});
m[r.day] = (m[r.day] ?? 0) + spent;
const c = ((byCategory[r.month] ??= {})[r.category] ??= {});
c[r.day] = (c[r.day] ?? 0) + spent;
}
return NextResponse.json({ daily, byCategory });
}
+39 -5
View File
@@ -1,12 +1,35 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth"; import { getCurrentUser } from "@/lib/auth";
import { queryRaw } from "@/lib/db"; import { queryRaw } from "@/lib/db";
import { OWNER_SCOPE, STATEMENTS_JOIN, mySplitOf } from "@/lib/analytics-sql"; import { MY_SPEND_SCOPE, STATEMENTS_JOIN, EXCLUDE_RECONCILED_SOURCE, mySplitOf, toDateStr } from "@/lib/analytics-sql";
/**
* Fees and interest over an explicit window.
*
* This used to aggregate every statement ever imported with no date filter, and
* the UI printed the result with no period label — so a lifetime-to-date total
* read as a current-period one, and grew forever. `months=0` asks for all time
* deliberately, which is a different claim from asking for it by accident.
*/
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const user = await getCurrentUser(req); const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 }); if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
const { searchParams } = new URL(req.url);
const monthsParam = Number(searchParams.get("months") ?? "12");
const months = Number.isFinite(monthsParam) ? Math.min(Math.max(monthsParam, 0), 120) : 12;
const allTime = months === 0;
const now = new Date();
const from = new Date(now.getFullYear(), now.getMonth() - months + 1, 1);
const fromStr = toDateStr(from);
const toStr = toDateStr(new Date(now.getFullYear(), now.getMonth() + 1, 1));
// A statement is dated by the period it covers, not by when it was imported.
const stmtWindow = allTime ? "" : `AND billing_end_date >= $2 AND billing_end_date < $3`;
const txnWindow = allTime ? "" : `AND t.transaction_date >= $2 AND t.transaction_date < $3`;
const windowParams = allTime ? [] : [fromStr, toStr];
// Statement-level fees and interest (aggregated by Gemini from the PDF) // Statement-level fees and interest (aggregated by Gemini from the PDF)
const stmtRows = await queryRaw<{ const stmtRows = await queryRaw<{
bank_name: string; bank_name: string;
@@ -19,10 +42,11 @@ export async function GET(req: NextRequest) {
SUM(COALESCE(interest_charged, 0))::numeric(12,2) AS interest SUM(COALESCE(interest_charged, 0))::numeric(12,2) AS interest
FROM statements FROM statements
WHERE owner_id = $1 WHERE owner_id = $1
${stmtWindow}
GROUP BY bank_name GROUP BY bank_name
HAVING SUM(COALESCE(fees_charged, 0)) + SUM(COALESCE(interest_charged, 0)) > 0 HAVING SUM(COALESCE(fees_charged, 0)) + SUM(COALESCE(interest_charged, 0)) > 0
ORDER BY (SUM(COALESCE(fees_charged, 0)) + SUM(COALESCE(interest_charged, 0))) DESC`, ORDER BY (SUM(COALESCE(fees_charged, 0)) + SUM(COALESCE(interest_charged, 0))) DESC`,
[user.id] [user.id, ...windowParams]
); );
// Transaction-level fee and interest line items (split-adjusted) // Transaction-level fee and interest line items (split-adjusted)
@@ -47,10 +71,12 @@ export async function GET(req: NextRequest) {
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1 LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1
${STATEMENTS_JOIN} ${STATEMENTS_JOIN}
WHERE ${OWNER_SCOPE} = $1 WHERE ${MY_SPEND_SCOPE()}
AND t.transaction_type IN ('fee', 'interest') AND t.transaction_type IN ('fee', 'interest')
AND ${EXCLUDE_RECONCILED_SOURCE}
${txnWindow}
ORDER BY t.transaction_date DESC`, ORDER BY t.transaction_date DESC`,
[user.id] [user.id, ...windowParams]
); );
const by_bank = stmtRows.map((r) => ({ const by_bank = stmtRows.map((r) => ({
@@ -69,5 +95,13 @@ export async function GET(req: NextRequest) {
const total_fees = by_bank.reduce((s, r) => s + r.fees, 0); const total_fees = by_bank.reduce((s, r) => s + r.fees, 0);
const total_interest = by_bank.reduce((s, r) => s + r.interest, 0); const total_interest = by_bank.reduce((s, r) => s + r.interest, 0);
return NextResponse.json({ by_bank, transactions, total_fees, total_interest }); return NextResponse.json({
by_bank,
transactions,
total_fees,
total_interest,
// The period is part of the answer — the client must be able to say what
// window these totals cover rather than implying "now".
period: { months, from: allTime ? null : fromStr, to: allTime ? null : toStr, all_time: allTime },
});
} }
@@ -1,7 +1,8 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth"; import { getCurrentUser } from "@/lib/auth";
import { queryRaw } from "@/lib/db"; import { queryRaw } from "@/lib/db";
import { OWNER_SCOPE, STATEMENTS_JOIN, EFFECTIVE_CATEGORY, mySplitOf } from "@/lib/analytics-sql"; import { MY_SPEND_SCOPE, STATEMENTS_JOIN, EXCLUDE_RECONCILED_SOURCE, EFFECTIVE_CATEGORY, mySplitOf } from "@/lib/analytics-sql";
import { bankLabel } from "@/lib/queries";
const MY_AMOUNT = mySplitOf(`COALESCE(t.amount_aud, t.amount)`); const MY_AMOUNT = mySplitOf(`COALESCE(t.amount_aud, t.amount)`);
@@ -39,15 +40,16 @@ export async function GET(
END::numeric(10,2) as my_amount, END::numeric(10,2) as my_amount,
t.transaction_type, t.transaction_type,
${EFFECTIVE_CATEGORY} as category, ${EFFECTIVE_CATEGORY} as category,
COALESCE(s.bank_name, 'Manual') as bank_name, ${bankLabel()} as bank_name,
t.statement_id t.statement_id
FROM transactions t FROM transactions t
${STATEMENTS_JOIN} ${STATEMENTS_JOIN}
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1 LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1
WHERE ${OWNER_SCOPE} = $1 WHERE ${MY_SPEND_SCOPE()}
AND t.transaction_type IN ('debit', 'fee', 'interest', 'refund', 'credit') AND t.transaction_type IN ('debit', 'fee', 'interest', 'refund', 'credit')
AND COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name, t.description) = $2 AND COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name, t.description) = $2
AND ${EXCLUDE_RECONCILED_SOURCE}
ORDER BY t.transaction_date DESC ORDER BY t.transaction_date DESC
LIMIT 500 LIMIT 500
`, [user.id, decoded]); `, [user.id, decoded]);
+6 -4
View File
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth"; import { getCurrentUser } from "@/lib/auth";
import { queryRaw } from "@/lib/db"; import { queryRaw } from "@/lib/db";
import { OWNER_SCOPE, STATEMENTS_JOIN, EXCLUDE_NON_SPEND, EFFECTIVE_CATEGORY, mySplitOf } from "@/lib/analytics-sql"; import { MY_SPEND_SCOPE, STATEMENTS_JOIN, EXCLUDE_NON_SPEND, EXCLUDE_RECONCILED_SOURCE, EFFECTIVE_CATEGORY, mySplitOf, toDateStr } from "@/lib/analytics-sql";
// Split-adjusted amount helper (positive for spend, negative for refunds) // Split-adjusted amount helper (positive for spend, negative for refunds)
const MY_AMOUNT = mySplitOf(`COALESCE(t.amount_aud, t.amount)`); const MY_AMOUNT = mySplitOf(`COALESCE(t.amount_aud, t.amount)`);
@@ -21,7 +21,7 @@ export async function GET(req: NextRequest) {
const cutoff = new Date(); const cutoff = new Date();
cutoff.setMonth(cutoff.getMonth() - months); cutoff.setMonth(cutoff.getMonth() - months);
const fromDate = cutoff.toISOString().slice(0, 10); const fromDate = toDateStr(cutoff);
// Merchant aggregates — net spend (debits + fees - refunds/credits) // Merchant aggregates — net spend (debits + fees - refunds/credits)
const rows = await queryRaw<{ const rows = await queryRaw<{
@@ -65,10 +65,11 @@ export async function GET(req: NextRequest) {
${STATEMENTS_JOIN} ${STATEMENTS_JOIN}
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1 LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1
WHERE ${OWNER_SCOPE} = $1 WHERE ${MY_SPEND_SCOPE()}
AND t.transaction_type IN ('debit', 'fee', 'interest', 'refund', 'credit') AND t.transaction_type IN ('debit', 'fee', 'interest', 'refund', 'credit')
AND t.transaction_date >= $2 AND t.transaction_date >= $2
AND ${EXCLUDE_NON_SPEND} AND ${EXCLUDE_NON_SPEND}
AND ${EXCLUDE_RECONCILED_SOURCE}
GROUP BY 1 GROUP BY 1
HAVING SUM(${SPEND_EXPR}) > 0 HAVING SUM(${SPEND_EXPR}) > 0
ORDER BY net_spend DESC ORDER BY net_spend DESC
@@ -90,11 +91,12 @@ export async function GET(req: NextRequest) {
${STATEMENTS_JOIN} ${STATEMENTS_JOIN}
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1 LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1
WHERE ${OWNER_SCOPE} = $1 WHERE ${MY_SPEND_SCOPE()}
AND t.transaction_type IN ('debit', 'fee', 'interest', 'refund', 'credit') AND t.transaction_type IN ('debit', 'fee', 'interest', 'refund', 'credit')
AND t.transaction_date >= $2 AND t.transaction_date >= $2
AND COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name, t.description) = ANY($3) AND COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name, t.description) = ANY($3)
AND ${EXCLUDE_NON_SPEND} AND ${EXCLUDE_NON_SPEND}
AND ${EXCLUDE_RECONCILED_SOURCE}
GROUP BY 1, 2 GROUP BY 1, 2
ORDER BY 1, 2 ORDER BY 1, 2
`, [user.id, fromDate, topMerchants]); `, [user.id, fromDate, topMerchants]);
+16 -6
View File
@@ -3,12 +3,16 @@ import { getCurrentUser } from "@/lib/auth";
import { queryRaw } from "@/lib/db"; import { queryRaw } from "@/lib/db";
import { import {
OWNER_SCOPE, OWNER_SCOPE,
MY_SPEND_SCOPE,
STATEMENTS_JOIN, STATEMENTS_JOIN,
EFFECTIVE_CATEGORY, EFFECTIVE_CATEGORY,
EXCLUDE_NON_SPEND, EXCLUDE_NON_SPEND,
EXCLUDE_RECONCILED_SOURCE,
NET_SPEND_ROWS, NET_SPEND_ROWS,
SPEND_SIGNED, SPEND_SIGNED,
INVESTMENT_SIGNED,
mySplitOf, mySplitOf,
toDateStr,
} from "@/lib/analytics-sql"; } from "@/lib/analytics-sql";
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
@@ -22,8 +26,8 @@ export async function GET(req: NextRequest) {
const endDate = new Date(now.getFullYear(), now.getMonth() + 1, 1); const endDate = new Date(now.getFullYear(), now.getMonth() + 1, 1);
const startDate = new Date(now.getFullYear(), now.getMonth() - monthCount + 1, 1); const startDate = new Date(now.getFullYear(), now.getMonth() - monthCount + 1, 1);
const startStr = startDate.toISOString().slice(0, 10); const startStr = toDateStr(startDate);
const endStr = endDate.toISOString().slice(0, 10); const endStr = toDateStr(endDate);
// Expenses: debits excluding transfers and investments, split-adjusted // Expenses: debits excluding transfers and investments, split-adjusted
const spendRows = await queryRaw<{ const spendRows = await queryRaw<{
@@ -35,15 +39,18 @@ export async function GET(req: NextRequest) {
`SELECT `SELECT
TO_CHAR(DATE_TRUNC('month', t.transaction_date::date), 'YYYY-MM') as month, TO_CHAR(DATE_TRUNC('month', t.transaction_date::date), 'YYYY-MM') as month,
${EFFECTIVE_CATEGORY} as category, ${EFFECTIVE_CATEGORY} as category,
SUM(${mySplitOf(SPEND_SIGNED)})::numeric(12,2) as total_spent, -- 4dp so the month total is summed from unrounded parts; every consumer
-- rounds for display. See the note in /api/analytics/daily.
SUM(${mySplitOf(SPEND_SIGNED)})::numeric(14,4) as total_spent,
COUNT(*)::int as transaction_count COUNT(*)::int as transaction_count
FROM transactions t FROM transactions t
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1 LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1
${STATEMENTS_JOIN} ${STATEMENTS_JOIN}
WHERE ${OWNER_SCOPE} = $1 WHERE ${MY_SPEND_SCOPE()}
AND ${NET_SPEND_ROWS} AND ${NET_SPEND_ROWS}
AND ${EXCLUDE_NON_SPEND} AND ${EXCLUDE_NON_SPEND}
AND ${EXCLUDE_RECONCILED_SOURCE}
AND t.transaction_date >= $2 AND t.transaction_date >= $2
AND t.transaction_date < $3 AND t.transaction_date < $3
GROUP BY 1, 2 GROUP BY 1, 2
@@ -67,6 +74,7 @@ export async function GET(req: NextRequest) {
WHERE ${OWNER_SCOPE} = $1 WHERE ${OWNER_SCOPE} = $1
AND t.transaction_type IN ('credit', 'payment') AND t.transaction_type IN ('credit', 'payment')
AND ${EFFECTIVE_CATEGORY} = 'income' AND ${EFFECTIVE_CATEGORY} = 'income'
AND ${EXCLUDE_RECONCILED_SOURCE}
AND t.transaction_date >= $2 AND t.transaction_date >= $2
AND t.transaction_date < $3 AND t.transaction_date < $3
GROUP BY 1 GROUP BY 1
@@ -74,7 +82,8 @@ export async function GET(req: NextRequest) {
[user.id, startStr, endStr] [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<{ const investmentRows = await queryRaw<{
month: string; month: string;
total_invested: number; total_invested: number;
@@ -82,13 +91,14 @@ export async function GET(req: NextRequest) {
}>( }>(
`SELECT `SELECT
TO_CHAR(DATE_TRUNC('month', t.transaction_date::date), 'YYYY-MM') as month, 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 COUNT(*)::int as transaction_count
FROM transactions t FROM transactions t
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
${STATEMENTS_JOIN} ${STATEMENTS_JOIN}
WHERE ${OWNER_SCOPE} = $1 WHERE ${OWNER_SCOPE} = $1
AND ${EFFECTIVE_CATEGORY} = 'investment' AND ${EFFECTIVE_CATEGORY} = 'investment'
AND ${EXCLUDE_RECONCILED_SOURCE}
AND t.transaction_date >= $2 AND t.transaction_date >= $2
AND t.transaction_date < $3 AND t.transaction_date < $3
GROUP BY 1 GROUP BY 1
+3 -2
View File
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth"; import { getCurrentUser } from "@/lib/auth";
import { queryRaw } from "@/lib/db"; import { queryRaw } from "@/lib/db";
import { OWNER_SCOPE, STATEMENTS_JOIN, EXCLUDE_NON_SPEND, EFFECTIVE_CATEGORY, mySplitOf } from "@/lib/analytics-sql"; import { MY_SPEND_SCOPE, STATEMENTS_JOIN, EXCLUDE_NON_SPEND, EXCLUDE_RECONCILED_SOURCE, EFFECTIVE_CATEGORY, mySplitOf } from "@/lib/analytics-sql";
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const user = await getCurrentUser(req); const user = await getCurrentUser(req);
@@ -28,9 +28,10 @@ export async function GET(req: NextRequest) {
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1 LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1
${STATEMENTS_JOIN} ${STATEMENTS_JOIN}
WHERE ${OWNER_SCOPE} = $1 WHERE ${MY_SPEND_SCOPE()}
AND t.transaction_type IN ('debit', 'fee') AND t.transaction_type IN ('debit', 'fee')
AND ${EXCLUDE_NON_SPEND} AND ${EXCLUDE_NON_SPEND}
AND ${EXCLUDE_RECONCILED_SOURCE}
AND COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) IS NOT NULL AND COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) IS NOT NULL
), ),
merchant_with_lag AS ( merchant_with_lag AS (
+9 -1
View File
@@ -8,6 +8,12 @@ export async function POST(req: NextRequest) {
const body = await req.json() as { const body = await req.json() as {
bank_name: string; 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: { transactions: {
date: string; date: string;
description: string; description: string;
@@ -17,6 +23,8 @@ export async function POST(req: NextRequest) {
foreign_currency_amount?: number; foreign_currency_amount?: number;
foreign_currency_code?: string; foreign_currency_code?: string;
category?: 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 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 }); 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() });
}
+67
View File
@@ -0,0 +1,67 @@
import { NextRequest, NextResponse } from "next/server";
import { sha256Of, type StoredFile } from "@/lib/intake-files";
import { liteLLMExtractor } from "@/lib/intake-extract";
import { pantryForwarder } from "@/lib/intake-pantry";
import { isIntakeSource } from "@/lib/intake-rules";
import { MAX_FILES, runIntake } from "@/lib/intake-router";
/**
* The one upload point (plan §11 C1). n8n forwards the iOS Shortcut's
* multipart POST here: `source`, `shared_by`, `batch_id`, and 1–6 files under
* `file`. The router reads what it is given and does every step that applies.
*
* Auth is a shared secret, like the two ingest siblings: this is machine-to-
* machine and there is no browser session to forward. Its own token so the
* Shortcut's credential rotates without touching the order or receipt lanes.
* Fails closed when unset.
*/
function authorised(req: NextRequest): boolean {
const expected = process.env.INTAKE_TOKEN;
if (!expected) return false;
const got = req.headers.get("x-ingest-token");
return !!got && got === expected;
}
const MAX_BYTES = 12_000_000;
export async function POST(req: NextRequest) {
if (!authorised(req)) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
let form: FormData;
try {
form = await req.formData();
} catch {
return NextResponse.json({ error: "multipart/form-data body required" }, { status: 400 });
}
const source = form.get("source");
if (!isIntakeSource(source)) {
return NextResponse.json({ error: "source must be one of uber_eats|doordash|coles|woolworths|other" }, { status: 400 });
}
const sharedBy = typeof form.get("shared_by") === "string" ? String(form.get("shared_by")).trim() || null : null;
const batchIdRaw = form.get("batch_id");
const batchId = typeof batchIdRaw === "string" && /^[0-9a-f-]{36}$/i.test(batchIdRaw) ? batchIdRaw.toLowerCase() : null;
if (batchIdRaw && !batchId) return NextResponse.json({ error: "batch_id must be a uuid" }, { status: 400 });
const files: StoredFile[] = [];
for (const entry of form.getAll("file")) {
if (!(entry instanceof File) || !entry.size) continue;
if (entry.size > MAX_BYTES) return NextResponse.json({ error: `${entry.name} is larger than 12 MB` }, { status: 400 });
const bytes = Buffer.from(await entry.arrayBuffer());
files.push({ name: entry.name || `file-${files.length + 1}`, type: entry.type || "", bytes, sha256: sha256Of(bytes) });
}
if (!files.length) return NextResponse.json({ error: "at least one file is required under field 'file'" }, { status: 400 });
if (files.length > MAX_FILES) return NextResponse.json({ error: `at most ${MAX_FILES} files per batch` }, { status: 400 });
try {
const result = await runIntake(
{ batchId, source, sharedBy, files },
{ extractor: liteLLMExtractor(), pantry: pantryForwarder() }
);
const { http_status, ...body } = result;
return NextResponse.json(body, { status: http_status });
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
return NextResponse.json({ error: message }, { status: 500 });
}
}
+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);
}
+173
View File
@@ -0,0 +1,173 @@
import { NextRequest, NextResponse } from "next/server";
import { queryRow } from "@/lib/db";
import {
parseOrderHTML,
parseOrderAmendment,
isAmendment,
validateOrderTotals,
processOrderIngestion,
applyOrderAmendment,
reconcilePendingOrders,
OrderParseError,
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.
*
* n8n polls the two mailboxes and POSTs each message here. The parsing lives in
* the app, not in an n8n Code node, because the n8n sandbox has no `require`
* and no filesystem — a parser there could not be unit-tested against the real
* fixture corpus, which is the whole reason this one is trustworthy.
*
* Auth is a shared secret, not the Traefik `x-forwarded-user` header: this is
* called machine-to-machine and there is no browser session to forward.
*/
function authorised(req: NextRequest): boolean {
const expected = process.env.ORDER_INGEST_TOKEN;
if (!expected) return false; // fail closed when unconfigured
const got = req.headers.get("x-ingest-token");
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 });
}
let body: { html?: string; meta?: MessageMeta; dryRun?: boolean };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "invalid JSON" }, { status: 400 });
}
const { html, meta, dryRun } = body;
if (!html || !meta?.messageId || !meta?.subject || !meta?.receivedAt) {
return NextResponse.json(
{ error: "html and meta{messageId,subject,receivedAt} are required" },
{ status: 400 }
);
}
try {
// Amendments restate an existing order; they are not receipts.
if (isAmendment(html)) {
const amendment = parseOrderAmendment(html, meta);
if (dryRun) return NextResponse.json({ kind: "amendment", amendment });
const applied = await applyOrderAmendment(amendment);
return NextResponse.json({ kind: "amendment", amendment, applied });
}
const order = parseOrderHTML(html, meta);
const check = validateOrderTotals(order, html);
if (!check.ok) {
// Refuse rather than record a number we cannot stand behind.
return NextResponse.json(
{ kind: "rejected", reason: check.reason, order_reference: order.order_reference },
{ status: 422 }
);
}
if (dryRun) return NextResponse.json({ kind: "order", order });
const result = await processOrderIngestion(order, {
messageId: meta.messageId,
subject: meta.subject,
sender: meta.sender,
});
// What we said about this merchant before, so the Slack nudge can warn at
// the moment the order lands rather than waiting for someone to open the
// app. `result.transactionId` is excluded because a brand-new order has no
// verdict yet — anything found is genuinely a previous visit.
const verdict = result.skipped
? null
: await merchantVerdict(order.merchant_name, result.transactionId);
return NextResponse.json({
kind: "order",
order_reference: order.order_reference,
merchant: order.merchant_name,
total: order.totals.total_charged,
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
// notices. Expected traffic — 200 and silent, or the alert channel fills
// with noise and stops being read.
if (e instanceof NotAReceiptError) {
return NextResponse.json({ kind: "skipped", reason: e.message });
}
// IS a receipt, could not be parsed. This is the failure that matters and
// it must be loud: a provider template change breaks every order at once,
// and the only other symptom is spend quietly ceasing to appear. Returning
// 200 here — as this route originally did — made the most likely
// production failure completely invisible.
if (e instanceof OrderParseError) {
return NextResponse.json(
{ kind: "parse_failed", reason: e.message, messageId: e.messageId },
{ status: 422 }
);
}
const message = e instanceof Error ? e.message : String(e);
return NextResponse.json({ error: message }, { status: 500 });
}
}
/** Statement-import hook: resolve orders parked awaiting a card statement. */
export async function PATCH(req: NextRequest) {
if (!authorised(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const out = await reconcilePendingOrders();
return NextResponse.json(out);
}
+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 });
}
@@ -1,35 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { queryRaw } from "@/lib/db";
import { getCurrentUser } from "@/lib/auth";
interface BalanceRow {
participant_id: number;
name: string;
total_owed: number;
transaction_count: number;
}
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
const { id } = await params;
const rows = await queryRaw<BalanceRow>(
`SELECT ts.participant_id, p.name,
SUM(COALESCE(t.amount_aud, t.amount) * ts.share_percent / 100)::numeric(12,2) as total_owed,
COUNT(*)::int as transaction_count
FROM transaction_splits ts
JOIN transactions t ON t.id = ts.transaction_id
JOIN participants p ON p.id = ts.participant_id
WHERE ts.participant_id = $1 AND ts.settled = false
GROUP BY ts.participant_id, p.name`,
[Number(id)]
);
return NextResponse.json(
rows[0] ?? { participant_id: Number(id), total_owed: 0, transaction_count: 0 }
);
}
+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 { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth"; import { getCurrentUser } from "@/lib/auth";
import { queryRaw } from "@/lib/db"; import { queryRaw } from "@/lib/db";
import { completeSplit } from "@/lib/splits";
interface SnapshotEntry { interface SnapshotEntry {
transaction_id: number; 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] [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( await queryRaw(
+13 -7
View File
@@ -17,8 +17,12 @@ export async function GET(req: NextRequest) {
matched: number; matched: number;
transactions_affected: number; transactions_affected: number;
reverted_at: string | null; reverted_at: string | null;
rule_id: number | null;
rule_name: string | null;
source: string | null;
}>( }>(
`SELECT id, applied_at, split_from, matched, transactions_affected, reverted_at `SELECT id, applied_at, split_from, matched, transactions_affected, reverted_at,
rule_id, rule_name, source
FROM rule_apply_runs WHERE owner_id = $1 ORDER BY applied_at DESC LIMIT 20`, FROM rule_apply_runs WHERE owner_id = $1 ORDER BY applied_at DESC LIMIT 20`,
[user.id] [user.id]
); );
@@ -37,11 +41,11 @@ export async function POST(req: NextRequest) {
// Manual-only rules ("quick actions") never take part in a condition-matched // Manual-only rules ("quick actions") never take part in a condition-matched
// run — their conditions are typically empty, so they would match every // run — their conditions are typically empty, so they would match every
// transaction. They are fired from the transactions page against a selection. // transaction. They are fired from the transactions page against a selection.
const rules = await queryRaw<{ id: number; conditions: unknown; actions: unknown }>( const rules = await queryRaw<{ id: number; name: string; conditions: unknown; actions: unknown }>(
ruleId ruleId
? `SELECT id, conditions, actions FROM rules ? `SELECT id, name, conditions, actions FROM rules
WHERE owner_id = $1 AND id = $2 AND manual_only = false` WHERE owner_id = $1 AND id = $2 AND manual_only = false`
: `SELECT id, conditions, actions FROM rules : `SELECT id, name, conditions, actions FROM rules
WHERE owner_id = $1 AND enabled = true AND manual_only = false WHERE owner_id = $1 AND enabled = true AND manual_only = false
ORDER BY priority DESC`, ORDER BY priority DESC`,
ruleId ? [user.id, ruleId] : [user.id] ruleId ? [user.id, ruleId] : [user.id]
@@ -92,9 +96,11 @@ export async function POST(req: NextRequest) {
// --- Save run record --- // --- Save run record ---
const run = await queryRaw<{ id: number }>( const run = await queryRaw<{ id: number }>(
`INSERT INTO rule_apply_runs (owner_id, split_from, matched, transactions_affected, snapshot) `INSERT INTO rule_apply_runs (owner_id, split_from, matched, transactions_affected, snapshot,
VALUES ($1, $2, $3, $4, $5) RETURNING id`, rule_id, rule_name, source)
[user.id, splitFrom, matched, affectedIds.size, JSON.stringify(snapshot)] VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id`,
[user.id, splitFrom, matched, affectedIds.size, JSON.stringify(snapshot),
ruleId, ruleId ? (rules[0]?.name ?? null) : null, ruleId ? "rule" : "all"]
); );
return NextResponse.json({ id: run[0].id, matched, transactions_affected: affectedIds.size }); return NextResponse.json({ id: run[0].id, matched, transactions_affected: affectedIds.size });
+134
View File
@@ -0,0 +1,134 @@
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { queryRaw } from "@/lib/db";
/**
* What a rule run actually did — the before-state from its snapshot set against
* the values now.
*
* The run list only had counts, which is not enough to decide whether to revert:
* "13 matches · 13 transactions" reads the same whether it renamed a merchant or
* split your history with someone. Reverting is destructive, so the detail has
* to be visible before the button is pressed.
*/
interface SnapshotEntry {
transaction_id: number;
had_override: boolean;
prev_category_override: string | null;
prev_merchant_normalized: string | null;
prev_tag_ids: number[];
prev_splits: { participant_id: number; share_percent: number; settled: boolean }[];
}
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
const { id } = await params;
const runs = await queryRaw<{
id: number; applied_at: string; reverted_at: string | null;
split_from: string | null; matched: number; transactions_affected: number;
rule_id: number | null; rule_name: string | null; source: string | null;
snapshot: unknown;
}>(
`SELECT id, applied_at, reverted_at, split_from, matched, transactions_affected,
rule_id, rule_name, source, snapshot
FROM rule_apply_runs WHERE id = $1 AND owner_id = $2`,
[Number(id), user.id]
);
if (!runs.length) return NextResponse.json({ error: "Run not found" }, { status: 404 });
const run = runs[0];
const snapshot = (typeof run.snapshot === "string"
? JSON.parse(run.snapshot) : run.snapshot) as SnapshotEntry[];
const byId = new Map(snapshot.map((s) => [s.transaction_id, s]));
const ids = snapshot.map((s) => s.transaction_id);
if (ids.length === 0) {
return NextResponse.json({ run: { ...run, snapshot: undefined }, transactions: [] });
}
const current = await queryRaw<{
id: number; transaction_date: string; description: string;
amount: number; amount_aud: number | null; bank_name: string;
category: string | null; category_override: string | null;
merchant_normalized: string | null; merchant_override: string | null;
merchant_name: string | null;
tag_ids: number[]; splits: { participant_id: number; share_percent: number }[];
}>(
`SELECT t.id, t.transaction_date::text, t.description, t.amount, t.amount_aud,
COALESCE(s.bank_name, 'Manual') as bank_name,
t.category, o.category_override,
t.merchant_normalized, o.merchant_normalized as merchant_override, t.merchant_name,
COALESCE((SELECT json_agg(tt.tag_id) FROM transaction_tags tt WHERE tt.transaction_id = t.id), '[]'::json) as tag_ids,
COALESCE((SELECT json_agg(json_build_object('participant_id', ts.participant_id, 'share_percent', ts.share_percent))
FROM transaction_splits ts WHERE ts.transaction_id = t.id), '[]'::json) as splits
FROM transactions t
LEFT JOIN statements s ON s.id = t.statement_id
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
WHERE t.id = ANY($1::int[])
ORDER BY t.transaction_date DESC`,
[ids]
);
const parse = <T,>(v: unknown, fallback: T): T =>
(typeof v === "string" ? JSON.parse(v) : v) ?? fallback;
const transactions = current.map((t) => {
const prev = byId.get(t.id);
const nowTags: number[] = parse(t.tag_ids, []);
const nowSplits = parse<{ participant_id: number; share_percent: number }[]>(t.splits, []);
const prevTags = prev?.prev_tag_ids ?? [];
const prevSplits = prev?.prev_splits ?? [];
const nowCategory = t.category_override ?? t.category;
const prevCategory = prev?.prev_category_override ?? t.category;
const nowMerchant = t.merchant_override ?? t.merchant_normalized ?? t.merchant_name;
const prevMerchant = prev?.prev_merchant_normalized ?? t.merchant_normalized ?? t.merchant_name;
const changes: { field: string; from: string | null; to: string | null }[] = [];
if (nowCategory !== prevCategory) {
changes.push({ field: "category", from: prevCategory, to: nowCategory });
}
if (nowMerchant !== prevMerchant) {
changes.push({ field: "merchant", from: prevMerchant, to: nowMerchant });
}
const addedTags = nowTags.filter((x) => !prevTags.includes(x));
if (addedTags.length) {
changes.push({ field: "tags", from: null, to: addedTags.join(",") });
}
const fmtSplits = (arr: { participant_id: number; share_percent: number }[]) =>
arr.length ? arr.map((s) => `${s.participant_id}:${Number(s.share_percent)}%`).sort().join(" ") : null;
if (fmtSplits(nowSplits) !== fmtSplits(prevSplits)) {
changes.push({ field: "split", from: fmtSplits(prevSplits), to: fmtSplits(nowSplits) });
}
return {
id: t.id,
transaction_date: t.transaction_date,
description: t.description,
amount: t.amount,
amount_aud: t.amount_aud,
bank_name: t.bank_name,
merchant: nowMerchant,
changes,
};
});
return NextResponse.json({
run: {
id: run.id, applied_at: run.applied_at, reverted_at: run.reverted_at,
split_from: run.split_from, matched: run.matched,
transactions_affected: run.transactions_affected,
rule_id: run.rule_id, rule_name: run.rule_name, source: run.source,
},
// A reverted run still lists its transactions, but they will show no changes
// because the values are back where they started.
transactions,
still_changed: transactions.filter((t) => t.changes.length > 0).length,
});
}
+426
View File
@@ -0,0 +1,426 @@
import { NextRequest, NextResponse } from "next/server";
import { queryRaw, queryRow } from "@/lib/db";
import {
verifySlackSignature,
participantForSlackUser,
slackUserForParticipant,
} from "@/lib/slack-verify";
import { nudgeBlocks, detailsModal, partnerNudgeBlocks } from "@/lib/slack-blocks";
import { handleIntakeAction, isIntakeAction, parseActionValue } from "@/lib/intake-actions";
import { pantryForwarder } from "@/lib/intake-pantry";
import { completeSplit } from "@/lib/splits";
import {
RATINGS,
OWNER_PARTICIPANT_ID,
SECOND_CONSUMER_ID,
merchantVerdict,
type Rating,
type ItemOpinion,
} from "@/lib/order-reviews";
/**
* Slack button presses on the order nudge.
*
* Pressing "Shared 50/50" splits the transaction here and now and edits the
* message in place. It deliberately does NOT link back to the app: being sent
* to a web app to answer a yes/no question is enough friction that the question
* stops getting answered (user, 2026-07-28).
*
* **Slack does not reach this route directly.** It posts to an n8n webhook,
* which forwards the raw body and Slack's signature headers here. That was the
* user's suggestion (2026-07-28) and it is the better shape: n8n already
* terminates public webhooks, so the app keeps its blanket OAuth chain and
* gains no internet-facing unauthenticated route. n8n cannot do the verifying
* itself — its Code node sandbox has no `require`, so no `crypto`.
*
* Two independent gates, both fail closed:
* 1. `x-ingest-token`, proving the call came from n8n over the internal
* network. Same shared secret as the order ingest route.
* 2. Slack's v0 request signature over the forwarded raw body, proving the
* payload really came from Slack and is not a replay.
*
* The card is updated by POSTing to `payload.response_url`, NOT by returning a
* message body. Block Kit interactivity ignores the HTTP response body — that
* replacement behaviour belongs to legacy attachment-style messages. Assuming
* otherwise meant every press wrote correctly and then left the card showing
* stale state, so a working button looked dead and got pressed twice, undoing
* itself. `response_url` needs no bot token, which is why this stays in the app.
*/
export async function POST(req: NextRequest) {
const expected = process.env.ORDER_INGEST_TOKEN;
if (!expected || req.headers.get("x-ingest-token") !== expected) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Must be the RAW body Slack signed — re-serialising the parsed form changes
// the bytes and every signature check fails. n8n forwards it verbatim.
const raw = await req.text();
if (
!verifySlackSignature(
raw,
req.headers.get("x-slack-request-timestamp"),
req.headers.get("x-slack-signature")
)
) {
return NextResponse.json({ error: "bad signature" }, { status: 401 });
}
const payloadRaw = new URLSearchParams(raw).get("payload");
if (!payloadRaw) return NextResponse.json({ error: "no payload" }, { status: 400 });
const payload = JSON.parse(payloadRaw);
if (payload.type === "view_submission") return handleModalSubmit(payload);
if (payload.type !== "block_actions") return new NextResponse(null, { status: 200 });
const action = payload.actions?.[0];
// Intake taps (plan §11 C5) act on a persisted batch, not a transaction, so
// their `value` is JSON `{batch_id, ...}` rather than `<id>:<verb>`. They are
// dispatched on action_id before the order-nudge parse below, which would
// otherwise read the JSON as a non-numeric transaction id and give up.
if (isIntakeAction(action?.action_id)) {
const reply = await handleIntakeAction(action.action_id, parseActionValue(action?.value), { pantry: pantryForwarder() });
await updateMessage(
payload.response_url,
reply.replace
? { replace_original: true, blocks: reply.blocks, text: reply.text }
: { response_type: "ephemeral", replace_original: false, text: reply.text }
);
return NextResponse.json({ blocks: reply.blocks, text: reply.text });
}
// A button carries `value`; a select carries it on the chosen option. The
// ratings moved to a select because four buttons became four full-width rows
// on Slack mobile, so both shapes have to resolve or rating silently stops
// working while sharing still does.
const rawValue = action?.value ?? action?.selected_option?.value ?? "";
const [idStr, verb, arg] = String(rawValue).split(":");
const transactionId = Number(idStr);
if (!Number.isInteger(transactionId)) {
return NextResponse.json({ text: "Could not tell which order that was." });
}
const participantId = participantForSlackUser(payload.user?.id ?? "");
if (!participantId) {
// Ephemeral: only the presser sees it, so an unmapped colleague does not
// rewrite the shared message for everyone.
await updateMessage(payload.response_url, {
response_type: "ephemeral",
replace_original: false,
text: `I don't know which participant ${payload.user?.id} is — add them to SLACK_USER_MAP.`,
});
return NextResponse.json({});
}
// The modal needs `views.open` called with this trigger_id within ~3s. The
// app has no Slack bot token — n8n already holds the credential — so the
// view is returned and n8n makes the call. That keeps one copy of the token.
if (verb === "details") {
const view = await buildDetailsModal(transactionId, participantId);
if (!view) return NextResponse.json({ text: "That order is no longer in the ledger." });
return NextResponse.json({ action: "open_modal", trigger_id: payload.trigger_id, view });
}
let refused: string | null = null;
if (verb === "share") {
refused = await toggleShare(transactionId);
} else if (verb === "rate" && RATINGS.includes(arg as Rating)) {
await setRating(transactionId, participantId, arg as Rating);
}
const state = await nudgeState(transactionId);
if (!state) return NextResponse.json({ text: "That order is no longer in the ledger." });
// Ask the other person for their verdict, but only on the press that turned
// sharing ON — and only when it was someone else who shared it with them.
// Re-notifying on every subsequent rating press would make one shared meal
// a stream of DMs, which is how a useful nudge becomes muted.
const notify =
verb === "share" && state.shared && participantId !== SECOND_CONSUMER_ID
? buildPartnerNotify(state, payload.user?.name)
: null;
// Block Kit interactivity does NOT replace the message from the HTTP response
// body — that is legacy attachment-style behaviour, and assuming it meant the
// splits changed while the card kept showing stale state, so a working button
// looked dead and got pressed twice. The update has to go to `response_url`,
// which needs no token, so the app can post it directly.
if (refused) {
await updateMessage(payload.response_url, {
response_type: "ephemeral",
replace_original: false,
text: refused,
});
}
await updateMessage(payload.response_url, {
replace_original: true,
blocks: nudgeBlocks(state),
// Notification text for clients that cannot render blocks.
text: `${state.merchant} — ${state.currency} ${state.total.toFixed(2)}`,
});
// The blocks are echoed for callers that want the rendered card without
// pressing anything — the replay tooling posts them with chat.postMessage, so
// a card is never hand-written with a guessed `shared` state again. Slack
// ignores the body for block_actions, which is the whole reason the real
// update goes to response_url above.
return NextResponse.json({
...(notify ? { notify } : {}),
blocks: nudgeBlocks(state),
text: `${state.merchant} — ${state.currency} ${state.total.toFixed(2)}`,
});
}
/**
* Replace the card in place.
*
* `response_url` is a signed, single-use-ish Slack URL carried in the
* interaction payload; it needs no bot token, which is why this can live in the
* app rather than being handed back to n8n. Valid for 30 minutes and 5 uses —
* ample for a button press, and not something to cache.
*
* Failures are swallowed deliberately. The write already succeeded; throwing
* here would turn a cosmetic staleness into a 500 that Slack shows the user as
* a failed action, implying nothing happened when in fact it did.
*/
async function updateMessage(responseUrl: string | undefined, body: unknown) {
if (!responseUrl) return;
try {
await fetch(responseUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
} catch {
/* the state is correct even when the card is stale */
}
}
/**
* The DM payload for the other person, or null if we cannot address them.
*
* Returns an instruction rather than sending: the app holds no Slack bot token,
* so n8n — which already has the credential — makes the call. Same shape as the
* modal open.
*
* Null when SLACK_USER_MAP has no Slack id for the second consumer. Silent
* rather than an error: the split is still correct and complete, and failing
* the whole press because a DM could not be addressed would be worse than the
* missing nudge.
*/
function buildPartnerNotify(
state: Awaited<ReturnType<typeof nudgeState>>,
sharerName?: string
) {
if (!state) return null;
const slackUser = slackUserForParticipant(SECOND_CONSUMER_ID);
if (!slackUser) return null;
return {
user: slackUser,
text: `${state.merchant} — shared with you 50/50`,
blocks: partnerNudgeBlocks(state, sharerName || "It was"),
};
}
/** The modal view, pre-filled with whatever this person already said. */
async function buildDetailsModal(transactionId: number, participantId: number) {
const row = await queryRow<{
merchant: string | null;
line_items: { description?: string }[] | null;
}>(
`SELECT merchant_normalized AS merchant, line_items
FROM expense_metadata
WHERE transaction_id = $1 OR matched_transaction_id = $1
-- Ordered the same way as replaceLineItems (line-items.ts) and
-- matchTransaction (intake-router.ts), so every reader of this row
-- resolves the same one a write lands on. NULLS LAST because DESC is
-- NULLS FIRST and the row that merely POINTS at the transaction has a
-- NULL transaction_id.
-- Measured 2026-08-21: 667 expense_metadata rows, 147 of them
-- pointing-only, and ZERO transactions resolving to more than one row by
-- either link — so this changes nothing today. It is here so the readers
-- still agree on the day multiplicity appears.
ORDER BY (transaction_id = $1) DESC NULLS LAST, id
LIMIT 1`,
[transactionId]
);
if (!row) return null;
const existing = await queryRow<{ note: string | null; item_verdicts: ItemOpinion[] }>(
`SELECT note, item_verdicts FROM order_reviews
WHERE transaction_id = $1 AND participant_id = $2`,
[transactionId, participantId]
);
const items = (row.line_items ?? [])
.map((i) => (i?.description ?? "").trim())
.filter(Boolean);
return detailsModal(
transactionId,
participantId,
row.merchant ?? "Order",
items,
{ note: existing?.note ?? null, itemVerdicts: existing?.item_verdicts ?? [] }
);
}
/**
* The modal came back. Save the note and the per-item verdicts.
*
* `response_action: "clear"` closes it. Returning a plain 200 with no body
* leaves the modal open with a spinner, which reads as a hang.
*
* The rating is NOT touched here — it lives on the card, and a modal that
* silently reset it would undo a decision the user did not revisit.
*/
async function handleModalSubmit(payload: {
view: { private_metadata: string; state: { values: Record<string, Record<string, { value?: string; selected_option?: { value: string } }>> } };
}) {
const meta = JSON.parse(payload.view.private_metadata ?? "{}");
const transactionId = Number(meta.t);
const participantId = Number(meta.p);
const items: string[] = Array.isArray(meta.i) ? meta.i : [];
if (!Number.isInteger(transactionId) || !Number.isInteger(participantId)) {
return NextResponse.json({ response_action: "clear" });
}
const values = payload.view.state.values ?? {};
const note = values.note?.value?.value?.trim() || null;
// Slack returns block ids and values, never the labels, so the item text is
// recovered from private_metadata by index.
const itemVerdicts: ItemOpinion[] = [];
items.forEach((item, i) => {
const picked = values[`item_${i}`]?.verdict?.selected_option?.value;
if (picked === "loved" || picked === "never") {
itemVerdicts.push({ item, verdict: picked });
}
});
await queryRaw(
`INSERT INTO order_reviews (transaction_id, participant_id, note, item_verdicts)
VALUES ($1, $2, $3, $4::jsonb)
ON CONFLICT (transaction_id, participant_id) DO UPDATE
SET note = EXCLUDED.note,
item_verdicts = EXCLUDED.item_verdicts,
updated_at = now()`,
[transactionId, participantId, note, JSON.stringify(itemVerdicts)]
);
return NextResponse.json({ response_action: "clear" });
}
/**
* Share or unshare, as a real 50/50 split.
*
* The split IS the record that an order was shared, so there is no separate
* flag to keep in step. Safe to clear on an ingested order because such a row
* is post-cutover by construction — the DB CHECK forbids credits orders before
* 2026-01-09 — so there is no settled historical obligation to lose.
*/
async function toggleShare(transactionId: number): Promise<string | null> {
const existing = await queryRaw<{ participant_id: number; share_percent: string }>(
`SELECT participant_id, share_percent FROM transaction_splits WHERE transaction_id = $1`,
[transactionId]
);
// Refuse to touch an arrangement this button cannot express. Splits are made
// by hand here, so a third participant or an uneven share is deliberate — and
// a one-tap button that silently flattened it would destroy a decision made
// with more care than the tap that undid it.
const foreign = existing.filter(
(e) => e.participant_id !== SECOND_CONSUMER_ID && e.participant_id !== OWNER_PARTICIPANT_ID
);
const uneven = existing.some(
(e) => e.participant_id === SECOND_CONSUMER_ID && Number(e.share_percent) !== 50
);
if (foreign.length || uneven) {
// Say so. Returning silently left the card unchanged, which reads exactly
// like a broken button — and a button that looks broken gets pressed again.
return foreign.length
? "This one is split with someone else, so I left it alone. Change it in the app."
: "This one is not an even 50/50, so I left it alone. Change it in the app.";
}
if (existing.some((e) => e.participant_id === SECOND_CONSUMER_ID)) {
await queryRaw(`DELETE FROM transaction_splits WHERE transaction_id = $1`, [
transactionId,
]);
return null;
}
await queryRaw(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
VALUES ($1, $2, 50)
ON CONFLICT (transaction_id, participant_id)
DO UPDATE SET share_percent = 50`,
[transactionId, SECOND_CONSUMER_ID]
);
// Both halves, not just theirs. The button means "50/50", and a lone row for
// the other person renders as a 50% share against a blank. The payer's half
// comes from completeSplit rather than a second hardcoded insert, so it lands
// on whoever actually owns the row instead of assuming that is me.
await completeSplit(transactionId);
return null;
}
async function setRating(transactionId: number, participantId: number, rating: Rating) {
await queryRaw(
`INSERT INTO order_reviews (transaction_id, participant_id, rating, order_again)
VALUES ($1, $2, $3, $4)
ON CONFLICT (transaction_id, participant_id) DO UPDATE
SET rating = EXCLUDED.rating,
order_again = EXCLUDED.order_again,
updated_at = now()`,
// "bad" and "never" both mean no; only "never" warns on a future order.
[transactionId, participantId, rating, rating !== "never" && rating !== "bad"]
);
}
/** Everything the refreshed message needs, read back after the write. */
async function nudgeState(transactionId: number) {
const row = await queryRow<{
merchant: string | null;
currency: string | null;
amount: string;
}>(
`SELECT em.merchant_normalized AS merchant,
COALESCE(em.currency, 'AUD') AS currency,
t.amount
FROM transactions t
LEFT JOIN expense_metadata em
ON em.transaction_id = t.id OR em.matched_transaction_id = t.id
WHERE t.id = $1`,
[transactionId]
);
if (!row) return null;
const [splits, ratings, verdict] = await Promise.all([
queryRaw<{ participant_id: number }>(
`SELECT participant_id FROM transaction_splits WHERE transaction_id = $1`,
[transactionId]
),
queryRaw<{ name: string; rating: Rating }>(
`SELECT p.name, r.rating FROM order_reviews r
JOIN participants p ON p.id = r.participant_id
WHERE r.transaction_id = $1 AND r.rating IS NOT NULL
ORDER BY r.participant_id`,
[transactionId]
),
merchantVerdict(row.merchant, transactionId),
]);
return {
transactionId,
merchant: row.merchant ?? "Unknown merchant",
currency: row.currency ?? "AUD",
total: Number(row.amount),
shared: splits.some((s) => s.participant_id === SECOND_CONSUMER_ID),
ratings,
warn: verdict?.warn ?? false,
warnNote: verdict?.history.find((h) => h.note)?.note ?? null,
};
}
+40 -2
View File
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth"; import { getCurrentUser } from "@/lib/auth";
import { queryRaw } from "@/lib/db"; import { queryRaw } from "@/lib/db";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { isTripParticipant } from "@/lib/queries";
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
const user = await getCurrentUser(req); const user = await getCurrentUser(req);
@@ -21,15 +22,22 @@ export async function GET(req: NextRequest) {
payment_date: string; payment_date: string;
notes: string | null; notes: string | null;
linked_transaction_id: number | null; linked_transaction_id: number | null;
trip_id: number | null;
trip_name: string | null;
created_at: string; 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, `SELECT sp.id, sp.from_participant_id, pf.name as from_name,
sp.to_participant_id, pt.name as to_name, sp.to_participant_id, pt.name as to_name,
sp.amount, sp.payment_date, sp.notes, 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 FROM split_payments sp
JOIN participants pf ON pf.id = sp.from_participant_id JOIN participants pf ON pf.id = sp.from_participant_id
JOIN participants pt ON pt.id = sp.to_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) WHERE (sp.from_participant_id = $1 OR sp.to_participant_id = $1)
AND (sp.from_participant_id = $2 OR sp.to_participant_id = $2) AND (sp.from_participant_id = $2 OR sp.to_participant_id = $2)
ORDER BY sp.payment_date DESC, sp.created_at DESC`, ORDER BY sp.payment_date DESC, sp.created_at DESC`,
@@ -50,9 +58,10 @@ export async function POST(req: NextRequest) {
payment_date: string; payment_date: string;
notes?: string; notes?: string;
linked_transaction_id?: number; 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) { if (!from_participant_id || !to_participant_id || !amount || !payment_date) {
return NextResponse.json({ error: "Missing required fields" }, { status: 400 }); return NextResponse.json({ error: "Missing required fields" }, { status: 400 });
@@ -60,6 +69,24 @@ export async function POST(req: NextRequest) {
if (amount <= 0) { if (amount <= 0) {
return NextResponse.json({ error: "Amount must be positive" }, { status: 400 }); 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({ const payment = await prisma.split_payments.create({
data: { data: {
@@ -69,6 +96,7 @@ export async function POST(req: NextRequest) {
payment_date: new Date(payment_date), payment_date: new Date(payment_date),
notes: notes || null, notes: notes || null,
linked_transaction_id: linked_transaction_id || 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")); const id = Number(sp.get("id"));
if (!id) return NextResponse.json({ error: "id required" }, { status: 400 }); 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 } }); await prisma.split_payments.delete({ where: { id } });
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} }
-46
View File
@@ -1,46 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { queryRaw } from "@/lib/db";
import { getCurrentUser } from "@/lib/auth";
// A split may be settled by the transaction's effective owner or by the
// participant the split belongs to.
const SCOPE = `
AND EXISTS (
SELECT 1 FROM transactions t
LEFT JOIN statements s ON s.id = t.statement_id
WHERE t.id = transaction_splits.transaction_id
AND (COALESCE(t.owner_id, s.owner_id) = $2 OR transaction_splits.participant_id = $2)
)`;
export async function POST(req: NextRequest) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
const body = await req.json();
const { participant_id, split_ids } = body as {
participant_id?: number;
split_ids?: number[];
};
if (participant_id) {
const rows = await queryRaw<{ id: number }>(
`UPDATE transaction_splits SET settled = true, settled_at = NOW()
WHERE participant_id = $1 AND settled = false ${SCOPE}
RETURNING id`,
[participant_id, user.id]
);
return NextResponse.json({ settled: rows.length });
}
if (split_ids?.length) {
const rows = await queryRaw<{ id: number }>(
`UPDATE transaction_splits SET settled = true, settled_at = NOW()
WHERE id = ANY($1::int[]) AND settled = false ${SCOPE}
RETURNING id`,
[split_ids, user.id]
);
return NextResponse.json({ settled: rows.length });
}
return NextResponse.json({ error: "participant_id or split_ids required" }, { status: 400 });
}
+57
View File
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getStatementById } from "@/lib/queries"; import { getStatementById } from "@/lib/queries";
import { getCurrentUser } from "@/lib/auth"; import { getCurrentUser } from "@/lib/auth";
import { prisma, queryRaw } from "@/lib/db";
export async function GET( export async function GET(
req: NextRequest, req: NextRequest,
@@ -15,3 +16,59 @@ export async function GET(
} }
return NextResponse.json(stmt); return NextResponse.json(stmt);
} }
/**
* Reassign a statement — whose account it is.
*
* The statements page has had an owner dropdown since it was built, wired to
* `useUpdateStatement`, which PATCHed a route that had no PATCH handler. Every
* change 405'd, and because the hook never checked `res.ok` the failure was
* silent: the select snapped back on refetch and looked like a UI glitch.
*
* Both tables are written because both carry the owner. The effective owner is
* `COALESCE(t.owner_id, s.owner_id)`, and 2,194 statement rows carry their own
* copy against 1,803 that inherit — so updating `statements` alone would move
* less than half the rows and split one account's history between two people.
* All 2,194 agree with their statement today; a row that disagrees was set
* deliberately and is left alone rather than swept up.
*/
export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
const { id } = await params;
const statementId = Number(id);
const stmt = await getStatementById(statementId);
// Only the current owner reassigns. A statement is an account, not a shared
// expense — there is no split that grants a second person a say in it.
if (!stmt || stmt.owner_id !== user.id) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
const { owner_id } = (await req.json()) as { owner_id?: number };
if (typeof owner_id !== "number" || !Number.isInteger(owner_id)) {
return NextResponse.json({ error: "owner_id must be a participant id" }, { status: 400 });
}
const known = await queryRaw(`SELECT id FROM participants WHERE id = $1`, [owner_id]);
if (!known.length) {
return NextResponse.json({ error: "Unknown participant" }, { status: 400 });
}
if (owner_id === stmt.owner_id) {
return NextResponse.json({ ok: true, statement_id: statementId, rows_moved: 0 });
}
const [, rowsMoved] = await prisma.$transaction([
prisma.$executeRawUnsafe(`UPDATE statements SET owner_id = $1 WHERE id = $2`, owner_id, statementId),
prisma.$executeRawUnsafe(
`UPDATE transactions SET owner_id = $1 WHERE statement_id = $2 AND owner_id = $3`,
owner_id,
statementId,
stmt.owner_id
),
]);
return NextResponse.json({ ok: true, statement_id: statementId, rows_moved: rowsMoved });
}

Some files were not shown because too many files have changed in this diff Show More