wallet: a transaction for every tap (board #252, ING-11)
ci / lint-test (push) Successful in 52s
ci / lint-test (push) Successful in 52s
Step 3, and the last app-side piece. POST /api/wallet/ingest takes an
interpreted notification and writes a statementless card transaction plus
one expense_metadata row. Sibling to /api/orders/ingest and
/api/receipts/ingest, same shared-secret shape, its own token so the
wallet credential rotates without touching the order flow.
Nothing here knows about statements, and that is the point: statementless
+ payment_method 'card' is exactly the reconcile queue's predicate, so a
wallet row enters it unaided and the auto-matcher confirms it later.
THE PAIR IS MERGED, NEVER DEDUPED. One tap emits two notifications ~2-8s
apart: the issuer app has the card tail, Apple Wallet has the merchant and
the suburb, and neither alone is complete. Matched on amount and date
because the two share no identifier. My first version COALESCEd every
field, which made the result depend on arrival order — the issuer half
usually lands first, so the truncated all-caps "WYNDHAM VAL AU" beat the
subtitle's "Wyndham Vale, VIC". A subtitle-derived place now overwrites; a
descriptor-derived one only fills a gap. The test that caught this asserts
both arrival orders.
CURRENCY IS NEVER ASSUMED. Only Amex states one ("A$5,103.49"); Westpac,
CommBank and Apple Wallet write a bare "$", which is 14 of 21 captured
rows. Recorded as unknown and flagged, not assumed AUD — harmless at home,
a 23x error on a THB tap abroad. When a currency IS stated and is not AUD,
the NATIVE figure goes to foreign_currency_amount, because the AUD amount
is the bank's own conversion and a notification cannot predict it, while
the native pair is exact on both sides and is what the statement will
match on. transactions.amount is NOT NULL so it takes a provisional
conversion from the ledger-derived median rate, flagged aud_provisional,
overwritten at reconcile. No rate history means no_rate_reference rather
than an invented number.
wallet-descriptor.ts moves the positional parsing out of the model, which
handled ONE fixed-width format three ways on the same day's data: kept the
country code on "WYNDHAM VAL AU", stripped it on "Sydney" and "Perth", and
swallowed "SAN FRANCISOUS" whole — that being "SAN FRANCISO" truncated to
the 12-char city field plus "US", on the only foreign merchant in the
entire capture window. Every unit-test input is a real captured string.
14 unit + 12 integration tests. npm run test:all green (196 + 304), lint
clean.
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
import { describe, it, expect, beforeAll, beforeEach, vi } from "vitest";
|
||||
import { Pool } from "pg";
|
||||
import { createPool, resetDB, seedParticipants } from "./helpers";
|
||||
|
||||
/**
|
||||
* Wallet notifications becoming transactions.
|
||||
*
|
||||
* Every fixture is a real captured event from capture.wallet_events
|
||||
* (2026-08-13 .. 08-22). The rules being tested are the ones the design turned
|
||||
* on, so each has a reason rather than being a shape check:
|
||||
*
|
||||
* - a transaction for EVERY tap, because overseas is where live spend matters
|
||||
* - the pair is MERGED, never deduped: the issuer half has the card tail, the
|
||||
* Wallet half has the merchant and suburb, neither alone is complete
|
||||
* - the NATIVE amount is what gets stored for a foreign charge, because the
|
||||
* AUD figure is the bank's own conversion and cannot be predicted
|
||||
* - a bare "$" is recorded as unknown, never assumed AUD
|
||||
*/
|
||||
|
||||
const TOKEN = "test-wallet-token";
|
||||
let pool: Pool;
|
||||
let ownerId: number;
|
||||
let ingest: typeof import("../../app/api/wallet/ingest/route").POST;
|
||||
|
||||
const req = (body: unknown) =>
|
||||
({ headers: { get: (h: string) => (h === "x-ingest-token" ? TOKEN : null) }, json: async () => body }) as never;
|
||||
|
||||
const noAuth = (body: unknown) =>
|
||||
({ headers: { get: () => null }, json: async () => body }) as never;
|
||||
|
||||
// --- real captured events -------------------------------------------------
|
||||
const breadworldBank = {
|
||||
event_id: 64, kind: "transaction", direction: "debit", amount: 13.5,
|
||||
amount_text: "$13.50", currency: "$?", merchant: "Breadworld Bakery Cafe",
|
||||
card_tail: "8032", issuer: "Mastercard", notif_app: "Westpac",
|
||||
notif_body: "$13.50 paid from Mastercard ending...8032. Breadworld Bakery Cafe WYNDHAM VAL AU",
|
||||
occurred_at: "2026-08-13T08:01:28+10:00",
|
||||
};
|
||||
const breadworldWallet = {
|
||||
event_id: 65, kind: "transaction", direction: "unknown", amount: 13.5,
|
||||
amount_text: "$13.50", currency: "$?", merchant: "Breadworld Bakery Cafe",
|
||||
notif_app: "Wallet", notif_subtitle: "Breadworld Bakery Cafe, Wyndham Vale, VIC",
|
||||
occurred_at: "2026-08-13T08:01:35+10:00",
|
||||
};
|
||||
const amexRewardPay = {
|
||||
event_id: 87, kind: "transaction", direction: "debit", amount: 5103.49,
|
||||
amount_text: "A$5,103.49", currency: "AUD", merchant: "REWARDPAY",
|
||||
card_tail: "01000", issuer: "American Express", notif_app: "Amex AU",
|
||||
notif_body: "You have made a A$5,103.49 charge on your American Express® Card ending in 01000 at REWARDPAY.",
|
||||
occurred_at: "2026-08-20T11:54:43+10:00",
|
||||
};
|
||||
const ebayOrderPlaced = {
|
||||
event_id: 66, kind: "not_transaction", direction: "unknown", amount: null,
|
||||
notif_app: "Wallet", notif_body: "eBay has received your order.",
|
||||
occurred_at: "2026-08-13T09:45:55+10:00",
|
||||
};
|
||||
|
||||
/** Only the rows this lane wrote -- some tests seed statement history for the
|
||||
* rate reference, and those must not be mistaken for wallet output. */
|
||||
const txns = async () =>
|
||||
(await pool.query(
|
||||
`SELECT id, amount, amount_aud, merchant_normalized, location, source, source_ref,
|
||||
foreign_currency_amount, foreign_currency_code, payment_method, statement_id
|
||||
FROM transactions WHERE source = 'wallet' ORDER BY id`
|
||||
)).rows;
|
||||
|
||||
const metas = async () =>
|
||||
(await pool.query(`SELECT * FROM expense_metadata ORDER BY id`)).rows;
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.WALLET_INGEST_TOKEN = TOKEN;
|
||||
pool = createPool();
|
||||
vi.doMock("@/lib/auth", () => ({
|
||||
getCurrentUser: async () => ({ id: ownerId, name: "Alice", email: "alice@example.com" }),
|
||||
}));
|
||||
({ POST: ingest } = await import("../../app/api/wallet/ingest/route"));
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetDB(pool);
|
||||
await pool.query("DELETE FROM expense_metadata");
|
||||
({ ownerId } = await seedParticipants(pool));
|
||||
});
|
||||
|
||||
describe("auth", () => {
|
||||
it("fails closed without the token", async () => {
|
||||
expect((await ingest(noAuth(breadworldBank))).status).toBe(401);
|
||||
expect(await txns()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what reaches the ledger", () => {
|
||||
it("creates a statementless card transaction for a tap", async () => {
|
||||
const body = await (await ingest(req(breadworldBank))).json();
|
||||
expect(body.transactionId).toBeTruthy();
|
||||
|
||||
const [t] = await txns();
|
||||
expect(Number(t.amount)).toBe(13.5);
|
||||
expect(t.source).toBe("wallet");
|
||||
expect(t.source_ref).toBe("evt:64");
|
||||
// Statementless + card is exactly what puts it in the reconcile queue, with
|
||||
// no extra wiring: that is how the statement eventually confirms it.
|
||||
expect(t.statement_id).toBeNull();
|
||||
expect(t.payment_method).toBe("card");
|
||||
});
|
||||
|
||||
it("ignores an eBay order-lifecycle notification", async () => {
|
||||
// 10 of the 13 non-transactions in the capture window are these.
|
||||
const body = await (await ingest(req(ebayOrderPlaced))).json();
|
||||
expect(body.skipped).toBe("not_a_transaction");
|
||||
expect(await txns()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("is idempotent on the event id", async () => {
|
||||
await ingest(req(breadworldBank));
|
||||
const second = await (await ingest(req(breadworldBank))).json();
|
||||
expect(second.skipped).toBe("already_ingested");
|
||||
expect(await txns()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the notification pair", () => {
|
||||
it("merges the two halves into ONE transaction, keeping both contributions", async () => {
|
||||
await ingest(req(breadworldBank)); // has the card tail
|
||||
await ingest(req(breadworldWallet)); // has the suburb
|
||||
|
||||
const rows = await txns();
|
||||
expect(rows).toHaveLength(1);
|
||||
// The Wallet half supplies the suburb; the issuer half never had one.
|
||||
expect(rows[0].location).toBe("Wyndham Vale, VIC");
|
||||
|
||||
const [m] = await metas();
|
||||
// ...and the issuer half's card tail survives the merge.
|
||||
expect(m.card_last4).toBe("8032");
|
||||
expect(m.payment_method_detail).toBe("Mastercard");
|
||||
});
|
||||
|
||||
it("merges in either arrival order", async () => {
|
||||
await ingest(req(breadworldWallet));
|
||||
await ingest(req(breadworldBank));
|
||||
|
||||
expect(await txns()).toHaveLength(1);
|
||||
const [m] = await metas();
|
||||
expect(m.card_last4).toBe("8032");
|
||||
const [t] = await txns();
|
||||
expect(t.location).toBe("Wyndham Vale, VIC");
|
||||
});
|
||||
});
|
||||
|
||||
describe("currency", () => {
|
||||
it("records AUD when Amex states it explicitly", async () => {
|
||||
await ingest(req(amexRewardPay));
|
||||
const [t] = await txns();
|
||||
expect(t.foreign_currency_code).toBeNull();
|
||||
expect(Number(t.amount)).toBe(5103.49);
|
||||
const [m] = await metas();
|
||||
expect(m.currency).toBe("AUD");
|
||||
expect(m.flags).not.toContain("currency_unverified");
|
||||
});
|
||||
|
||||
it("flags a bare dollar sign rather than assuming AUD", async () => {
|
||||
// 14 of 21 captured transaction rows are a bare "$". Assuming AUD is
|
||||
// harmless at home and a 23x error on a THB tap abroad.
|
||||
await ingest(req(breadworldBank));
|
||||
const [m] = await metas();
|
||||
expect(m.currency).toBeNull();
|
||||
expect(m.flags).toContain("currency_unverified");
|
||||
});
|
||||
|
||||
it("stores a foreign charge on the NATIVE pair, with a provisional AUD", async () => {
|
||||
// Seed enough history for the median rate to exist (n >= 5).
|
||||
const st = await pool.query(
|
||||
`INSERT INTO statements (filename, account_number, bank_name, billing_start_date, billing_end_date, owner_id)
|
||||
VALUES ('s.pdf','1','Test Bank','2026-01-01','2026-01-31',$1) RETURNING id`, [ownerId]
|
||||
);
|
||||
for (let i = 0; i < 6; i++) {
|
||||
await pool.query(
|
||||
`INSERT INTO transactions (statement_id, transaction_date, description, amount,
|
||||
transaction_type, owner_id, foreign_currency_amount, foreign_currency_code)
|
||||
VALUES ($1,'2026-01-10','BANGKOK',4.57,'debit',$2,100,'THB')`, [st.rows[0].id, ownerId]
|
||||
);
|
||||
}
|
||||
|
||||
await ingest(req({
|
||||
event_id: 999, kind: "transaction", direction: "debit", amount: 500,
|
||||
amount_text: "฿500", merchant: "7-Eleven", notif_app: "Wallet",
|
||||
notif_subtitle: "7-Eleven, Bangkok", occurred_at: "2026-08-20T12:00:00+10:00",
|
||||
}));
|
||||
|
||||
const [t] = await txns();
|
||||
expect(Number(t.foreign_currency_amount)).toBe(500);
|
||||
expect(t.foreign_currency_code).toBe("THB");
|
||||
// The native figure is exact on both sides, so it is what the statement
|
||||
// will match on. amount is only ever provisional here.
|
||||
expect(Number(t.amount)).toBeCloseTo(22.85, 1);
|
||||
const [m] = await metas();
|
||||
expect(m.flags).toContain("aud_provisional");
|
||||
});
|
||||
|
||||
it("still records a foreign charge when there is no rate history", async () => {
|
||||
await ingest(req({
|
||||
event_id: 998, kind: "transaction", direction: "debit", amount: 42,
|
||||
amount_text: "€42.00", merchant: "Somewhere", notif_app: "Wallet",
|
||||
occurred_at: "2026-08-20T12:00:00+10:00",
|
||||
}));
|
||||
const [t] = await txns();
|
||||
expect(t.foreign_currency_code).toBe("EUR");
|
||||
const [m] = await metas();
|
||||
// Says so, rather than inventing a rate.
|
||||
expect(m.flags).toContain("no_rate_reference");
|
||||
});
|
||||
});
|
||||
|
||||
describe("location", () => {
|
||||
it("takes the suburb from the Wallet subtitle", async () => {
|
||||
await ingest(req(breadworldWallet));
|
||||
const [t] = await txns();
|
||||
expect(t.location).toBe("Wyndham Vale, VIC");
|
||||
});
|
||||
|
||||
it("falls back to the fixed-width descriptor, country included", async () => {
|
||||
// The issuer half has no subtitle; the country code is positional.
|
||||
await ingest(req({
|
||||
...breadworldBank, event_id: 75, amount: 42.44,
|
||||
merchant: "SP PEAKDESIGN",
|
||||
notif_body: "$42.44 paid from Mastercard ending...8032. SP PEAKDESIGN SAN FRANCISOUS",
|
||||
}));
|
||||
const [t] = await txns();
|
||||
expect(t.location).toBe("SAN FRANCISO US");
|
||||
const [m] = await metas();
|
||||
// The only foreign merchant in the whole capture window — worth surfacing.
|
||||
expect(m.flags).toContain("foreign_country_US");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user