Fare does not include fees that may be charged by your bank. Please contact your bank directly for inquiries.
\ No newline at end of file
diff --git a/src/__tests__/fixtures/orders/real/ut-reauth.html b/src/__tests__/fixtures/orders/real/ut-reauth.html
new file mode 100644
index 0000000..d519ef7
--- /dev/null
+++ b/src/__tests__/fixtures/orders/real/ut-reauth.html
@@ -0,0 +1,42 @@
+
+
Total AED 577.83
July 2, 2025
Thanks for being an Uber One member, Siddharth
We hope you enjoyed your ride this afternoon.
Total
AED 577.83
Base Fare
AED 5.40
Time
AED 5.04
Distance
AED 493.39
Subtotal
AED 503.83
Salik: Al Safa South to Al Safa North (Towards Sharjah)
AED 6.00
Salik: Al Barsha (Towards Sharjah)
AED 6.00
Salik: Jebel Ali
AED 6.00
Salik: Al Garhoud
AED 6.00
ITC fee
AED 5.00
Dubai Surcharge
AED 45.00
Payments
Citi Prestige ••••0253
7/2/25 5:19 PM
AED 17.67
Citi Prestige ••••0253
7/3/25 1:53 AM
AED 577.83
Visit the trip page for more information, including invoices (where available)
This is not a tax invoice. This is a payment receipt for the transportation service provided by Kamran Khan.
Fare does not include fees that may be charged by your bank. Please contact your bank directly for inquiries.
\ No newline at end of file
diff --git a/src/__tests__/unit/order-ingestion.test.ts b/src/__tests__/unit/order-ingestion.test.ts
index 09d4906..fc7aa68 100644
--- a/src/__tests__/unit/order-ingestion.test.ts
+++ b/src/__tests__/unit/order-ingestion.test.ts
@@ -412,3 +412,79 @@ describe("credits-funded orders are orders", () => {
expect(validateOrderTotals(o, notCredits).ok).toBe(false);
});
});
+
+describe("Uber Cash is credits, not a card", () => {
+ it("reads a payment line carrying a timestamp and a prefixed currency", () => {
+ // "Payments Uber Cash 10/17/25 8:50 PM A$54.87". The old pattern allowed
+ // neither the timestamp nor the A$ prefix, so credits_amount stayed null
+ // and the order was filed as card-settled — sent looking for a card leg
+ // that does not exist, and left as an orphan with nothing to match on.
+ const o = parseOrderHTML(
+ html("ue-aud-prefix"),
+ meta({
+ subject: "Your Friday evening order with Uber Eats",
+ sender: "Uber Receipts ",
+ })
+ );
+ expect(o.payment.credits_amount).toBe(54.87);
+ expect(o.payment.card_last4).toBeNull();
+ expect(o.payment.ambiguous).toBe(false);
+ });
+
+ it("still reads the plain form, where the timestamp follows the amount", () => {
+ // "Uber Cash $25.33 22/7/26 1:41 pm" — the older layout the widened
+ // pattern must not break.
+ const o = parseOrderHTML(
+ html("ue-00"),
+ meta({
+ subject: "Your order with Uber Eats",
+ sender: "Uber Receipts ",
+ })
+ );
+ expect(o.payment.credits_amount).toBe(25.33);
+ });
+
+ it("still reads the card leg of a mixed payment", () => {
+ // "Uber Cash $1.17 ... Westpac ••••8032 $15.33" — the credits half must
+ // not swallow the card half.
+ const o = parseOrderHTML(
+ html("ue-mixed"),
+ meta({ subject: "Your order with Uber Eats", sender: "Uber Receipts " })
+ );
+ expect(o.payment.credits_amount).toBe(1.17);
+ expect(o.payment.card_last4).toBe("8032");
+ });
+});
+
+describe("payment legs", () => {
+ const trip = (f: string) =>
+ parseOrderHTML(
+ html(f),
+ meta({
+ subject: "Your Wednesday afternoon trip with Uber",
+ sender: "Uber Receipts ",
+ })
+ );
+
+ it("does not add a superseded authorisation to the settled charge", () => {
+ // "Citi Prestige ••••0253 AED 17.67" then the same card "AED 577.83",
+ // against a stated total of 577.83. The first is a hold, not a part
+ // payment; adding it overstates the trip by the held amount.
+ const o = trip("ut-reauth");
+ expect(o.totals.total_charged).toBe(577.83);
+ expect(o.payment.card_amount).toBe(577.83);
+ expect(o.payment.card_last4).toBe("0253");
+ expect(validateOrderTotals(o, html("ut-reauth")).ok).toBe(true);
+ });
+
+ it("adds the legs of a genuinely split payment", () => {
+ // "PayPal - A$78.41" + "Uber Cash A$6.85" = 85.26. Neither leg
+ // equals the total, so both are real and both must be counted — and the
+ // PayPal leg carries no card mask to anchor on.
+ const o = trip("ut-paypal");
+ expect(o.totals.total_charged).toBe(85.26);
+ expect(o.payment.credits_amount).toBe(6.85);
+ expect(o.payment.card_amount).toBe(78.41);
+ expect(validateOrderTotals(o, html("ut-paypal")).ok).toBe(true);
+ });
+});
diff --git a/src/lib/order-parse.ts b/src/lib/order-parse.ts
index 29c1668..113980c 100644
--- a/src/lib/order-parse.ts
+++ b/src/lib/order-parse.ts
@@ -305,7 +305,12 @@ const SYMBOL_CURRENCY: Record = {
"£": "GBP",
};
-function parsePayment(platform: string, html: string, text: string): PaymentBreakdown {
+function parsePayment(
+ platform: string,
+ html: string,
+ text: string,
+ statedTotal: number | null = null
+): PaymentBreakdown {
const out: PaymentBreakdown = {
credits_amount: null,
card_amount: null,
@@ -348,22 +353,69 @@ function parsePayment(platform: string, html: string, text: string): PaymentBrea
" "
);
- const cash = text.match(/Uber Cash\s*(?:[A-Z]{3})?\s*\$?([\d,]+\.\d{2})/i);
- if (cash) out.credits_amount = money(cash[1]);
+ // "Payments Uber Cash $25.33" — but newer receipts put a timestamp between
+ // the label and the amount, and write the currency as a prefix:
+ // "Payments Uber Cash 10/17/25 8:50 PM A$54.87"
+ // Both defeated the old pattern, and the failure was silent and expensive:
+ // Uber Cash IS credits, so an unreadable payment line left credits_amount
+ // null and the order was filed as card-settled. It then went looking for a
+ // card leg that does not exist, found nothing, and became an orphan with no
+ // transaction and no card to match on. 118 of the captured messages sit in
+ // that state. The date is allowed for explicitly rather than by widening the
+ // gap, so a distant unrelated amount still cannot be captured.
+ const cash = text.match(
+ /Uber Cash\s*(?:\d{1,2}\/\d{1,2}\/\d{2,4}\s*)?(?:\d{1,2}:\d{2}\s*(?:AM|PM)?\s*)?(?:([A-Z]{3})\s*)?(?:[A-Z]{1,2})?[$₹€£]?\s*([\d,]+\.\d{2})/i
+ );
+ if (cash) out.credits_amount = money(cash[2]);
// Anchor on the masking, not on a list of card brands. Uber labels the card
// leg with whatever the issuer is called — "Westpac ••••8032 $15.33",
// "Mastercard ••••3893 (CBA Ultimate) CHF 51.23" — so a brand allowlist
// silently drops the card half of a mixed payment. Found in the backfill
// dry-run: Uber Cash $1.17 + Westpac ••••8032 $15.33 against a $16.50 total,
// which validateOrderTotals correctly refused rather than under-recording.
- const card = text.match(
- /(?:••••|\*{4}|\u2022{4})\s*(\d{4})[^\d]{0,40}?\$?\s*([\d,]+\.\d{2})/
+ // The gap was `[^\d]{0,40}` — no digits — which the newer layout breaks by
+ // printing a timestamp there: "Westpac ••••8032 2/14/25 1:59 PM A$8.68".
+ // That silently dropped the card half of every mixed payment in the new
+ // format, and stayed invisible only while Uber Cash was also unreadable:
+ // both legs missing meant the order looked card-settled and the whole total
+ // was booked to a card. Reading credits without fixing this reads half an
+ // order, and validateOrderTotals rightly refuses it.
+ //
+ // Every leg is summed rather than just the first. One order can be charged
+ // in several instalments to the SAME card — a Dubai trip billed as
+ // "Citi Prestige ••••0253 7/2/25 AED 17.67" and again the next day, totalling
+ // 577.83 — and an instrument can carry no mask at all ("PayPal - ").
+ // Taking one match under-reads both, and the order is then refused for a
+ // shortfall the receipt does not actually have.
+ const legs = [
+ ...text.matchAll(
+ /(?:(?:••••|\*{4}|\u2022{4})\s*(\d{4})|PayPal)[^$₹€£]{0,60}?(?:([A-Z]{3})\s+|(?:[A-Z]{1,2})?[$₹€£]\s?)([\d,]+\.\d{2})/g
+ ),
+ ];
+ //
+ // Summing is right for instalments but wrong for a re-auth. A Dubai trip
+ // prints "Citi Prestige ••••0253 AED 17.67" (the authorisation) and then
+ // "Citi Prestige ••••0253 AED 577.83" (the settled charge) against a stated
+ // total of 577.83 — the first leg is superseded, not additive, and adding it
+ // overstates the trip by the held amount. So a leg that already equals the
+ // stated total IS the payment; only when none does are the legs instalments
+ // that must be added. A mixed credits+card order is unaffected: neither leg
+ // equals the total there, which is exactly why it needs summing.
+ const exact = legs.find(
+ (l) => statedTotal !== null && Math.abs(money(l[3]) - statedTotal) < 0.02
);
- if (card) {
- out.card_last4 = card[1];
- out.card_amount = money(card[2]);
+ if (exact && out.credits_amount === null) {
+ out.card_last4 = exact[1] ?? null;
+ out.card_amount = money(exact[3]);
+ } else {
+ let cardTotal = 0;
+ for (const leg of legs) {
+ cardTotal += money(leg[3]);
+ if (leg[1] && !out.card_last4) out.card_last4 = leg[1];
+ }
+ if (legs.length > 0) out.card_amount = Number(cardTotal.toFixed(2));
}
- if (!cash && !card && /Payments\s+\S+\s+(?:[A-Z]{3}\s|\$)/.test(text)) {
+ if (!cash && legs.length === 0 && /Payments\s+\S+\s+(?:[A-Z]{3}\s|\$)/.test(text)) {
out.ambiguous = true;
}
return out;
@@ -559,7 +611,7 @@ export function parseOrderHTML(html: string, meta: MessageMeta): ParsedOrder {
}
// ---- payment -------------------------------------------------------------
- const payment = parsePayment(platform, clean, text);
+ const payment = parsePayment(platform, clean, text, totals.total_charged);
if (payment.ambiguous && is_family) {
// [Family] receipts name the payer, not an instrument ("Payments Siddharth
// LKR 3,783.20"). An earlier version read that as credits-funded. It is