Author SHA1 Message Date
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
siddharthd cc852e7c6f feat(rules): preview what a rule would change before applying it
ci / lint-test (push) Successful in 34s
Selecting a rule now shows the transactions it would alter, so a subset can be
ticked and applied rather than trusting a bulk run. The apply step takes explicit
transaction ids (the existing bulk apply_rule path), so what you tick is exactly
what changes - a rule whose conditions are too broad cannot reach further than
the preview showed.

Matches are split into 'would change' and 'already correct'. A merchant
normalisation rule matching 400 rows where 380 already hold the right value is 20
changes and 380 rows of noise; only the 20 are listed.

Preview is offered for every rule including manual_only quick actions, which
previously had no way to see their reach at all. A rule with no conditions
matches every transaction - that is how apply already behaves, so the preview
reports it prominently rather than hiding it.

Applies still snapshot to rule_apply_runs, so they remain revertable.
2026-07-26 15:08:29 +10:00
siddharthd 31a8177958 chore: untrack dump/ — raw statement exports must never be committed
A git add -A in 030490e swept the Wise CSV exports into the repo and they were
pushed. They contain account numbers, an IBAN, payer and payee names and full
transaction detail.

This removes them from HEAD and ignores the directory. It does NOT purge them
from history — 030490e still contains them.
2026-07-26 14:49:47 +10:00
71 changed files with 10954 additions and 449 deletions
+3
View File
@@ -42,3 +42,6 @@ yarn-error.log*
next-env.d.ts
/src/generated/prisma
# Raw statement exports — real financial data, never commit
dump/
+34
View File
@@ -0,0 +1,34 @@
# 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`.
## Build, Test, and Development Commands
- `npm ci` installs the locked dependency set (Node 22 is used in CI).
- `npm run dev` starts the local Next.js development server.
- `npm run build` creates a production build; `npm start` serves it.
- `npm run lint` runs the Next.js ESLint configuration. Existing lint debt makes CI lint advisory, but new code should pass.
- `npm test` runs fast unit tests.
- `npm run test:setup` prepares the PostgreSQL test database using `.env.test`.
- `npm run test:integration` runs database-backed tests.
- `npm run test:all` runs both test suites.
## Coding Style & Naming Conventions
Use strict TypeScript, two-space indentation, semicolons, and double quotes, matching existing files. Name React components and types in PascalCase, functions and variables in camelCase, and files/routes in kebab-case. Use the `@/` alias for imports from `src/`. Preserve owner scoping and prefer transaction overrides with `COALESCE` in financial queries. Every API route must authenticate before accessing data.
## Testing Guidelines
Vitest is the test framework. Name tests `*.test.ts` and place pure logic tests under `unit/`; put PostgreSQL-dependent behavior under `integration/`. Add regression coverage for query, rule, reconciliation, and category changes. No numeric coverage threshold is configured; focus on meaningful edge cases and run `npm run test:all` before submitting database-related changes.
## Database, Security & Configuration
Add schema changes as the next numbered `prisma/migrations/NNNN_description/migration.sql`. Never commit `.env`, `.env.test`, raw statements in `dump/`, or other financial data. Consult `CLAUDE.md` and relevant `docs/` notes before changing splits, settlements, loans, reconciliation, or statement accounting.
## Commits & Pull Requests
History follows concise Conventional Commit-style subjects such as `feat(rules): preview rule changes`, `fix(trips): ...`, and `docs: ...`. Keep commits focused. Pull requests should explain behavior and data-model impact, link related issues, list validation commands, and include screenshots for UI changes. Ensure unit tests and the production build pass; call out any known lint warnings or migration steps.
+169
View File
@@ -109,6 +109,75 @@ it silently deletes the unlogged remainder from spend totals.
- `participants` — people; `id=1` is "Me" (the primary user)
- `account_owner_mappings` — persists bank+account → owner assignments
### Shared expenses and settlement — read before touching
The model is under active redesign. See `docs/shared-expenses-design.md` for the
proposal and what is already decided. Three traps:
**`transaction_splits.settled` is dead data.** It is `false` on every row. Its
only writer was `/api/splits/settle`, removed in `3f04cbd` because nothing called
it and one request could mark all of a participant's splits settled. Do not build
on this flag until settlement contexts exist.
**`getParticipantBalances` computes `splits payments` and is correct.** Do not
"fix" it to exclude settled splits — the payments that settled them are still
subtracted, so you would double-count. The two settlement models (running tab vs
per-split flag) must not be mixed.
**Settlement cannot be attributed per trip.** `split_payments` records only
from/to/amount/date. Any per-trip settled/unsettled figure is fabricated; the
trip view used to show one and always reported 100% unsettled. Trips show share
only, and point at `/shared` for real balances.
Also: settlements already exist twice. Four of eight `split_payments` match an
offset-account credit exactly on amount and date, with `linked_transaction_id`
populated on only one. And Sonu's loan contributions (`…emi` in the offset
account, 39 rows, $37,980.24) are categorised `transfers`, indistinguishable from
ordinary internal transfers.
**Partial split coverage inside a category is usually correct, not a gap.** Only
*shared* items are split. `utilities` sits at 69% yours because Globird, OVO, GWW
and home telecoms are split while Telstra, Vodafone, Optus and JB Hi-Fi Mobile
are personal. `subscriptions` is 91% because Uber One, Amazon Prime and OnePass
are shared while Claude, OpenAI, Anthropic, OpenRouter, You.com, LinkedIn, Xero,
Billdu, Spotify and Patreon are not. `fees` and `charity` are 100% yours and
correct. Check the merchants before concluding a rule was never applied — a
category-level ratio that "looks wrong" usually is not.
Splits exist in this app from **2026-01-09** only; earlier splits lived in
SplitMyExpenses. So a trailing-12-month per-person series splices six months of
gross onto six months of net. Use FebJun 2026 for anything per-person.
### The shared loan
The loan is a **separate ledger**, not a shared expense and not a settlement
context — a contribution must never be able to settle a dinner. Sonu's obligation
is a fixed 50% of the repayment; actual contributions vary, and the difference is
a tracked receivable ($4,000.00 over 2025-07 → 2026-06).
Do not derive the share from actual payments. During her 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.
Loan interest reconciles exactly: `repayments interest fees = balance
reduction`. It stays categorised `loan_interest` and counts as spend — over 12
months $63,500 of cash left and debt fell $44,127.36, and the $16,523.64
difference bought nothing. Excluding it would leave the balance sheet unable to
reconcile cash out against equity gained.
**The repayment is voluntarily above contracted, and the gap is the largest
flexible cost in the whole picture.** Contracted is $1,190.54/fortnight
($2,579.50/mo annualised); the actual direct debit is $2,500.00/fortnight
($5,416.67/mo). That is $2,837.17/mo of overpayment, and it is *not* sunk — it
shows up as `statements.redraw_available`, which grew $62,387.17 → $81,017.42
across the two most recent loan statements. Sonu returned to $1,250/fortnight in
July 2026 after the reduced $750 period during her leave.
Treat the repayment as two figures whenever asking "what does this cost me":
the contracted floor and the actual. `scheduled_repayment` holds the actual
($2,500), not the contracted minimum — the contracted figure is not in the DB at
all. See `docs/expense-baseline.md`.
### Import Date (`created_at`)
`transactions.created_at` is the import timestamp (DB default `now()`). In the transactions and shared views, the "Imported" column shows:
@@ -123,6 +192,80 @@ Conditions are AND-evaluated. Fields: `merchant_normalized`, `description`, `cat
`contains` and `equals` operators are case-insensitive (both sides `.toLowerCase()`).
**A rule with zero conditions matches every transaction.** Both apply paths use
`conditions.length === 0 || conditions.every(...)`. Rule 43 "Home 50/50 Sonu" has
no conditions and a 50/50 split action — applying it blindly would split all
~3,700 transactions with another participant. That is what `manual_only` is for:
those rules are excluded from bulk runs and fire from the transactions page
against a hand-picked selection.
### Previewing a rule before applying it
`GET /api/rules/[id]/matches` is a dry run — it writes nothing and returns only
the transactions a rule would actually *change*, with already-correct rows
summarised as a count. The Preview button on the rules page uses it.
Apply then goes through `POST /api/transactions/bulk` with `action: "apply_rule"`
and **explicit transaction ids**, not the conditions. That is the safety
property: a rule whose conditions are too broad cannot reach further than what
the preview showed and the user ticked.
Prefer this over auto-applying rules on ingestion. It fails safe, works
retroactively, and tells you which rules are consistent enough to automate later.
### Rule apply history
`rule_apply_runs` snapshots the before-state so a run can be reverted, and since
migration 0017 also records `rule_id`, `rule_name` and `source`
(`all` | `rule` | `selection`). `rule_name` is denormalised deliberately and
there is no FK to `rules` — history must stay readable after a rule is renamed or
deleted, and deleting a rule must not cascade away the audit trail.
`GET /api/rules/runs/[id]` diffs that snapshot against current values. Rows
changed by something else since the run are flagged, because reverting restores
the pre-run value and discards the later edit.
### Trusting extracted statement data
**Balance assertions are the check that works.** `getStatements` computes
`opening + movement closing`; the statements page flags any statement that does
not reconcile. Sign depends on what the balance means — on a credit card or loan
it is what you owe, so spending increases it; on a transaction or offset account
it is what you hold. 11 pre-existing statements currently fail, ~$4,177
unexplained, including two adjacent ANZ statements off by exactly ±$230.38 (a
transaction filed against the wrong one).
**Do not derive `opening_balance` from `closing movement`.** It is an accounting
identity, so every statement would reconcile and the check would go permanently
green. A null that reads "unverified" is worth more than a number that is right
by construction. For the same reason, do not add a totals assertion comparing
`total_debits` to the summed rows — those totals are now *computed* from the rows
(see the N8N `Parse Gemini Result` node), so that check can never fail.
**Gemini invents summary fields the statement does not print.** Wise PDFs show
only a closing balance; asked for an opening balance anyway, the model produced
11,277.08 against a truth of 0.00, and on another statement read the running
balance of the oldest row. Every transaction was extracted perfectly in both
cases — verified row for row against the CSV exports. When a balance assertion
fails, suspect the summary before the transactions.
**Gemini drops rows silently on long tables.** `finishReason` was `STOP`, not
`MAX_TOKENS`, so raising `maxOutputTokens` does not help. This did *not* actually
occur on the Wise imports (that was the summary bug above), but it is why an
empty statement must not throw: a document that errors never gets tagged, so it
is re-fetched every poll forever and blocks everything behind it in the queue
(`ordering=-created`, `page_size=1`).
**FX is per transaction date**, via Frankfurter (ECB daily, free, no key), with
weekends resolving to the prior publication. A single spot rate across a 15-month
statement is wrong by up to 20%. Wise's own rates are more accurate in principle
but differ by only 0.05% and exist on 44 of 194 rows, so mixing bases is not
worth it.
**When comparing CSV exports to extracted data, order by full timestamp
including milliseconds.** Two of one statement's rows are 1ms apart; dropping the
fraction reversed them and produced a bogus opening balance.
## Development Patterns
### Adding a new API route
@@ -226,3 +369,29 @@ break or go stale. And the views are **not** owner-scoped and do **not** merge
See `README.md`**Known Gaps / TODOs** for full details.
**Payment provider tracking**: `merchant_normalized` currently conflates payment provider (PayPal, Afterpay, Zip) with the actual merchant. Plan: add `payment_provider` column, update Gemini prompt to extract it separately, backfill from `merchant_name` patterns, surface in UI filters.
### Open as of 2026-07-26
- **Shared expenses redesign** — `docs/shared-expenses-design.md`. Phase 0 done;
Phases 14 unbuilt. Deliberately paused to live with the current behaviour
before committing to a model designed in one session.
- **Expense baseline / emergency reserve** — `docs/expense-baseline.md`. One-off
analysis, nothing built. Records four data corrections the raw numbers need
(misfiled Raiz/super/brokerage debits, `other` credits read as negative spend,
`government` conflating ATO with rates/rego, `fees` being mostly annual) and
why only FebJun 2026 is trustworthy for per-person figures.
- **11 statements fail the balance assertion**, ~$4,177 unexplained. Predates
this work. One ANZ statement is off by exactly $0.50, traced to a misread digit
in fee rows ($5.00 vs $5.50).
- **28 Up Bank debits are categorised `other`** ($3,760.74). Up only categorised
16 of 88 rows. The `Payee` field is populated throughout, so merchant rules plus
the rule preview should clear most of it.
- **Up item sales are categorised `income`** ($4,238.04 across 19 credits — iPad,
drone, camera). Correct in that they are excluded from spend, but it mixes
asset disposals into the income line alongside salary.
- **`payment_method` is not shown in the transactions list** — settable on create
and edit only. Worth a column or filter if cash becomes routine.
- **Raw statement exports live in `dump/`**, gitignored since `31a8177`. They were
committed by accident in `030490e` and remain in that commit's history; the repo
has no GitHub remote, so exposure is limited to the local Gitea. Purging history
was offered and not actioned.
+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 612 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 410
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 **FebJun 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 FebJun 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 $424773/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.
+287
View File
@@ -0,0 +1,287 @@
# Shared expenses, settlement, and the shared loan — design proposal
Status: **proposal, nothing built**. Written 2026-07-26 for review.
## Why this exists
Three questions have no answer in the current model:
1. Which splits does a settlement payment settle?
2. Is the Europe trip settled, separately from the ongoing household tab?
3. Whose expense is a $2,500 loan repayment when Sonu funds part of it?
They look like three problems. They are one: **the app records money moving, and
separately records who owes whom, and the two never meet.**
---
## What is actually broken
### Two settlement models, neither finished
| Model | Where | State |
|---|---|---|
| Running tab | `split_payments` (from, to, amount, date) | **in use** — 8 payments, $37,881.10 |
| Per-split flag | `transaction_splits.settled` | **never used** — all 673 splits are `false` |
They are honoured inconsistently:
- `getParticipantBalances` (the shared page) ignores `settled` entirely
- `/api/participants/[id]/balance` filters on `settled = false`
- `getTripAnalytics` reports settled/unsettled **from the unused flag**
The third is a live bug. Every trip shows 100% unsettled forever, even though
Molina has paid $20,782.79 against $19,556.07 of splits and is square.
### Settlements exist twice, unlinked
Four of the eight recorded payments match an offset-account credit exactly:
| Payment date | From | Amount | Offset transaction |
|---|---|---:|---|
| 2026-02-02 | Molina | 7,500.00 | `Transfer from - MEGHALEE BOSE mummy Pa…` |
| 2026-04-12 | Sonu | 3,779.33 | `Transfer from - MEGHALEE BOSE transfer` |
| 2026-04-21 | Molina | 1,685.24 | `Transfer from - MEGHALEE BOSE mummy split` |
| 2026-05-16 | Sonu | 4,794.06 | `Transfer from - MEGHALEE BOSE transfer` |
The same money is a `split_payments` row *and* a `transactions` row.
`split_payments.linked_transaction_id` exists but only 1 of 8 rows uses it. So a
settlement is bookkeeping that happens to resemble a bank credit, rather than
being that credit.
### The shared loan is invisible
Sonu's contributions are already in the ledger and unrecognised:
| 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 |
All are categorised `transfers` — correct for spend purposes, but it means a
loan contribution and an expense settlement are indistinguishable.
Meanwhile the loan itself, over the 12 imported months:
| | |
|---|---:|
| Principal repaid (`investment`, excluded from spend) | $63,500.00 |
| Interest charged (`loan_interest`, the only part counted as spend) | $16,523.64 |
At roughly $25,000/year of `emi` against ~$80,000 of annual repayments, Sonu
funds about **31%** — of both the equity being built and the interest being paid.
Today 100% of the interest counts as your spend and 100% of the equity as yours.
---
## The core problem
The model conflates two different things:
- **Money movement** — a credit landed in the offset account
- **Obligation** — someone owed someone else, and now owes less
A settlement is both. A loan contribution is both. Right now movement lives in
`transactions` and obligation lives in `transaction_splits` / `split_payments`,
with nothing joining them. That is why a payment cannot say what it settles: it
was never attached to anything in the first place.
---
## Proposed model
### 1. Settlement contexts
Splits belong to something that is settled **as a unit**. Payments name which
unit they settle. Balance is computed per context, not globally.
| Context | Splits | Settled by | State |
|---|---|---|---|
| Household (default) | ongoing | periodic payments | running tab |
| Europe 2026 | trip-bound | lump sum | closeable |
| Pre-2026 (SplitMyExpenses) | historical | settled elsewhere | **born closed** |
A closed context still contributes to analytics — you see your true share — but
contributes nothing to what anyone owes.
This answers all three opening questions, and it dissolves the `splitFrom` date
cutoff: pre-2026 splits can be applied retroactively **because they are born
into a closed context**, so they fix the analytics without creating debt. No date
guard needed, no risk of resurrecting settled obligations.
Mechanically: `settlement_contexts` table; `transaction_splits.context_id`;
`split_payments.context_id`. `transaction_splits.settled` becomes derived
("is my context closed?") or is dropped.
### 2. Payments are transactions, not a side table
A settlement is the offset-account credit. `split_payments` becomes a thin
attribution layer over a real transaction rather than a parallel record of it:
- Populate `linked_transaction_id` on all existing payments where a match exists
- On ingestion, an incoming credit that looks like a settlement is *proposed* as
one for confirmation, rather than silently becoming `transfers`
- A payment with no matching transaction (cash, or an account not imported)
stays as a manual row — the model must tolerate that
### 3. The shared loan — a separate ledger
Not a settlement context. The loan is a jointly funded asset with its own
obligation, and mixing it with expense settlement would let a contribution
accidentally settle a dinner.
- `emi` credits are recognised as **contributions**, not generic transfers
- A **contribution schedule** states what is owed per period (50% of the
repayment), independent of what was actually paid
- The running difference is a **receivable** — currently $4,000.00
The schedule matters: during Sonu's leave the obligation did not change, only the
payment did. A percentage-of-actual model would silently redefine her share as
30% and make the shortfall disappear.
---
## Migration path
### Phase 0 — DONE (2026-07-26, commit `3f04cbd`)
Stop the trip view reporting a settlement breakdown it cannot compute.
The original plan was "make `getTripAnalytics` and `getParticipantBalances`
agree". **That plan was wrong** and reading the code before building is what
caught it:
- `getParticipantBalances` is *not* buggy. It computes `splits payments`,
which is coherent. Excluding settled splits there while still subtracting the
payments that settled them would have double-counted and broken a working page.
- The real defect was narrower: the trip view showed Settled/Unsettled from
`transaction_splits.settled`, which nothing sets. A correct per-trip figure is
not computable at all, because `split_payments` has no trip attribution.
So the fix was **subtractive**: the trip view now shows each participant's share
and points at Shared for what is actually owed.
Also removed `/api/splits/settle` — unreachable from the UI but live on its URL,
where one call with `participant_id` would mark every one of that person's splits
settled, writing a flag nothing reads.
`transaction_splits.settled` / `settled_at` still exist and are now pure dead
data. Phase 1 either repurposes them ("is my context closed?") or drops them.
### Phase 1 — settlement contexts
Add contexts; put every existing split in "Household"; every payment likewise.
Balance queries group by context. Touches `queries.ts` (both balance CTEs),
`shared/page.tsx`, `trips/[id]/page.tsx`, `split-payments/route.ts`. ~1 day.
### Phase 2 — link payments to transactions
Backfill `linked_transaction_id` for the four exact matches; flag the other four
for manual linking. On ingestion, propose a matching credit as a settlement
rather than silently categorising it `transfers`. ~half a day.
### Phase 3 — retroactive pre-2026 split
Create the "Pre-2026" closed context. Apply household split rules into it via the
rule preview (`/api/rules/[id]/matches`, built 2026-07-26) — fixes ~$97,627 of
the trailing 12 months currently shown as 100% yours. Then delete the `splitFrom`
cutoff entirely.
**Validate the ratio first.** This assumes today's 50/50 held through 2025. The
SplitMyExpenses CSVs should be used to *check* that assumption — not to
reconcile, since transactions were sometimes combined and exact matching is
impossible.
### Phase 4 — loan ledger
Contribution schedule, contributions recognised from `emi` credits, running
receivable. Independent of contexts — the loan is a separate ledger. ~12 days.
---
## Decisions taken (2026-07-26)
### The loan is separate from shared expenses
Different obligations, different rhythms, different nature: one funds an asset,
the other funds consumption. They do not share a settlement context and a
contribution is never a settlement.
### The share is 50/50, fixed — with the shortfall tracked
Not derived from actual payments, which fluctuate. Sonu's obligation is half the
repayment; the difference between obligation and actual is a **receivable**, and
it is the interesting number.
Over 2025-07-01 → 2026-06-30:
| | |
|---|---:|
| 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:
| Rate | Payments | Period |
|---|---:|---|
| $1,250 | 15 | Aug 2025 Feb 2026 (the correct 50%) |
| $1,000 | 3 | Jul 2025 (pre-adjustment) |
| $750 | 8 | Mar Jun 2026 (leave) |
So the model needs a **contribution schedule** (expected per period) alongside
actual contributions, with the running difference as a tracked balance. A flat
percentage cannot express "obligation unchanged, payment temporarily reduced,
difference owed".
### Interest: recommended as expense, pending final call
The mechanics are as described — interest is debited to the loan and repayments
pay down the combined balance. Reconciles exactly:
134: 31,000.00 8,553.27 = 22,446.73 = balance reduction
133: 32,500.00 7,970.37 2,849.00 = 21,680.63 = balance reduction
But mechanics are not the same as economics. Over 12 months $63,500 of cash left
and debt fell by $44,127.36. The $16,523.64 difference bought nothing and is not
recoverable — that is an expense by definition. Excluding it leaves the balance
sheet unable to reconcile cash out against equity gained, and understates annual
cost by ~10%.
The legitimate concern is that interest is **non-discretionary**. The answer to
that is a fixed-commitments grouping alongside rent, insurance and utilities —
a presentation change, not an exclusion.
**Recommendation: keep `loan_interest` as spend, add a fixed/discretionary
split.** Flagged rather than settled: it is a judgement about what "spend" means
in your own reporting.
## Open questions
1. **Does equity need tracking per person?** If Sonu accrues a share of the
principal, that is a balance-sheet item the app has no concept of. Probably
belongs in the net-worth view rather than here.
4. **Attribution of forwarded payments.** `mummy` in the description reliably
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.
5. **Retroactive split ratios.** Applying today's household rules to 2025
assumes the arrangement has not changed. The SplitMyExpenses CSVs could give
real historical shares, but transactions were sometimes combined, so matching
is imperfect. Recommendation: use today's ratios, accept the approximation —
the goal is a truer analytics picture, not a restated ledger.
---
## What I would not do
- **Do not** restate history from the SplitMyExpenses CSVs. The combining problem
makes exact reconciliation impossible, and the value is low: those balances are
settled and will not change.
- **Do not** make the loan a shared *expense*. It is a funded asset. Modelling it
as a recurring split would put $2,500 a fortnight of principal into spend,
which is the error migration 0014 was written to prevent.
+715
View File
@@ -0,0 +1,715 @@
# UI and information architecture review
**Date:** 2026-07-26
**Status:** Priority 0 implemented 2026-07-27 (see below). Priorities 14 remain
proposals.
## 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 by `src/__tests__/integration/analytics-sql.test.ts`.
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 2526 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 FebruaryJune 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
This is coherent and should not be changed to exclude splits marked `settled`.
The `transaction_splits.settled` field is dead data and must not be used for new
UI claims.
### 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
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
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
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
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
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.
@@ -1,84 +0,0 @@
"TransferWise ID",Date,"Date Time",Amount,Currency,Description,"Payment Reference","Running Balance","Exchange From","Exchange To","Exchange Rate","Payer Name","Payee Name","Payee Account Number",Merchant,"Card Last Four Digits","Card Holder Full Name",Attachment,Note,"Total fees","Exchange To Amount","Transaction Type","Transaction Details Type"
TRANSFER-2268507611,24-07-2026,"24-07-2026 23:59:50.116",-10000.00,USD,"Sent money to Siddharth Bose",,775.89,,,,,"Siddharth Bose",68844145,,,,,,0.00,,DEBIT,TRANSFER
TRANSFER-2235277367,07-07-2026,"07-07-2026 18:05:17.109",10782.00,USD,"Received money from HDR Global Services (Bermuda) with reference INV 2026-06 EXP2687","INV 2026-06 EXP2687",10775.89,,,,"HDR Global Services (Bermuda)",,,,,,,,6.11,,CREDIT,DEPOSIT
FEE-TRANSFER-2235277367,07-07-2026,"07-07-2026 18:05:17.108",-6.11,USD,"Wise Charges for: TRANSFER-2235277367","INV 2026-06 EXP2687",-6.11,,,,"HDR Global Services (Bermuda)",,,,,,,,0,,DEBIT,DEPOSIT
TRANSFER-2216007380,28-06-2026,"28-06-2026 11:37:55.004",-3968.98,USD,"Sent money to Siddharth Bose (fee: 11.63 USD)",,0.00,USD,AUD,1.44949,,"Siddharth Bose","(939200) 519049940",,,,,,11.63,5753.00,DEBIT,TRANSFER
FEE-TRANSFER-2216007380,28-06-2026,"28-06-2026 11:37:55.003",-11.63,USD,"Wise Charges for: TRANSFER-2216007380",,3968.98,,,,,Wise,,,,,,,0,,DEBIT,TRANSFER
TRANSFER-2171403032,04-06-2026,"04-06-2026 05:11:15.833",-6776.49,USD,"Sent money to Siddharth Bose (fee: 19.51 USD)",,3980.61,USD,AUD,1.40233,,"Siddharth Bose","(939200) 519049940",,,,,,19.51,9502.88,DEBIT,TRANSFER
FEE-TRANSFER-2171403032,04-06-2026,"04-06-2026 05:11:15.832",-19.51,USD,"Wise Charges for: TRANSFER-2171403032",,10757.10,,,,,Wise,,,,,,,0,,DEBIT,TRANSFER
TRANSFER-2157538435,28-05-2026,"28-05-2026 17:32:10.213",10782.00,USD,"Received money from HDR Global Services (Bermuda) with reference INV 2026-05 EXP2557","INV 2026-05 EXP2557",10776.61,,,,"HDR Global Services (Bermuda)",,,,,,,,6.11,,CREDIT,DEPOSIT
FEE-TRANSFER-2157538435,28-05-2026,"28-05-2026 17:32:10.212",-6.11,USD,"Wise Charges for: TRANSFER-2157538435","INV 2026-05 EXP2557",-5.39,,,,"HDR Global Services (Bermuda)",,,,,,,,0,,DEBIT,DEPOSIT
TRANSFER-2143509822,20-05-2026,"20-05-2026 21:01:23.608",-10765.33,USD,"Sent money to Siddharth Bose (fee: 30.67 USD)",,0.72,USD,AUD,1.40351,,"Siddharth Bose","(013040) 408556264",,,,,,30.67,15109.25,DEBIT,TRANSFER
FEE-TRANSFER-2143509822,20-05-2026,"20-05-2026 21:01:23.607",-30.67,USD,"Wise Charges for: TRANSFER-2143509822",,10766.05,,,,,Wise,,,,,,,0,,DEBIT,TRANSFER
TRANSFER-2128716995,12-05-2026,"12-05-2026 17:33:31.247",10802.83,USD,"Received money from HDR Global Services (Bermuda) with reference INV 2026-04 EXP2421","INV 2026-04 EXP2421",10796.72,,,,"HDR Global Services (Bermuda)",,,,,,,,6.11,,CREDIT,DEPOSIT
FEE-TRANSFER-2128716995,12-05-2026,"12-05-2026 17:33:31.246",-6.11,USD,"Wise Charges for: TRANSFER-2128716995","INV 2026-04 EXP2421",-6.11,,,,"HDR Global Services (Bermuda)",,,,,,,,0,,DEBIT,DEPOSIT
TRANSFER-2114526979,05-05-2026,"05-05-2026 09:34:50.397",-6835.10,USD,"Sent money to Siddharth Bose (fee: 19.67 USD)",,0.00,USD,AUD,1.39509,,"Siddharth Bose","(939200) 519049940",,,,,,19.67,9535.58,DEBIT,TRANSFER
FEE-TRANSFER-2114526979,05-05-2026,"05-05-2026 09:34:50.396",-19.67,USD,"Wise Charges for: TRANSFER-2114526979",,6835.10,,,,,Wise,,,,,,,0,,DEBIT,TRANSFER
TRANSFER-2075646899,14-04-2026,"14-04-2026 06:19:35.500",-4000.00,USD,"Sent money to Siddharth Bose",,6854.77,,,,,"Siddharth Bose",68844145,,,,,,0.00,,DEBIT,TRANSFER
TRANSFER-2054903037,02-04-2026,"02-04-2026 17:07:38.780",10790.80,USD,"Received money from HDR Global Services (Bermuda) with reference INV 2026-03 EXP2282","INV 2026-03 EXP2282",10854.77,,,,"HDR Global Services (Bermuda)",,,,,,,,6.11,,CREDIT,DEPOSIT
FEE-TRANSFER-2054903037,02-04-2026,"02-04-2026 17:07:38.779",-6.11,USD,"Wise Charges for: TRANSFER-2054903037","INV 2026-03 EXP2282",63.97,,,,"HDR Global Services (Bermuda)",,,,,,,,0,,DEBIT,DEPOSIT
TRANSFER-2043244817,27-03-2026,"27-03-2026 17:25:48.750",-9971.56,USD,"Sent money to Siddharth Bose (fee: 28.44 USD)",,70.08,USD,AUD,1.45117,,"Siddharth Bose","(939200) 519049940",,,,,,28.44,14470.43,DEBIT,TRANSFER
FEE-TRANSFER-2043244817,27-03-2026,"27-03-2026 17:25:48.749",-28.44,USD,"Wise Charges for: TRANSFER-2043244817",,10041.64,,,,,Wise,,,,,,,0,,DEBIT,TRANSFER
TRANSFER-2009199370,07-03-2026,"07-03-2026 10:00:47.387",-6000.00,USD,"Sent money to Siddharth Bose",,10070.08,,,,,"Siddharth Bose",68844145,,,,,,0.00,,DEBIT,TRANSFER
TRANSFER-2007700796,06-03-2026,"06-03-2026 18:32:12.643",10790.80,USD,"Received money from HDR Global Services (Bermuda) with reference INV 2026-02 EXP2148","INV 2026-02 EXP2148",16070.08,,,,"HDR Global Services (Bermuda)",,,,,,,,6.11,,CREDIT,DEPOSIT
FEE-TRANSFER-2007700796,06-03-2026,"06-03-2026 18:32:12.642",-6.11,USD,"Wise Charges for: TRANSFER-2007700796","INV 2026-02 EXP2148",5279.28,,,,"HDR Global Services (Bermuda)",,,,,,,,0,,DEBIT,DEPOSIT
TRANSFER-1960000797,07-02-2026,"07-02-2026 00:12:56.532",-25000.00,USD,"Sent money to Siddharth Bose",,5285.39,,,,,"Siddharth Bose",68844145,,,,,,0.00,,DEBIT,TRANSFER
TRANSFER-1953505794,03-02-2026,"03-02-2026 18:16:51.173",10790.80,USD,"Received money from HDR Global Services (Bermuda) with reference INV 2026-01 EXP1987","INV 2026-01 EXP1987",30285.39,,,,"HDR Global Services (Bermuda)",,,,,,,,6.11,,CREDIT,DEPOSIT
FEE-TRANSFER-1953505794,03-02-2026,"03-02-2026 18:16:51.172",-6.11,USD,"Wise Charges for: TRANSFER-1953505794","INV 2026-01 EXP1987",19494.59,,,,"HDR Global Services (Bermuda)",,,,,,,,0,,DEBIT,DEPOSIT
TRANSFER-1950103478,02-02-2026,"02-02-2026 10:12:23.989",-500.00,USD,"Sent money to Siddharth Bose",,19500.70,,,,,"Siddharth Bose",68844145,,,,,,0.00,,DEBIT,TRANSFER
TRANSFER-1946979742,31-01-2026,"31-01-2026 00:45:43.330",-2181.00,USD,"Sent money to Siddharth Bose",,20000.70,,,,,"Siddharth Bose",68844145,,,,,,0.00,,DEBIT,TRANSFER
TRANSFER-1908177420,07-01-2026,"07-01-2026 18:02:18.983",10790.80,USD,"Received money from HDR Global Services (Bermuda) with reference INV 2025-12 EXP1817","INV 2025-12 EXP1817",22181.70,,,,"HDR Global Services (Bermuda)",,,,,,,,6.11,,CREDIT,DEPOSIT
FEE-TRANSFER-1908177420,07-01-2026,"07-01-2026 18:02:18.982",-6.11,USD,"Wise Charges for: TRANSFER-1908177420","INV 2025-12 EXP1817",11390.90,,,,"HDR Global Services (Bermuda)",,,,,,,,0,,DEBIT,DEPOSIT
TRANSFER-1847010079,01-12-2025,"01-12-2025 21:17:07.414",10790.69,USD,"Received money from HDR Global Services (Bermuda) with reference INV 2025-11 EXP1683","INV 2025-11 EXP1683",11397.01,,,,"HDR Global Services (Bermuda)",,,,,,,,6.11,,CREDIT,DEPOSIT
FEE-TRANSFER-1847010079,01-12-2025,"01-12-2025 21:17:07.413",-6.11,USD,"Wise Charges for: TRANSFER-1847010079","INV 2025-11 EXP1683",606.32,,,,"HDR Global Services (Bermuda)",,,,,,,,0,,DEBIT,DEPOSIT
CARD-3157819100,25-11-2025,"25-11-2025 15:16:16.619",-12.65,USD,"Card transaction of 10.99 EUR issued by Mega Limited AUCKLAND (fee: 0.02 USD)",,612.43,USD,EUR,0.86851,,,,"Mega Limited AUCKLAND",4233,"Siddharth Bose",,,0.02,10.99,DEBIT,CARD
FEE-CARD-3157819100,25-11-2025,"25-11-2025 15:16:16.618",-0.02,USD,"Wise Charges for: CARD-3157819100",,625.08,,,,,,,"Mega Limited AUCKLAND",4233,"Siddharth Bose",,,0,,DEBIT,CARD
TRANSFER-1833504017,24-11-2025,"24-11-2025 01:15:43.299",-10973.71,USD,"Sent money to Siddharth Bose (fee: 26.29 USD)",,625.10,USD,AUD,1.54883,,"Siddharth Bose","(939200) 519049940",,,,,,26.29,16996.41,DEBIT,TRANSFER
FEE-TRANSFER-1833504017,24-11-2025,"24-11-2025 01:15:43.298",-26.29,USD,"Wise Charges for: TRANSFER-1833504017",,11598.81,,,,,Wise,,,,,,,0,,DEBIT,TRANSFER
CARD-3115289050,13-11-2025,"13-11-2025 20:15:06.123",-34.85,USD,"Card transaction of 30.00 EUR issued by Sta Spa-Ag BOLZANO (fee: 0.10 USD)",,11625.10,USD,EUR,0.86092,,,,"Sta Spa-Ag BOLZANO",8531,"Siddharth Bose",,,0.10,30.00,DEBIT,CARD
FEE-CARD-3115289050,13-11-2025,"13-11-2025 20:15:06.122",-0.10,USD,"Wise Charges for: CARD-3115289050",,11659.95,,,,,,,"Sta Spa-Ag BOLZANO",8531,"Siddharth Bose",,,0,,DEBIT,CARD
TRANSFER-1808432866,07-11-2025,"07-11-2025 18:13:22.798",10790.69,USD,"Received money from HDR Global Services (Bermuda) with reference INV 2025-10 EXP1512","INV 2025-10 EXP1512",11660.05,,,,"HDR Global Services (Bermuda)",,,,,,,,6.11,,CREDIT,DEPOSIT
FEE-TRANSFER-1808432866,07-11-2025,"07-11-2025 18:13:22.797",-6.11,USD,"Wise Charges for: TRANSFER-1808432866","INV 2025-10 EXP1512",869.36,,,,"HDR Global Services (Bermuda)",,,,,,,,0,,DEBIT,DEPOSIT
TRANSFER-1805800459,06-11-2025,"06-11-2025 04:53:13.751",-19943.51,USD,"Sent money to Siddharth Bose (fee: 56.49 USD)",,875.47,USD,AUD,1.53692,,"Siddharth Bose","(013040) 408556264",,,,,,56.49,30651.58,DEBIT,TRANSFER
FEE-TRANSFER-1805800459,06-11-2025,"06-11-2025 04:53:13.750",-56.49,USD,"Wise Charges for: TRANSFER-1805800459",,20818.98,,,,,Wise,,,,,,,0,,DEBIT,TRANSFER
CARD-3088194739,06-11-2025,"06-11-2025 04:37:29.830",-45.92,USD,"Card transaction of 40.00 EUR issued by Via Soana 24 ROMA (fee: 0.13 USD)",,20875.47,USD,EUR,0.87104,,,,"Via Soana 24 ROMA",8531,"Siddharth Bose",,,0.13,40.00,DEBIT,CARD
FEE-CARD-3088194739,06-11-2025,"06-11-2025 04:37:29.829",-0.13,USD,"Wise Charges for: CARD-3088194739",,20921.39,,,,,,,"Via Soana 24 ROMA",8531,"Siddharth Bose",,,0,,DEBIT,CARD
CARD-3046253352,25-10-2025,"25-10-2025 15:01:15.513",-12.78,USD,"Card transaction of 10.99 EUR issued by Mega Limited AUCKLAND (fee: 0.03 USD)",,20921.52,USD,EUR,0.86014,,,,"Mega Limited AUCKLAND",4233,"Siddharth Bose",,,0.03,10.99,DEBIT,CARD
FEE-CARD-3046253352,25-10-2025,"25-10-2025 15:01:15.512",-0.03,USD,"Wise Charges for: CARD-3046253352",,20934.30,,,,,,,"Mega Limited AUCKLAND",4233,"Siddharth Bose",,,0,,DEBIT,CARD
TRANSFER-1784386011,24-10-2025,"24-10-2025 10:39:46.041",-6513.98,USD,"Sent money to Siddharth Bose (fee: 18.89 USD)",,20934.33,USD,AUD,1.53516,,"Siddharth Bose","(013040) 408556264",,,,,,18.89,10000.00,DEBIT,TRANSFER
FEE-TRANSFER-1784386011,24-10-2025,"24-10-2025 10:39:46.040",-18.89,USD,"Wise Charges for: TRANSFER-1784386011",,27448.31,,,,,Wise,,,,,,,0,,DEBIT,TRANSFER
CARD-3027732792,20-10-2025,"20-10-2025 08:58:43.670",549.70,USD,"Card transaction of -549.70 USD issued by Etihad Airw 6072412191144 ABU DHABI",,27467.20,,,,,,,"Etihad Airw 6072412191144 ABU DHABI",4233,"Siddharth Bose",,,0.00,,CREDIT,CARD
CARD-3023391491,19-10-2025,"19-10-2025 02:04:15.660",-82.50,USD,"Card transaction of 82.50 USD issued by *Sfo Intl Airport-Itb N SAN FRANCISCO",,26917.50,,,,,,,"*Sfo Intl Airport-Itb N SAN FRANCISCO",8531,"Siddharth Bose",,,0.00,,DEBIT,CARD
TRANSFER-1772299455,16-10-2025,"16-10-2025 08:00:56.288",-1500.00,USD,"Sent money to Siddharth Bose",,27000.00,,,,,"Siddharth Bose",68844145,,,,,,0.00,,DEBIT,TRANSFER
TRANSFER-1765630070,11-10-2025,"11-10-2025 22:28:23.964",-321.88,USD,"Sent money to Siddharth Bose",,28500.00,,,,,"Siddharth Bose",68844145,,,,,,0.00,,DEBIT,TRANSFER
TRANSFER-1755893777,06-10-2025,"06-10-2025 07:59:23.646",-3955.49,USD,"Sent money to Siddharth Bose (fee: 11.74 USD)",,28821.88,USD,AUD,1.51688,,"Siddharth Bose","(033501) 394758",,,,,,11.74,6000.00,DEBIT,TRANSFER
FEE-TRANSFER-1755893777,06-10-2025,"06-10-2025 07:59:23.645",-11.74,USD,"Wise Charges for: TRANSFER-1755893777",,32777.37,,,,,Wise,,,,,,,0,,DEBIT,TRANSFER
CARD-2962375193,02-10-2025,"02-10-2025 08:41:01.066",4.53,USD,"Card transaction of -4.53 USD issued by Patreon* Membership Internet",,32789.11,,,,,,,"Patreon* Membership Internet",4233,"Siddharth Bose",,,0.00,,CREDIT,CARD
TRANSFER-1748256868,01-10-2025,"01-10-2025 18:47:09.850",10790.69,USD,"Received money from HDR Global Services (Bermuda) with reference INV 2025-09 EXP1335","INV 2025-09 EXP1335",32784.58,,,,"HDR Global Services (Bermuda)",,,,,,,,6.11,,CREDIT,DEPOSIT
FEE-TRANSFER-1748256868,01-10-2025,"01-10-2025 18:47:09.849",-6.11,USD,"Wise Charges for: TRANSFER-1748256868","INV 2025-09 EXP1335",21993.89,,,,"HDR Global Services (Bermuda)",,,,,,,,0,,DEBIT,DEPOSIT
TRANSFER-1742899854,28-09-2025,"28-09-2025 23:23:11.284",-1993.76,USD,"Sent money to Siddharth Bose (fee: 6.24 USD)",,22000.00,USD,AUD,1.52753,,"Siddharth Bose","(939200) 519049940",,,,,,6.24,3045.53,DEBIT,TRANSFER
FEE-TRANSFER-1742899854,28-09-2025,"28-09-2025 23:23:11.283",-6.24,USD,"Wise Charges for: TRANSFER-1742899854",,23993.76,,,,,Wise,,,,,,,0,,DEBIT,TRANSFER
TRANSFER-1738593468,25-09-2025,"25-09-2025 23:12:15.826",-82.39,USD,"Sent money to Siddharth Bose",,24000.00,,,,,"Siddharth Bose",68844145,,,,,,0.00,,DEBIT,TRANSFER
CARD-2937450107,25-09-2025,"25-09-2025 14:53:54.340",-12.91,USD,"Card transaction of 10.99 EUR issued by Mega Limited AUCKLAND (fee: 0.03 USD)",,24082.39,USD,EUR,0.85103,,,,"Mega Limited AUCKLAND",4233,"Siddharth Bose",,,0.03,10.99,DEBIT,CARD
FEE-CARD-2937450107,25-09-2025,"25-09-2025 14:53:54.339",-0.03,USD,"Wise Charges for: CARD-2937450107",,24095.30,,,,,,,"Mega Limited AUCKLAND",4233,"Siddharth Bose",,,0,,DEBIT,CARD
CARD-2931031224,23-09-2025,"23-09-2025 19:41:24.887",-549.70,USD,"Card transaction of 549.70 USD issued by Etihad Airw 6072412191144 ABU DHABI",,24095.33,,,,,,,"Etihad Airw 6072412191144 ABU DHABI",4233,"Siddharth Bose",,,0.00,,DEBIT,CARD
CARD-2929927553,23-09-2025,"23-09-2025 10:05:31.187",-16.50,USD,"Card transaction of 16.50 USD issued by Patreon* Membership Internet",,24645.03,,,,,,,"Patreon* Membership Internet",4233,"Siddharth Bose",,,0.00,,DEBIT,CARD
CARD-2928693059,23-09-2025,"23-09-2025 01:24:48.519",-4.53,USD,"Card transaction of 4.53 USD issued by Patreon* Membership Internet",,24661.53,,,,,,,"Patreon* Membership Internet",4233,"Siddharth Bose",,,0.00,,DEBIT,CARD
TRANSFER-1706024945,04-09-2025,"04-09-2025 18:17:21.211",-2675.24,USD,"Sent money to Siddharth Bose (fee: 8.15 USD)",,24666.06,USD,AUD,1.53257,,"Siddharth Bose","(033501) 394758",,,,,,8.15,4100.00,DEBIT,TRANSFER
FEE-TRANSFER-1706024945,04-09-2025,"04-09-2025 18:17:21.210",-8.15,USD,"Wise Charges for: TRANSFER-1706024945",,27341.30,,,,,Wise,,,,,,,0,,DEBIT,TRANSFER
TRANSFER-1702141390,02-09-2025,"02-09-2025 16:47:10.556",10713.76,USD,"Received money from HDR Global Services (Bermuda) with reference INV 2025-08 EXP1167","INV 2025-08 EXP1167",27349.45,,,,"HDR Global Services (Bermuda)",,,,,,,,6.11,,CREDIT,DEPOSIT
FEE-TRANSFER-1702141390,02-09-2025,"02-09-2025 16:47:10.555",-6.11,USD,"Wise Charges for: TRANSFER-1702141390","INV 2025-08 EXP1167",16635.69,,,,"HDR Global Services (Bermuda)",,,,,,,,0,,DEBIT,DEPOSIT
TRANSFER-1658801843,04-08-2025,"04-08-2025 23:43:36.588",-4985.39,USD,"Sent money to Siddharth Bose (fee: 14.61 USD)",,16641.80,USD,AUD,1.54261,,"Siddharth Bose","(013040) 408556264",,,,,,14.61,7690.51,DEBIT,TRANSFER
FEE-TRANSFER-1658801843,04-08-2025,"04-08-2025 23:43:36.587",-14.61,USD,"Wise Charges for: TRANSFER-1658801843",,21627.19,,,,,Wise,,,,,,,0,,DEBIT,TRANSFER
TRANSFER-1648433412,29-07-2025,"29-07-2025 17:31:44.230",10789.27,USD,"Received money from HDR Global Services (Bermuda) with reference INV 2025-07 EXP1043","INV 2025-07 EXP1043",21641.80,,,,"HDR Global Services (Bermuda)",,,,,,,,6.11,,CREDIT,DEPOSIT
FEE-TRANSFER-1648433412,29-07-2025,"29-07-2025 17:31:44.229",-6.11,USD,"Wise Charges for: TRANSFER-1648433412","INV 2025-07 EXP1043",10852.53,,,,"HDR Global Services (Bermuda)",,,,,,,,0,,DEBIT,DEPOSIT
TRANSFER-1626714880,14-07-2025,"14-07-2025 15:47:02.565",10864.75,USD,"Received money from HDR Global Services (Bermuda) with reference INV 2025-05 EXP722","INV 2025-05 EXP722",10858.64,,,,"HDR Global Services (Bermuda)",,,,,,,,6.11,,CREDIT,DEPOSIT
FEE-TRANSFER-1626714880,14-07-2025,"14-07-2025 15:47:02.564",-6.11,USD,"Wise Charges for: TRANSFER-1626714880","INV 2025-05 EXP722",-6.11,,,,"HDR Global Services (Bermuda)",,,,,,,,0,,DEBIT,DEPOSIT
TRANSFER-1593587151,22-06-2025,"22-06-2025 13:55:31.085",-10827.67,USD,"Sent money to Siddharth Bose (fee: 30.97 USD)","Sent via Wise",0.00,USD,AUD,1.55063,,"Siddharth Bose","(013040) 408556264",,,,,,30.97,16789.71,DEBIT,TRANSFER
FEE-TRANSFER-1593587151,22-06-2025,"22-06-2025 13:55:31.084",-30.97,USD,"Wise Charges for: TRANSFER-1593587151","Sent via Wise",10827.67,,,,,Wise,,,,,,,0,,DEBIT,TRANSFER
TRANSFER-1565423196,02-06-2025,"02-06-2025 20:32:13.789",10864.75,USD,"Received money from HDR GLOBAL SERVICES (BERMUDA) with reference INV 2025-05 EXP722","INV 2025-05 EXP722",10858.64,,,,"HDR GLOBAL SERVICES (BERMUDA)",,,,,,,,6.11,,CREDIT,DEPOSIT
FEE-TRANSFER-1565423196,02-06-2025,"02-06-2025 20:32:13.788",-6.11,USD,"Wise Charges for: TRANSFER-1565423196","INV 2025-05 EXP722",-6.11,,,,"HDR GLOBAL SERVICES (BERMUDA)",,,,,,,,0,,DEBIT,DEPOSIT
BALANCE-3505957179,31-05-2025,"31-05-2025 10:32:45.687",-10753.05,USD,"Converted 10,783.16 USD to 16,719.38 AUD (fee: 30.11 USD)",,0.00,USD,AUD,1.55485,,,,,,,,,30.11,16719.38,DEBIT,CONVERSION
FEE-BALANCE-3505957179,31-05-2025,"31-05-2025 10:32:45.686",-30.11,USD,"Wise Charges for: BALANCE-3505957179",,10753.05,,,,,,,,,,,,0,,DEBIT,CONVERSION
TRANSFER-1541924892,16-05-2025,"16-05-2025 17:06:44.192",10789.27,USD,"Received money from HDR GLOBAL SERVICES (BERMUDA) with reference INV 2025-04 EXP617","INV 2025-04 EXP617",10783.16,,,,"HDR GLOBAL SERVICES (BERMUDA)",,,,,,,,6.11,,CREDIT,DEPOSIT
FEE-TRANSFER-1541924892,16-05-2025,"16-05-2025 17:06:44.191",-6.11,USD,"Wise Charges for: TRANSFER-1541924892","INV 2025-04 EXP617",-6.11,,,,"HDR GLOBAL SERVICES (BERMUDA)",,,,,,,,0,,DEBIT,DEPOSIT
1 TransferWise ID Date Date Time Amount Currency Description Payment Reference Running Balance Exchange From Exchange To Exchange Rate Payer Name Payee Name Payee Account Number Merchant Card Last Four Digits Card Holder Full Name Attachment Note Total fees Exchange To Amount Transaction Type Transaction Details Type
2 TRANSFER-2268507611 24-07-2026 24-07-2026 23:59:50.116 -10000.00 USD Sent money to Siddharth Bose 775.89 Siddharth Bose 68844145 0.00 DEBIT TRANSFER
3 TRANSFER-2235277367 07-07-2026 07-07-2026 18:05:17.109 10782.00 USD Received money from HDR Global Services (Bermuda) with reference INV 2026-06 EXP2687 INV 2026-06 EXP2687 10775.89 HDR Global Services (Bermuda) 6.11 CREDIT DEPOSIT
4 FEE-TRANSFER-2235277367 07-07-2026 07-07-2026 18:05:17.108 -6.11 USD Wise Charges for: TRANSFER-2235277367 INV 2026-06 EXP2687 -6.11 HDR Global Services (Bermuda) 0 DEBIT DEPOSIT
5 TRANSFER-2216007380 28-06-2026 28-06-2026 11:37:55.004 -3968.98 USD Sent money to Siddharth Bose (fee: 11.63 USD) 0.00 USD AUD 1.44949 Siddharth Bose (939200) 519049940 11.63 5753.00 DEBIT TRANSFER
6 FEE-TRANSFER-2216007380 28-06-2026 28-06-2026 11:37:55.003 -11.63 USD Wise Charges for: TRANSFER-2216007380 3968.98 Wise 0 DEBIT TRANSFER
7 TRANSFER-2171403032 04-06-2026 04-06-2026 05:11:15.833 -6776.49 USD Sent money to Siddharth Bose (fee: 19.51 USD) 3980.61 USD AUD 1.40233 Siddharth Bose (939200) 519049940 19.51 9502.88 DEBIT TRANSFER
8 FEE-TRANSFER-2171403032 04-06-2026 04-06-2026 05:11:15.832 -19.51 USD Wise Charges for: TRANSFER-2171403032 10757.10 Wise 0 DEBIT TRANSFER
9 TRANSFER-2157538435 28-05-2026 28-05-2026 17:32:10.213 10782.00 USD Received money from HDR Global Services (Bermuda) with reference INV 2026-05 EXP2557 INV 2026-05 EXP2557 10776.61 HDR Global Services (Bermuda) 6.11 CREDIT DEPOSIT
10 FEE-TRANSFER-2157538435 28-05-2026 28-05-2026 17:32:10.212 -6.11 USD Wise Charges for: TRANSFER-2157538435 INV 2026-05 EXP2557 -5.39 HDR Global Services (Bermuda) 0 DEBIT DEPOSIT
11 TRANSFER-2143509822 20-05-2026 20-05-2026 21:01:23.608 -10765.33 USD Sent money to Siddharth Bose (fee: 30.67 USD) 0.72 USD AUD 1.40351 Siddharth Bose (013040) 408556264 30.67 15109.25 DEBIT TRANSFER
12 FEE-TRANSFER-2143509822 20-05-2026 20-05-2026 21:01:23.607 -30.67 USD Wise Charges for: TRANSFER-2143509822 10766.05 Wise 0 DEBIT TRANSFER
13 TRANSFER-2128716995 12-05-2026 12-05-2026 17:33:31.247 10802.83 USD Received money from HDR Global Services (Bermuda) with reference INV 2026-04 EXP2421 INV 2026-04 EXP2421 10796.72 HDR Global Services (Bermuda) 6.11 CREDIT DEPOSIT
14 FEE-TRANSFER-2128716995 12-05-2026 12-05-2026 17:33:31.246 -6.11 USD Wise Charges for: TRANSFER-2128716995 INV 2026-04 EXP2421 -6.11 HDR Global Services (Bermuda) 0 DEBIT DEPOSIT
15 TRANSFER-2114526979 05-05-2026 05-05-2026 09:34:50.397 -6835.10 USD Sent money to Siddharth Bose (fee: 19.67 USD) 0.00 USD AUD 1.39509 Siddharth Bose (939200) 519049940 19.67 9535.58 DEBIT TRANSFER
16 FEE-TRANSFER-2114526979 05-05-2026 05-05-2026 09:34:50.396 -19.67 USD Wise Charges for: TRANSFER-2114526979 6835.10 Wise 0 DEBIT TRANSFER
17 TRANSFER-2075646899 14-04-2026 14-04-2026 06:19:35.500 -4000.00 USD Sent money to Siddharth Bose 6854.77 Siddharth Bose 68844145 0.00 DEBIT TRANSFER
18 TRANSFER-2054903037 02-04-2026 02-04-2026 17:07:38.780 10790.80 USD Received money from HDR Global Services (Bermuda) with reference INV 2026-03 EXP2282 INV 2026-03 EXP2282 10854.77 HDR Global Services (Bermuda) 6.11 CREDIT DEPOSIT
19 FEE-TRANSFER-2054903037 02-04-2026 02-04-2026 17:07:38.779 -6.11 USD Wise Charges for: TRANSFER-2054903037 INV 2026-03 EXP2282 63.97 HDR Global Services (Bermuda) 0 DEBIT DEPOSIT
20 TRANSFER-2043244817 27-03-2026 27-03-2026 17:25:48.750 -9971.56 USD Sent money to Siddharth Bose (fee: 28.44 USD) 70.08 USD AUD 1.45117 Siddharth Bose (939200) 519049940 28.44 14470.43 DEBIT TRANSFER
21 FEE-TRANSFER-2043244817 27-03-2026 27-03-2026 17:25:48.749 -28.44 USD Wise Charges for: TRANSFER-2043244817 10041.64 Wise 0 DEBIT TRANSFER
22 TRANSFER-2009199370 07-03-2026 07-03-2026 10:00:47.387 -6000.00 USD Sent money to Siddharth Bose 10070.08 Siddharth Bose 68844145 0.00 DEBIT TRANSFER
23 TRANSFER-2007700796 06-03-2026 06-03-2026 18:32:12.643 10790.80 USD Received money from HDR Global Services (Bermuda) with reference INV 2026-02 EXP2148 INV 2026-02 EXP2148 16070.08 HDR Global Services (Bermuda) 6.11 CREDIT DEPOSIT
24 FEE-TRANSFER-2007700796 06-03-2026 06-03-2026 18:32:12.642 -6.11 USD Wise Charges for: TRANSFER-2007700796 INV 2026-02 EXP2148 5279.28 HDR Global Services (Bermuda) 0 DEBIT DEPOSIT
25 TRANSFER-1960000797 07-02-2026 07-02-2026 00:12:56.532 -25000.00 USD Sent money to Siddharth Bose 5285.39 Siddharth Bose 68844145 0.00 DEBIT TRANSFER
26 TRANSFER-1953505794 03-02-2026 03-02-2026 18:16:51.173 10790.80 USD Received money from HDR Global Services (Bermuda) with reference INV 2026-01 EXP1987 INV 2026-01 EXP1987 30285.39 HDR Global Services (Bermuda) 6.11 CREDIT DEPOSIT
27 FEE-TRANSFER-1953505794 03-02-2026 03-02-2026 18:16:51.172 -6.11 USD Wise Charges for: TRANSFER-1953505794 INV 2026-01 EXP1987 19494.59 HDR Global Services (Bermuda) 0 DEBIT DEPOSIT
28 TRANSFER-1950103478 02-02-2026 02-02-2026 10:12:23.989 -500.00 USD Sent money to Siddharth Bose 19500.70 Siddharth Bose 68844145 0.00 DEBIT TRANSFER
29 TRANSFER-1946979742 31-01-2026 31-01-2026 00:45:43.330 -2181.00 USD Sent money to Siddharth Bose 20000.70 Siddharth Bose 68844145 0.00 DEBIT TRANSFER
30 TRANSFER-1908177420 07-01-2026 07-01-2026 18:02:18.983 10790.80 USD Received money from HDR Global Services (Bermuda) with reference INV 2025-12 EXP1817 INV 2025-12 EXP1817 22181.70 HDR Global Services (Bermuda) 6.11 CREDIT DEPOSIT
31 FEE-TRANSFER-1908177420 07-01-2026 07-01-2026 18:02:18.982 -6.11 USD Wise Charges for: TRANSFER-1908177420 INV 2025-12 EXP1817 11390.90 HDR Global Services (Bermuda) 0 DEBIT DEPOSIT
32 TRANSFER-1847010079 01-12-2025 01-12-2025 21:17:07.414 10790.69 USD Received money from HDR Global Services (Bermuda) with reference INV 2025-11 EXP1683 INV 2025-11 EXP1683 11397.01 HDR Global Services (Bermuda) 6.11 CREDIT DEPOSIT
33 FEE-TRANSFER-1847010079 01-12-2025 01-12-2025 21:17:07.413 -6.11 USD Wise Charges for: TRANSFER-1847010079 INV 2025-11 EXP1683 606.32 HDR Global Services (Bermuda) 0 DEBIT DEPOSIT
34 CARD-3157819100 25-11-2025 25-11-2025 15:16:16.619 -12.65 USD Card transaction of 10.99 EUR issued by Mega Limited AUCKLAND (fee: 0.02 USD) 612.43 USD EUR 0.86851 Mega Limited AUCKLAND 4233 Siddharth Bose 0.02 10.99 DEBIT CARD
35 FEE-CARD-3157819100 25-11-2025 25-11-2025 15:16:16.618 -0.02 USD Wise Charges for: CARD-3157819100 625.08 Mega Limited AUCKLAND 4233 Siddharth Bose 0 DEBIT CARD
36 TRANSFER-1833504017 24-11-2025 24-11-2025 01:15:43.299 -10973.71 USD Sent money to Siddharth Bose (fee: 26.29 USD) 625.10 USD AUD 1.54883 Siddharth Bose (939200) 519049940 26.29 16996.41 DEBIT TRANSFER
37 FEE-TRANSFER-1833504017 24-11-2025 24-11-2025 01:15:43.298 -26.29 USD Wise Charges for: TRANSFER-1833504017 11598.81 Wise 0 DEBIT TRANSFER
38 CARD-3115289050 13-11-2025 13-11-2025 20:15:06.123 -34.85 USD Card transaction of 30.00 EUR issued by Sta Spa-Ag BOLZANO (fee: 0.10 USD) 11625.10 USD EUR 0.86092 Sta Spa-Ag BOLZANO 8531 Siddharth Bose 0.10 30.00 DEBIT CARD
39 FEE-CARD-3115289050 13-11-2025 13-11-2025 20:15:06.122 -0.10 USD Wise Charges for: CARD-3115289050 11659.95 Sta Spa-Ag BOLZANO 8531 Siddharth Bose 0 DEBIT CARD
40 TRANSFER-1808432866 07-11-2025 07-11-2025 18:13:22.798 10790.69 USD Received money from HDR Global Services (Bermuda) with reference INV 2025-10 EXP1512 INV 2025-10 EXP1512 11660.05 HDR Global Services (Bermuda) 6.11 CREDIT DEPOSIT
41 FEE-TRANSFER-1808432866 07-11-2025 07-11-2025 18:13:22.797 -6.11 USD Wise Charges for: TRANSFER-1808432866 INV 2025-10 EXP1512 869.36 HDR Global Services (Bermuda) 0 DEBIT DEPOSIT
42 TRANSFER-1805800459 06-11-2025 06-11-2025 04:53:13.751 -19943.51 USD Sent money to Siddharth Bose (fee: 56.49 USD) 875.47 USD AUD 1.53692 Siddharth Bose (013040) 408556264 56.49 30651.58 DEBIT TRANSFER
43 FEE-TRANSFER-1805800459 06-11-2025 06-11-2025 04:53:13.750 -56.49 USD Wise Charges for: TRANSFER-1805800459 20818.98 Wise 0 DEBIT TRANSFER
44 CARD-3088194739 06-11-2025 06-11-2025 04:37:29.830 -45.92 USD Card transaction of 40.00 EUR issued by Via Soana 24 ROMA (fee: 0.13 USD) 20875.47 USD EUR 0.87104 Via Soana 24 ROMA 8531 Siddharth Bose 0.13 40.00 DEBIT CARD
45 FEE-CARD-3088194739 06-11-2025 06-11-2025 04:37:29.829 -0.13 USD Wise Charges for: CARD-3088194739 20921.39 Via Soana 24 ROMA 8531 Siddharth Bose 0 DEBIT CARD
46 CARD-3046253352 25-10-2025 25-10-2025 15:01:15.513 -12.78 USD Card transaction of 10.99 EUR issued by Mega Limited AUCKLAND (fee: 0.03 USD) 20921.52 USD EUR 0.86014 Mega Limited AUCKLAND 4233 Siddharth Bose 0.03 10.99 DEBIT CARD
47 FEE-CARD-3046253352 25-10-2025 25-10-2025 15:01:15.512 -0.03 USD Wise Charges for: CARD-3046253352 20934.30 Mega Limited AUCKLAND 4233 Siddharth Bose 0 DEBIT CARD
48 TRANSFER-1784386011 24-10-2025 24-10-2025 10:39:46.041 -6513.98 USD Sent money to Siddharth Bose (fee: 18.89 USD) 20934.33 USD AUD 1.53516 Siddharth Bose (013040) 408556264 18.89 10000.00 DEBIT TRANSFER
49 FEE-TRANSFER-1784386011 24-10-2025 24-10-2025 10:39:46.040 -18.89 USD Wise Charges for: TRANSFER-1784386011 27448.31 Wise 0 DEBIT TRANSFER
50 CARD-3027732792 20-10-2025 20-10-2025 08:58:43.670 549.70 USD Card transaction of -549.70 USD issued by Etihad Airw 6072412191144 ABU DHABI 27467.20 Etihad Airw 6072412191144 ABU DHABI 4233 Siddharth Bose 0.00 CREDIT CARD
51 CARD-3023391491 19-10-2025 19-10-2025 02:04:15.660 -82.50 USD Card transaction of 82.50 USD issued by *Sfo Intl Airport-Itb N SAN FRANCISCO 26917.50 *Sfo Intl Airport-Itb N SAN FRANCISCO 8531 Siddharth Bose 0.00 DEBIT CARD
52 TRANSFER-1772299455 16-10-2025 16-10-2025 08:00:56.288 -1500.00 USD Sent money to Siddharth Bose 27000.00 Siddharth Bose 68844145 0.00 DEBIT TRANSFER
53 TRANSFER-1765630070 11-10-2025 11-10-2025 22:28:23.964 -321.88 USD Sent money to Siddharth Bose 28500.00 Siddharth Bose 68844145 0.00 DEBIT TRANSFER
54 TRANSFER-1755893777 06-10-2025 06-10-2025 07:59:23.646 -3955.49 USD Sent money to Siddharth Bose (fee: 11.74 USD) 28821.88 USD AUD 1.51688 Siddharth Bose (033501) 394758 11.74 6000.00 DEBIT TRANSFER
55 FEE-TRANSFER-1755893777 06-10-2025 06-10-2025 07:59:23.645 -11.74 USD Wise Charges for: TRANSFER-1755893777 32777.37 Wise 0 DEBIT TRANSFER
56 CARD-2962375193 02-10-2025 02-10-2025 08:41:01.066 4.53 USD Card transaction of -4.53 USD issued by Patreon* Membership Internet 32789.11 Patreon* Membership Internet 4233 Siddharth Bose 0.00 CREDIT CARD
57 TRANSFER-1748256868 01-10-2025 01-10-2025 18:47:09.850 10790.69 USD Received money from HDR Global Services (Bermuda) with reference INV 2025-09 EXP1335 INV 2025-09 EXP1335 32784.58 HDR Global Services (Bermuda) 6.11 CREDIT DEPOSIT
58 FEE-TRANSFER-1748256868 01-10-2025 01-10-2025 18:47:09.849 -6.11 USD Wise Charges for: TRANSFER-1748256868 INV 2025-09 EXP1335 21993.89 HDR Global Services (Bermuda) 0 DEBIT DEPOSIT
59 TRANSFER-1742899854 28-09-2025 28-09-2025 23:23:11.284 -1993.76 USD Sent money to Siddharth Bose (fee: 6.24 USD) 22000.00 USD AUD 1.52753 Siddharth Bose (939200) 519049940 6.24 3045.53 DEBIT TRANSFER
60 FEE-TRANSFER-1742899854 28-09-2025 28-09-2025 23:23:11.283 -6.24 USD Wise Charges for: TRANSFER-1742899854 23993.76 Wise 0 DEBIT TRANSFER
61 TRANSFER-1738593468 25-09-2025 25-09-2025 23:12:15.826 -82.39 USD Sent money to Siddharth Bose 24000.00 Siddharth Bose 68844145 0.00 DEBIT TRANSFER
62 CARD-2937450107 25-09-2025 25-09-2025 14:53:54.340 -12.91 USD Card transaction of 10.99 EUR issued by Mega Limited AUCKLAND (fee: 0.03 USD) 24082.39 USD EUR 0.85103 Mega Limited AUCKLAND 4233 Siddharth Bose 0.03 10.99 DEBIT CARD
63 FEE-CARD-2937450107 25-09-2025 25-09-2025 14:53:54.339 -0.03 USD Wise Charges for: CARD-2937450107 24095.30 Mega Limited AUCKLAND 4233 Siddharth Bose 0 DEBIT CARD
64 CARD-2931031224 23-09-2025 23-09-2025 19:41:24.887 -549.70 USD Card transaction of 549.70 USD issued by Etihad Airw 6072412191144 ABU DHABI 24095.33 Etihad Airw 6072412191144 ABU DHABI 4233 Siddharth Bose 0.00 DEBIT CARD
65 CARD-2929927553 23-09-2025 23-09-2025 10:05:31.187 -16.50 USD Card transaction of 16.50 USD issued by Patreon* Membership Internet 24645.03 Patreon* Membership Internet 4233 Siddharth Bose 0.00 DEBIT CARD
66 CARD-2928693059 23-09-2025 23-09-2025 01:24:48.519 -4.53 USD Card transaction of 4.53 USD issued by Patreon* Membership Internet 24661.53 Patreon* Membership Internet 4233 Siddharth Bose 0.00 DEBIT CARD
67 TRANSFER-1706024945 04-09-2025 04-09-2025 18:17:21.211 -2675.24 USD Sent money to Siddharth Bose (fee: 8.15 USD) 24666.06 USD AUD 1.53257 Siddharth Bose (033501) 394758 8.15 4100.00 DEBIT TRANSFER
68 FEE-TRANSFER-1706024945 04-09-2025 04-09-2025 18:17:21.210 -8.15 USD Wise Charges for: TRANSFER-1706024945 27341.30 Wise 0 DEBIT TRANSFER
69 TRANSFER-1702141390 02-09-2025 02-09-2025 16:47:10.556 10713.76 USD Received money from HDR Global Services (Bermuda) with reference INV 2025-08 EXP1167 INV 2025-08 EXP1167 27349.45 HDR Global Services (Bermuda) 6.11 CREDIT DEPOSIT
70 FEE-TRANSFER-1702141390 02-09-2025 02-09-2025 16:47:10.555 -6.11 USD Wise Charges for: TRANSFER-1702141390 INV 2025-08 EXP1167 16635.69 HDR Global Services (Bermuda) 0 DEBIT DEPOSIT
71 TRANSFER-1658801843 04-08-2025 04-08-2025 23:43:36.588 -4985.39 USD Sent money to Siddharth Bose (fee: 14.61 USD) 16641.80 USD AUD 1.54261 Siddharth Bose (013040) 408556264 14.61 7690.51 DEBIT TRANSFER
72 FEE-TRANSFER-1658801843 04-08-2025 04-08-2025 23:43:36.587 -14.61 USD Wise Charges for: TRANSFER-1658801843 21627.19 Wise 0 DEBIT TRANSFER
73 TRANSFER-1648433412 29-07-2025 29-07-2025 17:31:44.230 10789.27 USD Received money from HDR Global Services (Bermuda) with reference INV 2025-07 EXP1043 INV 2025-07 EXP1043 21641.80 HDR Global Services (Bermuda) 6.11 CREDIT DEPOSIT
74 FEE-TRANSFER-1648433412 29-07-2025 29-07-2025 17:31:44.229 -6.11 USD Wise Charges for: TRANSFER-1648433412 INV 2025-07 EXP1043 10852.53 HDR Global Services (Bermuda) 0 DEBIT DEPOSIT
75 TRANSFER-1626714880 14-07-2025 14-07-2025 15:47:02.565 10864.75 USD Received money from HDR Global Services (Bermuda) with reference INV 2025-05 EXP722 INV 2025-05 EXP722 10858.64 HDR Global Services (Bermuda) 6.11 CREDIT DEPOSIT
76 FEE-TRANSFER-1626714880 14-07-2025 14-07-2025 15:47:02.564 -6.11 USD Wise Charges for: TRANSFER-1626714880 INV 2025-05 EXP722 -6.11 HDR Global Services (Bermuda) 0 DEBIT DEPOSIT
77 TRANSFER-1593587151 22-06-2025 22-06-2025 13:55:31.085 -10827.67 USD Sent money to Siddharth Bose (fee: 30.97 USD) Sent via Wise 0.00 USD AUD 1.55063 Siddharth Bose (013040) 408556264 30.97 16789.71 DEBIT TRANSFER
78 FEE-TRANSFER-1593587151 22-06-2025 22-06-2025 13:55:31.084 -30.97 USD Wise Charges for: TRANSFER-1593587151 Sent via Wise 10827.67 Wise 0 DEBIT TRANSFER
79 TRANSFER-1565423196 02-06-2025 02-06-2025 20:32:13.789 10864.75 USD Received money from HDR GLOBAL SERVICES (BERMUDA) with reference INV 2025-05 EXP722 INV 2025-05 EXP722 10858.64 HDR GLOBAL SERVICES (BERMUDA) 6.11 CREDIT DEPOSIT
80 FEE-TRANSFER-1565423196 02-06-2025 02-06-2025 20:32:13.788 -6.11 USD Wise Charges for: TRANSFER-1565423196 INV 2025-05 EXP722 -6.11 HDR GLOBAL SERVICES (BERMUDA) 0 DEBIT DEPOSIT
81 BALANCE-3505957179 31-05-2025 31-05-2025 10:32:45.687 -10753.05 USD Converted 10,783.16 USD to 16,719.38 AUD (fee: 30.11 USD) 0.00 USD AUD 1.55485 30.11 16719.38 DEBIT CONVERSION
82 FEE-BALANCE-3505957179 31-05-2025 31-05-2025 10:32:45.686 -30.11 USD Wise Charges for: BALANCE-3505957179 10753.05 0 DEBIT CONVERSION
83 TRANSFER-1541924892 16-05-2025 16-05-2025 17:06:44.192 10789.27 USD Received money from HDR GLOBAL SERVICES (BERMUDA) with reference INV 2025-04 EXP617 INV 2025-04 EXP617 10783.16 HDR GLOBAL SERVICES (BERMUDA) 6.11 CREDIT DEPOSIT
84 FEE-TRANSFER-1541924892 16-05-2025 16-05-2025 17:06:44.191 -6.11 USD Wise Charges for: TRANSFER-1541924892 INV 2025-04 EXP617 -6.11 HDR GLOBAL SERVICES (BERMUDA) 0 DEBIT DEPOSIT
@@ -1,6 +0,0 @@
"TransferWise ID",Date,"Date Time",Amount,Currency,Description,"Payment Reference","Running Balance","Exchange From","Exchange To","Exchange Rate","Payer Name","Payee Name","Payee Account Number",Merchant,"Card Last Four Digits","Card Holder Full Name",Attachment,Note,"Total fees","Exchange To Amount","Transaction Type","Transaction Details Type"
TRANSFER-1562381704,31-05-2025,"31-05-2025 10:35:03.049",-16718.38,AUD,"Sent money to Siddharth Bose (fee: 1.00 AUD)","Sent via Wise",0.00,,,,,"Siddharth Bose",408556264,,,,,,1.00,,DEBIT,TRANSFER
FEE-TRANSFER-1562381704,31-05-2025,"31-05-2025 10:35:03.048",-1.00,AUD,"Wise Charges for: TRANSFER-1562381704","Sent via Wise",16718.38,,,,,Wise,,,,,,,0,,DEBIT,TRANSFER
BALANCE-3505957179,31-05-2025,"31-05-2025 10:32:45.687",16719.38,AUD,"Converted 10,783.16 USD to 16,719.38 AUD",,16719.38,USD,AUD,1.55485,,,,,,,,,0.00,16719.38,CREDIT,CONVERSION
"BANK_DETAILS_ORDER_CHECKOUT-invoice-17067107",22-04-2025,"22-04-2025 21:45:08.441",-22.00,AUD,"Wise bank details acquisition",,0.00,,,,,,,,,,,,0.00,,DEBIT,UNKNOWN
TRANSFER-1506981660,22-04-2025,"22-04-2025 21:45:07.288",22.00,AUD,"Topped up account",,22.00,,,,,,,,,,,,0.00,,CREDIT,MONEY_ADDED
1 TransferWise ID Date Date Time Amount Currency Description Payment Reference Running Balance Exchange From Exchange To Exchange Rate Payer Name Payee Name Payee Account Number Merchant Card Last Four Digits Card Holder Full Name Attachment Note Total fees Exchange To Amount Transaction Type Transaction Details Type
2 TRANSFER-1562381704 31-05-2025 31-05-2025 10:35:03.049 -16718.38 AUD Sent money to Siddharth Bose (fee: 1.00 AUD) Sent via Wise 0.00 Siddharth Bose 408556264 1.00 DEBIT TRANSFER
3 FEE-TRANSFER-1562381704 31-05-2025 31-05-2025 10:35:03.048 -1.00 AUD Wise Charges for: TRANSFER-1562381704 Sent via Wise 16718.38 Wise 0 DEBIT TRANSFER
4 BALANCE-3505957179 31-05-2025 31-05-2025 10:32:45.687 16719.38 AUD Converted 10,783.16 USD to 16,719.38 AUD 16719.38 USD AUD 1.55485 0.00 16719.38 CREDIT CONVERSION
5 BANK_DETAILS_ORDER_CHECKOUT-invoice-17067107 22-04-2025 22-04-2025 21:45:08.441 -22.00 AUD Wise bank details acquisition 0.00 0.00 DEBIT UNKNOWN
6 TRANSFER-1506981660 22-04-2025 22-04-2025 21:45:07.288 22.00 AUD Topped up account 22.00 0.00 CREDIT MONEY_ADDED
@@ -1,4 +0,0 @@
"TransferWise ID",Date,"Date Time",Amount,Currency,Description,"Payment Reference","Running Balance","Exchange From","Exchange To","Exchange Rate","Payer Name","Payee Name","Payee Account Number",Merchant,"Card Last Four Digits","Card Holder Full Name",Attachment,Note,"Total fees","Exchange To Amount","Transaction Type","Transaction Details Type"
CARD-2958747517,01-10-2025,"01-10-2025 09:03:26.018",-30.00,AUD,"Card transaction of 76.00 AUD issued by Air India Limited Gurugram",,0.00,,,,,,,"Air India Limited Gurugram",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
TRANSFER-1738581938,25-09-2025,"25-09-2025 23:10:05.406",30.09,AUD,"Topped up account",,30.00,,,,,,,,,,,,0.09,,CREDIT,MONEY_ADDED
FEE-TRANSFER-1738581938,25-09-2025,"25-09-2025 23:10:05.405",-0.09,AUD,"Wise Charges for: TRANSFER-1738581938",,-0.09,,,,,,,,,,,,0,,DEBIT,MONEY_ADDED
1 TransferWise ID Date Date Time Amount Currency Description Payment Reference Running Balance Exchange From Exchange To Exchange Rate Payer Name Payee Name Payee Account Number Merchant Card Last Four Digits Card Holder Full Name Attachment Note Total fees Exchange To Amount Transaction Type Transaction Details Type
2 CARD-2958747517 01-10-2025 01-10-2025 09:03:26.018 -30.00 AUD Card transaction of 76.00 AUD issued by Air India Limited Gurugram 0.00 Air India Limited Gurugram 7824 Siddharth Bose 0.00 DEBIT CARD
3 TRANSFER-1738581938 25-09-2025 25-09-2025 23:10:05.406 30.09 AUD Topped up account 30.00 0.09 CREDIT MONEY_ADDED
4 FEE-TRANSFER-1738581938 25-09-2025 25-09-2025 23:10:05.405 -0.09 AUD Wise Charges for: TRANSFER-1738581938 -0.09 0 DEBIT MONEY_ADDED
@@ -1,99 +0,0 @@
"TransferWise ID",Date,"Date Time",Amount,Currency,Description,"Payment Reference","Running Balance","Exchange From","Exchange To","Exchange Rate","Payer Name","Payee Name","Payee Account Number",Merchant,"Card Last Four Digits","Card Holder Full Name",Attachment,Note,"Total fees","Exchange To Amount","Transaction Type","Transaction Details Type"
TRANSFER-2268510041,25-07-2026,"25-07-2026 00:00:53.745",-10000.00,USD,"Sent money to Interactive Brokers LLC (fee: 1.13 USD)",547528752,450.41,,,,,"Interactive Brokers LLC","(021000021) 6******02",,,,,,1.13,,DEBIT,TRANSFER
FEE-TRANSFER-2268510041,25-07-2026,"25-07-2026 00:00:53.744",-1.13,USD,"Wise Charges for: TRANSFER-2268510041",547528752,10450.41,,,,,Wise,,,,,,,0,,DEBIT,TRANSFER
TRANSFER-2268507611,24-07-2026,"24-07-2026 23:59:52.113",10000.00,USD,"Received money from Siddharth Bose with reference ",,10451.54,,,,"Siddharth Bose",,,,,,,,0.00,,CREDIT,DEPOSIT
CARD-3976207925,27-06-2026,"27-06-2026 10:07:35.409",-16.50,USD,"Card transaction of 16.50 USD issued by Patreon* Membership Internet",,451.54,,,,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
CARD-3841569125,27-05-2026,"27-05-2026 00:39:20.325",-16.50,USD,"Card transaction of 16.50 USD issued by Patreon* Membership Internet",,468.04,,,,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
CARD-3805642152,18-05-2026,"18-05-2026 10:23:36.446",-1.10,USD,"Card transaction of 1.10 USD issued by Patreon* Membership Internet",,484.54,,,,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
CARD-3685714866,18-04-2026,"18-04-2026 10:32:14.560",-1.10,USD,"Card transaction of 1.10 USD issued by Patreon* Membership Internet",,485.64,,,,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
TRANSFER-2075650616,14-04-2026,"14-04-2026 06:21:23.847",-4000.00,USD,"Sent money to Interactive Brokers LLC (fee: 1.13 USD)",,486.74,,,,,"Interactive Brokers LLC","(021000021) 6******02",,,,,,1.13,,DEBIT,TRANSFER
FEE-TRANSFER-2075650616,14-04-2026,"14-04-2026 06:21:23.846",-1.13,USD,"Wise Charges for: TRANSFER-2075650616",,4486.74,,,,,Wise,,,,,,,0,,DEBIT,TRANSFER
TRANSFER-2075646899,14-04-2026,"14-04-2026 06:19:38.179",4000.00,USD,"Received money from Siddharth Bose with reference ",,4487.87,,,,"Siddharth Bose",,,,,,,,0.00,,CREDIT,DEPOSIT
CARD-3567908768,18-03-2026,"18-03-2026 10:28:01.251",-1.10,USD,"Card transaction of 1.10 USD issued by Patreon* Membership Internet",,487.87,,,,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
TRANSFER-2009203196,07-03-2026,"07-03-2026 10:05:01.880",-6000.00,USD,"Sent money to Interactive Brokers LLC (fee: 1.13 USD)",499493306,488.97,,,,,"Interactive Brokers LLC","(021000021) 6******02",,,,,,1.13,,DEBIT,TRANSFER
FEE-TRANSFER-2009203196,07-03-2026,"07-03-2026 10:05:01.879",-1.13,USD,"Wise Charges for: TRANSFER-2009203196",499493306,6488.97,,,,,Wise,,,,,,,0,,DEBIT,TRANSFER
TRANSFER-2009199370,07-03-2026,"07-03-2026 10:00:50.551",6000.00,USD,"Received money from Siddharth Bose with reference ",,6490.10,,,,"Siddharth Bose",,,,,,,,0.00,,CREDIT,DEPOSIT
CARD-3491856978,25-02-2026,"25-02-2026 10:10:28.993",-3.30,USD,"Card transaction of 3.30 USD issued by Patreon* Membership Internet",,490.10,,,,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
CARD-3467922012,18-02-2026,"18-02-2026 10:10:19.758",-1.10,USD,"Card transaction of 1.10 USD issued by Patreon* Membership Internet",,493.40,,,,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
CARD-3453741061,14-02-2026,"14-02-2026 10:15:05.143",-3.30,USD,"Card transaction of 3.30 USD issued by Patreon* Membership Internet",,494.50,,,,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
TRANSFER-1959996575,07-02-2026,"07-02-2026 00:14:08.112",-25000.00,USD,"Sent money to Interactive Brokers LLC (fee: 1.13 USD)",490625745,497.80,,,,,"Interactive Brokers LLC","(021000021) 6******02",,,,,,1.13,,DEBIT,TRANSFER
FEE-TRANSFER-1959996575,07-02-2026,"07-02-2026 00:14:08.111",-1.13,USD,"Wise Charges for: TRANSFER-1959996575",490625745,25497.80,,,,,Wise,,,,,,,0,,DEBIT,TRANSFER
TRANSFER-1960000797,07-02-2026,"07-02-2026 00:12:59.295",25000.00,USD,"Received money from Siddharth Bose with reference ",,25498.93,,,,"Siddharth Bose",,,,,,,,0.00,,CREDIT,DEPOSIT
CARD-3410915740,02-02-2026,"02-02-2026 13:08:23.820",-5.50,USD,"Card transaction of 5.50 USD issued by Patreon* Membership Internet",,498.93,,,,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
TRANSFER-1950103478,02-02-2026,"02-02-2026 10:12:26.999",500.00,USD,"Received money from Siddharth Bose with reference ",,504.43,,,,"Siddharth Bose",,,,,,,,0.00,,CREDIT,DEPOSIT
CARD-3408417070,01-02-2026,"01-02-2026 21:32:05.683",-1.15,USD,"Card transaction of 1.65 AUD issued by Patreon* Membership Internet (fee: 0.01 USD)",,4.43,USD,AUD,1.43647,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0.01,1.65,DEBIT,CARD
FEE-CARD-3408417070,01-02-2026,"01-02-2026 21:32:05.682",-0.01,USD,"Wise Charges for: CARD-3408417070",,5.58,,,,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0,,DEBIT,CARD
TRANSFER-1946983328,31-01-2026,"31-01-2026 00:47:24.337",-2440.00,USD,"Sent money to Interactive Brokers LLC (fee: 1.13 USD)",488140942,5.59,,,,,"Interactive Brokers LLC","(021000021) 6******02",,,,,,1.13,,DEBIT,TRANSFER
FEE-TRANSFER-1946983328,31-01-2026,"31-01-2026 00:47:24.336",-1.13,USD,"Wise Charges for: TRANSFER-1946983328",488140942,2445.59,,,,,Wise,,,,,,,0,,DEBIT,TRANSFER
TRANSFER-1946979742,31-01-2026,"31-01-2026 00:45:46.524",2181.00,USD,"Received money from Siddharth Bose with reference ",,2446.72,,,,"Siddharth Bose",,,,,,,,0.00,,CREDIT,DEPOSIT
CARD-3382069917,25-01-2026,"25-01-2026 10:32:05.449",-3.30,USD,"Card transaction of 3.30 USD issued by Patreon* Membership Internet",,265.72,,,,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
CARD-3374520543,23-01-2026,"23-01-2026 10:25:32.264",-16.50,USD,"Card transaction of 16.50 USD issued by Patreon* Membership Internet",,269.02,,,,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
CARD-3357157631,18-01-2026,"18-01-2026 10:15:13.990",-1.10,USD,"Card transaction of 1.10 USD issued by Patreon* Membership Internet",,285.52,,,,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
CARD-3342293626,14-01-2026,"14-01-2026 10:13:23.175",-3.30,USD,"Card transaction of 3.30 USD issued by Patreon* Membership Internet",,286.62,,,,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
CARD-3299985499,02-01-2026,"02-01-2026 23:06:34.860",-5.29,USD,"Card transaction of 104.51 EUR issued by Sp Nord Studios FORDINGBRIDGE (fee: 0.01 USD)",,289.92,USD,EUR,0.85281,,,,"Sp Nord Studios FORDINGBRIDGE",7824,"Siddharth Bose",,,0.01,4.51,DEBIT,CARD
FEE-CARD-3299985499,02-01-2026,"02-01-2026 23:06:34.859",-0.01,USD,"Wise Charges for: CARD-3299985499",,295.21,,,,,,,"Sp Nord Studios FORDINGBRIDGE",7824,"Siddharth Bose",,,0,,DEBIT,CARD
CARD-3296046085,01-01-2026,"01-01-2026 22:12:53.296",-5.50,USD,"Card transaction of 5.50 USD issued by Patreon* Membership Internet",,295.22,,,,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
CARD-3296045671,01-01-2026,"01-01-2026 22:12:45.273",-1.10,USD,"Card transaction of 1.65 AUD issued by Patreon* Membership Internet (fee: 0.01 USD)",,300.72,USD,AUD,1.49869,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0.01,1.65,DEBIT,CARD
FEE-CARD-3296045671,01-01-2026,"01-01-2026 22:12:45.272",-0.01,USD,"Wise Charges for: CARD-3296045671",,301.82,,,,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0,,DEBIT,CARD
CARD-3268020843,25-12-2025,"25-12-2025 05:42:18.806",-3.30,USD,"Card transaction of 3.30 USD issued by Patreon* Membership Internet",,301.83,,,,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
CARD-3260621879,23-12-2025,"23-12-2025 10:22:35.011",-16.50,USD,"Card transaction of 16.50 USD issued by Patreon* Membership Internet",,305.13,,,,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
CARD-3241565056,18-12-2025,"18-12-2025 10:16:09.323",-1.10,USD,"Card transaction of 1.10 USD issued by Patreon* Membership Internet",,321.63,,,,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
CARD-3227480675,14-12-2025,"14-12-2025 10:22:05.979",-3.30,USD,"Card transaction of 3.30 USD issued by Patreon* Membership Internet",,322.73,,,,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
CARD-3188378869,07-12-2025,"07-12-2025 00:17:33.286",-7.41,USD,"Card transaction of 27.00 QAR issued by Talabat.com DOHA (fee: 0.06 USD)",,326.03,USD,QAR,3.64480,,,,"Talabat.com DOHA",7824,"Siddharth Bose",,,0.06,27.00,DEBIT,CARD
FEE-CARD-3188378869,07-12-2025,"07-12-2025 00:17:33.285",-0.06,USD,"Wise Charges for: CARD-3188378869",,333.44,,,,,,,"Talabat.com DOHA",7824,"Siddharth Bose",,,0,,DEBIT,CARD
CARD-3188378869,06-12-2025,"06-12-2025 19:20:41.782",7.48,USD,"Card transaction of 27.00 QAR issued by Talabat.com DOHA (fee: 0.06 USD)",,333.50,USD,QAR,3.64115,,,,"Talabat.com DOHA",7824,"Siddharth Bose",,,0.06,27.00,CREDIT,CARD
CARD-3197774302,06-12-2025,"06-12-2025 04:59:25.894",-10.97,USD,"Card transaction of 40.00 QAR issued by Talabat.com DOHA (fee: 0.08 USD)",,326.02,USD,QAR,3.64480,,,,"Talabat.com DOHA",7824,"Siddharth Bose",,,0.08,40.00,DEBIT,CARD
FEE-CARD-3197774302,06-12-2025,"06-12-2025 04:59:25.893",-0.08,USD,"Wise Charges for: CARD-3197774302",,336.99,,,,,,,"Talabat.com DOHA",7824,"Siddharth Bose",,,0,,DEBIT,CARD
CARD-3195872742,05-12-2025,"05-12-2025 20:14:56.638",-11.52,USD,"Card transaction of 42.00 QAR issued by Talabat.com DOHA (fee: 0.09 USD)",,337.07,USD,QAR,3.64480,,,,"Talabat.com DOHA",7824,"Siddharth Bose",,,0.09,42.00,DEBIT,CARD
FEE-CARD-3195872742,05-12-2025,"05-12-2025 20:14:56.637",-0.09,USD,"Wise Charges for: CARD-3195872742",,348.59,,,,,,,"Talabat.com DOHA",7824,"Siddharth Bose",,,0,,DEBIT,CARD
CARD-3193767606,05-12-2025,"05-12-2025 04:01:04.690",-12.48,USD,"Card transaction of 45.50 QAR issued by Talabat.com DOHA (fee: 0.09 USD)",,348.68,USD,QAR,3.64490,,,,"Talabat.com DOHA",7824,"Siddharth Bose",,,0.09,45.50,DEBIT,CARD
FEE-CARD-3193767606,05-12-2025,"05-12-2025 04:01:04.689",-0.09,USD,"Wise Charges for: CARD-3193767606",,361.16,,,,,,,"Talabat.com DOHA",7824,"Siddharth Bose",,,0,,DEBIT,CARD
CARD-3192102297,04-12-2025,"04-12-2025 19:47:04.471",-11.91,USD,"Card transaction of 43.40 QAR issued by Talabat.com DOHA (fee: 0.09 USD)",,361.25,USD,QAR,3.64490,,,,"Talabat.com DOHA",7824,"Siddharth Bose",,,0.09,43.40,DEBIT,CARD
FEE-CARD-3192102297,04-12-2025,"04-12-2025 19:47:04.470",-0.09,USD,"Wise Charges for: CARD-3192102297",,373.16,,,,,,,"Talabat.com DOHA",7824,"Siddharth Bose",,,0,,DEBIT,CARD
CARD-3190030701,04-12-2025,"04-12-2025 03:27:05.435",-12.35,USD,"Card transaction of 45.00 QAR issued by Talabat.com DOHA (fee: 0.09 USD)",,373.25,USD,QAR,3.64475,,,,"Talabat.com DOHA",7824,"Siddharth Bose",,,0.09,45.00,DEBIT,CARD
FEE-CARD-3190030701,04-12-2025,"04-12-2025 03:27:05.434",-0.09,USD,"Wise Charges for: CARD-3190030701",,385.60,,,,,,,"Talabat.com DOHA",7824,"Siddharth Bose",,,0,,DEBIT,CARD
CARD-3188378869,03-12-2025,"03-12-2025 19:20:21.222",-7.42,USD,"Card transaction of 27.00 QAR issued by Talabat.com DOHA (fee: 0.06 USD)",,385.69,USD,QAR,3.64115,,,,"Talabat.com DOHA",7824,"Siddharth Bose",,,0.06,27.00,DEBIT,CARD
FEE-CARD-3188378869,03-12-2025,"03-12-2025 19:20:21.221",-0.06,USD,"Wise Charges for: CARD-3188378869",,393.11,,,,,,,"Talabat.com DOHA",7824,"Siddharth Bose",,,0,,DEBIT,CARD
CARD-3181379836,01-12-2025,"01-12-2025 22:28:23.718",-5.50,USD,"Card transaction of 5.50 USD issued by Patreon* Membership Internet",,393.17,,,,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
CARD-3181379541,01-12-2025,"01-12-2025 22:28:19.540",-1.08,USD,"Card transaction of 1.65 AUD issued by Patreon* Membership Internet (fee: 0.01 USD)",,398.67,USD,AUD,1.52579,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0.01,1.65,DEBIT,CARD
FEE-CARD-3181379541,01-12-2025,"01-12-2025 22:28:19.539",-0.01,USD,"Wise Charges for: CARD-3181379541",,399.75,,,,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0,,DEBIT,CARD
CARD-3163094631,27-11-2025,"27-11-2025 01:18:02.955",-15.02,USD,"Card transaction of 15.02 USD issued by Holafly Limited Dublin",,399.76,,,,,,,"Holafly Limited Dublin",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
CARD-3150513905,23-11-2025,"23-11-2025 10:19:47.934",-16.50,USD,"Card transaction of 16.50 USD issued by Patreon* Membership Internet",,414.78,,,,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
BALANCE-4340683172,19-11-2025,"19-11-2025 02:51:28.940",-115.78,USD,"Converted 116.11 USD to 100.00 EUR (fee: 0.33 USD)",,431.28,USD,EUR,0.86367,,,,,,,,,0.33,100.00,DEBIT,CONVERSION
FEE-BALANCE-4340683172,19-11-2025,"19-11-2025 02:51:28.939",-0.33,USD,"Wise Charges for: BALANCE-4340683172",,547.06,,,,,,,,,,,,0,,DEBIT,CONVERSION
CARD-3132186281,18-11-2025,"18-11-2025 10:19:06.228",-1.10,USD,"Card transaction of 1.10 USD issued by Patreon* Membership Internet",,547.39,,,,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
CARD-3127641729,17-11-2025,"17-11-2025 02:39:26.338",-250.84,USD,"Card transaction of 299.70 EUR issued by Bkg*Booking.com Hotel (888)850-3958 (fee: 0.73 USD)",,548.49,USD,EUR,0.86051,,,,"Bkg*Booking.com Hotel (888)850-3958",7824,"Siddharth Bose",,,0.73,215.85,DEBIT,CARD
FEE-CARD-3127641729,17-11-2025,"17-11-2025 02:39:26.337",-0.73,USD,"Wise Charges for: CARD-3127641729",,799.33,,,,,,,"Bkg*Booking.com Hotel (888)850-3958",7824,"Siddharth Bose",,,0,,DEBIT,CARD
CARD-3117684709,14-11-2025,"14-11-2025 10:19:28.530",-3.30,USD,"Card transaction of 3.30 USD issued by Patreon* Membership Internet",,800.06,,,,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
BALANCE-4317152848,14-11-2025,"14-11-2025 06:16:50.965",-407.19,USD,"Converted 408.37 USD to 350.00 EUR (fee: 1.18 USD)",,803.36,USD,EUR,0.85955,,,,,,,,,1.18,350.00,DEBIT,CONVERSION
FEE-BALANCE-4317152848,14-11-2025,"14-11-2025 06:16:50.964",-1.18,USD,"Wise Charges for: BALANCE-4317152848",,1210.55,,,,,,,,,,,,0,,DEBIT,CONVERSION
CARD-3103414412,10-11-2025,"10-11-2025 09:06:17.274",40.70,USD,"Card transaction of -40.70 USD issued by Etihad Airw 6072412191638 ABU DHABI",,1211.73,,,,,,,"Etihad Airw 6072412191638 ABU DHABI",7824,"Siddharth Bose",,,0.00,,CREDIT,CARD
CARD-3072261097,01-11-2025,"01-11-2025 21:01:50.003",-5.50,USD,"Card transaction of 5.50 USD issued by Patreon* Membership Internet",,1171.03,,,,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
CARD-3072260355,01-11-2025,"01-11-2025 21:01:37.857",-1.08,USD,"Card transaction of 1.65 AUD issued by Patreon* Membership Internet (fee: 0.01 USD)",,1176.53,USD,AUD,1.52788,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0.01,1.65,DEBIT,CARD
FEE-CARD-3072260355,01-11-2025,"01-11-2025 21:01:37.856",-0.01,USD,"Wise Charges for: CARD-3072260355",,1177.61,,,,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0,,DEBIT,CARD
CARD-3059635570,29-10-2025,"29-10-2025 07:59:17.150",-57.55,USD,"Card transaction of 8,768.00 ETB issued by Ethiopian Ai0714402899536 ETHIOPIA (fee: 0.44 USD)",,1177.62,USD,ETB,152.35500,,,,"Ethiopian Ai0714402899536 ETHIOPIA",7824,"Siddharth Bose",,,0.44,8768.00,DEBIT,CARD
FEE-CARD-3059635570,29-10-2025,"29-10-2025 07:59:17.149",-0.44,USD,"Wise Charges for: CARD-3059635570",,1235.17,,,,,,,"Ethiopian Ai0714402899536 ETHIOPIA",7824,"Siddharth Bose",,,0,,DEBIT,CARD
TRANSFER-1788804021,27-10-2025,"27-10-2025 19:09:13.072",-320.31,USD,"Sent money to USMAN TAHIR (fee: 2.59 USD)",,1235.61,USD,PKR,280.97500,,"USMAN TAHIR","PK64 ALFH 0143 0010 0738 5905",,,,,,2.59,90000.00,DEBIT,TRANSFER
FEE-TRANSFER-1788804021,27-10-2025,"27-10-2025 19:09:13.071",-2.59,USD,"Wise Charges for: TRANSFER-1788804021",,1555.92,,,,,Wise,,,,,,,0,,DEBIT,TRANSFER
CARD-3027275365,27-10-2025,"27-10-2025 05:07:31.020",-6.00,USD,"Card transaction of 6.00 USD issued by Sq *Sf Ferry Building Sui San Francisco",,1558.51,,,,,,,"Sq *Sf Ferry Building Sui San Francisco",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
CARD-3027275365,25-10-2025,"25-10-2025 04:45:09.096",6.00,USD,"Card transaction of 6.00 USD issued by Sq *Sf Ferry Building Sui San Francisco",,1564.51,,,,,,,"Sq *Sf Ferry Building Sui San Francisco",7824,"Siddharth Bose",,,0.00,,CREDIT,CARD
CARD-3038407535,23-10-2025,"23-10-2025 10:08:41.710",-16.50,USD,"Card transaction of 16.50 USD issued by Patreon* Membership Internet",,1558.51,,,,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
CARD-3037798186,23-10-2025,"23-10-2025 04:35:28.733",-3.00,USD,"Card transaction of 3.00 USD issued by Clipper Systems Mobile #1 CONCORD",,1575.01,,,,,,,"Clipper Systems Mobile #1 CONCORD",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
CARD-3027416520,20-10-2025,"20-10-2025 05:45:30.898",-22.43,USD,"Card transaction of 22.43 USD issued by Tst* Cholita Linda - Ferr SAN FRANCISCO",,1578.01,,,,,,,"Tst* Cholita Linda - Ferr SAN FRANCISCO",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
CARD-3027275365,20-10-2025,"20-10-2025 04:42:14.178",-6.00,USD,"Card transaction of 6.00 USD issued by Sq *Sf Ferry Building Sui San Francisco",,1600.44,,,,,,,"Sq *Sf Ferry Building Sui San Francisco",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
CARD-3027237014,20-10-2025,"20-10-2025 04:26:31.989",-7.61,USD,"Card transaction of 7.61 USD issued by Tst*El Porteno - Ferry B San Francisco",,1606.44,,,,,,,"Tst*El Porteno - Ferry B San Francisco",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
CARD-3024465363,19-10-2025,"19-10-2025 08:57:38.344",-14.01,USD,"Card transaction of 14.01 USD issued by In-N-Outfishermanswhar SAN FRANCISCO",,1614.05,,,,,,,"In-N-Outfishermanswhar SAN FRANCISCO",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
CARD-3024374797,19-10-2025,"19-10-2025 08:05:06.371",-15.00,USD,"Card transaction of 15.00 USD issued by Clipper Systems Mobile #1 CONCORD",,1628.06,,,,,,,"Clipper Systems Mobile #1 CONCORD",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
CARD-3020765939,18-10-2025,"18-10-2025 10:08:02.684",-1.10,USD,"Card transaction of 1.10 USD issued by Patreon* Membership Internet",,1643.06,,,,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
TRANSFER-1772299455,16-10-2025,"16-10-2025 08:00:59.610",1500.00,USD,"Received money from Siddharth Bose with reference ",,1644.16,,,,"Siddharth Bose",,,,,,,,0.00,,CREDIT,DEPOSIT
CARD-3006275078,14-10-2025,"14-10-2025 10:08:06.860",-3.30,USD,"Card transaction of 3.30 USD issued by Patreon* Membership Internet",,144.16,,,,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
CARD-2997500294,11-10-2025,"11-10-2025 22:30:42.693",-219.70,USD,"Card transaction of 219.70 USD issued by Etihad Airw 6072412191638 ABU DHABI",,147.46,,,,,,,"Etihad Airw 6072412191638 ABU DHABI",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
CARD-2997495723,11-10-2025,"11-10-2025 22:29:34.687",1.31,USD,"Card transaction of 2.00 AUD issued by Google *Chrome Temp cc@google.com",,367.16,USD,AUD,1.54381,,,,"Google *Chrome Temp cc@google.com",7824,"Siddharth Bose",,,0.00,2.00,CREDIT,CARD
CARD-2997495723,11-10-2025,"11-10-2025 22:29:33.590",-1.31,USD,"Card transaction of 2.00 AUD issued by Google *Chrome Temp cc@google.com",,365.85,USD,AUD,1.54381,,,,"Google *Chrome Temp cc@google.com",7824,"Siddharth Bose",,,0.00,2.00,DEBIT,CARD
TRANSFER-1765630070,11-10-2025,"11-10-2025 22:28:27.692",321.88,USD,"Received money from Siddharth Bose with reference ",,367.16,,,,"Siddharth Bose",,,,,,,,0.00,,CREDIT,DEPOSIT
CARD-2960780824,01-10-2025,"01-10-2025 22:51:51.843",-1.09,USD,"Card transaction of 1.65 AUD issued by Patreon* Membership Internet (fee: 0.01 USD)",,45.28,USD,AUD,1.51012,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0.01,1.65,DEBIT,CARD
FEE-CARD-2960780824,01-10-2025,"01-10-2025 22:51:51.842",-0.01,USD,"Wise Charges for: CARD-2960780824",,46.37,,,,,,,"Patreon* Membership Internet",7824,"Siddharth Bose",,,0,,DEBIT,CARD
CARD-2960780366,01-10-2025,"01-10-2025 22:51:44.881",-5.50,USD,"Card transaction of 5.50 USD issued by Patreon* Membership 833-9728766",,46.38,,,,,,,"Patreon* Membership 833-9728766",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
CARD-2958747517,01-10-2025,"01-10-2025 09:03:26.059",-30.42,USD,"Card transaction of 76.00 AUD issued by Air India Limited Gurugram (fee: 0.09 USD)",,51.88,USD,AUD,1.51194,,,,"Air India Limited Gurugram",7824,"Siddharth Bose",,,0.09,46.00,DEBIT,CARD
FEE-CARD-2958747517,01-10-2025,"01-10-2025 09:03:26.058",-0.09,USD,"Wise Charges for: CARD-2958747517",,82.30,,,,,,,"Air India Limited Gurugram",7824,"Siddharth Bose",,,0,,DEBIT,CARD
TRANSFER-1738593468,25-09-2025,"25-09-2025 23:13:04.157",82.39,USD,"Received money from Siddharth Bose with reference ",,82.39,,,,"Siddharth Bose",,,,,,,,0.00,,CREDIT,DEPOSIT
1 TransferWise ID Date Date Time Amount Currency Description Payment Reference Running Balance Exchange From Exchange To Exchange Rate Payer Name Payee Name Payee Account Number Merchant Card Last Four Digits Card Holder Full Name Attachment Note Total fees Exchange To Amount Transaction Type Transaction Details Type
2 TRANSFER-2268510041 25-07-2026 25-07-2026 00:00:53.745 -10000.00 USD Sent money to Interactive Brokers LLC (fee: 1.13 USD) 547528752 450.41 Interactive Brokers LLC (021000021) 6******02 1.13 DEBIT TRANSFER
3 FEE-TRANSFER-2268510041 25-07-2026 25-07-2026 00:00:53.744 -1.13 USD Wise Charges for: TRANSFER-2268510041 547528752 10450.41 Wise 0 DEBIT TRANSFER
4 TRANSFER-2268507611 24-07-2026 24-07-2026 23:59:52.113 10000.00 USD Received money from Siddharth Bose with reference 10451.54 Siddharth Bose 0.00 CREDIT DEPOSIT
5 CARD-3976207925 27-06-2026 27-06-2026 10:07:35.409 -16.50 USD Card transaction of 16.50 USD issued by Patreon* Membership Internet 451.54 Patreon* Membership Internet 7824 Siddharth Bose 0.00 DEBIT CARD
6 CARD-3841569125 27-05-2026 27-05-2026 00:39:20.325 -16.50 USD Card transaction of 16.50 USD issued by Patreon* Membership Internet 468.04 Patreon* Membership Internet 7824 Siddharth Bose 0.00 DEBIT CARD
7 CARD-3805642152 18-05-2026 18-05-2026 10:23:36.446 -1.10 USD Card transaction of 1.10 USD issued by Patreon* Membership Internet 484.54 Patreon* Membership Internet 7824 Siddharth Bose 0.00 DEBIT CARD
8 CARD-3685714866 18-04-2026 18-04-2026 10:32:14.560 -1.10 USD Card transaction of 1.10 USD issued by Patreon* Membership Internet 485.64 Patreon* Membership Internet 7824 Siddharth Bose 0.00 DEBIT CARD
9 TRANSFER-2075650616 14-04-2026 14-04-2026 06:21:23.847 -4000.00 USD Sent money to Interactive Brokers LLC (fee: 1.13 USD) 486.74 Interactive Brokers LLC (021000021) 6******02 1.13 DEBIT TRANSFER
10 FEE-TRANSFER-2075650616 14-04-2026 14-04-2026 06:21:23.846 -1.13 USD Wise Charges for: TRANSFER-2075650616 4486.74 Wise 0 DEBIT TRANSFER
11 TRANSFER-2075646899 14-04-2026 14-04-2026 06:19:38.179 4000.00 USD Received money from Siddharth Bose with reference 4487.87 Siddharth Bose 0.00 CREDIT DEPOSIT
12 CARD-3567908768 18-03-2026 18-03-2026 10:28:01.251 -1.10 USD Card transaction of 1.10 USD issued by Patreon* Membership Internet 487.87 Patreon* Membership Internet 7824 Siddharth Bose 0.00 DEBIT CARD
13 TRANSFER-2009203196 07-03-2026 07-03-2026 10:05:01.880 -6000.00 USD Sent money to Interactive Brokers LLC (fee: 1.13 USD) 499493306 488.97 Interactive Brokers LLC (021000021) 6******02 1.13 DEBIT TRANSFER
14 FEE-TRANSFER-2009203196 07-03-2026 07-03-2026 10:05:01.879 -1.13 USD Wise Charges for: TRANSFER-2009203196 499493306 6488.97 Wise 0 DEBIT TRANSFER
15 TRANSFER-2009199370 07-03-2026 07-03-2026 10:00:50.551 6000.00 USD Received money from Siddharth Bose with reference 6490.10 Siddharth Bose 0.00 CREDIT DEPOSIT
16 CARD-3491856978 25-02-2026 25-02-2026 10:10:28.993 -3.30 USD Card transaction of 3.30 USD issued by Patreon* Membership Internet 490.10 Patreon* Membership Internet 7824 Siddharth Bose 0.00 DEBIT CARD
17 CARD-3467922012 18-02-2026 18-02-2026 10:10:19.758 -1.10 USD Card transaction of 1.10 USD issued by Patreon* Membership Internet 493.40 Patreon* Membership Internet 7824 Siddharth Bose 0.00 DEBIT CARD
18 CARD-3453741061 14-02-2026 14-02-2026 10:15:05.143 -3.30 USD Card transaction of 3.30 USD issued by Patreon* Membership Internet 494.50 Patreon* Membership Internet 7824 Siddharth Bose 0.00 DEBIT CARD
19 TRANSFER-1959996575 07-02-2026 07-02-2026 00:14:08.112 -25000.00 USD Sent money to Interactive Brokers LLC (fee: 1.13 USD) 490625745 497.80 Interactive Brokers LLC (021000021) 6******02 1.13 DEBIT TRANSFER
20 FEE-TRANSFER-1959996575 07-02-2026 07-02-2026 00:14:08.111 -1.13 USD Wise Charges for: TRANSFER-1959996575 490625745 25497.80 Wise 0 DEBIT TRANSFER
21 TRANSFER-1960000797 07-02-2026 07-02-2026 00:12:59.295 25000.00 USD Received money from Siddharth Bose with reference 25498.93 Siddharth Bose 0.00 CREDIT DEPOSIT
22 CARD-3410915740 02-02-2026 02-02-2026 13:08:23.820 -5.50 USD Card transaction of 5.50 USD issued by Patreon* Membership Internet 498.93 Patreon* Membership Internet 7824 Siddharth Bose 0.00 DEBIT CARD
23 TRANSFER-1950103478 02-02-2026 02-02-2026 10:12:26.999 500.00 USD Received money from Siddharth Bose with reference 504.43 Siddharth Bose 0.00 CREDIT DEPOSIT
24 CARD-3408417070 01-02-2026 01-02-2026 21:32:05.683 -1.15 USD Card transaction of 1.65 AUD issued by Patreon* Membership Internet (fee: 0.01 USD) 4.43 USD AUD 1.43647 Patreon* Membership Internet 7824 Siddharth Bose 0.01 1.65 DEBIT CARD
25 FEE-CARD-3408417070 01-02-2026 01-02-2026 21:32:05.682 -0.01 USD Wise Charges for: CARD-3408417070 5.58 Patreon* Membership Internet 7824 Siddharth Bose 0 DEBIT CARD
26 TRANSFER-1946983328 31-01-2026 31-01-2026 00:47:24.337 -2440.00 USD Sent money to Interactive Brokers LLC (fee: 1.13 USD) 488140942 5.59 Interactive Brokers LLC (021000021) 6******02 1.13 DEBIT TRANSFER
27 FEE-TRANSFER-1946983328 31-01-2026 31-01-2026 00:47:24.336 -1.13 USD Wise Charges for: TRANSFER-1946983328 488140942 2445.59 Wise 0 DEBIT TRANSFER
28 TRANSFER-1946979742 31-01-2026 31-01-2026 00:45:46.524 2181.00 USD Received money from Siddharth Bose with reference 2446.72 Siddharth Bose 0.00 CREDIT DEPOSIT
29 CARD-3382069917 25-01-2026 25-01-2026 10:32:05.449 -3.30 USD Card transaction of 3.30 USD issued by Patreon* Membership Internet 265.72 Patreon* Membership Internet 7824 Siddharth Bose 0.00 DEBIT CARD
30 CARD-3374520543 23-01-2026 23-01-2026 10:25:32.264 -16.50 USD Card transaction of 16.50 USD issued by Patreon* Membership Internet 269.02 Patreon* Membership Internet 7824 Siddharth Bose 0.00 DEBIT CARD
31 CARD-3357157631 18-01-2026 18-01-2026 10:15:13.990 -1.10 USD Card transaction of 1.10 USD issued by Patreon* Membership Internet 285.52 Patreon* Membership Internet 7824 Siddharth Bose 0.00 DEBIT CARD
32 CARD-3342293626 14-01-2026 14-01-2026 10:13:23.175 -3.30 USD Card transaction of 3.30 USD issued by Patreon* Membership Internet 286.62 Patreon* Membership Internet 7824 Siddharth Bose 0.00 DEBIT CARD
33 CARD-3299985499 02-01-2026 02-01-2026 23:06:34.860 -5.29 USD Card transaction of 104.51 EUR issued by Sp Nord Studios FORDINGBRIDGE (fee: 0.01 USD) 289.92 USD EUR 0.85281 Sp Nord Studios FORDINGBRIDGE 7824 Siddharth Bose 0.01 4.51 DEBIT CARD
34 FEE-CARD-3299985499 02-01-2026 02-01-2026 23:06:34.859 -0.01 USD Wise Charges for: CARD-3299985499 295.21 Sp Nord Studios FORDINGBRIDGE 7824 Siddharth Bose 0 DEBIT CARD
35 CARD-3296046085 01-01-2026 01-01-2026 22:12:53.296 -5.50 USD Card transaction of 5.50 USD issued by Patreon* Membership Internet 295.22 Patreon* Membership Internet 7824 Siddharth Bose 0.00 DEBIT CARD
36 CARD-3296045671 01-01-2026 01-01-2026 22:12:45.273 -1.10 USD Card transaction of 1.65 AUD issued by Patreon* Membership Internet (fee: 0.01 USD) 300.72 USD AUD 1.49869 Patreon* Membership Internet 7824 Siddharth Bose 0.01 1.65 DEBIT CARD
37 FEE-CARD-3296045671 01-01-2026 01-01-2026 22:12:45.272 -0.01 USD Wise Charges for: CARD-3296045671 301.82 Patreon* Membership Internet 7824 Siddharth Bose 0 DEBIT CARD
38 CARD-3268020843 25-12-2025 25-12-2025 05:42:18.806 -3.30 USD Card transaction of 3.30 USD issued by Patreon* Membership Internet 301.83 Patreon* Membership Internet 7824 Siddharth Bose 0.00 DEBIT CARD
39 CARD-3260621879 23-12-2025 23-12-2025 10:22:35.011 -16.50 USD Card transaction of 16.50 USD issued by Patreon* Membership Internet 305.13 Patreon* Membership Internet 7824 Siddharth Bose 0.00 DEBIT CARD
40 CARD-3241565056 18-12-2025 18-12-2025 10:16:09.323 -1.10 USD Card transaction of 1.10 USD issued by Patreon* Membership Internet 321.63 Patreon* Membership Internet 7824 Siddharth Bose 0.00 DEBIT CARD
41 CARD-3227480675 14-12-2025 14-12-2025 10:22:05.979 -3.30 USD Card transaction of 3.30 USD issued by Patreon* Membership Internet 322.73 Patreon* Membership Internet 7824 Siddharth Bose 0.00 DEBIT CARD
42 CARD-3188378869 07-12-2025 07-12-2025 00:17:33.286 -7.41 USD Card transaction of 27.00 QAR issued by Talabat.com DOHA (fee: 0.06 USD) 326.03 USD QAR 3.64480 Talabat.com DOHA 7824 Siddharth Bose 0.06 27.00 DEBIT CARD
43 FEE-CARD-3188378869 07-12-2025 07-12-2025 00:17:33.285 -0.06 USD Wise Charges for: CARD-3188378869 333.44 Talabat.com DOHA 7824 Siddharth Bose 0 DEBIT CARD
44 CARD-3188378869 06-12-2025 06-12-2025 19:20:41.782 7.48 USD Card transaction of 27.00 QAR issued by Talabat.com DOHA (fee: 0.06 USD) 333.50 USD QAR 3.64115 Talabat.com DOHA 7824 Siddharth Bose 0.06 27.00 CREDIT CARD
45 CARD-3197774302 06-12-2025 06-12-2025 04:59:25.894 -10.97 USD Card transaction of 40.00 QAR issued by Talabat.com DOHA (fee: 0.08 USD) 326.02 USD QAR 3.64480 Talabat.com DOHA 7824 Siddharth Bose 0.08 40.00 DEBIT CARD
46 FEE-CARD-3197774302 06-12-2025 06-12-2025 04:59:25.893 -0.08 USD Wise Charges for: CARD-3197774302 336.99 Talabat.com DOHA 7824 Siddharth Bose 0 DEBIT CARD
47 CARD-3195872742 05-12-2025 05-12-2025 20:14:56.638 -11.52 USD Card transaction of 42.00 QAR issued by Talabat.com DOHA (fee: 0.09 USD) 337.07 USD QAR 3.64480 Talabat.com DOHA 7824 Siddharth Bose 0.09 42.00 DEBIT CARD
48 FEE-CARD-3195872742 05-12-2025 05-12-2025 20:14:56.637 -0.09 USD Wise Charges for: CARD-3195872742 348.59 Talabat.com DOHA 7824 Siddharth Bose 0 DEBIT CARD
49 CARD-3193767606 05-12-2025 05-12-2025 04:01:04.690 -12.48 USD Card transaction of 45.50 QAR issued by Talabat.com DOHA (fee: 0.09 USD) 348.68 USD QAR 3.64490 Talabat.com DOHA 7824 Siddharth Bose 0.09 45.50 DEBIT CARD
50 FEE-CARD-3193767606 05-12-2025 05-12-2025 04:01:04.689 -0.09 USD Wise Charges for: CARD-3193767606 361.16 Talabat.com DOHA 7824 Siddharth Bose 0 DEBIT CARD
51 CARD-3192102297 04-12-2025 04-12-2025 19:47:04.471 -11.91 USD Card transaction of 43.40 QAR issued by Talabat.com DOHA (fee: 0.09 USD) 361.25 USD QAR 3.64490 Talabat.com DOHA 7824 Siddharth Bose 0.09 43.40 DEBIT CARD
52 FEE-CARD-3192102297 04-12-2025 04-12-2025 19:47:04.470 -0.09 USD Wise Charges for: CARD-3192102297 373.16 Talabat.com DOHA 7824 Siddharth Bose 0 DEBIT CARD
53 CARD-3190030701 04-12-2025 04-12-2025 03:27:05.435 -12.35 USD Card transaction of 45.00 QAR issued by Talabat.com DOHA (fee: 0.09 USD) 373.25 USD QAR 3.64475 Talabat.com DOHA 7824 Siddharth Bose 0.09 45.00 DEBIT CARD
54 FEE-CARD-3190030701 04-12-2025 04-12-2025 03:27:05.434 -0.09 USD Wise Charges for: CARD-3190030701 385.60 Talabat.com DOHA 7824 Siddharth Bose 0 DEBIT CARD
55 CARD-3188378869 03-12-2025 03-12-2025 19:20:21.222 -7.42 USD Card transaction of 27.00 QAR issued by Talabat.com DOHA (fee: 0.06 USD) 385.69 USD QAR 3.64115 Talabat.com DOHA 7824 Siddharth Bose 0.06 27.00 DEBIT CARD
56 FEE-CARD-3188378869 03-12-2025 03-12-2025 19:20:21.221 -0.06 USD Wise Charges for: CARD-3188378869 393.11 Talabat.com DOHA 7824 Siddharth Bose 0 DEBIT CARD
57 CARD-3181379836 01-12-2025 01-12-2025 22:28:23.718 -5.50 USD Card transaction of 5.50 USD issued by Patreon* Membership Internet 393.17 Patreon* Membership Internet 7824 Siddharth Bose 0.00 DEBIT CARD
58 CARD-3181379541 01-12-2025 01-12-2025 22:28:19.540 -1.08 USD Card transaction of 1.65 AUD issued by Patreon* Membership Internet (fee: 0.01 USD) 398.67 USD AUD 1.52579 Patreon* Membership Internet 7824 Siddharth Bose 0.01 1.65 DEBIT CARD
59 FEE-CARD-3181379541 01-12-2025 01-12-2025 22:28:19.539 -0.01 USD Wise Charges for: CARD-3181379541 399.75 Patreon* Membership Internet 7824 Siddharth Bose 0 DEBIT CARD
60 CARD-3163094631 27-11-2025 27-11-2025 01:18:02.955 -15.02 USD Card transaction of 15.02 USD issued by Holafly Limited Dublin 399.76 Holafly Limited Dublin 7824 Siddharth Bose 0.00 DEBIT CARD
61 CARD-3150513905 23-11-2025 23-11-2025 10:19:47.934 -16.50 USD Card transaction of 16.50 USD issued by Patreon* Membership Internet 414.78 Patreon* Membership Internet 7824 Siddharth Bose 0.00 DEBIT CARD
62 BALANCE-4340683172 19-11-2025 19-11-2025 02:51:28.940 -115.78 USD Converted 116.11 USD to 100.00 EUR (fee: 0.33 USD) 431.28 USD EUR 0.86367 0.33 100.00 DEBIT CONVERSION
63 FEE-BALANCE-4340683172 19-11-2025 19-11-2025 02:51:28.939 -0.33 USD Wise Charges for: BALANCE-4340683172 547.06 0 DEBIT CONVERSION
64 CARD-3132186281 18-11-2025 18-11-2025 10:19:06.228 -1.10 USD Card transaction of 1.10 USD issued by Patreon* Membership Internet 547.39 Patreon* Membership Internet 7824 Siddharth Bose 0.00 DEBIT CARD
65 CARD-3127641729 17-11-2025 17-11-2025 02:39:26.338 -250.84 USD Card transaction of 299.70 EUR issued by Bkg*Booking.com Hotel (888)850-3958 (fee: 0.73 USD) 548.49 USD EUR 0.86051 Bkg*Booking.com Hotel (888)850-3958 7824 Siddharth Bose 0.73 215.85 DEBIT CARD
66 FEE-CARD-3127641729 17-11-2025 17-11-2025 02:39:26.337 -0.73 USD Wise Charges for: CARD-3127641729 799.33 Bkg*Booking.com Hotel (888)850-3958 7824 Siddharth Bose 0 DEBIT CARD
67 CARD-3117684709 14-11-2025 14-11-2025 10:19:28.530 -3.30 USD Card transaction of 3.30 USD issued by Patreon* Membership Internet 800.06 Patreon* Membership Internet 7824 Siddharth Bose 0.00 DEBIT CARD
68 BALANCE-4317152848 14-11-2025 14-11-2025 06:16:50.965 -407.19 USD Converted 408.37 USD to 350.00 EUR (fee: 1.18 USD) 803.36 USD EUR 0.85955 1.18 350.00 DEBIT CONVERSION
69 FEE-BALANCE-4317152848 14-11-2025 14-11-2025 06:16:50.964 -1.18 USD Wise Charges for: BALANCE-4317152848 1210.55 0 DEBIT CONVERSION
70 CARD-3103414412 10-11-2025 10-11-2025 09:06:17.274 40.70 USD Card transaction of -40.70 USD issued by Etihad Airw 6072412191638 ABU DHABI 1211.73 Etihad Airw 6072412191638 ABU DHABI 7824 Siddharth Bose 0.00 CREDIT CARD
71 CARD-3072261097 01-11-2025 01-11-2025 21:01:50.003 -5.50 USD Card transaction of 5.50 USD issued by Patreon* Membership Internet 1171.03 Patreon* Membership Internet 7824 Siddharth Bose 0.00 DEBIT CARD
72 CARD-3072260355 01-11-2025 01-11-2025 21:01:37.857 -1.08 USD Card transaction of 1.65 AUD issued by Patreon* Membership Internet (fee: 0.01 USD) 1176.53 USD AUD 1.52788 Patreon* Membership Internet 7824 Siddharth Bose 0.01 1.65 DEBIT CARD
73 FEE-CARD-3072260355 01-11-2025 01-11-2025 21:01:37.856 -0.01 USD Wise Charges for: CARD-3072260355 1177.61 Patreon* Membership Internet 7824 Siddharth Bose 0 DEBIT CARD
74 CARD-3059635570 29-10-2025 29-10-2025 07:59:17.150 -57.55 USD Card transaction of 8,768.00 ETB issued by Ethiopian Ai0714402899536 ETHIOPIA (fee: 0.44 USD) 1177.62 USD ETB 152.35500 Ethiopian Ai0714402899536 ETHIOPIA 7824 Siddharth Bose 0.44 8768.00 DEBIT CARD
75 FEE-CARD-3059635570 29-10-2025 29-10-2025 07:59:17.149 -0.44 USD Wise Charges for: CARD-3059635570 1235.17 Ethiopian Ai0714402899536 ETHIOPIA 7824 Siddharth Bose 0 DEBIT CARD
76 TRANSFER-1788804021 27-10-2025 27-10-2025 19:09:13.072 -320.31 USD Sent money to USMAN TAHIR (fee: 2.59 USD) 1235.61 USD PKR 280.97500 USMAN TAHIR PK64 ALFH 0143 0010 0738 5905 2.59 90000.00 DEBIT TRANSFER
77 FEE-TRANSFER-1788804021 27-10-2025 27-10-2025 19:09:13.071 -2.59 USD Wise Charges for: TRANSFER-1788804021 1555.92 Wise 0 DEBIT TRANSFER
78 CARD-3027275365 27-10-2025 27-10-2025 05:07:31.020 -6.00 USD Card transaction of 6.00 USD issued by Sq *Sf Ferry Building Sui San Francisco 1558.51 Sq *Sf Ferry Building Sui San Francisco 7824 Siddharth Bose 0.00 DEBIT CARD
79 CARD-3027275365 25-10-2025 25-10-2025 04:45:09.096 6.00 USD Card transaction of 6.00 USD issued by Sq *Sf Ferry Building Sui San Francisco 1564.51 Sq *Sf Ferry Building Sui San Francisco 7824 Siddharth Bose 0.00 CREDIT CARD
80 CARD-3038407535 23-10-2025 23-10-2025 10:08:41.710 -16.50 USD Card transaction of 16.50 USD issued by Patreon* Membership Internet 1558.51 Patreon* Membership Internet 7824 Siddharth Bose 0.00 DEBIT CARD
81 CARD-3037798186 23-10-2025 23-10-2025 04:35:28.733 -3.00 USD Card transaction of 3.00 USD issued by Clipper Systems Mobile #1 CONCORD 1575.01 Clipper Systems Mobile #1 CONCORD 7824 Siddharth Bose 0.00 DEBIT CARD
82 CARD-3027416520 20-10-2025 20-10-2025 05:45:30.898 -22.43 USD Card transaction of 22.43 USD issued by Tst* Cholita Linda - Ferr SAN FRANCISCO 1578.01 Tst* Cholita Linda - Ferr SAN FRANCISCO 7824 Siddharth Bose 0.00 DEBIT CARD
83 CARD-3027275365 20-10-2025 20-10-2025 04:42:14.178 -6.00 USD Card transaction of 6.00 USD issued by Sq *Sf Ferry Building Sui San Francisco 1600.44 Sq *Sf Ferry Building Sui San Francisco 7824 Siddharth Bose 0.00 DEBIT CARD
84 CARD-3027237014 20-10-2025 20-10-2025 04:26:31.989 -7.61 USD Card transaction of 7.61 USD issued by Tst*El Porteno - Ferry B San Francisco 1606.44 Tst*El Porteno - Ferry B San Francisco 7824 Siddharth Bose 0.00 DEBIT CARD
85 CARD-3024465363 19-10-2025 19-10-2025 08:57:38.344 -14.01 USD Card transaction of 14.01 USD issued by In-N-Outfishermanswhar SAN FRANCISCO 1614.05 In-N-Outfishermanswhar SAN FRANCISCO 7824 Siddharth Bose 0.00 DEBIT CARD
86 CARD-3024374797 19-10-2025 19-10-2025 08:05:06.371 -15.00 USD Card transaction of 15.00 USD issued by Clipper Systems Mobile #1 CONCORD 1628.06 Clipper Systems Mobile #1 CONCORD 7824 Siddharth Bose 0.00 DEBIT CARD
87 CARD-3020765939 18-10-2025 18-10-2025 10:08:02.684 -1.10 USD Card transaction of 1.10 USD issued by Patreon* Membership Internet 1643.06 Patreon* Membership Internet 7824 Siddharth Bose 0.00 DEBIT CARD
88 TRANSFER-1772299455 16-10-2025 16-10-2025 08:00:59.610 1500.00 USD Received money from Siddharth Bose with reference 1644.16 Siddharth Bose 0.00 CREDIT DEPOSIT
89 CARD-3006275078 14-10-2025 14-10-2025 10:08:06.860 -3.30 USD Card transaction of 3.30 USD issued by Patreon* Membership Internet 144.16 Patreon* Membership Internet 7824 Siddharth Bose 0.00 DEBIT CARD
90 CARD-2997500294 11-10-2025 11-10-2025 22:30:42.693 -219.70 USD Card transaction of 219.70 USD issued by Etihad Airw 6072412191638 ABU DHABI 147.46 Etihad Airw 6072412191638 ABU DHABI 7824 Siddharth Bose 0.00 DEBIT CARD
91 CARD-2997495723 11-10-2025 11-10-2025 22:29:34.687 1.31 USD Card transaction of 2.00 AUD issued by Google *Chrome Temp cc@google.com 367.16 USD AUD 1.54381 Google *Chrome Temp cc@google.com 7824 Siddharth Bose 0.00 2.00 CREDIT CARD
92 CARD-2997495723 11-10-2025 11-10-2025 22:29:33.590 -1.31 USD Card transaction of 2.00 AUD issued by Google *Chrome Temp cc@google.com 365.85 USD AUD 1.54381 Google *Chrome Temp cc@google.com 7824 Siddharth Bose 0.00 2.00 DEBIT CARD
93 TRANSFER-1765630070 11-10-2025 11-10-2025 22:28:27.692 321.88 USD Received money from Siddharth Bose with reference 367.16 Siddharth Bose 0.00 CREDIT DEPOSIT
94 CARD-2960780824 01-10-2025 01-10-2025 22:51:51.843 -1.09 USD Card transaction of 1.65 AUD issued by Patreon* Membership Internet (fee: 0.01 USD) 45.28 USD AUD 1.51012 Patreon* Membership Internet 7824 Siddharth Bose 0.01 1.65 DEBIT CARD
95 FEE-CARD-2960780824 01-10-2025 01-10-2025 22:51:51.842 -0.01 USD Wise Charges for: CARD-2960780824 46.37 Patreon* Membership Internet 7824 Siddharth Bose 0 DEBIT CARD
96 CARD-2960780366 01-10-2025 01-10-2025 22:51:44.881 -5.50 USD Card transaction of 5.50 USD issued by Patreon* Membership 833-9728766 46.38 Patreon* Membership 833-9728766 7824 Siddharth Bose 0.00 DEBIT CARD
97 CARD-2958747517 01-10-2025 01-10-2025 09:03:26.059 -30.42 USD Card transaction of 76.00 AUD issued by Air India Limited Gurugram (fee: 0.09 USD) 51.88 USD AUD 1.51194 Air India Limited Gurugram 7824 Siddharth Bose 0.09 46.00 DEBIT CARD
98 FEE-CARD-2958747517 01-10-2025 01-10-2025 09:03:26.058 -0.09 USD Wise Charges for: CARD-2958747517 82.30 Air India Limited Gurugram 7824 Siddharth Bose 0 DEBIT CARD
99 TRANSFER-1738593468 25-09-2025 25-09-2025 23:13:04.157 82.39 USD Received money from Siddharth Bose with reference 82.39 Siddharth Bose 0.00 CREDIT DEPOSIT
@@ -1,6 +0,0 @@
"TransferWise ID",Date,"Date Time",Amount,Currency,Description,"Payment Reference","Running Balance","Exchange From","Exchange To","Exchange Rate","Payer Name","Payee Name","Payee Account Number",Merchant,"Card Last Four Digits","Card Holder Full Name",Attachment,Note,"Total fees","Exchange To Amount","Transaction Type","Transaction Details Type"
CARD-3299985499,02-01-2026,"02-01-2026 23:06:34.804",-100.00,EUR,"Card transaction of 104.51 EUR issued by Sp Nord Studios FORDINGBRIDGE",,0.00,,,,,,,"Sp Nord Studios FORDINGBRIDGE",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
BALANCE-4340683172,19-11-2025,"19-11-2025 02:51:28.940",100.00,EUR,"Converted 116.11 USD to 100.00 EUR",,100.00,USD,EUR,0.86367,,,,,,,,,0.00,100.00,CREDIT,CONVERSION
CARD-3127641729,17-11-2025,"17-11-2025 02:39:26.289",-83.85,EUR,"Card transaction of 299.70 EUR issued by Bkg*Booking.com Hotel (888)850-3958",,0.00,,,,,,,"Bkg*Booking.com Hotel (888)850-3958",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
CARD-3117227825,14-11-2025,"14-11-2025 06:17:54.371",-266.15,EUR,"Card transaction of 266.15 EUR issued by Bkg*Hotel At Booking.c (888)850-3958",,83.85,,,,,,,"Bkg*Hotel At Booking.c (888)850-3958",7824,"Siddharth Bose",,,0.00,,DEBIT,CARD
BALANCE-4317152848,14-11-2025,"14-11-2025 06:16:50.965",350.00,EUR,"Converted 408.37 USD to 350.00 EUR",,350.00,USD,EUR,0.85955,,,,,,,,,0.00,350.00,CREDIT,CONVERSION
1 TransferWise ID Date Date Time Amount Currency Description Payment Reference Running Balance Exchange From Exchange To Exchange Rate Payer Name Payee Name Payee Account Number Merchant Card Last Four Digits Card Holder Full Name Attachment Note Total fees Exchange To Amount Transaction Type Transaction Details Type
2 CARD-3299985499 02-01-2026 02-01-2026 23:06:34.804 -100.00 EUR Card transaction of 104.51 EUR issued by Sp Nord Studios FORDINGBRIDGE 0.00 Sp Nord Studios FORDINGBRIDGE 7824 Siddharth Bose 0.00 DEBIT CARD
3 BALANCE-4340683172 19-11-2025 19-11-2025 02:51:28.940 100.00 EUR Converted 116.11 USD to 100.00 EUR 100.00 USD EUR 0.86367 0.00 100.00 CREDIT CONVERSION
4 CARD-3127641729 17-11-2025 17-11-2025 02:39:26.289 -83.85 EUR Card transaction of 299.70 EUR issued by Bkg*Booking.com Hotel (888)850-3958 0.00 Bkg*Booking.com Hotel (888)850-3958 7824 Siddharth Bose 0.00 DEBIT CARD
5 CARD-3117227825 14-11-2025 14-11-2025 06:17:54.371 -266.15 EUR Card transaction of 266.15 EUR issued by Bkg*Hotel At Booking.c (888)850-3958 83.85 Bkg*Hotel At Booking.c (888)850-3958 7824 Siddharth Bose 0.00 DEBIT CARD
6 BALANCE-4317152848 14-11-2025 14-11-2025 06:16:50.965 350.00 EUR Converted 408.37 USD to 350.00 EUR 350.00 USD EUR 0.85955 0.00 350.00 CREDIT CONVERSION
@@ -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.';
+21
View File
@@ -18,6 +18,7 @@ model trips {
archived Boolean @default(false)
created_at DateTime @default(now())
overrides transaction_overrides[]
payments split_payments[]
}
model transaction_overrides {
@@ -75,9 +76,13 @@ model split_payments {
payment_date DateTime @db.Date
notes String?
linked_transaction_id Int?
trip_id Int?
created_at DateTime @default(now())
from_participant participants @relation("payments_from", fields: [from_participant_id], references: [id])
to_participant participants @relation("payments_to", fields: [to_participant_id], references: [id])
trip trips? @relation(fields: [trip_id], references: [id], onDelete: SetNull)
@@index([trip_id])
}
model tags {
@@ -184,6 +189,7 @@ model transactions {
reconciled_with transactions? @relation("reconciled", fields: [reconciled_with_id], references: [id], onDelete: SetNull)
reconciled_by transactions[] @relation("reconciled")
expense_metadata expense_metadata?
order_review order_reviews?
}
model expense_metadata {
@@ -193,6 +199,7 @@ model expense_metadata {
paperless_doc_id Int? @unique
source_email_subject String?
source_email_from String?
source_message_id String?
payment_method String?
payment_method_detail String?
order_reference String?
@@ -205,6 +212,20 @@ model expense_metadata {
extraction_model String? @default("gemini-2.5-flash")
created_at DateTime? @default(now())
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 @unique
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)
}
model rule_apply_runs {
@@ -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>
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,126 @@
import { describe, it, expect } from "vitest";
import { queryRaw, queryRow } from "../../lib/db";
import {
EXCLUDE_RECONCILED_SOURCE,
NATIVE_CURRENCY,
AMOUNT_UNCONVERTED,
} from "../../lib/analytics-sql";
/**
* These fragments are the ones that drifted.
*
* The reconciled-row exclusion lived only in `queries.ts` for months while every
* analytics route counted the superseded manual rows as spend — 48 rows, $4,474
* of double count, invisible because the transaction list (which did exclude
* them) looked right. The currency expression has the same shape of risk: it is
* read by two call sites with two different denomination conventions.
*
* Assertions are per-row against known fixtures rather than against aggregate
* totals, so a change in unrelated data cannot mask a regression.
*/
async function scratchTxn(cols: string, vals: string, params: unknown[] = []) {
const row = await queryRow<{ id: number }>(
`INSERT INTO transactions (${cols}) VALUES (${vals}) RETURNING id`,
params
);
return row!.id;
}
/** Does this row survive the predicate? */
async function passes(predicate: string, id: number): Promise<boolean> {
const rows = await queryRaw(
`SELECT t.id FROM transactions t
LEFT JOIN statements s ON s.id = t.statement_id
WHERE t.id = $1 AND (${predicate})`,
[id]
);
return rows.length === 1;
}
describe("EXCLUDE_RECONCILED_SOURCE", () => {
it("drops a manual row that a statement line has superseded", async () => {
const survivor = await scratchTxn(
"transaction_date, description, amount, transaction_type",
"'2026-03-01','Analytics fixture — survivor', 10.00, 'debit'"
);
const superseded = await scratchTxn(
"transaction_date, description, amount, transaction_type, reconciled_with_id",
"'2026-03-01','Analytics fixture — superseded', 10.00, 'debit', $1",
[survivor]
);
expect(await passes(EXCLUDE_RECONCILED_SOURCE, superseded)).toBe(false);
});
it("keeps an ordinary manual row that was never reconciled", async () => {
const id = await scratchTxn(
"transaction_date, description, amount, transaction_type",
"'2026-03-01','Analytics fixture — unreconciled', 10.00, 'debit'"
);
expect(await passes(EXCLUDE_RECONCILED_SOURCE, id)).toBe(true);
});
it("keeps a credits order row — nothing ever sets reconciled_with_id on one", async () => {
// The order slice records the card match in expense_metadata, not on the
// transaction, and needsCardMatch() holds these out of the reconcile queue.
// If that ever changes, this exclusion would start eating real spend.
const id = await scratchTxn(
"transaction_date, description, amount, transaction_type, payment_method",
"'2026-03-01','Order - Analytics fixture', 25.00, 'debit', 'credits'"
);
expect(await passes(EXCLUDE_RECONCILED_SOURCE, id)).toBe(true);
});
});
describe("NATIVE_CURRENCY", () => {
async function currencyOf(id: number) {
const row = await queryRow<{ ccy: string; unconverted: boolean }>(
`SELECT ${NATIVE_CURRENCY} AS ccy, ${AMOUNT_UNCONVERTED} AS unconverted
FROM transactions t LEFT JOIN statements s ON s.id = t.statement_id
WHERE t.id = $1`,
[id]
);
return row!;
}
it("reads a statement-less order row's own currency, not 'AUD'", async () => {
// The bug this guards: sourcing currency from s.currency alone labelled
// every foreign order row AUD, because an order has no statement.
const id = await scratchTxn(
"transaction_date, description, amount, transaction_type, payment_method, foreign_currency_amount, foreign_currency_code",
"'2026-03-01','Order - Foreign fixture', 3500.00, 'debit', 'credits', 3500.00, 'LKR'"
);
const { ccy, unconverted } = await currencyOf(id);
expect(ccy).toBe("LKR");
// No amount_aud: the ingest path refuses to assert an FX rate it lacks, so
// this row's AUD value is genuinely unknown and must be reported as such.
expect(unconverted).toBe(true);
});
it("prefers the statement's currency over the foreign-charge record", async () => {
// Opposite convention: on an AUD statement, `amount` is AUD and
// foreign_currency_code merely notes what was originally charged. Reading
// the foreign code here would mislabel an AUD row as USD.
const stmt = await queryRow<{ id: number }>(
`INSERT INTO statements (bank_name, account_number, billing_end_date, currency, filename)
VALUES ('Analytics Fixture Bank','0000','2026-03-31','AUD','analytics-fixture.pdf') RETURNING id`
);
const id = await scratchTxn(
"transaction_date, description, amount, amount_aud, transaction_type, statement_id, foreign_currency_amount, foreign_currency_code",
"'2026-03-01','Overseas purchase fixture', 45.00, 45.00, 'debit', $1, 30.00, 'USD'",
[stmt!.id]
);
const { ccy, unconverted } = await currencyOf(id);
expect(ccy).toBe("AUD");
expect(unconverted).toBe(false);
});
it("defaults a plain manual row to AUD", async () => {
const id = await scratchTxn(
"transaction_date, description, amount, transaction_type",
"'2026-03-01','Plain manual fixture', 12.00, 'debit'"
);
expect((await currencyOf(id)).ccy).toBe("AUD");
});
});
+1
View File
@@ -35,6 +35,7 @@ export async function resetDB(pool: Pool) {
transactions,
statements,
tags,
trips,
participants
RESTART IDENTITY CASCADE
`);
@@ -0,0 +1,106 @@
import { describe, it, expect, beforeAll } from "vitest";
import { readFileSync } from "fs";
import { resolve } from "path";
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";
let POST: any;
const req = (body: unknown, token: string | null = TOKEN) =>
({
headers: { get: (h: string) => (h === "x-ingest-token" ? token : null) },
json: async () => body,
}) as any;
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,508 @@
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 (DoorDash)'`
);
expect(Number(n!.c)).toBe(1);
});
it("I1: a credits order before the cutover is refused", async () => {
const p = parseOrderHTML(html("dd-01"), meta({ receivedAt: "2025-11-15T12:00:00Z" }));
const res = await processOrderIngestion(p);
expect(res.skipped).toBe("pre_cutover");
expect(res.transactionId).toBeNull();
await expect(
queryRaw(
`INSERT INTO transactions (transaction_date, amount, payment_method) VALUES ('2025-11-15', 20.00, 'credits')`
)
).rejects.toThrow();
});
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 platform in the description", async () => {
// "Order - Burger Corner" gives no way to know where to look for the
// detail, and the same restaurant can be on both platforms.
const p = parseOrderHTML(html("dd-01"), meta({ messageId: `desc-${Date.now()}` }));
const res = await processOrderIngestion(p);
const row = await queryRow<{ description: string }>(
`SELECT description FROM transactions WHERE id = $1`, [res.transactionId]
);
expect(row!.description).toMatch(/\(DoorDash\)$/);
});
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();
});
});
+159 -1
View File
@@ -8,7 +8,7 @@ mockDbWithPool(pool);
// Dynamic import AFTER the mock ensures getTransactions / getParticipantBalances
// use the test pool rather than Prisma's singleton.
const { getTransactions, getParticipantBalances } = await import("@/lib/queries");
const { getTransactions, getParticipantBalances, getTripAnalytics } = await import("@/lib/queries");
beforeEach(async () => {
await resetDB(pool);
@@ -253,3 +253,161 @@ describe("getParticipantBalances", () => {
expect(bobBalance!.unsettled_count).toBe(2);
});
});
describe("getTransactions — order provenance for the description sub-line", () => {
it("carries the route and platform of an order-derived row", async () => {
// Five rows all reading "Order - Uber Trip" are indistinguishable in the
// list; where the trip went is the only thing that separates them, and it
// was already stored.
const { ownerId } = await seedParticipants(pool);
const txId = await insertTransaction(pool, ownerId, { description: "Order - Uber Trip" });
await pool.query(
`INSERT INTO expense_metadata (source, order_reference, platform, route, transaction_id)
VALUES ('email', $1, 'uber',
'[{"label":"Pick-up","time":"7:32 pm","address":"Terminal 2, Melbourne Airport (MEL), Tullamarine VIC 3045, Australia"},
{"label":"Drop-off","time":"8:10 pm","address":"19 Lady Penrhyn Dr, Wyndham Vale VIC 3024, Australia"}]'::jsonb,
$2)`,
[`route-${Date.now()}`, txId]
);
const { data } = await getTransactions(ownerId, { limit: 50, offset: 0 });
const row = data.find((r) => r.id === txId)!;
expect(row.order_platform).toBe("uber");
expect(row.order_route).toHaveLength(2);
expect(row.order_route![0].address).toContain("Melbourne Airport");
});
it("resolves from the statement line for a card-settled order", async () => {
// A card-settled order creates no transaction of its own (I5) — the
// receipt points at the statement line through matched_transaction_id.
const { ownerId } = await seedParticipants(pool);
const txId = await insertTransaction(pool, ownerId, { description: "UBER *TRIP AUCKLAND" });
await pool.query(
`INSERT INTO expense_metadata (source, order_reference, platform, route, matched_transaction_id)
VALUES ('email', $1, 'uber',
'[{"label":"Pick-up","time":null,"address":"64 Federal Street, Auckland 1010, NZ"},
{"label":"Drop-off","time":null,"address":"International Terminal, Auckland 2022, New Zealand"}]'::jsonb,
$2)`,
[`route-card-${Date.now()}`, txId]
);
const { data } = await getTransactions(ownerId, { limit: 50, offset: 0 });
const row = data.find((r) => r.id === txId)!;
expect(row.order_route).toHaveLength(2);
});
it("leaves an ordinary transaction with no route", async () => {
const { ownerId } = await seedParticipants(pool);
const txId = await insertTransaction(pool, ownerId, { description: "COLES 1234" });
const { data } = await getTransactions(ownerId, { limit: 50, offset: 0 });
const row = data.find((r) => r.id === txId)!;
expect(row.order_route).toBeNull();
expect(row.order_platform).toBeNull();
});
});
// ── settlement scope: settled + split_payments.trip_id (migration 0022) ───────
describe("getParticipantBalances — settled", () => {
it("excludes a settled split from what is owed", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const txId = await insertTransaction(pool, ownerId, { amount: 100 });
await pool.query(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent, settled)
VALUES ($1, $2, 50, true)`,
[txId, otherId]
);
const balances = await getParticipantBalances(ownerId);
const bob = balances.find((b) => b.id === otherId);
expect(Number(bob!.total_owed)).toBeCloseTo(0);
});
it("still counts an unsettled split alongside a settled one", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const settledTx = await insertTransaction(pool, ownerId, { amount: 100 });
const liveTx = await insertTransaction(pool, ownerId, { amount: 40 });
await pool.query(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent, settled)
VALUES ($1, $2, 50, true), ($3, $2, 50, false)`,
[settledTx, otherId, liveTx]
);
const balances = await getParticipantBalances(ownerId);
const bob = balances.find((b) => b.id === otherId);
// Only the live split counts: 50% of 40.
expect(Number(bob!.total_owed)).toBeCloseTo(20);
});
});
describe("getTripAnalytics — per-trip settlement", () => {
async function seedTrip(ownerId: number, otherId: number) {
const trip = await pool.query(
`INSERT INTO trips (owner_id, name, start_date, end_date)
VALUES ($1, 'Test Trip', '2026-03-01', '2026-03-10') RETURNING id`,
[ownerId]
);
const tripId = trip.rows[0].id as number;
const txId = await insertTransaction(pool, ownerId, { amount: 200, category: "travel" });
await pool.query(
`INSERT INTO transaction_overrides (transaction_id, trip_id) VALUES ($1, $2)`,
[txId, tripId]
);
await pool.query(
`INSERT INTO transaction_splits (transaction_id, participant_id, share_percent)
VALUES ($1, $2, 50)`,
[txId, otherId]
);
return tripId;
}
it("reports the gross share before any payment", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const tripId = await seedTrip(ownerId, otherId);
const { participant_splits } = await getTripAnalytics(tripId, ownerId);
const bob = participant_splits.find((r) => r.participant_id === otherId);
expect(Number(bob!.owed)).toBeCloseTo(100);
});
it("nets off a payment scoped to that trip", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const tripId = await seedTrip(ownerId, otherId);
await pool.query(
`INSERT INTO split_payments (from_participant_id, to_participant_id, amount, payment_date, trip_id)
VALUES ($1, $2, 60, '2026-03-15', $3)`,
[otherId, ownerId, tripId]
);
const { participant_splits } = await getTripAnalytics(tripId, ownerId);
const bob = participant_splits.find((r) => r.participant_id === otherId);
expect(Number(bob!.owed)).toBeCloseTo(40);
});
// The point of the whole scope column: settling the household tab must not
// make a trip look paid. Before trip_id existed there was one global pool and
// this distinction could not be expressed.
it("ignores a household payment when reporting the trip", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const tripId = await seedTrip(ownerId, otherId);
await pool.query(
`INSERT INTO split_payments (from_participant_id, to_participant_id, amount, payment_date, trip_id)
VALUES ($1, $2, 60, '2026-03-15', NULL)`,
[otherId, ownerId]
);
const { participant_splits } = await getTripAnalytics(tripId, ownerId);
const bob = participant_splits.find((r) => r.participant_id === otherId);
expect(Number(bob!.owed)).toBeCloseTo(100);
});
it("drops a settled split from the trip figure", async () => {
const { ownerId, otherId } = await seedParticipants(pool);
const tripId = await seedTrip(ownerId, otherId);
await pool.query(`UPDATE transaction_splits SET settled = true WHERE participant_id = $1`, [otherId]);
const { participant_splits } = await getTripAnalytics(tripId, ownerId);
const bob = participant_splits.find((r) => r.participant_id === otherId);
expect(bob === undefined || Number(bob.owed) === 0).toBe(true);
});
});
+322
View File
@@ -0,0 +1,322 @@
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 platform", () => {
expect(orderDescription("doordash", "Mad Mex")).toBe("Order - Mad Mex (DoorDash)");
expect(orderDescription("ubereats", "Coles (Wyndham Vale)")).toBe(
"Order - Coles (Wyndham Vale) (Uber Eats)"
);
});
it("does not restate a platform the merchant already names", () => {
// A trip's merchant is literally "Uber Trip"; "(Uber)" after it says
// nothing. What identifies a trip is its addresses, and those live in the
// Order details panel.
expect(orderDescription("uber", "Uber Trip")).toBe("Order - Uber Trip");
});
});
+87
View File
@@ -0,0 +1,87 @@
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { queryRaw } from "@/lib/db";
import {
OWNER_SCOPE,
STATEMENTS_JOIN,
EXCLUDE_NON_SPEND,
EXCLUDE_RECONCILED_SOURCE,
EFFECTIVE_CATEGORY,
NET_SPEND_ROWS,
SPEND_SIGNED,
mySplitOf,
toDateStr,
} from "@/lib/analytics-sql";
/**
* Daily net spend, by month, day-of-month and category.
*
* This exists so the spend-pace chart stops computing its own totals. It used to
* sum gross `amount_aud ?? amount` over `transaction_type = 'debit'` in the
* browser, which meant it ignored personal share, refunds, fees, interest, and
* itemised loan repayments — every rule the headline applies. The two numbers
* could disagree while both were labelled "spend", and the chart's own baseline
* line was drawn from the split-adjusted monthly totals, so the two series in
* one chart were on different bases.
*
* Day-of-month granularity is also what lets the page compare a partial current
* month against prior months *through the same day*, instead of against their
* full-month totals — which always made a month in progress look thrifty.
*
* Same fragments as /api/analytics/monthly. If that route's semantics change,
* this one changes with it.
*/
export async function GET(req: NextRequest) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
const { searchParams } = new URL(req.url);
const monthCount = Math.min(Math.max(Number(searchParams.get("months") || "12"), 1), 24);
const now = new Date();
const endDate = new Date(now.getFullYear(), now.getMonth() + 1, 1);
const startDate = new Date(now.getFullYear(), now.getMonth() - monthCount + 1, 1);
const rows = await queryRaw<{ month: string; day: number; category: string; spent: string }>(
`SELECT
TO_CHAR(DATE_TRUNC('month', t.transaction_date::date), 'YYYY-MM') as month,
EXTRACT(DAY FROM t.transaction_date::date)::int as day,
${EFFECTIVE_CATEGORY} as category,
-- 4dp, not 2. This is grouped finer than /monthly (by day as well as
-- category), so rounding each bucket to cents and summing accumulates a
-- different error than rounding per category does — the pace chart ended
-- the month a few cents off the headline it sits under. Round once, at
-- display time.
SUM(${mySplitOf(SPEND_SIGNED)})::numeric(14,4) as spent
FROM transactions t
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
LEFT JOIN transaction_splits ts ON ts.transaction_id = t.id AND ts.participant_id = $1
${STATEMENTS_JOIN}
WHERE ${OWNER_SCOPE} = $1
AND ${NET_SPEND_ROWS}
AND ${EXCLUDE_NON_SPEND}
AND ${EXCLUDE_RECONCILED_SOURCE}
AND t.transaction_date >= $2
AND t.transaction_date < $3
GROUP BY 1, 2, 3
ORDER BY 1, 2`,
[user.id, toDateStr(startDate), toDateStr(endDate)]
);
// Sparse by design — a day with no spend has no entry, and the client treats
// a missing day as zero. Emitting 31 zeroes per month per category would
// dominate the payload.
const daily: Record<string, Record<number, number>> = {};
const byCategory: Record<string, Record<string, Record<number, number>>> = {};
for (const r of rows) {
const spent = Number(r.spent);
const m = (daily[r.month] ??= {});
m[r.day] = (m[r.day] ?? 0) + spent;
const c = ((byCategory[r.month] ??= {})[r.category] ??= {});
c[r.day] = (c[r.day] ?? 0) + spent;
}
return NextResponse.json({ daily, byCategory });
}
+38 -4
View File
@@ -1,12 +1,35 @@
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { queryRaw } from "@/lib/db";
import { OWNER_SCOPE, STATEMENTS_JOIN, mySplitOf } from "@/lib/analytics-sql";
import { OWNER_SCOPE, STATEMENTS_JOIN, EXCLUDE_RECONCILED_SOURCE, mySplitOf, toDateStr } from "@/lib/analytics-sql";
/**
* Fees and interest over an explicit window.
*
* This used to aggregate every statement ever imported with no date filter, and
* the UI printed the result with no period label — so a lifetime-to-date total
* read as a current-period one, and grew forever. `months=0` asks for all time
* deliberately, which is a different claim from asking for it by accident.
*/
export async function GET(req: NextRequest) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
const { searchParams } = new URL(req.url);
const monthsParam = Number(searchParams.get("months") ?? "12");
const months = Number.isFinite(monthsParam) ? Math.min(Math.max(monthsParam, 0), 120) : 12;
const allTime = months === 0;
const now = new Date();
const from = new Date(now.getFullYear(), now.getMonth() - months + 1, 1);
const fromStr = toDateStr(from);
const toStr = toDateStr(new Date(now.getFullYear(), now.getMonth() + 1, 1));
// A statement is dated by the period it covers, not by when it was imported.
const stmtWindow = allTime ? "" : `AND billing_end_date >= $2 AND billing_end_date < $3`;
const txnWindow = allTime ? "" : `AND t.transaction_date >= $2 AND t.transaction_date < $3`;
const windowParams = allTime ? [] : [fromStr, toStr];
// Statement-level fees and interest (aggregated by Gemini from the PDF)
const stmtRows = await queryRaw<{
bank_name: string;
@@ -19,10 +42,11 @@ export async function GET(req: NextRequest) {
SUM(COALESCE(interest_charged, 0))::numeric(12,2) AS interest
FROM statements
WHERE owner_id = $1
${stmtWindow}
GROUP BY bank_name
HAVING SUM(COALESCE(fees_charged, 0)) + SUM(COALESCE(interest_charged, 0)) > 0
ORDER BY (SUM(COALESCE(fees_charged, 0)) + SUM(COALESCE(interest_charged, 0))) DESC`,
[user.id]
[user.id, ...windowParams]
);
// Transaction-level fee and interest line items (split-adjusted)
@@ -49,8 +73,10 @@ export async function GET(req: NextRequest) {
${STATEMENTS_JOIN}
WHERE ${OWNER_SCOPE} = $1
AND t.transaction_type IN ('fee', 'interest')
AND ${EXCLUDE_RECONCILED_SOURCE}
${txnWindow}
ORDER BY t.transaction_date DESC`,
[user.id]
[user.id, ...windowParams]
);
const by_bank = stmtRows.map((r) => ({
@@ -69,5 +95,13 @@ export async function GET(req: NextRequest) {
const total_fees = by_bank.reduce((s, r) => s + r.fees, 0);
const total_interest = by_bank.reduce((s, r) => s + r.interest, 0);
return NextResponse.json({ by_bank, transactions, total_fees, total_interest });
return NextResponse.json({
by_bank,
transactions,
total_fees,
total_interest,
// The period is part of the answer — the client must be able to say what
// window these totals cover rather than implying "now".
period: { months, from: allTime ? null : fromStr, to: allTime ? null : toStr, all_time: allTime },
});
}
@@ -1,7 +1,8 @@
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { queryRaw } from "@/lib/db";
import { OWNER_SCOPE, STATEMENTS_JOIN, EFFECTIVE_CATEGORY, mySplitOf } from "@/lib/analytics-sql";
import { OWNER_SCOPE, STATEMENTS_JOIN, EXCLUDE_RECONCILED_SOURCE, EFFECTIVE_CATEGORY, mySplitOf } from "@/lib/analytics-sql";
import { bankLabel } from "@/lib/queries";
const MY_AMOUNT = mySplitOf(`COALESCE(t.amount_aud, t.amount)`);
@@ -39,7 +40,7 @@ export async function GET(
END::numeric(10,2) as my_amount,
t.transaction_type,
${EFFECTIVE_CATEGORY} as category,
COALESCE(s.bank_name, 'Manual') as bank_name,
${bankLabel()} as bank_name,
t.statement_id
FROM transactions t
${STATEMENTS_JOIN}
@@ -48,6 +49,7 @@ export async function GET(
WHERE ${OWNER_SCOPE} = $1
AND t.transaction_type IN ('debit', 'fee', 'interest', 'refund', 'credit')
AND COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name, t.description) = $2
AND ${EXCLUDE_RECONCILED_SOURCE}
ORDER BY t.transaction_date DESC
LIMIT 500
`, [user.id, decoded]);
+4 -2
View File
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { queryRaw } from "@/lib/db";
import { OWNER_SCOPE, STATEMENTS_JOIN, EXCLUDE_NON_SPEND, EFFECTIVE_CATEGORY, mySplitOf } from "@/lib/analytics-sql";
import { OWNER_SCOPE, STATEMENTS_JOIN, EXCLUDE_NON_SPEND, EXCLUDE_RECONCILED_SOURCE, EFFECTIVE_CATEGORY, mySplitOf, toDateStr } from "@/lib/analytics-sql";
// Split-adjusted amount helper (positive for spend, negative for refunds)
const MY_AMOUNT = mySplitOf(`COALESCE(t.amount_aud, t.amount)`);
@@ -21,7 +21,7 @@ export async function GET(req: NextRequest) {
const cutoff = new Date();
cutoff.setMonth(cutoff.getMonth() - months);
const fromDate = cutoff.toISOString().slice(0, 10);
const fromDate = toDateStr(cutoff);
// Merchant aggregates — net spend (debits + fees - refunds/credits)
const rows = await queryRaw<{
@@ -69,6 +69,7 @@ export async function GET(req: NextRequest) {
AND t.transaction_type IN ('debit', 'fee', 'interest', 'refund', 'credit')
AND t.transaction_date >= $2
AND ${EXCLUDE_NON_SPEND}
AND ${EXCLUDE_RECONCILED_SOURCE}
GROUP BY 1
HAVING SUM(${SPEND_EXPR}) > 0
ORDER BY net_spend DESC
@@ -95,6 +96,7 @@ export async function GET(req: NextRequest) {
AND t.transaction_date >= $2
AND COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name, t.description) = ANY($3)
AND ${EXCLUDE_NON_SPEND}
AND ${EXCLUDE_RECONCILED_SOURCE}
GROUP BY 1, 2
ORDER BY 1, 2
`, [user.id, fromDate, topMerchants]);
+10 -3
View File
@@ -6,9 +6,11 @@ import {
STATEMENTS_JOIN,
EFFECTIVE_CATEGORY,
EXCLUDE_NON_SPEND,
EXCLUDE_RECONCILED_SOURCE,
NET_SPEND_ROWS,
SPEND_SIGNED,
mySplitOf,
toDateStr,
} from "@/lib/analytics-sql";
export async function GET(req: NextRequest) {
@@ -22,8 +24,8 @@ export async function GET(req: NextRequest) {
const endDate = new Date(now.getFullYear(), now.getMonth() + 1, 1);
const startDate = new Date(now.getFullYear(), now.getMonth() - monthCount + 1, 1);
const startStr = startDate.toISOString().slice(0, 10);
const endStr = endDate.toISOString().slice(0, 10);
const startStr = toDateStr(startDate);
const endStr = toDateStr(endDate);
// Expenses: debits excluding transfers and investments, split-adjusted
const spendRows = await queryRaw<{
@@ -35,7 +37,9 @@ export async function GET(req: NextRequest) {
`SELECT
TO_CHAR(DATE_TRUNC('month', t.transaction_date::date), 'YYYY-MM') as month,
${EFFECTIVE_CATEGORY} as category,
SUM(${mySplitOf(SPEND_SIGNED)})::numeric(12,2) as total_spent,
-- 4dp so the month total is summed from unrounded parts; every consumer
-- rounds for display. See the note in /api/analytics/daily.
SUM(${mySplitOf(SPEND_SIGNED)})::numeric(14,4) as total_spent,
COUNT(*)::int as transaction_count
FROM transactions t
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
@@ -44,6 +48,7 @@ export async function GET(req: NextRequest) {
WHERE ${OWNER_SCOPE} = $1
AND ${NET_SPEND_ROWS}
AND ${EXCLUDE_NON_SPEND}
AND ${EXCLUDE_RECONCILED_SOURCE}
AND t.transaction_date >= $2
AND t.transaction_date < $3
GROUP BY 1, 2
@@ -67,6 +72,7 @@ export async function GET(req: NextRequest) {
WHERE ${OWNER_SCOPE} = $1
AND t.transaction_type IN ('credit', 'payment')
AND ${EFFECTIVE_CATEGORY} = 'income'
AND ${EXCLUDE_RECONCILED_SOURCE}
AND t.transaction_date >= $2
AND t.transaction_date < $3
GROUP BY 1
@@ -89,6 +95,7 @@ export async function GET(req: NextRequest) {
${STATEMENTS_JOIN}
WHERE ${OWNER_SCOPE} = $1
AND ${EFFECTIVE_CATEGORY} = 'investment'
AND ${EXCLUDE_RECONCILED_SOURCE}
AND t.transaction_date >= $2
AND t.transaction_date < $3
GROUP BY 1
+2 -1
View File
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { queryRaw } from "@/lib/db";
import { OWNER_SCOPE, STATEMENTS_JOIN, EXCLUDE_NON_SPEND, EFFECTIVE_CATEGORY, mySplitOf } from "@/lib/analytics-sql";
import { OWNER_SCOPE, STATEMENTS_JOIN, EXCLUDE_NON_SPEND, EXCLUDE_RECONCILED_SOURCE, EFFECTIVE_CATEGORY, mySplitOf } from "@/lib/analytics-sql";
export async function GET(req: NextRequest) {
const user = await getCurrentUser(req);
@@ -31,6 +31,7 @@ export async function GET(req: NextRequest) {
WHERE ${OWNER_SCOPE} = $1
AND t.transaction_type IN ('debit', 'fee')
AND ${EXCLUDE_NON_SPEND}
AND ${EXCLUDE_RECONCILED_SOURCE}
AND COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) IS NOT NULL
),
merchant_with_lag AS (
+121
View File
@@ -0,0 +1,121 @@
import { NextRequest, NextResponse } from "next/server";
import {
parseOrderHTML,
parseOrderAmendment,
isAmendment,
validateOrderTotals,
processOrderIngestion,
applyOrderAmendment,
reconcilePendingOrders,
OrderParseError,
NotAReceiptError,
type MessageMeta,
} from "@/lib/order-ingestion";
/**
* 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;
}
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,
});
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,
});
} 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);
}
@@ -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 }
);
}
+127
View File
@@ -0,0 +1,127 @@
import { NextRequest, NextResponse } from "next/server";
import { getCurrentUser } from "@/lib/auth";
import { queryRaw } from "@/lib/db";
import { getTransactions } from "@/lib/queries";
import { evaluateCondition, type Condition, type Actions } from "@/lib/rules";
/**
* Dry run: which transactions would this rule touch, and what would change?
*
* Nothing is written. This exists so a rule can be inspected before it is
* applied — the apply path (POST /api/transactions/bulk with action
* "apply_rule") takes an explicit list of ids, so the flow is preview, pick,
* then apply.
*
* Matches are split into two groups because they need different attention:
* `changes` are rows the rule would actually alter, `noops` already have the
* value the rule would set. A merchant-normalisation rule matching 400 rows
* where 380 are already correct is 20 changes and 380 rows of noise.
*/
interface Change {
field: "category" | "merchant" | "tags" | "split";
from: string | null;
to: string;
}
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 rules = await queryRaw<{
id: number; name: string; conditions: unknown; actions: unknown;
}>(
`SELECT id, name, conditions, actions FROM rules WHERE id = $1 AND owner_id = $2`,
[Number(id), user.id]
);
if (!rules.length) return NextResponse.json({ error: "Rule not found" }, { status: 404 });
const rule = rules[0];
const conditions = (typeof rule.conditions === "string"
? JSON.parse(rule.conditions) : rule.conditions) as Condition[];
const actions = (typeof rule.actions === "string"
? JSON.parse(rule.actions) : rule.actions) as Actions;
const { searchParams } = new URL(req.url);
const statementId = searchParams.get("statement_id");
const limit = Math.min(2000, Math.max(1, Number(searchParams.get("limit") || "500")));
const { data: transactions } = await getTransactions(user.id, {
limit: 100000,
offset: 0,
...(statementId ? { statement_id: statementId } : {}),
});
// A rule with no conditions matches every transaction. That is how the apply
// endpoint behaves too, so the preview must show it rather than hide it —
// seeing "matches 3,721" is the point.
const matchAll = conditions.length === 0;
const tagIds = new Set(actions.add_tag_ids ?? []);
const splitTarget = actions.apply_split ?? [];
const changes: unknown[] = [];
let noops = 0;
for (const tx of transactions) {
if (!matchAll && !conditions.every((c) => evaluateCondition(c, tx))) continue;
const diff: Change[] = [];
if (actions.set_category && tx.effective_category !== actions.set_category) {
diff.push({ field: "category", from: tx.effective_category ?? null, to: actions.set_category });
}
if (actions.set_merchant && tx.effective_merchant !== actions.set_merchant) {
diff.push({ field: "merchant", from: tx.effective_merchant ?? null, to: actions.set_merchant });
}
if (tagIds.size) {
const have = new Set((tx.tags ?? []).map((t) => t.id));
const missing = [...tagIds].filter((t) => !have.has(t));
if (missing.length) {
diff.push({ field: "tags", from: null, to: missing.join(",") });
}
}
if (splitTarget.length) {
// Compare the participant/share set the rule would write against what is
// already there; a re-run that changes nothing should not look like work.
const have = new Map((tx.splits ?? []).map((s) => [s.participant_id, Number(s.share_percent)]));
const differs = splitTarget.length !== have.size
|| splitTarget.some((s) => have.get(s.participant_id) !== Number(s.share_percent));
if (differs) {
diff.push({
field: "split",
from: [...have.entries()].map(([p, s]) => `${p}:${s}%`).join(" ") || null,
to: splitTarget.map((s) => `${s.participant_id}:${s.share_percent}%`).join(" "),
});
}
}
if (diff.length === 0) { noops++; continue; }
changes.push({
id: tx.id,
transaction_date: tx.transaction_date,
description: tx.description,
amount: tx.amount,
amount_aud: tx.amount_aud,
currency: tx.currency,
bank_name: tx.bank_name,
effective_merchant: tx.effective_merchant,
effective_category: tx.effective_category,
changes: diff,
});
}
return NextResponse.json({
rule: { id: rule.id, name: rule.name, matches_everything: matchAll },
total_matched: changes.length + noops,
would_change: changes.length,
already_correct: noops,
transactions: changes.slice(0, limit),
truncated: changes.length > limit,
});
}
+13 -7
View File
@@ -17,8 +17,12 @@ export async function GET(req: NextRequest) {
matched: number;
transactions_affected: number;
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`,
[user.id]
);
@@ -37,11 +41,11 @@ export async function POST(req: NextRequest) {
// Manual-only rules ("quick actions") never take part in a condition-matched
// run — their conditions are typically empty, so they would match every
// 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
? `SELECT id, conditions, actions FROM rules
? `SELECT id, name, conditions, actions FROM rules
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
ORDER BY priority DESC`,
ruleId ? [user.id, ruleId] : [user.id]
@@ -92,9 +96,11 @@ export async function POST(req: NextRequest) {
// --- Save run record ---
const run = await queryRaw<{ id: number }>(
`INSERT INTO rule_apply_runs (owner_id, split_from, matched, transactions_affected, snapshot)
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
[user.id, splitFrom, matched, affectedIds.size, JSON.stringify(snapshot)]
`INSERT INTO rule_apply_runs (owner_id, split_from, matched, transactions_affected, snapshot,
rule_id, rule_name, source)
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 });
+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,
});
}
-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 });
}
@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from "next/server";
import { queryRow } from "@/lib/db";
import { getCurrentUser } from "@/lib/auth";
import { canAccessTransactions } from "@/lib/queries";
/**
* Order provenance for one transaction.
*
* `expense_metadata` has held the itemised receipt since ingestion started and
* nothing in the UI ever read it — a transaction that came from a DoorDash or
* Uber Eats receipt showed a merchant and an amount, with the item list and the
* delivery addresses sitting unread in the row behind it (user, 2026-07-27).
*
* Read-only. The receipt is a record of what a provider sent; editing it here
* would make provenance mean nothing.
*/
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const user = await getCurrentUser(req);
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
const { id } = await params;
if (!(await canAccessTransactions(user.id, [Number(id)]))) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
}
const row = await queryRow(
`SELECT platform, order_reference, line_items, route, subtotal, amount,
currency, card_last4, flags, source_email_subject, transaction_date
FROM expense_metadata
-- A card-settled order creates no transaction of its own (I5): the
-- statement line is the transaction, and the receipt points at it
-- through matched_transaction_id. Both directions have to resolve or the
-- detail is missing on exactly the orders that were paid by card.
WHERE transaction_id = $1 OR matched_transaction_id = $1
LIMIT 1`,
[Number(id)]
);
// Not an order — most transactions aren't. Null, not 404: the caller is
// asking "is there a receipt behind this?", and "no" is a normal answer.
return NextResponse.json(row ?? null);
}
+5 -3
View File
@@ -110,9 +110,11 @@ export async function POST(req: NextRequest) {
}
const run = await queryRaw<{ id: number }>(
`INSERT INTO rule_apply_runs (owner_id, split_from, matched, transactions_affected, snapshot)
VALUES ($1, NULL, $2, $3, $4) RETURNING id`,
[user.id, ids.length, ids.length, JSON.stringify(snapshot)]
`INSERT INTO rule_apply_runs (owner_id, split_from, matched, transactions_affected, snapshot,
rule_id, rule_name, source)
VALUES ($1, NULL, $2, $3, $4, $5, $6, 'selection') RETURNING id`,
[user.id, ids.length, ids.length, JSON.stringify(snapshot),
rules[0].id, rules[0].name]
);
return NextResponse.json({ updated: ids.length, run_id: run[0].id, rule: rules[0].name });
+89 -42
View File
@@ -16,7 +16,7 @@ import {
ReferenceLine,
} from "recharts";
import { useQueryClient } from "@tanstack/react-query";
import { useMonthlyAnalytics, useTransactions, useUpdateTransaction } from "@/lib/hooks";
import { useMonthlyAnalytics, useDailySpend, useTransactions, useUpdateTransaction } from "@/lib/hooks";
import { formatCategory, CATEGORIES } from "@/lib/categories";
import { CATEGORY_COLORS, CHART, TOOLTIP_STYLE } from "@/lib/category-colors";
@@ -37,6 +37,26 @@ function formatShortMonth(m: string): string {
const [year, month] = m.split("-");
return new Date(Number(year), Number(month) - 1, 1).toLocaleString("default", { month: "short" });
}
function daysInMonthOf(m: string): number {
const [year, month] = m.split("-").map(Number);
return new Date(year, month, 0).getDate();
}
/**
* How much of `month` has actually happened. A month in progress is only
* complete up to today; every earlier month is complete.
*/
function elapsedDays(m: string): number {
return m === currentMonthStr() ? new Date().getDate() : daysInMonthOf(m);
}
/** Spend in `month` from day 1 through `throughDay` inclusive. */
function spendThrough(days: Record<number, number> | undefined, throughDay: number): number {
if (!days) return 0;
let sum = 0;
for (const [d, v] of Object.entries(days)) {
if (Number(d) <= throughDay) sum += v;
}
return sum;
}
function fmt(n: number): string { return `$${Math.round(n).toLocaleString()}`; }
function fmtExact(n: number): string { return `$${n.toFixed(2)}`; }
function fmtSigned(n: number): string { return `${n >= 0 ? "+" : ""}$${Math.abs(n) >= 100 ? Math.round(Math.abs(n)).toLocaleString() : Math.abs(n).toFixed(0)}`; }
@@ -195,12 +215,14 @@ export default function AnalyticsPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [months]);
// Cumulative chart: fetch this month's transactions
const smFrom = `${selectedMonth}-01`;
const [smYear, smMonth] = selectedMonth.split("-").map(Number);
const smNextDate = new Date(smYear, smMonth, 1);
const smTo = `${smNextDate.getFullYear()}-${String(smNextDate.getMonth() + 1).padStart(2, "0")}-01`;
const { data: monthTxData } = useTransactions({ from: smFrom, to: smTo, limit: 1000 });
// Day-of-month spend, split-adjusted server-side by the same rules as the
// headline. Also what makes the comparisons below like-for-like.
const { data: dailyData } = useDailySpend(12);
// A month in progress is only comparable to prior months through the same day.
// Comparing 27 days of July against a full June always flattered July.
const compareDay = elapsedDays(selectedMonth);
const selectedIsPartial = selectedMonth === currentMonthStr();
// Category rows for selected month
const categoryRows = useMemo(() => {
@@ -226,22 +248,27 @@ export default function AnalyticsPage() {
.slice(0, 8);
}, [analytics, months, selectedMonth]);
// Top movers vs previous month
// Top movers vs previous month, compared through the same day of the month so
// a month in progress is not measured against a complete one.
const movers = useMemo(() => {
if (!analytics) return [];
const pm = prevMonth(selectedMonth);
if (!months.includes(pm)) return [];
return analytics.rows
.map((r) => ({
category: r.category,
delta: (r.spent[selectedMonth] || 0) - (r.spent[pm] || 0),
now: r.spent[selectedMonth] || 0,
before: r.spent[pm] || 0,
}))
const catsNow = dailyData?.byCategory?.[selectedMonth] ?? {};
const catsBefore = dailyData?.byCategory?.[pm] ?? {};
const categories = new Set([...Object.keys(catsNow), ...Object.keys(catsBefore)]);
return Array.from(categories)
.map((category) => {
const now = spendThrough(catsNow[category], compareDay);
const before = spendThrough(catsBefore[category], compareDay);
return { category, delta: now - before, now, before };
})
.filter((r) => Math.abs(r.delta) >= 1)
.sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta))
.slice(0, 6);
}, [analytics, months, selectedMonth]);
}, [analytics, dailyData, months, selectedMonth, compareDay]);
// Pareto chart data
const paretoData = useMemo(() => {
@@ -258,37 +285,40 @@ export default function AnalyticsPage() {
});
}, [categoryRows]);
// Cumulative spend chart data
// Cumulative spend chart data.
//
// Both series now come from the same server-side spend definition as the
// headline. The typical line is also a real averaged curve rather than the
// month total spread evenly — spending is lumpy (rent on the 1st, a shop on
// the weekend), so a straight line made ordinary months look erratic.
const cumulativeData = useMemo(() => {
const daysInMonth = new Date(smYear, smMonth, 0).getDate();
const isCurrentMonth = selectedMonth === currentMonthStr();
const today = new Date();
const lastDay = isCurrentMonth ? today.getDate() : daysInMonth;
const daysInMonth = daysInMonthOf(selectedMonth);
const lastDay = elapsedDays(selectedMonth);
const daily: Record<number, number> = {};
(monthTxData?.data ?? [])
.filter((tx) => tx.transaction_type === "debit" && !["transfers", "investment"].includes(tx.effective_category))
.forEach((tx) => {
const day = new Date(tx.transaction_date).getDate();
daily[day] = (daily[day] || 0) + Number(tx.amount_aud ?? tx.amount);
});
const priorMonths = analytics?.months.filter((m) => m !== selectedMonth) ?? [];
const priorAvg = priorMonths.length > 0
? priorMonths.reduce((s, m) => s + (analytics?.totals[m]?.spent || 0), 0) / priorMonths.length
: 0;
const daily = dailyData?.daily?.[selectedMonth] ?? {};
// Only complete months with data form the baseline. A month still in
// progress has no spend recorded past today, so including it would pull the
// typical curve down by however much of it has not happened yet.
const priorMonths = (analytics?.months ?? []).filter(
(m) => m !== selectedMonth && m !== currentMonthStr() && (analytics?.totals[m]?.spent || 0) > 0
);
let cum = 0;
return Array.from({ length: daysInMonth }, (_, i) => {
const day = i + 1;
if (day <= lastDay) cum += daily[day] || 0;
const typical = priorMonths.length
? priorMonths.reduce((s, m) => s + spendThrough(dailyData?.daily?.[m], day), 0) / priorMonths.length
: 0;
return {
day,
actual: day <= lastDay ? Math.round(cum * 100) / 100 : null,
typical: Math.round((priorAvg * day / daysInMonth) * 100) / 100,
typical: Math.round(typical * 100) / 100,
};
});
}, [monthTxData, analytics, selectedMonth, smYear, smMonth]);
}, [dailyData, analytics, selectedMonth]);
if (isLoading || !analytics) {
return (
@@ -303,16 +333,30 @@ export default function AnalyticsPage() {
const hasIncome = months.some((m) => (analytics.totals[m]?.income || 0) > 0);
const hasInvestments = months.some((m) => (analytics.totals[m]?.investments || 0) > 0);
// Hero delta vs the average of the other months that have data
const otherMonths = months.filter((m) => m !== selectedMonth && (analytics.totals[m]?.spent || 0) > 0);
// Hero delta vs the average of the other *complete* months that have data.
//
// Two partial-month traps here. The month in progress never belongs in the
// baseline, because most of it has not happened. And when the month in
// progress is the one selected, its running total has to be measured against
// the same slice of each prior month, not against their full totals.
const otherMonths = months.filter(
(m) => m !== selectedMonth && m !== currentMonthStr() && (analytics.totals[m]?.spent || 0) > 0
);
const comparableSpend = selectedIsPartial
? spendThrough(dailyData?.daily?.[selectedMonth], compareDay)
: totals.spent;
const avgSpend = otherMonths.length
? otherMonths.reduce((s, m) => s + (analytics.totals[m]?.spent || 0), 0) / otherMonths.length
? otherMonths.reduce(
(s, m) => s + (selectedIsPartial ? spendThrough(dailyData?.daily?.[m], compareDay) : analytics.totals[m]?.spent || 0),
0
) / otherMonths.length
: 0;
const avgDeltaPct = avgSpend > 0 ? Math.round(((totals.spent - avgSpend) / avgSpend) * 100) : 0;
const avgDeltaPct = avgSpend > 0 ? Math.round(((comparableSpend - avgSpend) / avgSpend) * 100) : 0;
const throughQualifier = selectedIsPartial ? ` through day ${compareDay}` : "";
const heroSentence =
avgSpend === 0 ? "" :
Math.abs(avgDeltaPct) <= 3 ? `in line with your ${otherMonths.length}-month average` :
`${Math.abs(avgDeltaPct)}% ${avgDeltaPct > 0 ? "above" : "below"} your ${otherMonths.length}-month average of ${fmt(avgSpend)}`;
Math.abs(avgDeltaPct) <= 3 ? `in line with your ${otherMonths.length}-month average${throughQualifier}` :
`${Math.abs(avgDeltaPct)}% ${avgDeltaPct > 0 ? "above" : "below"} your ${otherMonths.length}-month average of ${fmt(avgSpend)}${throughQualifier}`;
const pareto80idx = paretoData.findIndex((r) => r.cumulative >= 80);
const tableMonths = analytics.months.slice(0, 6); // newest-first, last 6
@@ -371,7 +415,10 @@ export default function AnalyticsPage() {
{movers.length > 0 && (
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-4">
<h3 className="text-sm font-medium mb-1">What changed</h3>
<p className="text-xs text-zinc-500 mb-4">Biggest category moves vs {formatShortMonth(prevMonth(selectedMonth))}</p>
<p className="text-xs text-zinc-500 mb-4">
Biggest category moves vs {formatShortMonth(prevMonth(selectedMonth))}
{selectedIsPartial && `, both through day ${compareDay}`}
</p>
<div className="grid sm:grid-cols-2 gap-x-8 gap-y-2.5">
{movers.map((m) => (
<div key={m.category} className="flex items-center gap-3">
+48 -5
View File
@@ -10,6 +10,18 @@ import { CHART, TOOLTIP_STYLE } from "@/lib/category-colors";
const SPEND_TYPES = new Set(["debit", "fee", "interest"]);
/**
* The API returns an exclusive upper bound (first day of the month after the
* window). Showing that date verbatim would claim a month the totals exclude,
* so `exclusive` steps back a day for display.
*/
function formatPeriodBound(iso: string | null, exclusive = false): string {
if (!iso) return "—";
const d = new Date(`${iso}T00:00:00`);
if (exclusive) d.setDate(d.getDate() - 1);
return d.toLocaleDateString("default", { month: "short", year: "numeric" });
}
function fmt(n: number) {
return new Intl.NumberFormat("en-AU", { style: "currency", currency: "AUD", maximumFractionDigits: 0 }).format(n);
}
@@ -41,10 +53,13 @@ const FREQ_LABEL: Record<string, string> = {
};
// ─── Section wrapper ────────────────────────────────────────────────
function Section({ title, children }: { title: string; children: React.ReactNode }) {
function Section({ title, aside, children }: { title: string; aside?: React.ReactNode; children: React.ReactNode }) {
return (
<div className="mb-8">
<h3 className="text-base font-semibold text-zinc-200 mb-3">{title}</h3>
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1 mb-3">
<h3 className="text-base font-semibold text-zinc-200">{title}</h3>
{aside}
</div>
{children}
</div>
);
@@ -298,7 +313,8 @@ export default function InsightsPage() {
const { data: analytics } = useMonthlyAnalytics(12);
const { data: analytics6 } = useMonthlyAnalytics(6);
const { data: subData } = useSubscriptions();
const { data: feesData } = useFees();
const [feeMonths, setFeeMonths] = useState(12);
const { data: feesData } = useFees(feeMonths);
// Build regular/occasional chart data
const chartData = useMemo(() => {
@@ -431,11 +447,38 @@ export default function InsightsPage() {
</Section>
{/* ── 4. Fees & Interest ── */}
<Section title="Fees & interest">
<Section
title="Fees & interest"
aside={
<div className="flex items-center gap-3">
{feesData?.period && (
<span className="text-xs text-zinc-500 tabular-nums">
{feesData.period.all_time
? "All time"
: `${formatPeriodBound(feesData.period.from)} ${formatPeriodBound(feesData.period.to, true)}`}
</span>
)}
<select
value={feeMonths}
onChange={(e) => setFeeMonths(Number(e.target.value))}
className="bg-zinc-900 border border-zinc-800 rounded px-2 py-1 text-xs text-zinc-300 focus:outline-none focus:border-indigo-500 cursor-pointer"
aria-label="Fees period"
>
<option value={3}>Last 3 months</option>
<option value={6}>Last 6 months</option>
<option value={12}>Last 12 months</option>
<option value={24}>Last 24 months</option>
<option value={0}>All time</option>
</select>
</div>
}
>
{!feesData ? (
<p className="text-zinc-500 text-sm">Loading...</p>
) : feesData.by_bank.length === 0 && feesData.transactions.length === 0 ? (
<p className="text-zinc-500 text-sm">No fees or interest recorded across your statements.</p>
<p className="text-zinc-500 text-sm">
No fees or interest recorded {feesData.period?.all_time ? "on any statement" : "in this period"}.
</p>
) : (
<div className="space-y-4">
{feesData.by_bank.length > 0 && (
+65 -23
View File
@@ -3,6 +3,8 @@
import { useState } from "react";
import { useRules, useCreateRule, useUpdateRule, useDeleteRule, useApplyRules, useRuleRuns, useRevertRuleRun, useTags, useParticipants } from "@/lib/hooks";
import { CATEGORIES, formatCategory } from "@/lib/categories";
import { RulePreviewModal } from "@/components/rule-preview-modal";
import { RuleRunDetail } from "@/components/rule-run-detail";
const FIELDS = [
{ value: "merchant_normalized", label: "Merchant" },
@@ -82,6 +84,8 @@ export default function RulesPage() {
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [applyResult, setApplyResult] = useState<{ matched: number; transactions_affected: number } | null>(null);
const [preview, setPreview] = useState<{ id: number; name: string } | null>(null);
const [expandedRun, setExpandedRun] = useState<number | null>(null);
const [name, setName] = useState("");
const [conditions, setConditions] = useState<Condition[]>([]);
const [actions, setActions] = useState<Actions>(EMPTY_ACTIONS);
@@ -449,27 +453,49 @@ export default function RulesPage() {
<h3 className="text-sm font-medium text-zinc-400 mb-2">Apply History</h3>
<div className="space-y-2">
{runs.map((run) => (
<div key={run.id} className={`flex items-center justify-between px-4 py-2.5 rounded-lg border text-sm ${run.reverted_at ? "bg-zinc-900/40 border-zinc-800 opacity-60" : "bg-zinc-900 border-zinc-700"}`}>
<div className="flex items-center gap-4">
<span className="text-zinc-300">{new Date(run.applied_at).toLocaleString()}</span>
<span className="text-zinc-500">{run.matched} matches · {run.transactions_affected} transactions</span>
{run.split_from && <span className="text-zinc-600 text-xs">splits from {run.split_from}</span>}
<div key={run.id} className={`rounded-lg border text-sm overflow-hidden ${run.reverted_at ? "bg-zinc-900/40 border-zinc-800 opacity-60" : "bg-zinc-900 border-zinc-700"}`}>
<div className="flex items-center justify-between px-4 py-2.5">
<div className="flex items-center gap-3 flex-wrap">
<button
onClick={() => setExpandedRun(expandedRun === run.id ? null : run.id)}
className="text-zinc-500 hover:text-zinc-200 w-4 text-left"
title="Show what this run changed"
>
{expandedRun === run.id ? "▾" : "▸"}
</button>
<span className="text-zinc-300">{new Date(run.applied_at).toLocaleString()}</span>
{/* Which rule ran matters most: a merchant rename and a
50/50 split of everything look identical as counts. */}
<span className="text-zinc-200">
{run.rule_name ?? (run.source === "all" ? "All rules" : "Unknown rule")}
</span>
{run.source === "selection" && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-indigo-900/50 text-indigo-300">
selection
</span>
)}
<span className="text-zinc-500">{run.transactions_affected} transactions</span>
{run.split_from && (
<span className="text-zinc-600 text-xs">splits from {String(run.split_from).slice(0, 10)}</span>
)}
</div>
{run.reverted_at ? (
<span className="text-xs text-zinc-500">reverted {new Date(run.reverted_at).toLocaleString()}</span>
) : (
<button
onClick={() => {
if (confirm("Revert this run? This will restore all affected transactions to their state before the rules were applied.")) {
revertRun.mutate(run.id);
}
}}
disabled={revertRun.isPending}
className="text-xs text-amber-400 hover:text-amber-300 disabled:opacity-50"
>
Revert
</button>
)}
</div>
{run.reverted_at ? (
<span className="text-xs text-zinc-500">reverted {new Date(run.reverted_at).toLocaleString()}</span>
) : (
<button
onClick={() => {
if (confirm("Revert this run? This will restore all affected transactions to their state before the rules were applied.")) {
revertRun.mutate(run.id);
}
}}
disabled={revertRun.isPending}
className="text-xs text-amber-400 hover:text-amber-300 disabled:opacity-50"
>
Revert
</button>
)}
{expandedRun === run.id && <RuleRunDetail runId={run.id} />}
</div>
))}
</div>
@@ -509,9 +535,17 @@ export default function RulesPage() {
<p className="text-xs text-zinc-500 mt-1">{humanAction(acts, tagNames, participantNames)}</p>
</div>
<div className="flex items-center gap-3 shrink-0">
{/* No Apply for quick actions: their conditions are empty, so a
bulk apply would hit every transaction. They run from the
transactions page against a selection instead. */}
{/* Preview is safe for every rule, including quick actions:
it writes nothing, and the apply step takes the ids you
tick rather than re-running the conditions. */}
<button
onClick={() => setPreview({ id: rule.id, name: rule.name })}
className="text-xs text-indigo-400 hover:text-indigo-300"
>
Preview
</button>
{/* No blind Apply for quick actions: their conditions are
empty, so a bulk apply would hit every transaction. */}
{!rule.manual_only && (
<button
onClick={() => handleApply(rule.id)}
@@ -554,6 +588,14 @@ export default function RulesPage() {
})}
</div>
)}
{preview && (
<RulePreviewModal
ruleId={preview.id}
ruleName={preview.name}
onClose={() => setPreview(null)}
/>
)}
</div>
);
}
+48 -11
View File
@@ -22,8 +22,12 @@ function formatDate(d: string) {
const SPEND_TYPES = new Set(["debit", "fee", "interest"]);
function formatAmount(n: number, type?: string) {
const formatted = `$${Number(n).toFixed(2)}`;
function formatAmount(n: number, type?: string, currency?: string) {
// A bare "$" on a non-AUD row was the visible half of the problem: the row
// read as dollars while the participant balances converted to AUD, so the
// two disagreed on screen with nothing to explain why.
const value = Number(n).toFixed(2);
const formatted = !currency || currency === "AUD" ? `$${value}` : `${currency} ${value}`;
return type && !SPEND_TYPES.has(type) ? `+${formatted}` : formatted;
}
@@ -298,7 +302,17 @@ export default function SharedPage() {
return <span className="ml-0.5">{sortDir === "desc" ? "↓" : "↑"}</span>;
}
const { data: balances = [], isLoading: balLoading } = useParticipantBalances(realTagIds);
const { data: allTags = [] } = useTags();
const { data: me } = useCurrentUser();
// Names the tag scope when one is active. Non-empty means the cards below are
// split totals rather than payable balances.
const tagScopeLabel =
realTagIds.length === 0
? null
: realTagIds.length === 1
? (allTags.find((t) => String(t.id) === realTagIds[0])?.name ?? "this tag")
: `${realTagIds.length} tags`;
const [addingParticipant, setAddingParticipant] = useState(false);
const [paymentModal, setPaymentModal] = useState<{ id: number; name: string; balance: number } | null>(null);
const [showHistory, setShowHistory] = useState<number | null>(null);
@@ -351,23 +365,37 @@ export default function SharedPage() {
<div>
<p className="font-medium">{b.name}</p>
<p className="text-xs text-zinc-500">
{settled ? "all square" : theyOweMe ? `owes you` : "you owe"}
{/* With a tag filter on, payments are deliberately not
subtracted — so this is a split total, not a payable
balance, and must not claim to be one. */}
{tagScopeLabel
? `split total in ${tagScopeLabel}`
: settled ? "all square" : theyOweMe ? "owes you" : "you owe"}
</p>
</div>
<div className="text-right">
<p className={`text-lg font-semibold ${settled ? "text-zinc-500" : theyOweMe ? "text-amber-400" : "text-blue-400"}`}>
<p className={`text-lg font-semibold ${tagScopeLabel ? "text-zinc-300" : settled ? "text-zinc-500" : theyOweMe ? "text-amber-400" : "text-blue-400"}`}>
${net.toFixed(2)}
</p>
{b.unconverted_count > 0 && (
<p className="text-[11px] text-amber-500/80 mt-0.5">
approx · {b.unconverted_count} unconverted
</p>
)}
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => setPaymentModal({ id: b.id, name: b.name, balance: b.total_owed })}
className="flex-1 py-1.5 text-xs font-medium bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg"
>
Record Payment
</button>
{/* Settling against a tag-scoped total would record a payment
for a figure that never was the debt. */}
{!tagScopeLabel && (
<button
onClick={() => setPaymentModal({ id: b.id, name: b.name, balance: b.total_owed })}
className="flex-1 py-1.5 text-xs font-medium bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg"
>
Record Payment
</button>
)}
<button
onClick={() => setShowHistory(showHistory === b.id ? null : b.id)}
className={`px-3 py-1.5 text-xs rounded-lg ${showHistory === b.id ? "bg-zinc-700 text-white" : "bg-zinc-800 text-zinc-500 hover:text-zinc-300"}`}
@@ -440,7 +468,16 @@ export default function SharedPage() {
)}
</td>
<td className={`px-4 py-3 text-right font-medium tabular-nums ${SPEND_TYPES.has(tx.transaction_type) ? "" : "text-green-400"}`}>
{formatAmount(tx.amount, tx.transaction_type)}
{formatAmount(tx.amount, tx.transaction_type, tx.currency)}
{tx.currency !== "AUD" && (
// Splits settle on the AUD figure, so show it next to the
// native one rather than leaving the two to differ silently.
<span className="block text-xs font-normal text-zinc-500">
{tx.amount_unconverted
? "AUD value unknown"
: `${formatAmount(Number(tx.amount_aud), tx.transaction_type, "AUD")} AUD`}
</span>
)}
</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1">
+67 -5
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useCallback, useRef, useEffect, Suspense } from "react";
import { useState, useCallback, useRef, useEffect, Suspense, Fragment } from "react";
import { useSearchParams } from "next/navigation";
import { useTransactions, useBanks, useUpdateTransaction, useBulkAction, useTags, useStatement, useCreateRule, useParticipants, useRecordPayment, useCurrentUser, useTrips, useAssignTransactionsToTrip, useRules } from "@/lib/hooks";
import { CATEGORIES, formatCategory } from "@/lib/categories";
@@ -9,7 +9,8 @@ import { TagPicker } from "@/components/tag-picker";
import { AddTransactionModal } from "@/components/add-transaction-modal";
import { EditTransactionModal } from "@/components/edit-transaction-modal";
import { CsvImportModal } from "@/components/csv-import-modal";
import type { TransactionRow } from "@/lib/queries";
import type { TransactionRow, RoutePointRow } from "@/lib/queries";
import { OrderDetails } from "@/components/order-details";
import type { RuleRow } from "@/lib/hooks";
function formatDate(d: string) {
@@ -475,6 +476,25 @@ function MultiSelect({
);
}
/**
* "Melbourne Airport (MEL) → Wyndham Vale VIC 3024" from the two stops on an
* Uber *trip* receipt. Deliveries are excluded by the caller: their merchant
* already identifies them, so the restaurant's street address would be clutter
* on every food order.
*
* Keeps the first two comma-segments of each address — a truncation, not a
* guess about geography. The venue or street comes first in Uber's format and
* is the identifying part; the full text stays in the title attribute.
*/
function routeSummary(route: RoutePointRow[] | null | undefined): string | null {
if (!route || route.length < 2) return null;
const short = (a: string) => a.split(",").slice(0, 2).join(",").trim();
const from = short(route[0].address);
const to = short(route[route.length - 1].address);
if (!from || !to) return null;
return `${from}${to}`;
}
export default function TransactionsPage() {
return (
<Suspense fallback={<p className="text-zinc-500 text-sm">Loading...</p>}>
@@ -537,6 +557,9 @@ function TransactionsContent() {
const [splitModal, setSplitModal] = useState<{ transactionId?: number; transactionIds?: number[]; amount?: number; description: string; merchant?: string } | null>(null);
const [addModal, setAddModal] = useState<{ prefill?: Parameters<typeof AddTransactionModal>[0]["prefill"]; title?: string } | null>(null);
const [editModal, setEditModal] = useState<TransactionRow | null>(null);
// Rows expanded to show the ingested receipt. A set, not a single id: the
// point is comparing several orders without losing your place.
const [expanded, setExpanded] = useState<Set<number>>(new Set());
const [showImportModal, setShowImportModal] = useState(false);
const [paymentModal, setPaymentModal] = useState<TransactionRow | null>(null);
const [rulePrompt, setRulePrompt] = useState<{
@@ -911,8 +934,8 @@ function TransactionsContent() {
<tr><td colSpan={11} className="p-8 text-center text-zinc-500">No transactions found</td></tr>
) : (
data.data.map((t) => (
<Fragment key={t.id}>
<tr
key={t.id}
className={`border-b border-zinc-800/50 hover:bg-zinc-900/30 ${
selected.has(t.id) ? "bg-zinc-800/40" : ""
}`}
@@ -928,9 +951,39 @@ function TransactionsContent() {
<td className={`p-2 whitespace-nowrap sticky left-8 z-10 border-r border-zinc-800/80 ${selected.has(t.id) ? "bg-zinc-800" : "bg-zinc-950"}`}>{formatDate(t.transaction_date)}</td>
<td className="p-2 whitespace-nowrap text-zinc-500 text-xs">{formatDate(t.created_at)}</td>
<td className="p-2 max-w-xs">
<p className="truncate" title={t.description}>{t.description}</p>
{t.notes && (
<div className="flex items-start gap-1.5">
{t.order_platform && (
// Only where there IS a receipt behind the row. A
// disclosure arrow on every transaction would promise
// detail that mostly does not exist.
<button
onClick={() => setExpanded((prev) => {
const next = new Set(prev);
if (next.has(t.id)) next.delete(t.id); else next.add(t.id);
return next;
})}
className="text-zinc-600 hover:text-zinc-300 leading-none mt-0.5 shrink-0"
title={expanded.has(t.id) ? "Hide receipt" : "Show the receipt this came from"}
aria-expanded={expanded.has(t.id)}
>
{expanded.has(t.id) ? "▾" : "▸"}
</button>
)}
<p className="truncate" title={t.description}>{t.description}</p>
</div>
{t.notes ? (
<p className="truncate text-xs text-zinc-500 italic mt-0.5" title={t.notes}>{t.notes}</p>
) : t.order_platform === "uber" && routeSummary(t.order_route) && (
// Five rows all reading "Order - Uber Trip" are
// indistinguishable. Where the trip went is what tells
// them apart, and it was already stored. A note the user
// wrote always wins — this only fills an empty line.
<p
className="truncate text-xs text-zinc-500 italic mt-0.5"
title={t.order_route!.map((r) => `${r.label}${r.time ? ` ${r.time}` : ""}: ${r.address}`).join("\n")}
>
{routeSummary(t.order_route)}
</p>
)}
</td>
<td className="p-2 max-w-[150px]">
@@ -1056,6 +1109,15 @@ function TransactionsContent() {
</button>
</td>
</tr>
{expanded.has(t.id) && (
<tr className="border-b border-zinc-800/50 bg-zinc-900/40">
<td />
<td colSpan={10} className="px-4 py-3">
<OrderDetails transactionId={t.id} currency={t.currency ?? null} bare />
</td>
</tr>
)}
</Fragment>
))
)}
</tbody>
+20 -5
View File
@@ -277,7 +277,7 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800">
{["Person", "Total Owed", "Settled", "Unsettled"].map((h) => (
{["Person", "Outstanding on this trip"].map((h) => (
<th
key={h}
className={`px-5 py-2.5 text-xs text-zinc-500 font-medium ${h === "Person" ? "text-left" : "text-right"}`}
@@ -291,15 +291,30 @@ export default function TripDetailPage({ params }: { params: Promise<{ id: strin
{participant_splits.map((p) => (
<tr key={p.participant_id} className="border-b border-zinc-800/50 last:border-0">
<td className="px-5 py-3 font-medium">{p.name}</td>
<td className="px-5 py-3 text-right tabular-nums font-mono">${Number(p.owed).toFixed(2)}</td>
<td className="px-5 py-3 text-right tabular-nums font-mono text-emerald-500">${Number(p.settled).toFixed(2)}</td>
<td className={`px-5 py-3 text-right tabular-nums font-mono ${p.unsettled > 0 ? "text-amber-400" : "text-zinc-600"}`}>
${Number(p.unsettled).toFixed(2)}
<td className="px-5 py-3 text-right tabular-nums font-mono">
<span className={Math.abs(Number(p.owed)) < 0.005 ? "text-zinc-500" : ""}>
${Number(p.owed).toFixed(2)}
</span>
{p.unconverted_count > 0 && (
<span className="block text-[11px] text-amber-500/80 mt-0.5 font-sans">
approx · {p.unconverted_count} unconverted
</span>
)}
</td>
</tr>
))}
</tbody>
</table>
{/* Settled/unsettled was reported from transaction_splits.settled,
which nothing sets — so every trip showed 100% unsettled forever,
including ones already paid in full. Settlement is tracked across
the whole relationship, not per trip: payments carry no trip
attribution, so a per-trip figure cannot be computed. */}
<p className="px-5 py-2.5 text-xs text-zinc-500 border-t border-zinc-800">
Settlement is tracked across all shared expenses, not per trip
see <Link href="/shared" className="text-zinc-400 hover:text-zinc-200 underline">Shared</Link> for
what is actually owed.
</p>
</div>
)}
@@ -10,6 +10,7 @@ import {
useTrips,
} from "@/lib/hooks";
import { SplitModal } from "./split-modal";
import { OrderDetails } from "./order-details";
import { CATEGORIES, formatCategory } from "@/lib/categories";
import type { TransactionRow, TagRow } from "@/lib/queries";
@@ -314,6 +315,8 @@ export function EditTransactionModal({
)}
</div>
<OrderDetails transactionId={transaction.id} currency={transaction.currency ?? null} />
</div>
{/* Footer */}
+95
View File
@@ -0,0 +1,95 @@
"use client";
import { useOrderReceipt, type OrderReceipt } from "@/lib/hooks";
const PLATFORM_LABEL: Record<string, string> = {
doordash: "DoorDash",
ubereats: "Uber Eats",
uber: "Uber",
};
/**
* The receipt behind a delivery order: what was actually bought, and where it
* went. All of it was already stored at ingest and none of it was reachable —
* the row showed a merchant and a total and nothing else.
*
* Read-only on purpose. This is what a provider sent, not something to edit.
*/
export function OrderDetails({
transactionId,
currency,
bare = false,
}: {
transactionId: number;
currency: string | null;
/** Drop the top border and heading spacing when embedded in a table row. */
bare?: boolean;
}) {
const { data: receipt, isLoading } = useOrderReceipt(transactionId);
if (isLoading || !receipt) return null;
const cur = receipt.currency ?? currency ?? "AUD";
const fmt = (n: number) => (cur === "AUD" ? `$${n.toFixed(2)}` : `${cur} ${n.toFixed(2)}`);
const items: OrderReceipt["line_items"] = receipt.line_items ?? [];
const route: OrderReceipt["route"] = receipt.route ?? [];
return (
<div className={bare ? "" : "border-t border-zinc-800 pt-4"}>
<div className="flex items-baseline justify-between mb-2">
<p className="text-xs text-zinc-500">
Order details
{receipt.platform && (
<span className="ml-1.5 text-zinc-400">{PLATFORM_LABEL[receipt.platform] ?? receipt.platform}</span>
)}
</p>
{receipt.card_last4 && (
<span className="text-xs text-zinc-600">card {receipt.card_last4}</span>
)}
</div>
{items.length > 0 ? (
<ul className="space-y-1.5 mb-3">
{items.map((it, i) => (
<li key={i} className="flex gap-2 text-xs">
<span className="text-zinc-600 tabular-nums shrink-0">{it.qty}×</span>
<span className="text-zinc-300 flex-1 min-w-0">
{it.description}
{it.options && it.options.length > 0 && (
<span className="block text-zinc-600">{it.options.join(" · ")}</span>
)}
</span>
<span className="text-zinc-400 tabular-nums shrink-0">{fmt(Number(it.amount))}</span>
</li>
))}
</ul>
) : (
// Uber itemises groceries but not restaurant orders, and orders taken
// before this was parsed have none either. Say which, rather than
// showing an empty list that reads like a bug.
<p className="text-xs text-zinc-600 italic mb-3">
No itemised list on this receipt
</p>
)}
{route.length > 0 && (
<div className="space-y-1">
{route.map((pt, i) => (
<div key={i} className="flex gap-2 text-xs">
<span className="text-zinc-600 shrink-0 w-24">
{pt.label}
{pt.time && <span className="block text-zinc-700">{pt.time}</span>}
</span>
<span className="text-zinc-400 flex-1">{pt.address}</span>
</div>
))}
</div>
)}
{receipt.order_reference && !receipt.order_reference.startsWith("msg:") && (
<p className="mt-3 text-[11px] text-zinc-700 font-mono break-all">
{receipt.order_reference}
</p>
)}
</div>
);
}
+164
View File
@@ -0,0 +1,164 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useRuleMatches, useApplyRuleToSelection, type RuleMatchChange } from "@/lib/hooks";
import { formatCategory } from "@/lib/categories";
/**
* Preview what a rule would do, then apply it to a chosen subset.
*
* The apply path takes explicit transaction ids rather than re-running the
* conditions, so what you tick is exactly what changes — a rule whose
* conditions are too broad cannot quietly reach further than the preview showed.
*/
function fmt(amount: number, currency = "AUD") {
return new Intl.NumberFormat("en-AU", { style: "currency", currency }).format(amount);
}
function describe(c: RuleMatchChange) {
if (c.field === "category") return `${formatCategory(c.from)}${formatCategory(c.to)}`;
if (c.field === "merchant") return `${c.from ?? "—"}${c.to}`;
if (c.field === "tags") return `+ tags`;
return c.from ? `split ${c.from}${c.to}` : `split → ${c.to}`;
}
export function RulePreviewModal({
ruleId,
ruleName,
onClose,
}: {
ruleId: number;
ruleName: string;
onClose: () => void;
}) {
const { data, isLoading, error } = useRuleMatches(ruleId);
const apply = useApplyRuleToSelection();
const [selected, setSelected] = useState<Set<number>>(new Set());
const [done, setDone] = useState<string | null>(null);
// Default to everything the rule would change — the common case is "yes, all
// of these", with deselection as the exception.
useEffect(() => {
if (data?.transactions) setSelected(new Set(data.transactions.map((t) => t.id)));
}, [data]);
const rows = data?.transactions ?? [];
const allSelected = rows.length > 0 && selected.size === rows.length;
const totalValue = useMemo(
() => rows.filter((r) => selected.has(r.id))
.reduce((a, r) => a + Number(r.amount_aud ?? r.amount), 0),
[rows, selected]
);
return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<div className="bg-zinc-900 border border-zinc-700 rounded-lg w-full max-w-4xl max-h-[85vh] flex flex-col">
<div className="px-5 py-4 border-b border-zinc-800">
<h2 className="text-lg font-medium">Preview: {ruleName}</h2>
{data && (
<p className="text-xs text-zinc-500 mt-1">
{data.total_matched} matched · <span className="text-amber-400">{data.would_change} would change</span>
{data.already_correct > 0 && ` · ${data.already_correct} already correct`}
</p>
)}
{data?.rule.matches_everything && (
<p className="text-xs text-red-400 mt-1">
This rule has no conditions it matches every transaction.
</p>
)}
</div>
<div className="flex-1 overflow-auto px-5 py-3">
{isLoading && <p className="text-sm text-zinc-500">Checking</p>}
{error && <p className="text-sm text-red-400">Failed to load matches.</p>}
{data && rows.length === 0 && (
<p className="text-sm text-zinc-500">
Nothing would change{data.already_correct > 0 && ` — all ${data.already_correct} matching transactions already have these values`}.
</p>
)}
{rows.length > 0 && (
<table className="w-full text-xs">
<thead className="sticky top-0 bg-zinc-900">
<tr className="border-b border-zinc-800 text-zinc-500">
<th className="p-2 w-8">
<input
type="checkbox"
checked={allSelected}
onChange={(e) =>
setSelected(e.target.checked ? new Set(rows.map((r) => r.id)) : new Set())
}
/>
</th>
<th className="text-left p-2">Date</th>
<th className="text-left p-2">Description</th>
<th className="text-right p-2">Amount</th>
<th className="text-left p-2">Would change</th>
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id} className="border-b border-zinc-800/40 hover:bg-zinc-800/30">
<td className="p-2">
<input
type="checkbox"
checked={selected.has(r.id)}
onChange={(e) => {
const next = new Set(selected);
if (e.target.checked) next.add(r.id); else next.delete(r.id);
setSelected(next);
}}
/>
</td>
<td className="p-2 text-zinc-500 whitespace-nowrap">{String(r.transaction_date).slice(0, 10)}</td>
<td className="p-2 text-zinc-300 max-w-[280px] truncate" title={r.description}>
{r.effective_merchant || r.description}
<span className="text-zinc-600 ml-2">{r.bank_name}</span>
</td>
<td className="p-2 text-right tabular-nums text-zinc-400">
{fmt(Number(r.amount_aud ?? r.amount))}
</td>
<td className="p-2 text-amber-400">
{r.changes.map((c, i) => <div key={i}>{describe(c)}</div>)}
</td>
</tr>
))}
</tbody>
</table>
)}
{data?.truncated && (
<p className="text-xs text-zinc-500 mt-2">Showing the first {rows.length} narrow the rule to see the rest.</p>
)}
</div>
<div className="px-5 py-3 border-t border-zinc-800 flex items-center justify-between">
<span className="text-xs text-zinc-500">
{done ?? `${selected.size} selected · ${fmt(totalValue)}`}
</span>
<div className="flex gap-2">
<button onClick={onClose} className="px-3 py-1.5 text-sm text-zinc-400 hover:text-white">
Close
</button>
<button
disabled={selected.size === 0 || apply.isPending}
onClick={() =>
apply.mutate(
{ ruleId, ids: Array.from(selected) },
{
onSuccess: () => {
setDone(`Applied to ${selected.size} transaction${selected.size !== 1 ? "s" : ""}`);
setSelected(new Set());
},
}
)
}
className="px-3 py-1.5 bg-emerald-700 hover:bg-emerald-600 disabled:opacity-40 rounded text-sm"
>
{apply.isPending ? "Applying…" : `Apply to ${selected.size}`}
</button>
</div>
</div>
</div>
</div>
);
}
+86
View File
@@ -0,0 +1,86 @@
"use client";
import { useRuleRunDetail } from "@/lib/hooks";
import { formatCategory } from "@/lib/categories";
/**
* The expanded body of an Apply History row: which transactions the run touched
* and what it changed on each, so Revert is an informed decision rather than a
* guess from a count.
*/
function fmt(n: number) {
return new Intl.NumberFormat("en-AU", { style: "currency", currency: "AUD" }).format(n);
}
function describe(c: { field: string; from: string | null; to: string | null }) {
if (c.field === "category") return `${formatCategory(c.from)}${formatCategory(c.to)}`;
if (c.field === "merchant") return `${c.from ?? "—"}${c.to ?? "—"}`;
if (c.field === "tags") return `+ tag ${c.to}`;
return `split ${c.from ?? "none"}${c.to ?? "none"}`;
}
export function RuleRunDetail({ runId }: { runId: number }) {
const { data, isLoading, error } = useRuleRunDetail(runId);
if (isLoading) return <p className="px-4 py-3 text-xs text-zinc-500">Loading</p>;
if (error) return <p className="px-4 py-3 text-xs text-red-400">Failed to load detail.</p>;
if (!data) return null;
const { transactions, still_changed, run } = data;
return (
<div className="border-t border-zinc-800 bg-zinc-950/60">
<div className="px-4 py-2 text-xs text-zinc-500 flex gap-4 flex-wrap">
<span>{transactions.length} transactions</span>
{run.reverted_at ? (
<span className="text-zinc-500">already reverted values are back to their originals</span>
) : (
<span className="text-amber-400">{still_changed} still showing this run&apos;s changes</span>
)}
{still_changed < transactions.length && !run.reverted_at && (
<span className="text-zinc-600">
{transactions.length - still_changed} since changed by something else reverting restores the pre-run value
</span>
)}
</div>
<div className="max-h-72 overflow-auto">
<table className="w-full text-xs">
<thead className="sticky top-0 bg-zinc-950">
<tr className="text-zinc-600 border-b border-zinc-800">
<th className="text-left px-4 py-1.5">Date</th>
<th className="text-left px-3 py-1.5">Transaction</th>
<th className="text-right px-3 py-1.5">Amount</th>
<th className="text-left px-3 py-1.5">Changed</th>
</tr>
</thead>
<tbody>
{transactions.map((t) => (
<tr key={t.id} className="border-b border-zinc-800/40">
<td className="px-4 py-1.5 text-zinc-500 whitespace-nowrap">
{String(t.transaction_date).slice(0, 10)}
</td>
<td className="px-3 py-1.5 text-zinc-300 max-w-[300px] truncate" title={t.description}>
{t.merchant || t.description}
<span className="text-zinc-600 ml-2">{t.bank_name}</span>
</td>
<td className="px-3 py-1.5 text-right tabular-nums text-zinc-400">
{fmt(Number(t.amount_aud ?? t.amount))}
</td>
<td className="px-3 py-1.5">
{t.changes.length === 0 ? (
<span className="text-zinc-600">no change</span>
) : (
t.changes.map((c, i) => (
<div key={i} className="text-amber-400">{describe(c)}</div>
))
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
+116 -3
View File
@@ -1,7 +1,7 @@
// Shared SQL fragments for analytics queries, so spend/income semantics stay
// identical across routes.
//
// Two rules every analytics query must follow:
// Three rules every analytics query must follow:
//
// 1. Join `statements` with LEFT JOIN and scope on COALESCE(t.owner_id, s.owner_id).
// An INNER JOIN silently drops every manual/CSV transaction (statement_id IS
@@ -11,6 +11,23 @@
// bank account and again as the underlying purchases on the card statement.
// Categorising the money movement as `transfers` and excluding it here is what
// stops the double count. Investments are a balance-sheet move, not spend.
// 3. Apply EXCLUDE_RECONCILED_SOURCE to every row-level query. The transaction
// queries have always done this (`queries.ts`); analytics never did, which is
// the other half of the same double count.
/**
* `YYYY-MM-DD` for a Date, read in local time.
*
* `toISOString().slice(0, 10)` is the obvious thing and it is wrong here: these
* are calendar boundaries built with `new Date(y, m, 1)`, which is local
* midnight. In any timezone east of UTC that converts to the *previous* day, so
* every window silently started and ended a day early — visible once the fees
* endpoint began reporting the range it had used ("2026-04-30" for a window
* meant to open on 1 May).
*/
export function toDateStr(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
/** Owner scoping that works for both statement-linked and manual transactions. */
export const OWNER_SCOPE = `COALESCE(t.owner_id, s.owner_id)`;
@@ -18,6 +35,56 @@ export const OWNER_SCOPE = `COALESCE(t.owner_id, s.owner_id)`;
/** Join clause to pair with OWNER_SCOPE. */
export const STATEMENTS_JOIN = `LEFT JOIN statements s ON s.id = t.statement_id`;
/**
* Drops the manual/CSV row that a statement line has superseded.
*
* Reconciliation keeps both rows: the manual one the user entered and the
* statement line it turned out to be. Only the statement line should count, or
* the same purchase is spent twice. `queries.ts` has always applied this; the
* analytics routes did not, so every reconciled row was double-counted in the
* category totals, movers, Pareto, and merchant rankings.
*
* Scoped to `statement_id IS NULL` deliberately: the *source* row is the manual
* one. A statement line pointing at something else is the survivor, not the
* duplicate.
*
* Order-receipt rows (`payment_method = 'credits'`) are unaffected — they are
* inserted with `reconciled_with_id` NULL and are held out of the reconcile
* queue by `needsCardMatch()`, so nothing ever sets it. If one is reconciled by
* hand against a card line, this is what stops it double-counting.
*/
export const EXCLUDE_RECONCILED_SOURCE = `NOT (t.statement_id IS NULL AND t.reconciled_with_id IS NOT NULL)`;
/**
* The currency `t.amount` is actually denominated in.
*
* Two different conventions meet here and the COALESCE order is what keeps them
* apart:
*
* - A statement row is denominated in its statement's currency. If that row is
* an overseas purchase on an AUD statement, `amount` is still AUD and
* `foreign_currency_code` merely records what was originally charged — so
* `s.currency` must win.
* - An order-receipt row has no statement. There, `amount` IS the native
* figure and `foreign_currency_code` names it (order-ingestion.ts leaves
* `amount_aud` NULL rather than asserting an FX rate it does not have).
*
* Reading `s.currency` alone labels every foreign order row as AUD.
* `s` must be the statements alias in scope.
*/
export const NATIVE_CURRENCY = `COALESCE(s.currency, t.foreign_currency_code, 'AUD')`;
/**
* True when a row's AUD value is unknown: it is denominated in something other
* than AUD and carries no converted figure.
*
* Every settlement total uses `COALESCE(t.amount_aud, t.amount)`, which for such
* a row silently nets a foreign figure against AUD ones. Rather than drop the
* row (which changes a balance with no trace) or convert it (with no rate),
* count these and let the UI say the balance is incomplete.
*/
export const AMOUNT_UNCONVERTED = `(t.amount_aud IS NULL AND ${NATIVE_CURRENCY} <> 'AUD')`;
/** Transaction types that represent money going out. */
export const SPEND_TYPES = `('debit', 'fee', 'interest')`;
@@ -71,12 +138,19 @@ export const myShare = (participant = "$1") => `COALESCE(
export const mySplitOf = (base: string, participant = "$1") =>
`((${base}) * ${myShare(participant)} / 100)`;
export const NON_BUDGET_TAGS = ['family'];
export const EXCLUDE_NON_BUDGET_TAGS = `NOT EXISTS (
SELECT 1 FROM transaction_tags tt JOIN tags tg ON tg.id = tt.tag_id
WHERE tt.transaction_id = t.id AND tg.name = ANY(ARRAY['family'])
)`;
/**
* Predicate excluding categories that are not spend.
* Predicate excluding categories and tags that are not spend.
* The COALESCE matters: a bare `category NOT IN (...)` evaluates to NULL for
* uncategorised rows, which silently drops them from spend totals.
*/
export const EXCLUDE_NON_SPEND = `${EFFECTIVE_CATEGORY} NOT IN ('transfers', 'investment', 'income')`;
export const EXCLUDE_NON_SPEND = `${EFFECTIVE_CATEGORY} NOT IN ('transfers', 'investment', 'income') AND ${EXCLUDE_NON_BUDGET_TAGS}`;
/**
* Rows that count towards NET spend — outgoings plus the refunds that cancel
@@ -97,3 +171,42 @@ export const SPEND_SIGNED = `CASE
WHEN t.transaction_type IN ('refund', 'credit') THEN -(${SPEND_BASE})
ELSE (${SPEND_BASE})
END`;
/**
* A split that still counts towards what someone owes.
*
* This is the line between the two questions the same table answers, and
* conflating them is what made three different "owed" figures disagree:
*
* - **Owed** — what is still outstanding between two people. Must apply this.
* - **Spend** — what a purchase cost me. Must NOT apply this.
*
* A settled split is still a real expense: my half of a 2025 grocery shop is my
* spend whether or not the other half was ever repaid. Filtering settled rows
* out of `myShare`/`mySplitOf` would re-inflate exactly the figures that
* importing settled history exists to correct.
*
* `settled` marks obligations discharged OUTSIDE this app — imported
* SplitMyExpenses history, whose repayments happened on a platform we no longer
* run and which therefore has no `split_payments` row here.
*
* A live obligation is NOT settled by flipping this. It is settled by recording
* the payment, and the balance nets to zero on its own. Doing both would
* subtract the settlement twice — the splits leave the sum AND the payment is
* deducted — driving the balance negative by the amount repaid. So there is
* deliberately no "mark settled" action anywhere: settling up is recording a
* payment, and this column is only ever written by the historical import.
*
* Assumes the `transaction_splits` alias is `ts`.
*/
export const ACTIVE_OBLIGATION = `ts.settled = false`;
/**
* The tab a split belongs to: its transaction's trip, else the household.
*
* Membership already lives on `transaction_overrides.trip_id`, so this is a
* read of existing data rather than a new grouping key. Assumes an
* `transaction_overrides` alias `o` is joined (LEFT — a transaction with no
* override row has no trip, which is the common case and means household).
*/
export const SPLIT_SCOPE = `o.trip_id`;
+5
View File
@@ -15,3 +15,8 @@ if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
export async function queryRaw<T>(sql: string, params: unknown[] = []): Promise<T[]> {
return prisma.$queryRawUnsafe<T[]>(sql, ...params);
}
export async function queryRow<T>(sql: string, params: unknown[] = []): Promise<T | null> {
const rows = await prisma.$queryRawUnsafe<T[]>(sql, ...params);
return rows.length > 0 ? rows[0] : null;
}
+154 -5
View File
@@ -1,7 +1,7 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import type { TransactionRow, StatementRow, TagRow, TripRow, TripAnalytics } from "./queries";
import type { TransactionRow, StatementRow, TagRow, TripRow, TripAnalytics, ParticipantBalance } from "./queries";
export type { TripRow, TripAnalytics };
import type { CurrentUser } from "./auth";
@@ -216,7 +216,7 @@ export function useParticipants() {
}
export function useParticipantBalances(tagIds?: string[]) {
return useQuery<{ id: number; name: string; total_owed: number; unsettled_count: number }[]>({
return useQuery<ParticipantBalance[]>({
queryKey: ["participant-balances", tagIds],
queryFn: async () => {
const params = tagIds?.length ? `?tag_ids=${tagIds.join(",")}` : "";
@@ -250,6 +250,33 @@ export function useTransactionSplits(transactionId: number) {
});
}
export interface OrderReceipt {
platform: "doordash" | "ubereats" | "uber" | null;
order_reference: string | null;
line_items: { qty: number; description: string; amount: number; options?: string[] }[];
route: { label: string; time: string | null; address: string }[];
subtotal: string | null;
amount: string | null;
currency: string | null;
card_last4: string | null;
flags: string[];
source_email_subject: string | null;
transaction_date: string | null;
}
/** The receipt behind a transaction, or null when it did not come from one. */
export function useOrderReceipt(transactionId: number) {
return useQuery<OrderReceipt | null>({
queryKey: ["order-receipt", transactionId],
queryFn: async () => {
const res = await fetch(`/api/transactions/${transactionId}/order`);
if (!res.ok) return null;
return res.json();
},
staleTime: Infinity, // a receipt never changes
});
}
export function useSetSplits() {
const qc = useQueryClient();
return useMutation({
@@ -530,6 +557,66 @@ export function useDeleteRule() {
});
}
export interface RuleMatchChange {
field: "category" | "merchant" | "tags" | "split";
from: string | null;
to: string;
}
export interface RuleMatchRow {
id: number;
transaction_date: string;
description: string;
amount: number;
amount_aud: number | null;
currency: string;
bank_name: string;
effective_merchant: string;
effective_category: string;
changes: RuleMatchChange[];
}
export interface RuleMatches {
rule: { id: number; name: string; matches_everything: boolean };
total_matched: number;
would_change: number;
already_correct: number;
transactions: RuleMatchRow[];
truncated: boolean;
}
/** Dry run — what a rule would change. Writes nothing. */
export function useRuleMatches(ruleId: number | null) {
return useQuery<RuleMatches>({
queryKey: ["rule-matches", ruleId],
enabled: ruleId != null,
queryFn: async () => {
const res = await fetch(`/api/rules/${ruleId}/matches`);
if (!res.ok) throw new Error("Failed to load rule matches");
return res.json();
},
});
}
/** Apply a rule to a hand-picked set of transactions (conditions ignored). */
export function useApplyRuleToSelection() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ ruleId, ids }: { ruleId: number; ids: number[] }) => {
const res = await fetch("/api/transactions/bulk", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "apply_rule", rule_id: ruleId, ids }),
});
if (!res.ok) throw new Error((await res.json()).error || "Failed to apply rule");
return res.json();
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["transactions"] });
qc.invalidateQueries({ queryKey: ["rule-matches"] });
qc.invalidateQueries({ queryKey: ["rules"] });
},
});
}
export function useApplyRules() {
const qc = useQueryClient();
return useMutation({
@@ -557,6 +644,38 @@ export interface RuleRun {
matched: number;
transactions_affected: number;
reverted_at: string | null;
// Provenance (migration 0017). NULL on runs recorded before it existed.
rule_id: number | null;
rule_name: string | null;
source: "all" | "rule" | "selection" | null;
}
export interface RuleRunDetail {
run: RuleRun & { transactions_affected: number };
transactions: {
id: number;
transaction_date: string;
description: string;
amount: number;
amount_aud: number | null;
bank_name: string;
merchant: string | null;
changes: { field: string; from: string | null; to: string | null }[];
}[];
still_changed: number;
}
/** What a run actually did — loaded on demand when a row is expanded. */
export function useRuleRunDetail(runId: number | null) {
return useQuery<RuleRunDetail>({
queryKey: ["rule-run-detail", runId],
enabled: runId != null,
queryFn: async () => {
const res = await fetch(`/api/rules/runs/${runId}`);
if (!res.ok) throw new Error("Failed to load run detail");
return res.json();
},
});
}
export function useRuleRuns() {
@@ -656,6 +775,27 @@ export function useMonthlyAnalytics(months?: number) {
});
}
/**
* Sparse by day-of-month; a missing day means zero.
* daily → { "2026-07": { 3: 42.10 } }
* byCategory → { "2026-07": { dining: { 3: 42.10 } } }
*/
export interface DailySpend {
daily: Record<string, Record<number, number>>;
byCategory: Record<string, Record<string, Record<number, number>>>;
}
export function useDailySpend(months?: number) {
const m = months || 12;
return useQuery<DailySpend>({
queryKey: ["analytics", "daily", m],
queryFn: async () => {
const res = await fetch(`/api/analytics/daily?months=${m}`);
return res.json();
},
});
}
export interface SubscriptionRow {
merchant: string;
category: string;
@@ -696,16 +836,25 @@ export interface FeeTxnRow {
bank_name: string;
}
export function useFees() {
export interface FeePeriod {
months: number;
from: string | null;
to: string | null;
all_time: boolean;
}
/** `months = 0` means all time. */
export function useFees(months = 12) {
return useQuery<{
by_bank: FeeBankRow[];
transactions: FeeTxnRow[];
total_fees: number;
total_interest: number;
period: FeePeriod;
}>({
queryKey: ["analytics", "fees"],
queryKey: ["analytics", "fees", months],
queryFn: async () => {
const res = await fetch("/api/analytics/fees");
const res = await fetch(`/api/analytics/fees?months=${months}`);
return res.json();
},
});
+434
View File
@@ -0,0 +1,434 @@
import { queryRaw, queryRow } from "./db";
import type { ParsedOrder } from "./order-parse";
export * from "./order-parse";
export const CUTOVER_DATE = "2026-01-09";
/**
* Owner for ingested orders. Analytics scope on COALESCE(t.owner_id,
* s.owner_id); an ingested order has no statement, so leaving owner_id NULL
* hides it from every view in the app while it sits in the table.
*/
export const DEFAULT_OWNER_ID = 1;
/** Human labels for the platform a receipt came from. */
export const PLATFORM_LABEL: Record<ParsedOrder["platform"], string> = {
doordash: "DoorDash",
ubereats: "Uber Eats",
uber: "Uber",
};
/**
* Transaction description.
*
* The merchant alone ("Order - Burger Corner") does not say where to go and
* look for the detail, and there are restaurants on both platforms. The
* platform is the one thing the parser always knows and used to discard.
*/
export function orderDescription(platform: ParsedOrder["platform"], merchant: string): string {
const label = PLATFORM_LABEL[platform];
// A trip's merchant is literally "Uber Trip", so the suffix would restate it
// — "Order - Uber Trip (Uber)". The addresses that actually identify a trip
// are in the Order details panel, not squeezed into the description.
if (merchant.toLowerCase().includes(label.toLowerCase())) return `Order - ${merchant}`;
return `Order - ${merchant} (${label})`;
}
export interface IngestResult {
transactionId: number | null;
metadataId: number | null;
flags: string[];
skipped?: string;
}
/**
* Resolves how much of a card-settled order actually hit the card.
*
* DoorDash writes "MasterCard Ending in 8032 and/or credits" without ever
* stating the split. The split is not in the mail — but it IS in the ledger:
* the card leg arrives on the statement for that card. Reconciling against it
* beats guessing (user, 2026-07-26).
*
* Subway receipt $29.08, statement 8032 charge $13.06 -> $16.02 was credits
* lahori receipt $50.85, statement 8032 charge $50.85 -> fully card
*
* Returns the card amount if a statement line can be matched, else null. A null
* means "unknown", and the caller must not invent a credits figure from it.
*/
export async function reconcileCardLeg(
order: ParsedOrder,
windowDays = 4
): Promise<{ cardAmount: number | null; matchedTransactionId: number | null }> {
const last4 = order.payment.card_last4;
if (!last4) return { cardAmount: null, matchedTransactionId: null };
const day = order.order_datetime.slice(0, 10);
const row = await queryRow<{ id: number; amount: string }>(
`SELECT t.id, t.amount::text
FROM transactions t
JOIN statements s ON s.id = t.statement_id
WHERE replace(s.account_number, '-', '') LIKE $1
AND t.transaction_date BETWEEN $2::date - $4::int AND $2::date + $4::int
AND (t.description ILIKE '%doordash%' OR t.description ILIKE '%uber%')
AND t.amount <= $3::numeric + 0.02
-- A statement line settles exactly one order. Without this, two orders
-- on the same card inside the window both match the same charge and
-- each books its own credits remainder — double-counting spend. At
-- 10-15 orders a month on one card that is not a corner case.
AND NOT EXISTS (
SELECT 1 FROM expense_metadata em
WHERE em.matched_transaction_id = t.id
AND ($5::text IS NULL OR em.order_reference IS DISTINCT FROM $5::text)
)
ORDER BY abs(t.amount - $3::numeric), abs(t.transaction_date - $2::date)
LIMIT 1`,
[`%${last4}`, day, order.totals.total_charged, windowDays, order.order_reference || null]
);
if (!row) return { cardAmount: null, matchedTransactionId: null };
return { cardAmount: Number(row.amount), matchedTransactionId: row.id };
}
async function ensureTag(name: string): Promise<number> {
const existing = await queryRow<{ id: number }>(`SELECT id FROM tags WHERE name = $1`, [name]);
if (existing) return existing.id;
const created = await queryRow<{ id: number }>(
`INSERT INTO tags (name, color) VALUES ($1, '#ef4444')
ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name RETURNING id`,
[name]
);
return created!.id;
}
/**
* Records one parsed order.
*
* Invariants enforced here:
* I1 no credits-funded order before the cutover — before it, splits lived in
* another system and re-importing double-counts.
* I5 a card-settled order creates NO transaction. The statement line is the
* transaction; creating another would double-count.
* I6 a credits-funded order creates a transaction for the credits portion at
* face value.
* I7 idempotent on (source, order_reference).
* I11 [Family] orders are imported and tagged, never silently dropped.
*/
export async function processOrderIngestion(
order: ParsedOrder,
options: {
messageId?: string;
backfillMode?: boolean;
ownerId?: number;
subject?: string;
sender?: string;
} = {}
): Promise<IngestResult> {
const flags = [...order.flags];
const day = order.order_datetime.slice(0, 10);
// ---- I7: idempotency ----------------------------------------------------
const existing = await queryRow<{ id: number; transaction_id: number | null }>(
`SELECT id, transaction_id FROM expense_metadata
WHERE source = 'email' AND order_reference = $1`,
[order.order_reference]
);
if (existing) {
return {
transactionId: existing.transaction_id,
metadataId: existing.id,
flags,
skipped: "already_ingested",
};
}
// ---- resolve the credits portion ----------------------------------------
let creditsAmount: number | null = null;
if (order.payment.ambiguous) {
const { cardAmount: reconciled } = await reconcileCardLeg(order);
if (reconciled === null) {
// No statement line yet. For a live order this is the NORMAL case, not an
// error — card statements arrive monthly, so an order ingested today has
// no card leg in the ledger for weeks (user, 2026-07-26).
//
// Deciding now would mean guessing. Instead the order is recorded as
// provenance with no transaction, and left pending: reconcilePendingOrders()
// resolves it once the statement lands. Backfill hits the same path and
// resolves immediately, because those statements are already imported.
flags.push("awaiting_card_statement");
} else {
const remainder = Number((order.totals.total_charged - reconciled).toFixed(2));
if (remainder > 0.02) {
creditsAmount = remainder;
flags.push(`split_reconciled_card_${reconciled.toFixed(2)}`);
}
}
} else {
creditsAmount = order.payment.credits_amount;
}
// ---- I1: cutover --------------------------------------------------------
if (creditsAmount !== null && day < CUTOVER_DATE) {
return { transactionId: null, metadataId: null, flags, skipped: "pre_cutover" };
}
// ---- I6 / I5 ------------------------------------------------------------
let transactionId: number | null = null;
if (creditsAmount !== null && creditsAmount > 0) {
const isAud = order.currency === "AUD";
if (!isAud) flags.push(`foreign_currency_${order.currency}`);
const txn = await queryRow<{ id: number }>(
`INSERT INTO transactions (
transaction_date, description, amount, amount_aud, category, payment_method,
merchant_name, merchant_normalized, transaction_type,
foreign_currency_amount, foreign_currency_code, owner_id
) VALUES ($1,$2,$3,$4,$5,'credits',$6,$6,'debit',$7,$8,$9)
RETURNING id`,
[
day,
orderDescription(order.platform, order.merchant_name),
creditsAmount,
// No FX rate is available at ingest, so amount_aud is left NULL for
// foreign orders rather than asserting a conversion we cannot make.
isAud ? creditsAmount : null,
resolveCategory(order),
order.merchant_name,
isAud ? null : creditsAmount,
isAud ? null : order.currency,
// Owner scoping is COALESCE(t.owner_id, s.owner_id). These rows carry
// no statement, so a NULL owner_id makes them invisible in every view
// in the app — present in the table, absent from the UI. The backfill
// inserted 85 rows nobody could see.
options.ownerId ?? DEFAULT_OWNER_ID,
]
);
transactionId = txn!.id;
// I11: tag, don't drop. The tag is what removes it from budgets.
if (order.is_family) {
const tagId = await ensureTag("family");
await queryRaw(
`INSERT INTO transaction_tags (transaction_id, tag_id) VALUES ($1,$2)
ON CONFLICT DO NOTHING`,
[transactionId, tagId]
);
}
}
// ---- provenance ---------------------------------------------------------
const pending = flags.includes("awaiting_card_statement");
const meta = await queryRow<{ id: number }>(
`INSERT INTO expense_metadata (
transaction_id, source, source_message_id, order_reference, line_items,
subtotal, amount, merchant_normalized, transaction_date,
card_last4, currency, flags, reconciled_at,
platform, source_email_subject, source_email_from, route
) VALUES ($1,'email',$2,$3,$4::jsonb,$5,$6,$7,$8,$9,$10,$11::jsonb,$12,$13,$14,$15,$16::jsonb)
RETURNING id`,
[
transactionId,
options.messageId || null,
order.order_reference,
JSON.stringify(order.line_items),
order.totals.subtotal,
order.totals.total_charged,
order.merchant_name,
day,
order.payment.card_last4,
order.currency,
JSON.stringify(flags),
pending ? null : new Date().toISOString(),
order.platform,
options.subject ?? null,
options.sender ?? null,
JSON.stringify(order.route ?? []),
]
);
return { transactionId, metadataId: meta!.id, flags };
}
/**
* Second pass over orders parked awaiting a card statement.
*
* Run after each statement import. For every pending order it retries the
* reconciliation; once the card leg appears, any remainder above it was paid in
* credits and becomes a transaction at that point. Orders whose card leg covers
* the whole total resolve to "fully card" and correctly create nothing.
*
* Idempotent: a resolved row gets reconciled_at set and is never revisited.
*/
export async function reconcilePendingOrders(): Promise<{
examined: number;
resolved: number;
created: number;
}> {
const pendingRows = await queryRaw<{
id: number;
order_reference: string;
amount: string;
transaction_date: string;
merchant_normalized: string;
card_last4: string | null;
currency: string | null;
platform: ParsedOrder["platform"] | null;
}>(
`SELECT id, order_reference, amount::text, transaction_date::text,
merchant_normalized, card_last4, currency, platform
FROM expense_metadata
WHERE transaction_id IS NULL
AND reconciled_at IS NULL
AND card_last4 IS NOT NULL`
);
let resolved = 0;
let created = 0;
for (const row of pendingRows) {
const total = Number(row.amount);
const probe: ParsedOrder = {
order_reference: row.order_reference,
platform: row.platform ?? "doordash",
merchant_name: row.merchant_normalized,
order_datetime: `${row.transaction_date}T00:00:00Z`,
currency: row.currency || "AUD",
payment: { credits_amount: null, card_amount: null, card_last4: row.card_last4, ambiguous: true },
totals: {
subtotal: null, taxes: null, delivery_fee: null,
service_fee: null, tip: null, discounts: null, total_charged: total,
},
line_items: [],
route: [],
is_family: false,
flags: [],
};
const { cardAmount, matchedTransactionId } = await reconcileCardLeg(probe);
if (cardAmount === null) continue; // statement still hasn't arrived
const remainder = Number((total - cardAmount).toFixed(2));
let txnId: number | null = null;
if (remainder > 0.02 && row.transaction_date >= CUTOVER_DATE) {
// Category from the merchant, never hardcoded. Hardcoding 'dining' here
// silently misfiled every grocery order that arrived with an unstated
// split — reintroducing, through the deferred path, exactly the
// misfiling resolveCategory() exists to prevent.
const category = resolveCategory({
merchant_name: row.merchant_normalized,
platform: "doordash",
} as ParsedOrder);
const txn = await queryRow<{ id: number }>(
`INSERT INTO transactions (
transaction_date, description, amount, amount_aud, category,
payment_method, merchant_name, merchant_normalized, transaction_type, owner_id
) VALUES ($1,$2,$3,$3,$5,'credits',$4,$4,'debit',$6)
RETURNING id`,
[row.transaction_date, orderDescription(row.platform ?? "doordash", row.merchant_normalized), remainder, row.merchant_normalized, category, DEFAULT_OWNER_ID]
);
txnId = txn!.id;
created++;
}
await queryRaw(
`UPDATE expense_metadata
SET transaction_id = COALESCE($2, transaction_id),
matched_transaction_id = $4,
reconciled_at = NOW(),
flags = flags || $3::jsonb
WHERE id = $1`,
[row.id, txnId, JSON.stringify([`card_leg_${cardAmount.toFixed(2)}`]), matchedTransactionId]
);
resolved++;
}
return { examined: pendingRows.length, resolved, created };
}
/**
* Category from the merchant.
*
* The spec's Correction 1 said never default to `dining`, because ~19% of the
* corpus is groceries and a blanket dining default misfiles a fifth of orders.
* That reasoning is right about groceries and wrong about the remedy: the
* earlier implementation sent everything unrecognised to `other`, and since it
* knew six merchants, that meant Carl's Jr, Taco Bell, Chilli India, Oporto,
* Hungry Jacks, Schnitz, Subway, Souvlaki GR and the rest all landed in
* `other` — worse than the problem it avoided.
*
* Deliberate reversal: grocery merchants are a closed, enumerable set;
* restaurants are an open one. So match groceries explicitly and let the
* residual be `dining`, which is what a delivery order otherwise is. A
* misfiled grocer is one rule away from fixed; a corpus in `other` is not.
*/
export function resolveCategory(order: ParsedOrder): string {
if (order.platform === "uber") return "transport";
const m = order.merchant_name.toLowerCase();
if (/woolworths|aldi|coles|glomark|keells|cargills|iga|costco/.test(m)) return "groceries";
return "dining";
}
/**
* Applies a refund / total-adjustment notice to an already-ingested order.
*
* The order it amends is matched by the UUID Uber reuses across the original
* receipt and the amendment. The recorded transaction is reduced to the new
* total rather than a compensating negative row being added: the order is one
* event and its cost changed, and a second row would misreport both the meal
* count and the merchant's spend.
*
* If the original has not been ingested (amendment arrived first, or the
* receipt predates the cutover), nothing is invented — the amendment is
* recorded as unmatched for a later pass.
*/
export async function applyOrderAmendment(a: {
order_reference: string | null;
new_total: number;
refund_amount: number | null;
previous_total: number | null;
order_datetime: string;
messageId: string;
}): Promise<{ matched: boolean; transactionId: number | null; adjusted: number | null }> {
if (!a.order_reference) return { matched: false, transactionId: null, adjusted: null };
const meta = await queryRow<{ id: number; transaction_id: number | null; amount: string }>(
`SELECT id, transaction_id, amount::text FROM expense_metadata
WHERE source = 'email' AND order_reference = $1`,
[a.order_reference]
);
if (!meta) return { matched: false, transactionId: null, adjusted: null };
await queryRaw(
`UPDATE expense_metadata
SET amount = $2,
flags = flags || $3::jsonb
WHERE id = $1`,
[
meta.id,
a.new_total,
JSON.stringify([
`amended_from_${Number(meta.amount).toFixed(2)}`,
a.refund_amount !== null ? `refund_${a.refund_amount.toFixed(2)}` : "amended",
]),
]
);
if (meta.transaction_id === null) {
// Card-settled or still pending: no transaction of ours to reduce. The
// refund will show on the statement in its own right.
return { matched: true, transactionId: null, adjusted: null };
}
await queryRaw(
`UPDATE transactions
SET amount = $2,
amount_aud = CASE WHEN foreign_currency_code IS NULL THEN $2 ELSE amount_aud END
WHERE id = $1`,
[meta.transaction_id, a.new_total]
);
return { matched: true, transactionId: meta.transaction_id, adjusted: a.new_total };
}
+672
View File
@@ -0,0 +1,672 @@
/**
* Order receipt parsing, written against REAL captured emails.
*
* History: the first version of this parser was written against synthetic
* fixtures that were shaped to match the code rather than the mail. It invented
* a <table><tr><td>Label</td><td>$X</td></tr> layout that DoorDash does not
* send, derived order_reference from Math.random(), and read the order date
* from a `Date: YYYY-MM-DD` string that appears in no real message. All of it
* passed its tests. This version is built from 36 real DoorDash and 29 real
* Uber Eats receipts; see docs in memory-bank/order-ingestion-implementation.md.
*
* Governing rule: parse or throw. Never fabricate a value that the mail did not
* state (I9). A caller that gets a ParsedOrder back can trust every field in it.
*/
export interface LineItem {
qty: number;
description: string;
amount: number;
options?: string[];
}
/**
* A stop on the receipt's map: pick-up, delivery, or (for a trip) the ride's
* start and end. Uber prints these for every order under `Order details`.
*/
export interface RoutePoint {
/** "Pick-up" / "Delivery" — whatever the receipt itself calls it. */
label: string;
/** Local time as printed, e.g. "1:20 pm". No date; the receipt gives none. */
time: string | null;
address: string;
}
export interface PaymentBreakdown {
credits_amount: number | null;
card_amount: number | null;
card_last4: string | null;
/** true when the mail states a payment method it does not fully disaggregate. */
ambiguous: boolean;
}
export interface OrderTotals {
subtotal: number | null;
taxes: number | null;
delivery_fee: number | null;
service_fee: number | null;
tip: number | null;
discounts: number | null;
total_charged: number;
}
export interface ParsedOrder {
order_reference: string;
platform: "doordash" | "ubereats" | "uber";
merchant_name: string;
order_datetime: string;
currency: string;
payment: PaymentBreakdown;
totals: OrderTotals;
line_items: LineItem[];
/** Uber only. Empty for DoorDash, whose receipts carry no addresses. */
route: RoutePoint[];
is_family: boolean;
flags: string[];
}
/** Everything the parser needs that lives on the message, not in the body. */
export interface MessageMeta {
/** Provider message id. The only stable per-mail identity DoorDash offers. */
messageId: string;
subject: string;
/** ISO 8601. The authoritative order date — the body carries no reliable one. */
receivedAt: string;
sender?: string;
}
/**
* The message is not a receipt at all — a promotion, a delivery update, an
* adjustment or refund notice. Expected traffic. Skipping it is correct and
* must not raise an alert, or the channel becomes noise and gets ignored.
*/
export class NotAReceiptError extends Error {
constructor(message: string, readonly messageId?: string) {
super(message);
this.name = "NotAReceiptError";
}
}
/**
* The message IS a receipt and could not be parsed. This is the failure that
* matters: a provider template change breaks every order at once, silently, and
* the only symptom is spend quietly ceasing to appear. It must alert loudly.
*/
export class OrderParseError extends Error {
constructor(message: string, readonly messageId?: string) {
super(message);
this.name = "OrderParseError";
}
}
const stripTags = (s: string) => s.replace(/<[^>]+>/g, " ");
const decodeEntities = (s: string) =>
s
.replace(/&nbsp;/gi, " ")
.replace(/&amp;/gi, "&")
.replace(/&#39;|&apos;/gi, "'")
.replace(/&quot;/gi, '"')
.replace(/&#36;/g, "$")
.replace(/&hellip;/gi, "…");
const collapse = (s: string) => s.replace(/\s+/g, " ").trim();
const money = (raw: string): number => Math.abs(parseFloat(raw.replace(/[$,]/g, "")));
/**
* DoorDash renders each total as its own nested table:
* <td align="left" ...>Subtotal</td> <!----> <td align="right" ...>$22.10</td>
* Reading the pair structurally is what stops the label/value rebinding that
* flattening causes (I9) — flattened, "Discounts -$24.09 Total Charged $14.64"
* invites a regex to bind the wrong number to the wrong label.
*/
function tdPairValue(html: string, label: string): number | null {
const re = new RegExp(
`<td[^>]*>\\s*${label}\\s*</td>\\s*<td[^>]*>\\s*(-?\\s*\\$?[\\d,]+\\.\\d{2})\\s*</td>`,
"i"
);
const m = html.match(re);
return m ? money(m[1]) : null;
}
/**
* Uber itemises only *grocery* orders — a restaurant receipt states a total and
* nothing else, which is why 67 of the 101 backfilled orders have no items.
* When it does itemise, the markup is far better than DoorDash's: every cell
* carries a `data-testid` naming its role and the item's own uuid, so quantity,
* title and amount can be bound to each other by id rather than by position.
*/
function parseUberLineItems(html: string): LineItem[] {
const items: LineItem[] = [];
const titleRe =
/data-testid="shoppingCart_item_title_([0-9a-f-]+)"[^>]*>([\s\S]*?)<\/td>/gi;
for (const m of html.matchAll(titleRe)) {
const [, id, rawTitle] = m;
const description = collapse(decodeEntities(stripTags(rawTitle)));
if (!description) continue;
const qtyM = html.match(
new RegExp(`data-testid="shoppingCart_item_quantity_${id}"[^>]*>\\s*(\\d+)\\s*<`, "i")
);
const amtM = html.match(
new RegExp(
`data-testid="shoppingCart_item_amount_${id}"[^>]*>([\\s\\S]*?)<\\/td>`,
"i"
)
);
const amtText = amtM ? collapse(decodeEntities(stripTags(amtM[1]))) : "";
const amtNum = amtText.match(/(-?[\d,]+\.\d{2})/);
items.push({
qty: qtyM ? parseInt(qtyM[1], 10) : 1,
description,
// A sold-out item prints 0.00 and is genuinely part of the order — it
// explains a total that does not match what was asked for. Keep it.
amount: amtNum ? money(amtNum[1]) : 0,
});
}
return items;
}
/**
* Uber's `Order details` block, anchored on `data-testid="address_point_N_*"`.
*
* The template repeats the whole block twice (once hidden for narrow screens),
* so the same stop appears more than once and has to be de-duplicated. This is
* the same markup a *trip* receipt uses for its start and destination — rides
* are not ingested today, but the reader will not need changing when they are.
*/
function parseUberRoute(html: string): RoutePoint[] {
const seen = new Set<string>();
const points: RoutePoint[] = [];
const labelRe = /data-testid="address_point_(\d+)_time"[^>]*>([\s\S]*?)<\/td>/gi;
for (const m of html.matchAll(labelRe)) {
const [, idx, rawLabel] = m;
const addrM = html.match(
new RegExp(`data-testid="address_point_${idx}_address"[^>]*>([\\s\\S]*?)<\\/td>`, "i")
);
if (!addrM) continue;
const address = collapse(decodeEntities(stripTags(addrM[1])));
// Delivery receipts share one cell between time and label — "1:20 pm -
// Pick-up". Trip receipts print the time alone, with no label at all, so
// the naive split put the time in `label` and left `time` null. Position
// carries the meaning there: first stop is where the ride began.
const combined = collapse(decodeEntities(stripTags(rawLabel)));
const split = combined.match(/^(.*?)\s+-\s+(.*)$/);
let time: string | null;
let label: string;
if (split) {
time = split[1];
label = split[2];
} else if (/^\d{1,2}:\d{2}\s*(am|pm)?$/i.test(combined)) {
time = combined;
label = ""; // filled in positionally below — the receipt gives none
} else {
time = null;
label = combined;
}
const key = `${label}|${time}|${address}`;
if (!address || seen.has(key)) continue;
seen.add(key);
points.push({ label, time, address });
}
// A trip receipt labels neither end. Position is the only thing that says
// which is which, and for a two-stop trip it says it unambiguously. Only
// filled where the receipt itself was silent, so a future template that does
// label its stops keeps its own wording.
if (points.length === 2 && points.every((p) => !p.label)) {
points[0].label = "Pick-up";
points[1].label = "Drop-off";
}
return points;
}
function parseDoorDashLineItems(html: string): LineItem[] {
// <td width="10%">1x</td><td width="75%"><b>Name</b> (Cat)<br><font>• Opt</font>…</td><td width="15%">$22.10</td>
const re =
/<td[^>]*width="10%"[^>]*>\s*(\d+)x\s*<\/td>\s*<td[^>]*width="75%"[^>]*>([\s\S]*?)<\/td>\s*<td[^>]*width="15%"[^>]*>\s*\$?([\d,]+\.\d{2})\s*<\/td>/gi;
const items: LineItem[] = [];
for (const m of html.matchAll(re)) {
const parts = decodeEntities(stripTags(m[2]))
.split("•")
.map((p) => collapse(p))
.filter(Boolean);
if (!parts.length) continue;
items.push({
qty: parseInt(m[1], 10),
description: parts[0],
amount: money(m[3]),
options: parts.slice(1),
});
}
return items;
}
/** Reads "<Label> [CUR] 1,234.56" out of already-flattened Uber receipt text. */
function extractLabelled(text: string, label: string): number | null {
const m = text.match(
new RegExp(`${label}\\s*(?:[A-Z]{3})?\\s*\\$?\\s*([\\d,]+\\.\\d{2})`, "i")
);
return m ? money(m[1]) : null;
}
function detectPlatform(meta: MessageMeta, html: string): ParsedOrder["platform"] {
const s = meta.subject || "";
const from = (meta.sender || "").toLowerCase();
if (/doordash/i.test(from) || /^\s*(\[Family\]\s*)?Order Confirmation for/i.test(s)) {
return "doordash";
}
if (/order with Uber Eats/i.test(s)) return "ubereats";
if (/trip with Uber|Uber receipt|Trip fare/i.test(s) || /Trip fare/i.test(html)) return "uber";
throw new NotAReceiptError(`cannot determine platform from subject: ${s}`, meta.messageId);
}
function parseMerchant(platform: string, meta: MessageMeta, text: string): string {
if (platform === "doordash") {
// Subject: "Order Confirmation for Siddharth from Mad Mex"
const m = meta.subject.match(/Order Confirmation for \S+\s+from\s+([\s\S]+?)\s*$/i);
if (m) return collapse(m[1]);
}
if (platform === "ubereats") {
// Body: "Here's your receipt for TEG Kebabs & Biryani."
const m = text.match(/receipt for\s+([\s\S]+?)\s*\.\s/i);
if (m) return collapse(m[1]);
}
if (platform === "uber") return "Uber Trip";
throw new OrderParseError(`cannot determine merchant`, meta.messageId);
}
function parsePayment(platform: string, html: string, text: string): PaymentBreakdown {
const out: PaymentBreakdown = {
credits_amount: null,
card_amount: null,
card_last4: null,
ambiguous: false,
};
if (platform === "doordash") {
// Match the instrument directly. An earlier version captured a trailing
// window delimited by a double space, which does not survive whitespace
// collapsing — so every card/mixed receipt fell through to the credits
// branch and booked the full total as credits.
if (/Paid with[\s\S]{0,60}?and\/or\s*credits/i.test(text)) {
out.ambiguous = true;
const l4 = text.match(/Paid with[\s\S]{0,60}?Ending in\s*(\d{3,4})/i);
out.card_last4 = l4 ? l4[1] : null;
return out;
}
const card = text.match(/Paid with[\s\S]{0,40}?Ending in\s*(\d{3,4})/i);
if (card) {
out.card_last4 = card[1];
return out; // card-only; amount filled from total
}
if (/Paid with\s+credits/i.test(text)) return out; // credits-only
return out;
}
// Uber Eats / Uber: "Payments Uber Cash $25.33", or a card line, or — on
// [Family] orders placed on a shared account — just the payer's name:
// "Payments Siddharth LKR 3,783.20". That last form names no instrument at
// all, so the split cannot be read from the mail and must not be invented.
// A declined attempt is still printed, immediately followed by "Failed":
// Visa ••••8841 LKR 4,267.01 7/5/26 7:20 pm Failed
// Siddharth LKR 3,757.01 7/5/26 9:02 pm
// (grocery order re-charged lower after sold-out items). Taking the first
// match would record money that never left the account, so drop the failed
// attempts before reading any instrument.
text = text.replace(
/(?:Visa|MasterCard|Amex|American Express|Uber Cash)[^.]{0,40}?[\d,]+\.\d{2}\s+\S+\s+\S+\s*(?:am|pm)?\s*Failed/gi,
" "
);
const cash = text.match(/Uber Cash\s*(?:[A-Z]{3})?\s*\$?([\d,]+\.\d{2})/i);
if (cash) out.credits_amount = money(cash[1]);
// Anchor on the masking, not on a list of card brands. Uber labels the card
// leg with whatever the issuer is called — "Westpac ••••8032 $15.33",
// "Mastercard ••••3893 (CBA Ultimate) CHF 51.23" — so a brand allowlist
// silently drops the card half of a mixed payment. Found in the backfill
// dry-run: Uber Cash $1.17 + Westpac ••••8032 $15.33 against a $16.50 total,
// which validateOrderTotals correctly refused rather than under-recording.
const card = text.match(
/(?:••••|\*{4}|\u2022{4})\s*(\d{4})[^\d]{0,40}?\$?\s*([\d,]+\.\d{2})/
);
if (card) {
out.card_last4 = card[1];
out.card_amount = money(card[2]);
}
if (!cash && !card && /Payments\s+\S+\s+(?:[A-Z]{3}\s|\$)/.test(text)) {
out.ambiguous = true;
}
return out;
}
export function parseOrderHTML(html: string, meta: MessageMeta): ParsedOrder {
if (!html || html.length < 200) {
throw new NotAReceiptError("body too short to be a receipt", meta.messageId);
}
const clean = html.replace(/<!--[\s\S]*?-->/g, "");
const text = collapse(decodeEntities(stripTags(clean)));
const flags: string[] = [];
let explicitCurrency: string | null = null;
// Grocery orders (ALDI, Woolworths) generate a follow-up "There are
// adjustments to your order" mail for out-of-stock and substituted items. It
// reuses the receipt layout but states `Total: $0.00` — the real amount is
// settled later. Ingesting it would book a $0.00 order and, worse, its
// order_reference would collide with nothing and create a phantom row.
// Refund / total-adjustment mails ("We adjusted the total for your recent
// order", "Previous total ... Refund ... New Total"). These restate an order
// already ingested and must be applied as an amendment, not inserted as a new
// order. Amendment handling is not in this pass — reject loudly so none is
// silently double-counted.
if (/We adjusted the total|Your refund has been applied|Previous total/i.test(text)) {
throw new NotAReceiptError(
"refund/total-adjustment notice — amends an existing order, not a new receipt",
meta.messageId
);
}
if (/There are adjustments to your order/i.test(text)) {
throw new NotAReceiptError(
"order-adjustment notice, not a receipt — no final total stated",
meta.messageId
);
}
// Uber sends TWO mails per trip with the same subject and the same total: a
// "charge summary" when the trip ends, then the real receipt once payment
// settles. The summary says so itself — "This is not a payment receipt ...
// You will receive a trip receipt when the payment is processed with payment
// information" — and it carries no tripReference, so order_reference would
// fall back to `msg:<message-id>` and I7 could not dedupe it against the
// receipt that follows. Every trip would be recorded twice.
if (/This is not a payment receipt|This is your charge summary/i.test(text)) {
throw new NotAReceiptError(
"charge summary, not a payment receipt — the real receipt follows",
meta.messageId
);
}
const platform = detectPlatform(meta, text);
const merchant_name = parseMerchant(platform, meta, text);
// ---- order_reference -----------------------------------------------------
// Uber embeds a real order UUID in the body. DoorDash embeds no order id at
// all, so the provider message id is the only stable identity available —
// which is correct for ingestion idempotency (one receipt = one order).
//
// Anchor on Uber's own `tripReference` cell — a hidden
// <td class="tripReference">xid<UUID></td> present in all 29 captured
// receipts. For ue-00 it equals the UUID the PDF redirect resolves to
// (ubereats.com/orders/34d6b4ee-...), so it is the order's real identity.
//
// The alternative, "first UUID in the document", is positional rather than
// semantic: 4 of 29 receipts carry several UUIDs, and if a template reshuffle
// ever put a per-send tracking id first, the symptom would be a reference
// that changes every fetch and silently duplicates every order on every
// backfill. Fall back to it only when the anchor is absent, and flag when
// that fallback is genuinely ambiguous.
let order_reference: string;
const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
const anchored = clean.match(
/tripReference[^>]*>\s*xid([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i
);
const firstUuid = clean.match(UUID_RE);
if (platform !== "doordash" && (anchored || firstUuid)) {
order_reference = (anchored ? anchored[1] : firstUuid![0]).toLowerCase();
if (!anchored) {
const distinct = new Set(
(clean.match(new RegExp(UUID_RE.source, "gi")) || []).map((u) => u.toLowerCase())
);
// Only ambiguous when there is more than one candidate to choose between.
if (distinct.size > 1) flags.push("order_uuid_ambiguous");
}
} else {
// DoorDash carries no order id anywhere in the receipt, so the provider
// message id is the only stable identity available. That is correct for
// ingestion idempotency: one receipt is one order.
if (!meta.messageId) {
throw new OrderParseError("no order id in body and no messageId supplied");
}
order_reference = `msg:${meta.messageId}`;
}
// ---- date ----------------------------------------------------------------
// From the message, never the body. DoorDash confirmations are sent at order
// time; the body's own date strings are inconsistent and locale-formatted.
const received = new Date(meta.receivedAt);
if (isNaN(received.getTime())) {
throw new OrderParseError(`unparseable receivedAt: ${meta.receivedAt}`, meta.messageId);
}
const order_datetime = received.toISOString();
// ---- [Family] ------------------------------------------------------------
// A subject prefix on Uber Eats: "[Family] Your Sunday evening order with…".
// Deliberately anchored — a bare substring search for "family" matches
// footer copy and merchant names, and a false positive here silently drops
// the order out of every budget.
const is_family = /^\s*\[Family\]/i.test(meta.subject || "");
// ---- totals --------------------------------------------------------------
let totals: OrderTotals;
if (platform === "doordash") {
// Grocery "Final receipt" mails price each item and carry no Total Charged
// row; the only stated total is the header. Fall back to it explicitly
// rather than letting a partial total through.
const headerTotal = text.match(/Total:\s*\$?([\d,]+\.\d{2})/i);
const total =
tdPairValue(clean, "Total Charged") ??
tdPairValue(clean, "Total") ??
(headerTotal ? money(headerTotal[1]) : null);
if (total === null) {
throw new OrderParseError("no total stated anywhere in receipt", meta.messageId);
}
totals = {
subtotal: tdPairValue(clean, "Subtotal"),
taxes: tdPairValue(clean, "Taxes"),
delivery_fee: tdPairValue(clean, "Delivery Fee"),
service_fee: tdPairValue(clean, "Service Fee"),
tip: tdPairValue(clean, "Tip"),
discounts: tdPairValue(clean, "Discounts"),
total_charged: total,
};
} else {
// Uber Eats states a Total, optionally in a foreign currency:
// "Total $25.33" | "Total LKR 3,783.20"
// The [Family] orders are placed for family in Sri Lanka and are priced in
// LKR — reading those as dollars would inflate them ~200x, which is a large
// part of why they must not reach a budget untagged.
const m = text.match(/(?:New Total|Total)\s*(?:([A-Z]{3})\s*)?\$?\s*([\d,]+\.\d{2})/);
if (!m) throw new OrderParseError("no Total found", meta.messageId);
totals = {
subtotal: extractLabelled(text, "Item subtotal"),
taxes: extractLabelled(text, "Tax"),
delivery_fee: extractLabelled(text, "Delivery Fee"),
service_fee: extractLabelled(text, "Service Fee"),
tip: null,
discounts: null,
total_charged: money(m[2]),
};
if (m[1]) explicitCurrency = m[1].toUpperCase().replace(/\$$/, "");
}
// ---- payment -------------------------------------------------------------
const payment = parsePayment(platform, clean, text);
if (payment.ambiguous && is_family) {
// [Family] receipts name the payer, not an instrument ("Payments Siddharth
// LKR 3,783.20"). An earlier version read that as credits-funded. It is
// not: the card statement carries all four of them (CBA ...3893, exact
// foreign_currency_amount matches), so creating a transaction duplicated
// spend that was already recorded — precisely the double-count I5 exists to
// prevent.
//
// Treated as card-settled: provenance only, no transaction. The statement
// line IS the transaction, and it is what should carry the `family` tag.
flags.push("family_card_settled_no_transaction");
} else if (payment.ambiguous) {
// Split not stated and resolvable from the card statement — left for the
// ingestion runner to reconcile, not guessed here.
flags.push("payment_split_not_stated");
} else if (platform === "doordash") {
// DoorDash names the method but not the amount; the total is the amount.
if (payment.card_last4) payment.card_amount = totals.total_charged;
else payment.credits_amount = totals.total_charged;
}
// ---- line items ----------------------------------------------------------
// Uber Eats receipts carry no itemisation (verified across 29 real mails).
const line_items =
platform === "doordash" ? parseDoorDashLineItems(clean) : parseUberLineItems(clean);
if (platform === "doordash" && line_items.length === 0) {
flags.push("no_line_items_parsed");
}
// Uber prints addresses on every receipt; DoorDash prints none at all, so an
// empty route there is expected rather than a parse failure.
const route = platform === "doordash" ? [] : parseUberRoute(clean);
const currency =
explicitCurrency ||
(/\b(NZD|USD|LKR|CHF|EUR|GBP|SGD|INR)\b/.test(text)
? (text.match(/\b(NZD|USD|LKR|CHF|EUR|GBP|SGD|INR)\b/) as RegExpMatchArray)[1]
: "AUD");
return {
order_reference,
platform,
merchant_name,
order_datetime,
currency,
payment,
totals,
line_items,
route,
is_family,
flags,
};
}
/**
* Integrity check on the amount we are about to record as spend.
*
* NOT an arithmetic reconciliation of the fee breakdown. Measured against 36
* real DoorDash receipts, the components do not sum to the total on 32 of them:
* DoorDash's `Discounts` line frequently equals subtotal + service fee exactly
* (Mad Mex: subtotal 22.10, service 1.99, Discounts -24.09, Total Charged
* 14.64) and sometimes differs by an unrelated margin. Whatever that line means
* to DoorDash, it is not a term in `total = components`.
*
* This corrects an earlier diagnosis that read the same numbers as an
* HTML-flattening artefact with a "true discount of $9.45". Parsing the table
* cells structurally yields the identical figures, and no $9.45 appears
* anywhere in the message — the receipt genuinely says this.
*
* So the breakdown is stored as provenance and never gated on. What IS checked
* is the number that becomes money in the ledger: DoorDash states the total
* twice, independently (a `Total: $X` header and a `Total Charged` table row),
* and those must agree. That catches a mis-parse, which is the failure that
* actually matters.
*/
export function validateOrderTotals(
order: ParsedOrder,
html?: string
): { ok: boolean; reason?: string } {
const t = order.totals;
if (!(t.total_charged > 0)) {
return { ok: false, reason: `non-positive total ${t.total_charged}` };
}
// Cross-check the header total against the table total where both exist.
if (html && order.platform === "doordash") {
const header = collapse(decodeEntities(stripTags(html))).match(
/Total:\s*\$?([\d,]+\.\d{2})/i
);
if (header) {
const stated = money(header[1]);
if (Math.abs(stated - t.total_charged) > 0.02) {
return {
ok: false,
reason: `header total ${stated.toFixed(2)} disagrees with Total Charged ${t.total_charged.toFixed(2)}`,
};
}
}
}
// The payment line must account for the total, or we are recording an amount
// no stated payment method covers.
if (!order.payment.ambiguous) {
const paid = (order.payment.credits_amount || 0) + (order.payment.card_amount || 0);
if (paid > 0 && Math.abs(paid - t.total_charged) > 0.02) {
return {
ok: false,
reason: `payments sum to ${paid.toFixed(2)} but receipt states ${t.total_charged.toFixed(2)}`,
};
}
}
return { ok: true };
}
export interface OrderAmendment {
order_reference: string | null;
previous_total: number | null;
refund_amount: number | null;
new_total: number;
order_datetime: string;
messageId: string;
}
/**
* Refund / total-adjustment notices restate an order that was already ingested:
*
* "We adjusted the total for your recent order from Coles (Wyndham Vale)."
* Previous total $49.94 · Refund -$4.21 · New Total $45.73
*
* These are amendments, not receipts — inserting one as a new order would
* double-count the meal and hide the refund. Uber embeds the same order UUID it
* used on the original receipt, so the amendment can be matched back to it.
*/
export function parseOrderAmendment(html: string, meta: MessageMeta): OrderAmendment {
const clean = html.replace(/<!--[\s\S]*?-->/g, "");
const text = collapse(decodeEntities(stripTags(clean)));
if (!/We adjusted the total|Your refund has been applied|Previous total/i.test(text)) {
throw new OrderParseError("not an amendment notice", meta.messageId);
}
const newTotal = text.match(/New Total\s*(?:[A-Z]{3})?\s*\$?\s*([\d,]+\.\d{2})/i);
if (!newTotal) {
throw new OrderParseError("amendment states no New Total", meta.messageId);
}
const prev = text.match(/Previous total\s*(?:[A-Z]{3})?\s*\$?\s*([\d,]+\.\d{2})/i);
const refund = text.match(/Refund\s*-?\s*(?:[A-Z]{3})?\s*\$?\s*([\d,]+\.\d{2})/i);
const uuid = clean.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i);
const received = new Date(meta.receivedAt);
if (isNaN(received.getTime())) {
throw new OrderParseError(`unparseable receivedAt: ${meta.receivedAt}`, meta.messageId);
}
return {
order_reference: uuid ? uuid[0].toLowerCase() : null,
previous_total: prev ? money(prev[1]) : null,
refund_amount: refund ? money(refund[1]) : null,
new_total: money(newTotal[1]),
order_datetime: received.toISOString(),
messageId: meta.messageId,
};
}
/** True when a message is an amendment rather than a receipt. */
export function isAmendment(html: string): boolean {
const text = collapse(decodeEntities(stripTags(html.replace(/<!--[\s\S]*?-->/g, ""))));
return /We adjusted the total|Your refund has been applied|Previous total/i.test(text);
}
+173 -47
View File
@@ -1,4 +1,11 @@
import { queryRaw } from "./db";
import { EXCLUDE_RECONCILED_SOURCE, NATIVE_CURRENCY, AMOUNT_UNCONVERTED, ACTIVE_OBLIGATION, STATEMENTS_JOIN } from "./analytics-sql";
export interface RoutePointRow {
label: string;
time: string | null;
address: string;
}
export interface TagRow {
id: number;
@@ -26,8 +33,11 @@ export interface TransactionRow {
principal_amount: number | null;
interest_amount: number | null;
// How it was paid (migration 0016). NULL = unknown, treated as reconcilable.
// 'cash' is excluded from reconciliation — see notCash().
// 'cash' and 'credits' are excluded from reconciliation — see needsCardMatch().
payment_method: string | null;
/** Uber pick-up/drop-off, when this row came from an order receipt. */
order_route: RoutePointRow[] | null;
order_platform: "doordash" | "ubereats" | "uber" | null;
// override fields
category_override: string | null;
merchant_override: string | null;
@@ -40,9 +50,13 @@ export interface TransactionRow {
my_amount: number;
// statement context (null for manual transactions)
bank_name: string;
// Native currency of the statement this row came from ('AUD' for manual rows).
// `amount` is in this currency; `amount_aud` is the converted figure.
// The currency `amount` is denominated in; `amount_aud` is the converted
// figure where one exists. Usually the statement's currency, but an
// order-receipt row has no statement and carries its own — see
// NATIVE_CURRENCY. Not simply 'AUD' for every statement-less row.
currency: string;
/** True when `amount` is non-AUD and no converted figure exists. */
amount_unconverted: boolean;
owner_id: number;
owner_name: string;
// tags
@@ -115,7 +129,7 @@ interface TransactionFilters {
export async function getTransactions(ownerId: number, filters: TransactionFilters) {
const conditions: string[] = [
`(COALESCE(t.owner_id, s.owner_id) = $1 OR EXISTS (SELECT 1 FROM transaction_splits ts_me WHERE ts_me.transaction_id = t.id AND ts_me.participant_id = $1))`,
`NOT (t.statement_id IS NULL AND t.reconciled_with_id IS NOT NULL)`,
EXCLUDE_RECONCILED_SOURCE,
];
const params: unknown[] = [ownerId];
let paramIdx = 2;
@@ -133,17 +147,24 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte
params.push(filters.categories);
}
if (filters.bank_names?.length) {
// "Manual" and "Gift Card" are not banks — they are the two shapes a
// statement-less row can take, and bankLabel() decides which. The filter
// has to split on the same condition or the chip selects nothing.
const hasManual = filters.bank_names.includes("Manual");
const bankList = filters.bank_names.filter((b) => b !== "Manual");
if (hasManual && bankList.length > 0) {
conditions.push(`(t.statement_id IS NULL OR s.bank_name = ANY($${paramIdx++}::text[]))`);
params.push(bankList);
} else if (hasManual) {
conditions.push(`t.statement_id IS NULL`);
} else {
conditions.push(`s.bank_name = ANY($${paramIdx++}::text[])`);
const hasGiftCard = filters.bank_names.includes("Gift Card");
const bankList = filters.bank_names.filter((b) => b !== "Manual" && b !== "Gift Card");
const alternatives: string[] = [];
if (hasManual) {
alternatives.push(`(t.statement_id IS NULL AND t.payment_method IS DISTINCT FROM 'credits')`);
}
if (hasGiftCard) {
alternatives.push(`(t.statement_id IS NULL AND t.payment_method = 'credits')`);
}
if (bankList.length > 0) {
alternatives.push(`s.bank_name = ANY($${paramIdx++}::text[])`);
params.push(bankList);
}
conditions.push(`(${alternatives.join(" OR ")})`);
}
if (filters.tag_ids?.length) {
const noTags = filters.tag_ids.includes("untagged");
@@ -210,8 +231,9 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte
o.category_override, o.merchant_normalized as merchant_override, o.notes, o.my_share_percent,
COALESCE(o.category_override, t.category) as effective_category,
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant,
COALESCE(s.bank_name, 'Manual') as bank_name,
COALESCE(s.currency, 'AUD') as currency,
${bankLabel()} as bank_name,
${NATIVE_CURRENCY} as currency,
${AMOUNT_UNCONVERTED} as amount_unconverted,
-- My share, resolved the same way analytics does it (see myShare in
-- analytics-sql.ts): explicit split row, then override, then whatever is
-- left after everyone else. Computed here so the UI cannot drift from
@@ -237,10 +259,23 @@ export async function getTransactions(ownerId: number, filters: TransactionFilte
tr.name as trip_name,
tr.color as trip_color,
txn_tags.tags,
txn_splits.splits
txn_splits.splits,
order_ctx.route as order_route,
order_ctx.platform as order_platform
FROM transactions t
LEFT JOIN transaction_overrides o ON o.transaction_id = t.id
LEFT JOIN statements s ON s.id = t.statement_id
-- Order provenance, for the sub-line under the description. Five rows all
-- reading "Order - Uber Trip" are indistinguishable; where they went is the
-- only thing that tells them apart, and it was already stored.
-- Both directions, because a card-settled order has no transaction of its
-- own and points at the statement line instead (I5).
LEFT JOIN LATERAL (
SELECT em.route, em.platform
FROM expense_metadata em
WHERE em.transaction_id = t.id OR em.matched_transaction_id = t.id
LIMIT 1
) order_ctx ON true
LEFT JOIN participants p ON p.id = COALESCE(t.owner_id, s.owner_id)
LEFT JOIN transactions src ON src.reconciled_with_id = t.id AND src.statement_id IS NULL
LEFT JOIN trips tr ON tr.id = o.trip_id
@@ -294,7 +329,7 @@ export async function getTransactionById(id: number) {
o.category_override, o.merchant_normalized as merchant_override, o.notes, o.my_share_percent,
COALESCE(o.category_override, t.category) as effective_category,
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant,
COALESCE(s.bank_name, 'Manual') as bank_name,
${bankLabel()} as bank_name,
COALESCE(t.owner_id, s.owner_id) as owner_id,
p.name as owner_name
FROM transactions t
@@ -376,12 +411,20 @@ export async function getMerchantSuggestions(search: string) {
}
export async function getBankNames() {
const [bankRows, manualCount] = await Promise.all([
const [bankRows, statementless] = await Promise.all([
queryRaw<{ bank_name: string }>(`SELECT DISTINCT bank_name FROM statements ORDER BY bank_name`),
queryRaw<{ count: number }>(`SELECT COUNT(*)::int as count FROM transactions WHERE statement_id IS NULL`),
queryRaw<{ label: string }>(
`SELECT DISTINCT ${bankLabel("t", "s")} as label
FROM transactions t LEFT JOIN statements s ON s.id = t.statement_id
WHERE t.statement_id IS NULL`
),
]);
const banks = bankRows.map((r) => r.bank_name);
if (manualCount[0]?.count > 0) banks.push("Manual");
// Order matters for the filter chips: real banks first, then the
// statement-less kinds, in a stable order rather than whatever the DB returns.
for (const label of ["Manual", "Gift Card"]) {
if (statementless.some((r) => r.label === label)) banks.push(label);
}
return banks;
}
@@ -390,6 +433,8 @@ export interface ParticipantBalance {
name: string;
total_owed: number;
unsettled_count: number;
/** Splits counted at a non-AUD figure because no converted amount exists. */
unconverted_count: number;
}
export async function getParticipantBalances(ownerId: number, tagIds?: number[]) {
@@ -403,6 +448,13 @@ export async function getParticipantBalances(ownerId: number, tagIds?: number[])
// Payments settle the total relationship between two people, not a specific tag.
// Only subtract payments when viewing the unfiltered total; with a tag filter
// active, show the raw split amount for that tag context only.
//
// That asymmetry is a symptom, not a design: a tag is a view and has no
// payments, so a tag-filtered balance had nothing honest to subtract. A trip
// does have payments (`split_payments.trip_id`, migration 0022), which is why
// the per-trip figure in getTripAnalytics can be netted and this one cannot.
// The fix for the tag case is to stop showing a balance there, not to invent
// one — see docs/shared-expenses-design.md.
const paymentsJoin = tagIds?.length ? "" : `
LEFT JOIN (
SELECT
@@ -419,7 +471,11 @@ export async function getParticipantBalances(ownerId: number, tagIds?: number[])
SELECT p.id, p.name,
COALESCE(SUM(splits.signed_amount), 0)::numeric(12,2)
${paymentsSelect} AS total_owed,
COALESCE(SUM(splits.split_count), 0)::int AS unsettled_count
COALESCE(SUM(splits.split_count), 0)::int AS unsettled_count,
-- Splits whose AUD value is unknown. They are still summed above (as
-- their native figure), so a non-zero count means this balance is
-- approximate and the UI has to say so.
COALESCE(SUM(splits.unconverted_count), 0)::int AS unconverted_count
FROM participants p
LEFT JOIN (
@@ -428,12 +484,14 @@ export async function getParticipantBalances(ownerId: number, tagIds?: number[])
-- currency, so splitting on it nets a USD figure against AUD ones.
SELECT ts.participant_id AS pid,
(CASE WHEN t.transaction_type IN ('debit', 'fee', 'interest') THEN COALESCE(t.amount_aud, t.amount) ELSE -COALESCE(t.amount_aud, t.amount) END) * ts.share_percent / 100 AS signed_amount,
1 AS split_count
1 AS split_count,
(CASE WHEN ${AMOUNT_UNCONVERTED} THEN 1 ELSE 0 END) AS unconverted_count
FROM transaction_splits ts
JOIN transactions t ON t.id = ts.transaction_id
LEFT JOIN statements s ON s.id = t.statement_id
WHERE COALESCE(t.owner_id, s.owner_id) = $1 AND ts.participant_id != $1
AND NOT (t.statement_id IS NULL AND t.reconciled_with_id IS NOT NULL)
AND ${EXCLUDE_RECONCILED_SOURCE}
AND ${ACTIVE_OBLIGATION}
${tagFilter}
UNION ALL
@@ -441,12 +499,14 @@ export async function getParticipantBalances(ownerId: number, tagIds?: number[])
-- I owe them: my splits on transactions they own
SELECT COALESCE(t.owner_id, s.owner_id) AS pid,
-((CASE WHEN t.transaction_type IN ('debit', 'fee', 'interest') THEN COALESCE(t.amount_aud, t.amount) ELSE -COALESCE(t.amount_aud, t.amount) END) * ts.share_percent / 100) AS signed_amount,
0 AS split_count
0 AS split_count,
(CASE WHEN ${AMOUNT_UNCONVERTED} THEN 1 ELSE 0 END) AS unconverted_count
FROM transaction_splits ts
JOIN transactions t ON t.id = ts.transaction_id
LEFT JOIN statements s ON s.id = t.statement_id
WHERE ts.participant_id = $1 AND COALESCE(t.owner_id, s.owner_id) != $1
AND NOT (t.statement_id IS NULL AND t.reconciled_with_id IS NOT NULL)
AND ${EXCLUDE_RECONCILED_SOURCE}
AND ${ACTIVE_OBLIGATION}
${tagFilter}
) splits ON splits.pid = p.id
${paymentsJoin}
@@ -533,8 +593,25 @@ export async function batchInsertCSVTransactions(
* transaction accounts are imported, and NULL means unknown — both stay
* candidates, which preserves the behaviour of every pre-existing row.
*/
export const notCash = (alias = "t") =>
`(${alias}.payment_method IS NULL OR ${alias}.payment_method <> 'cash')`;
/**
* Payment methods that can still be matched against a card statement line.
*
* Cash never appears on one. Neither does a credits-funded delivery order: the
* gift card already paid it, so there is no card leg coming, ever. Leaving
* those in the queue meant 81 orders sat in "pending reconciliation" waiting
* for a match that could not exist (user, 2026-07-27).
*/
export const needsCardMatch = (alias = "t") =>
`(${alias}.payment_method IS NULL OR ${alias}.payment_method NOT IN ('cash', 'credits'))`;
/**
* Bank label for a transaction. A row with no statement was not imported from
* one, and the label has to say *why*: "Manual" reads as "hand-entered, still
* awaiting a card line", which is wrong for a gift-card order — nothing is
* awaited. `s` must be the statements alias in scope.
*/
export const bankLabel = (t = "t", s = "s") =>
`COALESCE(${s}.bank_name, CASE WHEN ${t}.payment_method = 'credits' THEN 'Gift Card' ELSE 'Manual' END)`;
export interface PotentialMatch {
id: number;
@@ -578,7 +655,7 @@ export async function getPendingReconciliations(ownerId: number): Promise<Manual
WHERE ts.transaction_id = t.id
) txn_splits ON true
WHERE t.statement_id IS NULL AND t.owner_id = $1 AND t.reconciled_with_id IS NULL
AND ${notCash("t")}
AND ${needsCardMatch("t")}
ORDER BY t.transaction_date DESC, t.row_index ASC`,
[ownerId]
);
@@ -620,7 +697,7 @@ export async function getPendingReconciliations(ownerId: number): Promise<Manual
WHERE m.statement_id IS NULL
AND m.owner_id = $1
AND m.reconciled_with_id IS NULL
AND ${notCash("m")}
AND ${needsCardMatch("m")}
AND COALESCE(t.owner_id, s.owner_id) = $1
AND NOT EXISTS (
SELECT 1 FROM transactions mt WHERE mt.reconciled_with_id = t.id
@@ -687,7 +764,11 @@ export async function getSharedTransactions(ownerId: number, tagIds?: number[],
o.category_override, o.merchant_normalized as merchant_override, o.notes,
COALESCE(o.category_override, t.category) as effective_category,
COALESCE(o.merchant_normalized, t.merchant_normalized, t.merchant_name) as effective_merchant,
COALESCE(s.bank_name, 'Manual') as bank_name,
${bankLabel()} as bank_name,
-- The table renders t.amount, which is not always AUD. Without these the
-- rows visibly disagreed with the participant balances, which do convert.
${NATIVE_CURRENCY} as currency,
${AMOUNT_UNCONVERTED} as amount_unconverted,
COALESCE(t.owner_id, s.owner_id) as owner_id,
p_owner.name as owner_name,
COALESCE(src.created_at, t.created_at) as created_at,
@@ -714,10 +795,10 @@ export async function getSharedTransactions(ownerId: number, tagIds?: number[],
AND EXISTS (SELECT 1 FROM transaction_splits ts_me WHERE ts_me.transaction_id = t.id AND ts_me.participant_id = $1)
)
)
AND NOT (t.statement_id IS NULL AND t.reconciled_with_id IS NOT NULL)
AND ${EXCLUDE_RECONCILED_SOURCE}
${tagClause}
${participantClause}
GROUP BY t.id, o.category_override, o.merchant_normalized, o.notes, s.bank_name, s.owner_id, p_owner.name, src.created_at
GROUP BY t.id, o.category_override, o.merchant_normalized, o.notes, s.bank_name, s.currency, s.owner_id, p_owner.name, src.created_at
ORDER BY t.transaction_date DESC
`, params);
@@ -753,7 +834,14 @@ export interface TripAnalytics {
daily_spend: { date: string; amount: number }[];
top_merchants: { merchant: string; amount: number; count: number }[];
tag_breakdown: { tag_id: number; name: string; color: string; amount: number; count: number }[];
participant_splits: { participant_id: number; name: string; owed: number; settled: number; unsettled: number }[];
participant_splits: {
participant_id: number;
name: string;
/** Their share of this trip, net of payments scoped to it. */
owed: number;
/** Splits counted at a non-AUD figure because no converted amount exists. */
unconverted_count: number;
}[];
}
export async function getTrips(ownerId: number): Promise<TripRow[]> {
@@ -857,21 +945,59 @@ export async function getTripAnalytics(tripId: number, ownerId: number): Promise
ORDER BY 4 DESC
`, [tripId]),
queryRaw<{ participant_id: number; name: string; owed: number; settled: number; unsettled: number }>(`
SELECT
p.id AS participant_id,
p.name,
SUM(ts.share_percent / 100.0 * COALESCE(tx.amount_aud, tx.amount))::float AS owed,
SUM(CASE WHEN ts.settled THEN ts.share_percent / 100.0 * COALESCE(tx.amount_aud, tx.amount) ELSE 0 END)::float AS settled,
SUM(CASE WHEN NOT ts.settled THEN ts.share_percent / 100.0 * COALESCE(tx.amount_aud, tx.amount) ELSE 0 END)::float AS unsettled
FROM transaction_overrides o
JOIN transactions tx ON tx.id = o.transaction_id
JOIN transaction_splits ts ON ts.transaction_id = tx.id
JOIN participants p ON p.id = ts.participant_id
WHERE o.trip_id = $1
AND tx.transaction_type IN ('debit','fee','interest')
AND COALESCE(o.category_override, tx.category, 'other') NOT IN ('transfers', 'investment')
GROUP BY p.id
// Owed per participant for THIS trip, net of payments made against it.
//
// This was gross splits, with a comment explaining why it could not be
// anything better: `split_payments` carried no trip attribution, so a
// payment could not be assigned to a trip and every trip reported 100%
// unsettled including trips paid in full. `split_payments.trip_id`
// (migration 0022) closes that, so the figure is now real.
//
// Three exclusions, all load-bearing:
// - ACTIVE_OBLIGATION drops settled splits, so a closed trip reads zero
// rather than its original gross.
// - EXCLUDE_RECONCILED_SOURCE drops the manual row a statement line has
// superseded. The trip queries never applied it, so a reconciled trip
// expense was counted twice here.
// - AMOUNT_UNCONVERTED counts rows whose AUD value is unknown, the same
// way getParticipantBalances does. They are still summed (at their
// native figure), so a non-zero count means this total is approximate
// and the UI has to say so. A trip is where foreign rows actually live,
// so netting a EUR figure against AUD ones silently is most likely to
// bite exactly here.
//
// `transactions` is aliased `t` so the shared fragments apply directly —
// they assume that alias, and hand-inlining a copy is what let the
// reconciled-row exclusion drift out of the analytics routes to begin with.
queryRaw<{ participant_id: number; name: string; owed: number; unconverted_count: number }>(`
WITH owed AS (
SELECT ts.participant_id AS pid,
SUM(ts.share_percent / 100.0 * COALESCE(t.amount_aud, t.amount)) AS gross,
SUM(CASE WHEN ${AMOUNT_UNCONVERTED} THEN 1 ELSE 0 END) AS unconverted
FROM transaction_overrides o
JOIN transactions t ON t.id = o.transaction_id
${STATEMENTS_JOIN}
JOIN transaction_splits ts ON ts.transaction_id = t.id
WHERE o.trip_id = $1
AND t.transaction_type IN ('debit','fee','interest')
AND COALESCE(o.category_override, t.category, 'other') NOT IN ('transfers', 'investment')
AND ${ACTIVE_OBLIGATION}
AND ${EXCLUDE_RECONCILED_SOURCE}
GROUP BY ts.participant_id
),
paid AS (
SELECT sp.from_participant_id AS pid, SUM(sp.amount) AS amt
FROM split_payments sp
WHERE sp.trip_id = $1
GROUP BY sp.from_participant_id
)
SELECT p.id AS participant_id, p.name,
(COALESCE(owed.gross, 0) - COALESCE(paid.amt, 0))::float AS owed,
COALESCE(owed.unconverted, 0)::int AS unconverted_count
FROM participants p
LEFT JOIN owed ON owed.pid = p.id
LEFT JOIN paid ON paid.pid = p.id
WHERE owed.pid IS NOT NULL OR paid.pid IS NOT NULL
ORDER BY 3 DESC
`, [tripId]),
]);
+5
View File
@@ -20,5 +20,10 @@ export default defineConfig({
environment: "node",
include: ["src/__tests__/integration/**/*.test.ts"],
pool: "forks",
// Every integration file shares the one `personal_test` database, and
// helpers.resetDB() TRUNCATEs it (CASCADE reaches expense_metadata).
// Run files one at a time so a sibling's reset cannot delete rows another
// file just inserted — that raced non-deterministically across I6/I7/I11.
fileParallelism: false,
},
});