From 775e5cc08fdc6414d905d2045434152cf5bea687 Mon Sep 17 00:00:00 2001 From: siddharthd Date: Sun, 26 Jul 2026 19:00:59 +1000 Subject: [PATCH] feat(orders): add Uber Eats and Uber Rides support, fare parsing, and category resolution --- src/__tests__/unit/order-ingestion.test.ts | 18 +++++ src/lib/order-ingestion.ts | 87 +++++++++++++++------- 2 files changed, 77 insertions(+), 28 deletions(-) diff --git a/src/__tests__/unit/order-ingestion.test.ts b/src/__tests__/unit/order-ingestion.test.ts index 8ff52a7..b1d2b40 100644 --- a/src/__tests__/unit/order-ingestion.test.ts +++ b/src/__tests__/unit/order-ingestion.test.ts @@ -56,4 +56,22 @@ describe("Order Ingestion - Unit Tests", () => { expect(validRatings.includes("again")).toBe(true); expect(validRatings.includes(invalidRating)).toBe(false); }); + + it("8. Uber Eats receipt => platform 'ubereats', line_items [] (Diff 1), Uber Cash mapped", () => { + const html = `
Your Friday evening order with Uber Eats
Paid with Uber Cash $43.46
Total Charged $43.46
`; + const parsed = parseOrderHTML(html); + expect(parsed.platform).toBe("ubereats"); + expect(parsed.payment.credits_amount).toBe(43.46); + expect(parsed.line_items).toEqual([]); + }); + + it("9. Uber Ride receipt => platform 'uber', category 'transport' (Diff 2)", () => { + const html = `
Your Trip fare $84.14 · Booking Fee $1.35
Uber Cash $85.49
Total $85.49
`; + const parsed = parseOrderHTML(html); + expect(parsed.platform).toBe("uber"); + expect(parsed.payment.credits_amount).toBe(85.49); + + const resolved = resolveMerchantCategory(parsed.merchant_name, parsed.platform); + expect(resolved.category).toBe("transport"); + }); }); diff --git a/src/lib/order-ingestion.ts b/src/lib/order-ingestion.ts index 2a1b04f..8e92d13 100644 --- a/src/lib/order-ingestion.ts +++ b/src/lib/order-ingestion.ts @@ -41,9 +41,15 @@ export interface ParsedOrder { * Preserves HTML table structure to avoid cell-rebinding bugs (I9). */ export function parseOrderHTML(html: string): ParsedOrder { - // Extract payment line - const creditsMatch = html.match(/Paid with credits(?:\s+\$(\d+\.\d{2}))?/i); - const cardMatch = html.match(/Paid with (?:MasterCard|Visa|American Express|Card)(?:\s+Ending in (\d+))?(?:\s+\$(\d+\.\d{2}))?/i); + // Detect platform + const isUberEats = /Uber Eats/i.test(html); + const isUberRide = /UberX|UberXL|Uber Comfort|trip with Uber|Trip fare/i.test(html) || (/Uber/i.test(html) && !isUberEats && !/doordash/i.test(html)); + const platform: "doordash" | "ubereats" | "uber" = isUberEats ? "ubereats" : isUberRide ? "uber" : "doordash"; + + // Extract payment line (supports DoorDash "Paid with credits" and Uber "Uber Cash $XX.XX") + const uberCashMatch = html.match(/Uber Cash\s+\$?(\d+\.\d{2})/i); + const creditsMatch = html.match(/Paid with credits(?:\s+\$(\d+\.\d{2}))?/i) || uberCashMatch; + const cardMatch = html.match(/Paid with (?:MasterCard|Visa|American Express|Card|Mastercard)(?:\s+Ending in (\d+)|\s+••••(\d+))?(?:\s+\$?(\d+\.\d{2}))?/i); const mixedMatch = html.match(/Paid with credits\s+\$(\d+\.\d{2}),?\s+.*Ending in (\d+)\s+\$(\d+\.\d{2})/i); let credits_amount: number | null = null; @@ -54,11 +60,13 @@ export function parseOrderHTML(html: string): ParsedOrder { credits_amount = parseFloat(mixedMatch[1]); card_last4 = mixedMatch[2]; card_amount = parseFloat(mixedMatch[3]); + } else if (uberCashMatch) { + credits_amount = parseFloat(uberCashMatch[1]); } else if (creditsMatch) { credits_amount = creditsMatch[1] ? parseFloat(creditsMatch[1]) : null; } else if (cardMatch) { - card_last4 = cardMatch[1] || null; - card_amount = cardMatch[2] ? parseFloat(cardMatch[2]) : null; + card_last4 = cardMatch[1] || cardMatch[2] || null; + card_amount = cardMatch[3] ? parseFloat(cardMatch[3]) : null; } // Parse HTML tables for totals to avoid flattening rebinding (I9) @@ -74,12 +82,12 @@ export function parseOrderHTML(html: string): ParsedOrder { return null; }; - const subtotal = extractTableValue(/Subtotal/i) ?? 0; - const taxes = extractTableValue(/Taxes/i); + const subtotal = extractTableValue(/(?:Subtotal|Trip fare)/i) ?? 0; + const taxes = extractTableValue(/(?:Taxes|Government Levy)/i); const delivery_fee = extractTableValue(/Delivery Fee/i); - const service_fee = extractTableValue(/Service Fee/i); + const service_fee = extractTableValue(/(?:Service Fee|Booking Fee)/i); const tip = extractTableValue(/Tip/i); - const discounts = extractTableValue(/Discounts/i); + const discounts = extractTableValue(/(?:Discounts|Uber One Credits)/i); const total_charged = extractTableValue(/Total Charged/i) ?? extractTableValue(/Total/i) ?? subtotal; // If credits/card amounts were not explicit on single payment method, set total_charged @@ -91,36 +99,56 @@ export function parseOrderHTML(html: string): ParsedOrder { } // Parse merchant - const merchantMatch = html.match(/
\s*([A-Za-z0-9\s'&-]+)\s*<\/div>\s*
\s*Total:/i) || - html.match(/
\s*([A-Za-z0-9\s'&-]+)\s*<\/div>/i); - const merchant_name = merchantMatch ? merchantMatch[1].trim() : "Unknown Merchant"; - - // Parse line items - const line_items: LineItem[] = []; - const lineItemMatch = html.match(/(\d+)x\s+([^$]+)\s+\$(\d+\.\d{2})/i); - if (lineItemMatch) { - const qty = parseInt(lineItemMatch[1], 10); - const fullDesc = lineItemMatch[2].trim(); - const parts = fullDesc.split('•').map(p => p.trim()); - const description = parts[0]; - const options = parts.slice(1); - const amount = parseFloat(lineItemMatch[3]); - line_items.push({ qty, description, amount, options }); + let merchant_name = "Uber"; + if (platform === "uber") { + merchant_name = "Uber Trip"; + } else { + const merchantMatch = html.match(/
\s*([A-Za-z0-9\s'&-]+)\s*<\/div>\s*
\s*Total:/i) || + html.match(/order with\s+([A-Za-z0-9\s'&-]+)/i) || + html.match(/
\s*([A-Za-z0-9\s'&-]+)\s*<\/div>/i); + merchant_name = merchantMatch ? merchantMatch[1].trim() : "Uber Eats Merchant"; } + // Parse line items (Uber Eats has line_items: [] per Difference 1; Uber Rides parse fare components per Difference 2) + const line_items: LineItem[] = []; + if (platform === "uber") { + // Uber Ride fare components + const tripFare = extractTableValue(/Trip fare/i); + if (tripFare) line_items.push({ qty: 1, description: "Trip fare", amount: tripFare }); + const bookingFee = extractTableValue(/Booking Fee/i); + if (bookingFee) line_items.push({ qty: 1, description: "Booking Fee", amount: bookingFee }); + const airportFee = extractTableValue(/Airport fee/i); + if (airportFee) line_items.push({ qty: 1, description: "Airport fee", amount: airportFee }); + } else if (platform === "doordash") { + const lineItemMatch = html.match(/(\d+)x\s+([^$]+)\s+\$(\d+\.\d{2})/i); + if (lineItemMatch) { + const qty = parseInt(lineItemMatch[1], 10); + const fullDesc = lineItemMatch[2].trim(); + const parts = fullDesc.split('•').map(p => p.trim()); + const description = parts[0]; + const options = parts.slice(1); + const amount = parseFloat(lineItemMatch[3]); + line_items.push({ qty, description, amount, options }); + } + } + + // Currency extraction + const currencyMatch = html.match(/\b(AUD|NZD|LKR|USD)\b/i); + const currency = currencyMatch ? currencyMatch[1].toUpperCase() : "AUD"; + // Date parsing const dateMatch = html.match(/Date:\s*(\d{4}-\d{2}-\d{2})/i); const order_datetime = dateMatch ? `${dateMatch[1]}T12:00:00Z` : "2026-01-15T12:00:00Z"; - // Check if family order + // Check if family order (Difference 4) const is_family = html.includes("[Family]") || html.includes("family"); return { order_reference: `ORD-${Date.now()}-${Math.floor(Math.random()*1000)}`, - platform: "doordash", + platform, merchant_name, order_datetime, - currency: "AUD", + currency, payment: { credits_amount, card_amount, card_last4 }, totals: { subtotal, @@ -161,7 +189,10 @@ export function validateOrderTotals(order: ParsedOrder): boolean { /** * Resolves merchant name to category. Never defaults to 'dining' (I4). */ -export function resolveMerchantCategory(merchantName: string): { category: string; flagReview: boolean } { +export function resolveMerchantCategory(merchantName: string, platform?: string): { category: string; flagReview: boolean } { + if (platform === "uber" || merchantName.toLowerCase().includes("uber trip")) { + return { category: "transport", flagReview: false }; + } const lower = merchantName.toLowerCase(); if (lower.includes("woolworths") || lower.includes("aldi") || lower.includes("coles")) { return { category: "groceries", flagReview: false };