feat(orders): add Uber Eats and Uber Rides support, fare parsing, and category resolution
This commit is contained in:
@@ -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 = `<div>Your Friday evening order with Uber Eats</div><div>Paid with Uber Cash $43.46</div><div>Total Charged $43.46</div>`;
|
||||
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 = `<div>Your Trip fare $84.14 · Booking Fee $1.35</div><div>Uber Cash $85.49</div><div>Total $85.49</div>`;
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
+59
-28
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user