feat(orders): add Uber Eats and Uber Rides support, fare parsing, and category resolution

This commit is contained in:
2026-07-26 19:00:59 +10:00
parent bbb90238e4
commit 775e5cc08f
2 changed files with 77 additions and 28 deletions
+59 -28
View File
@@ -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(/<div>\s*([A-Za-z0-9\s'&-]+)\s*<\/div>\s*<div>\s*Total:/i) ||
html.match(/<div>\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(/<div>\s*([A-Za-z0-9\s'&-]+)\s*<\/div>\s*<div>\s*Total:/i) ||
html.match(/order with\s+([A-Za-z0-9\s'&-]+)/i) ||
html.match(/<div>\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 };