From 33db7d05ef21ccd0cfe655d2e2b0ed801495b00c Mon Sep 17 00:00:00 2001 From: siddharthd Date: Sun, 26 Jul 2026 22:25:26 +1000 Subject: [PATCH] feat(orders): rebuild the receipt parser against real captured email MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/__tests__/fixtures/orders/real/dd-01.html | 549 ++++++++++++ src/__tests__/fixtures/orders/real/dd-08.html | 817 ++++++++++++++++++ src/__tests__/fixtures/orders/real/dd-10.html | 592 +++++++++++++ src/__tests__/fixtures/orders/real/dd-27.html | 554 ++++++++++++ .../fixtures/orders/real/dd-meta.json | 254 ++++++ src/__tests__/fixtures/orders/real/ue-00.html | 254 ++++++ src/__tests__/fixtures/orders/real/ue-04.html | 274 ++++++ src/__tests__/fixtures/orders/real/ue-05.html | 254 ++++++ src/__tests__/fixtures/orders/real/ue-09.html | 274 ++++++ src/__tests__/fixtures/orders/real/ue-26.html | 254 ++++++ .../fixtures/orders/real/ue-meta.json | 205 +++++ src/lib/order-parse.ts | 433 ++++++++++ 12 files changed, 4714 insertions(+) create mode 100644 src/__tests__/fixtures/orders/real/dd-01.html create mode 100644 src/__tests__/fixtures/orders/real/dd-08.html create mode 100644 src/__tests__/fixtures/orders/real/dd-10.html create mode 100644 src/__tests__/fixtures/orders/real/dd-27.html create mode 100644 src/__tests__/fixtures/orders/real/dd-meta.json create mode 100644 src/__tests__/fixtures/orders/real/ue-00.html create mode 100644 src/__tests__/fixtures/orders/real/ue-04.html create mode 100644 src/__tests__/fixtures/orders/real/ue-05.html create mode 100644 src/__tests__/fixtures/orders/real/ue-09.html create mode 100644 src/__tests__/fixtures/orders/real/ue-26.html create mode 100644 src/__tests__/fixtures/orders/real/ue-meta.json create mode 100644 src/lib/order-parse.ts diff --git a/src/__tests__/fixtures/orders/real/dd-01.html b/src/__tests__/fixtures/orders/real/dd-01.html new file mode 100644 index 0000000..fbfa087 --- /dev/null +++ b/src/__tests__/fixtures/orders/real/dd-01.html @@ -0,0 +1,549 @@ + + + + DoorDash + + + + + + + + + + + + + + +
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + +
DOORDASH
+ + + + + + +
+ + + + + + + + +
+ + + + + + + + + + + + + + + + + + +
+ + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ + + + + + +
+ + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + +
+ + + + + + + +
+ + + + + + + +
+ + + + + + + +
+ + + + + + + +
+ + + + + + + +
+ + + + + + +
+ + + + + + + + +
+ + + + + + + + + + + + + + + + + + +
+ + + + + + + +
+ + + + + + +
+ + \ No newline at end of file diff --git a/src/__tests__/fixtures/orders/real/dd-08.html b/src/__tests__/fixtures/orders/real/dd-08.html new file mode 100644 index 0000000..54f5402 --- /dev/null +++ b/src/__tests__/fixtures/orders/real/dd-08.html @@ -0,0 +1,817 @@ + + + + + + + + + + + DoorDash + + + + + + + + + + + +
+
+  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏ +
+ + + + + + +
+ + + + + + + + + + + + + + + + +
+
+ + \ No newline at end of file diff --git a/src/__tests__/fixtures/orders/real/dd-10.html b/src/__tests__/fixtures/orders/real/dd-10.html new file mode 100644 index 0000000..fd6f1a4 --- /dev/null +++ b/src/__tests__/fixtures/orders/real/dd-10.html @@ -0,0 +1,592 @@ + + + + + + + + + + + DoorDash + + + + + + + + + + + +
+
+  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏  ­͏ +
+ + + + + + +
+ + + + + + + + + + + + + + + + +
+
+ + \ No newline at end of file diff --git a/src/__tests__/fixtures/orders/real/dd-27.html b/src/__tests__/fixtures/orders/real/dd-27.html new file mode 100644 index 0000000..c7247b6 --- /dev/null +++ b/src/__tests__/fixtures/orders/real/dd-27.html @@ -0,0 +1,554 @@ + + + + DoorDash + + + + + + + + + + + + + + +
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + +
DOORDASH
+ + + + + + +
+ + + + + + + + +
+ + + + + + + + + + + + + + + + + + +
+ + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ + + + + + +
+ + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + +
+ + + + + + + +
+ + + + + + + +
+ + + + + + + +
+ + + + + + + +
+ + + + + + + +
+ + + + + + +
+ + + + + + + + +
+ + + + + + + + + + + + + + + + + + +
+ + + + + + + +
+ + + + + + +
+ + \ No newline at end of file diff --git a/src/__tests__/fixtures/orders/real/dd-meta.json b/src/__tests__/fixtures/orders/real/dd-meta.json new file mode 100644 index 0000000..a41e0a6 --- /dev/null +++ b/src/__tests__/fixtures/orders/real/dd-meta.json @@ -0,0 +1,254 @@ +[ + { + "id": "19f89664ea7b3aeb", + "date": "Date: Wed, 22 Jul 2026 10:36:50 +0000 (UTC)", + "from": "From: DoorDash Order ", + "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 ", + "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 ", + "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 ", + "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 ", + "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 ", + "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 ", + "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 ", + "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 ", + "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 ", + "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 ", + "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 ", + "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 ", + "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 ", + "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 ", + "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 ", + "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 ", + "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 ", + "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 ", + "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 ", + "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 ", + "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 ", + "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 ", + "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 ", + "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 ", + "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 ", + "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 ", + "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 ", + "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 ", + "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 ", + "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 ", + "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 ", + "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 ", + "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 ", + "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 ", + "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 ", + "subject": "Subject: Order Confirmation for Siddharth from Chilli India", + "len": 37334 + } +] \ No newline at end of file diff --git a/src/__tests__/fixtures/orders/real/ue-00.html b/src/__tests__/fixtures/orders/real/ue-00.html new file mode 100644 index 0000000..fd9d537 --- /dev/null +++ b/src/__tests__/fixtures/orders/real/ue-00.html @@ -0,0 +1,254 @@ + +
\ No newline at end of file diff --git a/src/__tests__/fixtures/orders/real/ue-04.html b/src/__tests__/fixtures/orders/real/ue-04.html new file mode 100644 index 0000000..19b6722 --- /dev/null +++ b/src/__tests__/fixtures/orders/real/ue-04.html @@ -0,0 +1,274 @@ + +
\ No newline at end of file diff --git a/src/__tests__/fixtures/orders/real/ue-05.html b/src/__tests__/fixtures/orders/real/ue-05.html new file mode 100644 index 0000000..3b2f1ff --- /dev/null +++ b/src/__tests__/fixtures/orders/real/ue-05.html @@ -0,0 +1,254 @@ + +
\ No newline at end of file diff --git a/src/__tests__/fixtures/orders/real/ue-09.html b/src/__tests__/fixtures/orders/real/ue-09.html new file mode 100644 index 0000000..26082a4 --- /dev/null +++ b/src/__tests__/fixtures/orders/real/ue-09.html @@ -0,0 +1,274 @@ + +
\ No newline at end of file diff --git a/src/__tests__/fixtures/orders/real/ue-26.html b/src/__tests__/fixtures/orders/real/ue-26.html new file mode 100644 index 0000000..f863aea --- /dev/null +++ b/src/__tests__/fixtures/orders/real/ue-26.html @@ -0,0 +1,254 @@ + +
\ No newline at end of file diff --git a/src/__tests__/fixtures/orders/real/ue-meta.json b/src/__tests__/fixtures/orders/real/ue-meta.json new file mode 100644 index 0000000..1ee95da --- /dev/null +++ b/src/__tests__/fixtures/orders/real/ue-meta.json @@ -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 + } +] \ No newline at end of file diff --git a/src/lib/order-parse.ts b/src/lib/order-parse.ts new file mode 100644 index 0000000..775c983 --- /dev/null +++ b/src/lib/order-parse.ts @@ -0,0 +1,433 @@ +/** + * 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 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[]; +} + +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[]; + 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; +} + +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(/ /gi, " ") + .replace(/&/gi, "&") + .replace(/'|'/gi, "'") + .replace(/"/gi, '"') + .replace(/$/g, "$") + .replace(/…/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: + * + * 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( + `]*>\\s*${label}\\s*\\s*]*>\\s*(-?\\s*\\$?[\\d,]+\\.\\d{2})\\s*`, + "i" + ); + const m = html.match(re); + return m ? money(m[1]) : null; +} + +function parseDoorDashLineItems(html: string): LineItem[] { + // + const re = + /]*width="10%"[^>]*>\s*(\d+)x\s*<\/td>\s*]*width="75%"[^>]*>([\s\S]*?)<\/td>\s*]*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$X
Subtotal $22.101xName (Cat)
• Opt
$22.10