Match no-reply@flip.id transfer success mail (direct or forwarded), parse stacked Nominal/ID Transaksi fields, and map spendings with source=flip. Co-authored-by: Cursor <cursoragent@cursor.com>
462 lines
16 KiB
JavaScript
462 lines
16 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { describe, it } from "node:test";
|
|
import { parseBriQris, parseBriTransfer } from "../src/parsers/bri.js";
|
|
import { parseFlipTransfer } from "../src/parsers/flip.js";
|
|
import { parseGrabReceipt, extractGrabTripTimesFromHtml } from "../src/parsers/grab.js";
|
|
import { parseLivinReceipt, splitMerchantLocation } from "../src/parsers/livin.js";
|
|
import { parseTokopediaOrder } from "../src/parsers/tokopedia.js";
|
|
import { toSpendingRecord, toPocketBaseDate } from "../src/pocketbase/map.js";
|
|
import { briQris } from "../src/rules/bri-qris.js";
|
|
import { briTransfer } from "../src/rules/bri-transfer.js";
|
|
import { flipTransfer } from "../src/rules/flip-transfer.js";
|
|
import { livinQrPayment } from "../src/rules/livin-qr-payment.js";
|
|
import { grabEreceipt } from "../src/rules/grab-ereceipt.js";
|
|
import { tokopediaOrder } from "../src/rules/tokopedia-order.js";
|
|
import {
|
|
briQrisDirectBody,
|
|
briQrisForwardedBody,
|
|
briTransferDirectBody,
|
|
briTransferForwardedBody,
|
|
flipDirectBody,
|
|
flipForwardedBody,
|
|
grabBody,
|
|
grabFoodForwardedBody,
|
|
grabForwardedBody,
|
|
grabRideHtml,
|
|
grabTipForwardedBody,
|
|
livinDirectBody,
|
|
livinForwardedBody,
|
|
tokopediaMultiForwardedBody,
|
|
tokopediaSingleForwardedBody,
|
|
} from "./fixtures.js";
|
|
|
|
describe("splitMerchantLocation", () => {
|
|
it("splits clean multi-line penerima", () => {
|
|
const r = splitMerchantLocation([
|
|
"Eleven Chicken Cabang Gah",
|
|
"JAKARTA SELATAN - ID",
|
|
]);
|
|
assert.equal(r.merchant, "Eleven Chicken Cabang Gah");
|
|
assert.equal(r.location, "JAKARTA SELATAN - ID");
|
|
});
|
|
|
|
it("keeps glued merchant+city as merchant only", () => {
|
|
const r = splitMerchantLocation(["SNACK BAROKAHJAKARTA BARAT - ID"]);
|
|
assert.equal(r.merchant, "SNACK BAROKAHJAKARTA BARAT - ID");
|
|
assert.equal(r.location, null);
|
|
});
|
|
});
|
|
|
|
describe("parseLivinReceipt", () => {
|
|
it("parses direct HTML-stripped body", () => {
|
|
const r = parseLivinReceipt(livinDirectBody);
|
|
assert.ok(r);
|
|
assert.equal(r.amount, 14000);
|
|
assert.equal(r.reference, "2609131121540314991");
|
|
assert.equal(r.occurredAt, "2026-09-13T14:48:31+07:00");
|
|
assert.equal(r.details.payerName, "NASYAROBBY NUGRAHA PUTRA");
|
|
assert.equal(r.details.merchant, "SNACK BAROKAHJAKARTA BARAT - ID");
|
|
assert.equal(r.details.location, undefined);
|
|
assert.equal(r.details.qrisRef, "609532729372");
|
|
assert.equal(r.details.acquirer, "GoPay");
|
|
});
|
|
|
|
it("parses forwarded clean body", () => {
|
|
const r = parseLivinReceipt(livinForwardedBody);
|
|
assert.ok(r);
|
|
assert.equal(r.amount, 22000);
|
|
assert.equal(r.reference, "2609131121539559589");
|
|
assert.equal(r.occurredAt, "2026-09-13T10:43:51+07:00");
|
|
assert.equal(r.details.merchant, "Eleven Chicken Cabang Gah");
|
|
assert.equal(r.details.location, "JAKARTA SELATAN - ID");
|
|
});
|
|
|
|
it("returns null without amount/reference", () => {
|
|
assert.equal(parseLivinReceipt("Halo someone\nPenerima\nFoo"), null);
|
|
});
|
|
|
|
it("parses Indonesian month abbreviation (Agu)", () => {
|
|
const body = `Halo NASYAROBBY NUGRAHA PUTRA,
|
|
Penerima
|
|
RM Bu Sum Ebony, KalibataJAKARTA SELATAN - ID
|
|
Tanggal15 Agu 2026
|
|
Jam09:45:25 WIB
|
|
Nominal TransaksiRp 15.000,00
|
|
No. Referensi2608151121545704363
|
|
No. Ref. QRIS608550990105`;
|
|
const r = parseLivinReceipt(body);
|
|
assert.ok(r);
|
|
assert.equal(r.amount, 15000);
|
|
assert.equal(r.reference, "2608151121545704363");
|
|
assert.equal(r.occurredAt, "2026-08-15T09:45:25+07:00");
|
|
});
|
|
});
|
|
|
|
describe("parseGrabReceipt", () => {
|
|
it("parses direct Grab body", () => {
|
|
const r = parseGrabReceipt(grabBody);
|
|
assert.ok(r);
|
|
assert.equal(r.kind, "grab.ereceipt.ride");
|
|
assert.equal(r.amount, 16600);
|
|
assert.equal(r.reference, "A-9RXV4K2GWCLOAV");
|
|
assert.equal(r.occurredAt, "2026-09-13");
|
|
assert.equal(r.details.product, "Bike Hemat");
|
|
assert.equal(r.details.driver, "Mahendra");
|
|
assert.equal(r.details.passenger, "Bintang");
|
|
assert.deepEqual(r.details.compliments, ["Layanan Mantap"]);
|
|
});
|
|
|
|
it("merges pickup time from HTML into occurredAt", () => {
|
|
const r = parseGrabReceipt(grabBody, { html: grabRideHtml });
|
|
assert.ok(r);
|
|
assert.equal(r.occurredAt, "2026-09-13T10:47:00+07:00");
|
|
assert.equal(r.details.dropoffAt, "2026-09-13T10:58:00+07:00");
|
|
});
|
|
|
|
it("extracts standalone AM/PM trip times from HTML", () => {
|
|
const t = extractGrabTripTimesFromHtml(grabRideHtml);
|
|
assert.equal(t.pickup, "10:47:00");
|
|
assert.equal(t.dropoff, "10:58:00");
|
|
});
|
|
|
|
it("parses forwarded Grab body", () => {
|
|
const r = parseGrabReceipt(grabForwardedBody);
|
|
assert.ok(r);
|
|
assert.equal(r.amount, 16600);
|
|
assert.equal(r.reference, "A-9RXV4K2GWCLOAV");
|
|
assert.equal(r.details.product, "Bike Hemat");
|
|
assert.equal(r.kind, "grab.ereceipt.ride");
|
|
});
|
|
|
|
it("parses Grab tip as a separate kind/invoice", () => {
|
|
const r = parseGrabReceipt(grabTipForwardedBody);
|
|
assert.ok(r);
|
|
assert.equal(r.kind, "grab.ereceipt.tip");
|
|
assert.equal(r.variant, "tip");
|
|
assert.equal(r.amount, 2000);
|
|
assert.equal(r.reference, "A-9RXV4K2GWCLOAV:tip");
|
|
assert.equal(r.occurredAt, "2026-09-13T10:47:00+07:00");
|
|
assert.equal(r.details.product, "Bike Hemat");
|
|
assert.equal(r.details.variant, "tip");
|
|
});
|
|
|
|
it("parses Grab Food receipt", () => {
|
|
const r = parseGrabReceipt(grabFoodForwardedBody);
|
|
assert.ok(r);
|
|
assert.equal(r.kind, "grab.ereceipt.food");
|
|
assert.equal(r.variant, "food");
|
|
assert.equal(r.amount, 49000);
|
|
assert.equal(r.reference, "00112712674-C8EUEZDGEEAZTX");
|
|
assert.equal(r.occurredAt, "2026-09-11T18:24:00+07:00");
|
|
assert.equal(r.details.merchant, "Doner Kebab - Kalibata City");
|
|
assert.equal(r.details.vehicleType, "GrabFood");
|
|
assert.equal(r.details.passenger, "Bintang");
|
|
assert.equal(r.details.paymentMethod, "OVO Cash");
|
|
});
|
|
});
|
|
|
|
describe("parseBriTransfer", () => {
|
|
it("parses direct BRImo transfer body", () => {
|
|
const r = parseBriTransfer(briTransferDirectBody);
|
|
assert.ok(r);
|
|
assert.equal(r.amount, 4000000);
|
|
assert.equal(r.reference, "212189333864");
|
|
assert.equal(r.occurredAt, "2026-09-13T19:00:00+07:00");
|
|
assert.equal(r.details.payerName, "NASYAROBBY NUGRAHA PUTRA");
|
|
assert.equal(r.details.namaTujuan, "NELLY ARISANTI");
|
|
assert.equal(r.details.jenisTransaksi, "Transfer Bank BRI");
|
|
assert.equal(r.details.bankTujuan, "BANK BRI");
|
|
assert.equal(r.details.nomorTujuan, "1060 0100 2070 507");
|
|
assert.equal(r.details.biayaAdmin, 0);
|
|
assert.equal(
|
|
r.details.fundingSource,
|
|
"NASYAROBBY NUGRAHA PUTRA 0121 **** **** 500",
|
|
);
|
|
assert.equal(r.details.catatan, undefined);
|
|
});
|
|
|
|
it("parses forwarded BRImo transfer body", () => {
|
|
const r = parseBriTransfer(briTransferForwardedBody);
|
|
assert.ok(r);
|
|
assert.equal(r.amount, 4000000);
|
|
assert.equal(r.reference, "212189333864");
|
|
assert.equal(r.details.namaTujuan, "NELLY ARISANTI");
|
|
});
|
|
|
|
it("returns null without amount/reference", () => {
|
|
assert.equal(parseBriTransfer("Halo someone\nTransaksi Berhasil"), null);
|
|
});
|
|
});
|
|
|
|
describe("parseBriQris", () => {
|
|
it("parses direct BRImo QRIS body", () => {
|
|
const r = parseBriQris(briQrisDirectBody);
|
|
assert.ok(r);
|
|
assert.equal(r.amount, 674500);
|
|
assert.equal(r.reference, "185794483464");
|
|
assert.equal(r.occurredAt, "2026-07-30T19:21:34+07:00");
|
|
assert.equal(r.details.payerName, "NASYAROBBY NUGRAHA PUTRA");
|
|
assert.equal(r.details.merchant, "Shopee Indonesia");
|
|
assert.equal(r.details.location, "KOTA JAKARTA");
|
|
assert.equal(r.details.jenisTransaksi, "QRIS Bayar");
|
|
assert.equal(r.details.acquirer, "Shopeepay");
|
|
assert.equal(r.details.issuer, "BRI");
|
|
assert.equal(r.details.customerPan, "9360000210106600585");
|
|
assert.equal(r.details.merchantPan, "936009180021573212");
|
|
assert.equal(r.details.terminalId, "21573212");
|
|
assert.equal(r.details.biayaAdmin, 0);
|
|
assert.equal(
|
|
r.details.fundingSource,
|
|
"NASYAROBBY NUGRAHA PUTRA 0121 **** **** 500",
|
|
);
|
|
});
|
|
|
|
it("parses forwarded BRImo QRIS body", () => {
|
|
const r = parseBriQris(briQrisForwardedBody);
|
|
assert.ok(r);
|
|
assert.equal(r.amount, 674500);
|
|
assert.equal(r.reference, "185794483464");
|
|
assert.equal(r.details.merchant, "Shopee Indonesia");
|
|
});
|
|
|
|
it("falls back to Total Transaksi when Nominal missing", () => {
|
|
const body = `Halo FOO,
|
|
Total Transaksi
|
|
12.500
|
|
Nomor Referensi 999
|
|
Tanggal Transaksi 1 Jan 2026, 10:00:00 WIB
|
|
Nama Merchant Cafe
|
|
`;
|
|
const r = parseBriQris(body);
|
|
assert.ok(r);
|
|
assert.equal(r.amount, 12500);
|
|
assert.equal(r.reference, "999");
|
|
});
|
|
|
|
it("returns null without amount/reference", () => {
|
|
assert.equal(parseBriQris("Halo someone\nNama Merchant Foo"), null);
|
|
});
|
|
});
|
|
|
|
describe("toSpendingRecord", () => {
|
|
it("maps Livin envelope to spendings fields", () => {
|
|
const parsed = {
|
|
kind: "livin.qr_payment.success",
|
|
messageId: "1a099bd61efd234d",
|
|
amount: 14000,
|
|
reference: "2609131121540314991",
|
|
occurredAt: "2026-09-13T14:48:31+07:00",
|
|
details: {
|
|
merchant: "SNACK BAROKAHJAKARTA BARAT - ID",
|
|
qrisRef: "609532729372",
|
|
},
|
|
};
|
|
const rec = toSpendingRecord(parsed, livinQrPayment);
|
|
assert.equal(rec.invoice_id, "2609131121540314991");
|
|
assert.equal(rec.amount, 14000);
|
|
assert.equal(rec.source, "livin");
|
|
assert.equal(rec.description, "SNACK BAROKAHJAKARTA BARAT - ID");
|
|
assert.equal(rec.category, "");
|
|
assert.equal(rec.message_id, "1a099bd61efd234d");
|
|
assert.equal(rec.trx_date, toPocketBaseDate(parsed.occurredAt));
|
|
assert.deepEqual(rec.parsed, parsed);
|
|
});
|
|
|
|
it("maps Grab envelope to spendings fields", () => {
|
|
const parsed = {
|
|
kind: "grab.ereceipt.ride",
|
|
messageId: "1a099b8b9d239e5e",
|
|
amount: 16600,
|
|
reference: "A-9RXV4K2GWCLOAV",
|
|
occurredAt: "2026-09-13",
|
|
details: { product: "Bike Hemat", driver: "Mahendra" },
|
|
};
|
|
const rec = toSpendingRecord(parsed, grabEreceipt);
|
|
assert.equal(rec.invoice_id, "A-9RXV4K2GWCLOAV");
|
|
assert.equal(rec.source, "grab");
|
|
assert.equal(rec.description, "Grab Bike Hemat");
|
|
assert.equal(rec.trx_date, "2026-09-13 00:00:00.000Z");
|
|
assert.deepEqual(rec.parsed, parsed);
|
|
});
|
|
|
|
it("maps Grab tip envelope to spendings fields", () => {
|
|
const parsed = {
|
|
kind: "grab.ereceipt.tip",
|
|
messageId: "1a099e731cb511dc",
|
|
amount: 2000,
|
|
reference: "A-9RXV4K2GWCLOAV:tip",
|
|
occurredAt: "2026-09-13T10:47:00+07:00",
|
|
details: { product: "Bike Hemat", variant: "tip" },
|
|
};
|
|
const rec = toSpendingRecord(parsed, grabEreceipt);
|
|
assert.equal(rec.invoice_id, "A-9RXV4K2GWCLOAV:tip");
|
|
assert.equal(rec.source, "grab");
|
|
assert.equal(rec.description, "Grab Bike Hemat tip");
|
|
assert.equal(rec.trx_date, toPocketBaseDate(parsed.occurredAt));
|
|
});
|
|
|
|
it("maps Grab Food envelope to spendings fields", () => {
|
|
const parsed = {
|
|
kind: "grab.ereceipt.food",
|
|
messageId: "1a09a07cbd6f58cb",
|
|
amount: 49000,
|
|
reference: "00112712674-C8EUEZDGEEAZTX",
|
|
occurredAt: "2026-09-11T18:24:00+07:00",
|
|
details: {
|
|
variant: "food",
|
|
product: "Food",
|
|
merchant: "Doner Kebab - Kalibata City",
|
|
},
|
|
};
|
|
const rec = toSpendingRecord(parsed, grabEreceipt);
|
|
assert.equal(rec.invoice_id, "00112712674-C8EUEZDGEEAZTX");
|
|
assert.equal(rec.source, "grab");
|
|
assert.equal(rec.description, "Grab Food Doner Kebab - Kalibata City");
|
|
});
|
|
|
|
it("maps BRI transfer envelope to spendings fields", () => {
|
|
const parsed = {
|
|
kind: "bri.transfer.success",
|
|
messageId: "1a09b6cd9a3bc6bd",
|
|
amount: 4000000,
|
|
reference: "212189333864",
|
|
occurredAt: "2026-09-13T19:00:00+07:00",
|
|
details: {
|
|
namaTujuan: "NELLY ARISANTI",
|
|
jenisTransaksi: "Transfer Bank BRI",
|
|
},
|
|
};
|
|
const rec = toSpendingRecord(parsed, briTransfer);
|
|
assert.equal(rec.invoice_id, "212189333864");
|
|
assert.equal(rec.amount, 4000000);
|
|
assert.equal(rec.source, "bri");
|
|
assert.equal(rec.description, "NELLY ARISANTI");
|
|
assert.equal(rec.trx_date, toPocketBaseDate(parsed.occurredAt));
|
|
assert.deepEqual(rec.parsed, parsed);
|
|
});
|
|
|
|
it("maps BRI QRIS envelope to spendings fields", () => {
|
|
const parsed = {
|
|
kind: "bri.qris.success",
|
|
messageId: "1a09b9031c233407",
|
|
amount: 674500,
|
|
reference: "185794483464",
|
|
occurredAt: "2026-07-30T19:21:34+07:00",
|
|
details: {
|
|
merchant: "Shopee Indonesia",
|
|
location: "KOTA JAKARTA",
|
|
},
|
|
};
|
|
const rec = toSpendingRecord(parsed, briQris);
|
|
assert.equal(rec.invoice_id, "185794483464");
|
|
assert.equal(rec.amount, 674500);
|
|
assert.equal(rec.source, "bri");
|
|
assert.equal(rec.description, "Shopee Indonesia");
|
|
assert.equal(rec.trx_date, toPocketBaseDate(parsed.occurredAt));
|
|
});
|
|
|
|
it("maps Tokopedia envelope to spendings fields", () => {
|
|
const parsed = {
|
|
kind: "tokopedia.order.completed",
|
|
messageId: "1a09ba1248c7d2e8",
|
|
amount: 48805,
|
|
reference: "585871780568401286",
|
|
occurredAt: "2026-09-05",
|
|
details: { shop: "LuxePad.Creations" },
|
|
};
|
|
const rec = toSpendingRecord(parsed, tokopediaOrder);
|
|
assert.equal(rec.invoice_id, "585871780568401286");
|
|
assert.equal(rec.source, "tokopedia");
|
|
assert.equal(rec.description, "LuxePad.Creations - Tokopedia");
|
|
assert.equal(rec.trx_date, "2026-09-05 00:00:00.000Z");
|
|
});
|
|
|
|
it("maps Flip transfer envelope to spendings fields", () => {
|
|
const parsed = {
|
|
kind: "flip.transfer.success",
|
|
messageId: "1a0c704164e0cd54",
|
|
amount: 40001,
|
|
reference: "ST260918145355861M3ZG4",
|
|
occurredAt: "2026-09-18T14:53:00+07:00",
|
|
details: {
|
|
namaTujuan: "Rwen Ibrahim",
|
|
bankTujuan: "BCA",
|
|
nomorRekening: "0020215810",
|
|
},
|
|
};
|
|
const rec = toSpendingRecord(parsed, flipTransfer);
|
|
assert.equal(rec.invoice_id, "ST260918145355861M3ZG4");
|
|
assert.equal(rec.amount, 40001);
|
|
assert.equal(rec.source, "flip");
|
|
assert.equal(rec.description, "Rwen Ibrahim");
|
|
assert.equal(rec.trx_date, toPocketBaseDate(parsed.occurredAt));
|
|
assert.deepEqual(rec.parsed, parsed);
|
|
});
|
|
});
|
|
|
|
describe("parseFlipTransfer", () => {
|
|
it("parses direct stacked-label body", () => {
|
|
const r = parseFlipTransfer(flipDirectBody);
|
|
assert.ok(r);
|
|
assert.equal(r.amount, 40001);
|
|
assert.equal(r.reference, "ST260918145355861M3ZG4");
|
|
assert.equal(r.occurredAt, "2026-09-18T14:53:00+07:00");
|
|
assert.equal(r.details.namaTujuan, "Rwen Ibrahim");
|
|
assert.equal(r.details.bankTujuan, "BCA");
|
|
assert.equal(r.details.nomorRekening, "0020215810");
|
|
});
|
|
|
|
it("parses forwarded body", () => {
|
|
const r = parseFlipTransfer(flipForwardedBody);
|
|
assert.ok(r);
|
|
assert.equal(r.amount, 40001);
|
|
assert.equal(r.reference, "ST260918145355861M3ZG4");
|
|
assert.equal(r.occurredAt, "2026-09-18T14:53:00+07:00");
|
|
assert.equal(r.details.namaTujuan, "Rwen Ibrahim");
|
|
});
|
|
|
|
it("returns null when Nominal or ID Transaksi missing", () => {
|
|
assert.equal(parseFlipTransfer("BUKTI TRANSFER\nNominal\nRp10.000\n"), null);
|
|
assert.equal(
|
|
parseFlipTransfer("ID Transaksi\n#ABC\nNama Tujuan\nX\n"),
|
|
null,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("parseTokopediaOrder", () => {
|
|
it("parses single-item Pesanan Selesai", () => {
|
|
const r = parseTokopediaOrder(tokopediaSingleForwardedBody);
|
|
assert.ok(r);
|
|
assert.equal(r.amount, 48805);
|
|
assert.equal(r.reference, "585871780568401286");
|
|
assert.equal(r.occurredAt, "2026-09-05");
|
|
assert.equal(r.details.shop, "LuxePad.Creations");
|
|
assert.equal(r.details.trackingNumber, "JY1590448249");
|
|
assert.equal(r.details.shippingService, "Standard");
|
|
assert.equal(r.details.items.length, 1);
|
|
assert.match(r.details.items[0].name, /Mouse Pad Besar Anime Style/);
|
|
assert.equal(r.details.items[0].qty, 1);
|
|
assert.equal(r.details.items[0].unitPrice, 88750);
|
|
assert.equal(r.details.items[0].lineTotal, 88750);
|
|
});
|
|
|
|
it("parses multi-item order as one invoice", () => {
|
|
const r = parseTokopediaOrder(tokopediaMultiForwardedBody);
|
|
assert.ok(r);
|
|
assert.equal(r.amount, 26000);
|
|
assert.equal(r.reference, "585494052374283654");
|
|
assert.equal(r.occurredAt, "2026-08-14");
|
|
assert.equal(r.details.shop, "NNcolection09");
|
|
assert.equal(r.details.items.length, 2);
|
|
assert.match(r.details.items[0].name, /KOMBINASI BIRU/);
|
|
assert.match(r.details.items[1].name, /KOMBINASI MERAH/);
|
|
assert.equal(r.details.items[0].unitPrice, 10500);
|
|
assert.equal(r.details.items[1].lineTotal, 10500);
|
|
});
|
|
|
|
it("returns null without invoice/total", () => {
|
|
assert.equal(parseTokopediaOrder("Hai Roby\nPesananmu selesai."), null);
|
|
});
|
|
});
|