Implement parsing for Tokopedia "Pesanan Selesai" messages, including support for single and multi-item orders. Update rules to recognize Tokopedia order completion and enhance spending record mapping. Add tests to validate new functionality.
406 lines
15 KiB
JavaScript
406 lines
15 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { describe, it } from "node:test";
|
|
import { parseBriQris, parseBriTransfer } from "../src/parsers/bri.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 { 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,
|
|
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");
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|