Uber Cash IS credits, and the payment line had become unreadable in the
newer layout: "Payments Uber Cash 10/17/25 8:50 PM A$54.87" carries a
timestamp between the label and the amount, and writes the currency as a
prefix. Both defeated the pattern, so credits_amount stayed null and the
order was filed as card-settled — sent looking for a card leg that does
not exist, found nothing, and left as an orphan with no transaction and
no card tail to match on. 118 captured messages sit in that state, and
every one of them is pre-cutover, so reading them correctly means I1
skips them rather than storing enrichment that points at nothing.
The card leg had the same blind spot, hidden behind the first: the gap
between the mask and the amount was [^\d]{0,40}, which a timestamp
breaks. While BOTH legs were unreadable a mixed payment still looked
consistent — the order read as card-settled for the full total. Fixing
only credits reads half an order, which validateOrderTotals correctly
refuses. Found exactly that way: four messages that validated before
began failing "payments sum to 34.92 but receipt states 43.60".
Legs are summed rather than taken first, because one order can be charged
in instalments and an instrument can carry no mask at all (PayPal). But a
leg that already equals the stated total IS the payment, not an
instalment: a Dubai trip prints an AED 17.67 authorisation and then the
AED 577.83 settled charge, and adding the hold overstates the trip. A
mixed credits+card order is unaffected — neither leg equals the total
there, which is why it needs summing.
A/B over all 776 captures: 468 parsed by both, zero change to any amount,
currency or existing card tail, 0 lost, 118 credits figures newly read.
Mutation-tested: dropping the exact-leg rule fails 2 tests, dropping the
Uber Cash read fails 7.
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -412,3 +412,79 @@ describe("credits-funded orders are orders", () => {
|
|||||||
expect(validateOrderTotals(o, notCredits).ok).toBe(false);
|
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 <noreply@uber.com>",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
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 <noreply@uber.com>",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
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 <noreply@uber.com>" })
|
||||||
|
);
|
||||||
|
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 <noreply@uber.com>",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
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 - <email> 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
+62
-10
@@ -305,7 +305,12 @@ const SYMBOL_CURRENCY: Record<string, string> = {
|
|||||||
"£": "GBP",
|
"£": "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 = {
|
const out: PaymentBreakdown = {
|
||||||
credits_amount: null,
|
credits_amount: null,
|
||||||
card_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);
|
// "Payments Uber Cash $25.33" — but newer receipts put a timestamp between
|
||||||
if (cash) out.credits_amount = money(cash[1]);
|
// 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
|
// 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",
|
// leg with whatever the issuer is called — "Westpac ••••8032 $15.33",
|
||||||
// "Mastercard ••••3893 (CBA Ultimate) CHF 51.23" — so a brand allowlist
|
// "Mastercard ••••3893 (CBA Ultimate) CHF 51.23" — so a brand allowlist
|
||||||
// silently drops the card half of a mixed payment. Found in the backfill
|
// 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,
|
// dry-run: Uber Cash $1.17 + Westpac ••••8032 $15.33 against a $16.50 total,
|
||||||
// which validateOrderTotals correctly refused rather than under-recording.
|
// which validateOrderTotals correctly refused rather than under-recording.
|
||||||
const card = text.match(
|
// The gap was `[^\d]{0,40}` — no digits — which the newer layout breaks by
|
||||||
/(?:••••|\*{4}|\u2022{4})\s*(\d{4})[^\d]{0,40}?\$?\s*([\d,]+\.\d{2})/
|
// 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 - <email>").
|
||||||
|
// 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) {
|
if (exact && out.credits_amount === null) {
|
||||||
out.card_last4 = card[1];
|
out.card_last4 = exact[1] ?? null;
|
||||||
out.card_amount = money(card[2]);
|
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 (!cash && !card && /Payments\s+\S+\s+(?:[A-Z]{3}\s|\$)/.test(text)) {
|
if (legs.length > 0) out.card_amount = Number(cardTotal.toFixed(2));
|
||||||
|
}
|
||||||
|
if (!cash && legs.length === 0 && /Payments\s+\S+\s+(?:[A-Z]{3}\s|\$)/.test(text)) {
|
||||||
out.ambiguous = true;
|
out.ambiguous = true;
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
@@ -559,7 +611,7 @@ export function parseOrderHTML(html: string, meta: MessageMeta): ParsedOrder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- payment -------------------------------------------------------------
|
// ---- payment -------------------------------------------------------------
|
||||||
const payment = parsePayment(platform, clean, text);
|
const payment = parsePayment(platform, clean, text, totals.total_charged);
|
||||||
if (payment.ambiguous && is_family) {
|
if (payment.ambiguous && is_family) {
|
||||||
// [Family] receipts name the payer, not an instrument ("Payments Siddharth
|
// [Family] receipts name the payer, not an instrument ("Payments Siddharth
|
||||||
// LKR 3,783.20"). An earlier version read that as credits-funded. It is
|
// LKR 3,783.20"). An earlier version read that as credits-funded. It is
|
||||||
|
|||||||
Reference in New Issue
Block a user