7f8e363b5b21cb06a276a25261dd3e93c11c9b9c
11
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
dd0462a5f9 |
fix(orders): decode • so item options separate again
ci / lint-test (push) Successful in 53s
`•` was missing from the entity table, and that was not cosmetic. DoorDash separates an item's name from its options with a bullet and parseDoorDashLineItems splits on the literal "•" — so left encoded, the split never happened and the line collapsed into the description: "Bucket and Side Pack (Meal Deals) • Hot Bucket • Chips" with options []. The entity showed on screen and the structure behind it was gone. Numeric entities are now decoded generically rather than one at a time, which is how $ came to be listed individually while its neighbours were not, and & resolves last so a literal "•" stays as written instead of turning into a bullet. Repaired the 47 stored rows by re-parsing the captured email behind each one rather than string-replacing the entity, since a replacement would have fixed the display and left options [] underneath. Rehearsed first: all 47 re-parsed, all 47 gained options, 0 line items lost, 0 amounts changed. Old values kept in dump/rollback-line-items-20260728-223737.json. Pre-existing — 22 rows predate today — but the pre-cutover backfill more than doubled the affected rows, which is what surfaced it. Verified in the Order details panel, not in SQL. |
||
|
|
fe104a9618 |
fix(orders): read both legs of an Uber payment
ci / lint-test (push) Successful in 53s
Uber Cash IS credits, and the payment line had become unreadable in the
newer layout: "Payments Uber Cash 10/17/25 8:50 PM A$54.87" carries a
timestamp between the label and the amount, and writes the currency as a
prefix. Both defeated the pattern, so credits_amount stayed null and the
order was filed as card-settled — sent looking for a card leg that does
not exist, found nothing, and left as an orphan with no transaction and
no card tail to match on. 118 captured messages sit in that state, and
every one of them is pre-cutover, so reading them correctly means I1
skips them rather than storing enrichment that points at nothing.
The card leg had the same blind spot, hidden behind the first: the gap
between the mask and the amount was [^\d]{0,40}, which a timestamp
breaks. While BOTH legs were unreadable a mixed payment still looked
consistent — the order read as card-settled for the full total. Fixing
only credits reads half an order, which validateOrderTotals correctly
refuses. Found exactly that way: four messages that validated before
began failing "payments sum to 34.92 but receipt states 43.60".
Legs are summed rather than taken first, because one order can be charged
in instalments and an instrument can carry no mask at all (PayPal). But a
leg that already equals the stated total IS the payment, not an
instalment: a Dubai trip prints an AED 17.67 authorisation and then the
AED 577.83 settled charge, and adding the hold overstates the trip. A
mixed credits+card order is unaffected — neither leg equals the total
there, which is why it needs summing.
A/B over all 776 captures: 468 parsed by both, zero change to any amount,
currency or existing card tail, 0 lost, 118 credits figures newly read.
Mutation-tested: dropping the exact-leg rule fails 2 tests, dropping the
Uber Cash read fails 7.
|
||
|
|
63aaf1eb21 |
fix(orders): read A$ totals and credits-funded receipts
ci / lint-test (push) Successful in 51s
Two parse bugs that between them made 218 of 776 captured messages
unreadable. Neither was the "old template" they were filed as.
Uber writes the currency three ways and only two were handled. "Total
A$54.87" is what it sends for ordinary Australian orders — A$ misses
[A-Z]{3} by one character — so 98 of 275 Uber Eats mails and 78 of 275
trip mails failed with "no Total found" while the amount sat in plain
sight. Most were 2024-2025, i.e. current mail. NZ$, US$, S$, HK$, C$ and
a bare rupee/euro/pound symbol are handled the same way. A bare "$" is
still left unresolved on purpose: a dozen currencies use it, and the
body-wide scan that reads the receipt's own stated code should win.
A DoorDash order paid from credits states "Total Charged $0.00"
truthfully, above a real subtotal. Read literally that is a $0 order, and
validateOrderTotals rejected 88 of them as non-positive — discarding
exactly the credit-funded spend this pipeline exists to surface. The
order's value is its subtotal; recording zero would show the order and
hide what it cost. Guarded on the receipt actually saying credits, so an
empty mail still fails rather than inheriting a stray subtotal, and the
header cross-check stands down for these or it rejects the figure the
parser deliberately overrode.
Measured A/B over all 776 captures: 254 parsed by both parsers with zero
change to any amount or currency, 0 lost, 214 newly readable. Both fixes
mutation-tested — reverting the regex fails 4 tests, removing the credits
branch fails 3.
Fixtures are real captured receipts, per the 2026-07-26 rewrite: the
earlier synthetic suite passed while the parser could not read a real
email.
No history replay — I7 idempotency refuses re-reads and that needs an
explicit update mode. This fixes ingest from here on.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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.
|
||
|
|
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. |
||
|
|
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.
|
||
|
|
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. |
||
|
|
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. |