feat(watcher): add BRI QRIS parsing and rules
Implement parsing for BRI QRIS transactions, including direct and forwarded message handling. Update rules to recognize QRIS success messages and adjust spending record mapping accordingly. Enhance tests to cover new functionality.
This commit is contained in:
@@ -38,6 +38,7 @@ Resolve original sender:
|
||||
| `grab.ereceipt.tip` | same From/Subject as ride; detected from tip body copy | tip invoice_id = `{Booking ID}:tip` |
|
||||
| `grab.ereceipt.food` | same From/Subject; body has GrabFood / `Selamat menikmati makanan` | `Pesanan ID` as invoice_id |
|
||||
| `bri.transfer.success` | `bankbri@bri.co.id` | `Pemindahan Dana Sesama Rekening BRI` |
|
||||
| `bri.qris.success` | `bankbri@bri.co.id` | `Pembelian QRIS Berhasil` |
|
||||
|
||||
Production path is **direct provider mail**. Forwards still work via the Fwd header. Do **not** match on the forwarder envelope From.
|
||||
|
||||
@@ -48,9 +49,8 @@ Rules: `apps/watcher/src/rules/`. Parsers: `apps/watcher/src/parsers/`.
|
||||
- **Required for a spending row:** `amount` + `reference` (invoice id). Fail soft (log + break) if missing — never throw (Pub/Sub must ack).
|
||||
- **Livin:** support clean forwarded lines and jammed HTML-stripped labels (`Tanggal13 Sep 2026`, `Tanggal15 Agu 2026`). Indonesian month names/abbreviations (`Agu`/`Agustus`, `Des`, …) are accepted. When merchant and city are glued on one line, keep the whole string as `merchant`; only split location when it is already a separate line ending in `- ID`. Do not maintain a city-name list.
|
||||
- **Grab:** plain text often has date-only `Picked up on …`; pickup/dropoff times (`10:47AM`) live in HTML. Watcher keeps `message.html` and merges the first standalone AM/PM time into `occurredAt` (WIB). Tip receipts (`Your tip goes a long way…`, timestamp like `13 Sep 26 10:47 +0700`) become `grab.ereceipt.tip` with `invoice_id` `{bookingId}:tip` so they do not collide with the ride.
|
||||
- **BRI / BRImo:** `Pemindahan Dana Sesama Rekening BRI` — amount from `Nominal` (not `Total`), `invoice_id` from `Nomor Referensi`, `description` from `Nama Tujuan`, datetime from combined `Tanggal` (`13 September 2026 , 19:00:00 WIB`). Other BRI subjects are out of scope until samples appear.
|
||||
- **BRI / BRImo:** transfer (`Pemindahan Dana Sesama Rekening BRI`) — amount from `Nominal`, `invoice_id` from `Nomor Referensi`, `description` from `Nama Tujuan`, datetime from `Tanggal`. QRIS (`Pembelian QRIS Berhasil`) — amount from `Nominal` (fallback `Total Transaksi`), `description` from `Nama Merchant`, datetime from `Tanggal Transaksi`. Other BRI subjects stay out of scope until samples appear.
|
||||
- Open-ended text (names, products, driver, compliments) is best-effort optional `details`.
|
||||
|
||||
## PocketBase
|
||||
|
||||
Env: `POCKETBASE_URL`, `POCKETBASE_USER`, `POCKETBASE_PASSWORD` (a `_superusers` account). Collection: `spendings`.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { parseBriTransfer } from "../parsers/bri.js";
|
||||
import { parseBriQris, parseBriTransfer } from "../parsers/bri.js";
|
||||
import { parseGrabReceipt } from "../parsers/grab.js";
|
||||
import { parseLivinReceipt } from "../parsers/livin.js";
|
||||
import { notifyReceiptIssue } from "../notify/ntfy.js";
|
||||
@@ -7,6 +7,7 @@ const PARSERS = {
|
||||
"livin.qr_payment.success": parseLivinReceipt,
|
||||
"grab.ereceipt.ride": parseGrabReceipt,
|
||||
"bri.transfer.success": parseBriTransfer,
|
||||
"bri.qris.success": parseBriQris,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -66,3 +66,80 @@ export function parseBriTransfer(body) {
|
||||
details,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse BRImo / Bank BRI QRIS purchase body (plain text).
|
||||
* Required: Nominal (or Total Transaksi) + Nomor Referensi.
|
||||
*/
|
||||
export function parseBriQris(body) {
|
||||
if (!body) return null;
|
||||
|
||||
const payerMatch = body.match(/Halo\s+([^,\n]+)/i);
|
||||
const payerName = payerMatch ? payerMatch[1].trim() : null;
|
||||
|
||||
const tanggalRaw = captureAfterLabel(body, "Tanggal Transaksi");
|
||||
const dateYmd = parseDayMonthYear(tanggalRaw);
|
||||
const timeHms = parseTimeWib(tanggalRaw);
|
||||
const occurredAt = toWibIso(dateYmd, timeHms);
|
||||
|
||||
const nominalRaw = captureAfterLabel(body, "Nominal");
|
||||
let amount = parseIdrAmount(nominalRaw);
|
||||
if (amount == null) {
|
||||
const totalBlock = captureBlockAfterLabel(body, "Total Transaksi", [
|
||||
"Tujuan",
|
||||
"Nama Merchant",
|
||||
"Nomor Referensi",
|
||||
]);
|
||||
amount = parseIdrAmount(totalBlock?.[0] || null);
|
||||
}
|
||||
|
||||
const reference =
|
||||
captureAfterLabel(body, "Nomor Referensi") ||
|
||||
body.match(/Nomor\s+Referensi\s*([^\s\n]+)/i)?.[1] ||
|
||||
null;
|
||||
|
||||
if (amount == null || !reference) return null;
|
||||
|
||||
const merchant = captureAfterLabel(body, "Nama Merchant");
|
||||
const location = captureAfterLabel(body, "Lokasi Merchant");
|
||||
const jenisTransaksi = captureAfterLabel(body, "Jenis Transaksi");
|
||||
const acquirer = captureAfterLabel(body, "Nama Pengakuisisi");
|
||||
const issuer = captureAfterLabel(body, "Nama Penerbit");
|
||||
const customerPan = captureAfterLabel(body, "Kode PAN Pelanggan");
|
||||
const merchantPan = captureAfterLabel(body, "Merchant PAN");
|
||||
const terminalId = captureAfterLabel(body, "ID Terminal");
|
||||
const catatanRaw = captureAfterLabel(body, "Catatan");
|
||||
const biayaAdmin = parseIdrAmount(captureAfterLabel(body, "Biaya Admin"));
|
||||
|
||||
const rekening = captureAfterLabel(body, "Rekening Sumber Dana");
|
||||
const namaSumber = captureAfterLabel(body, "Nama Sumber Dana");
|
||||
const fundingParts = [namaSumber, rekening].filter(Boolean);
|
||||
const fundingSource =
|
||||
fundingParts.length > 0
|
||||
? fundingParts.join(" ").replace(/\s+/g, " ").trim()
|
||||
: null;
|
||||
|
||||
const details = {};
|
||||
if (payerName) details.payerName = payerName;
|
||||
if (merchant) details.merchant = merchant.trim();
|
||||
if (location) details.location = location.trim();
|
||||
if (jenisTransaksi) details.jenisTransaksi = jenisTransaksi.trim();
|
||||
if (acquirer) details.acquirer = acquirer.trim();
|
||||
if (issuer) details.issuer = issuer.trim();
|
||||
if (customerPan) details.customerPan = customerPan.trim();
|
||||
if (merchantPan) details.merchantPan = merchantPan.trim();
|
||||
if (terminalId) details.terminalId = terminalId.trim();
|
||||
if (catatanRaw != null && catatanRaw.trim() && catatanRaw.trim() !== "-") {
|
||||
details.catatan = catatanRaw.trim();
|
||||
}
|
||||
if (biayaAdmin != null) details.biayaAdmin = biayaAdmin;
|
||||
if (fundingSource) details.fundingSource = fundingSource;
|
||||
|
||||
return {
|
||||
amount,
|
||||
currency: "IDR",
|
||||
reference: reference.trim(),
|
||||
occurredAt,
|
||||
details,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ export function toSpendingRecord(parsed, rule) {
|
||||
if (source === "livin") {
|
||||
description = parsed.details?.merchant || "";
|
||||
} else if (source === "bri") {
|
||||
description = parsed.details?.namaTujuan || "";
|
||||
description = parsed.details?.namaTujuan || parsed.details?.merchant || "";
|
||||
} else if (source === "grab") {
|
||||
if (parsed.kind === "grab.ereceipt.food" || parsed.details?.variant === "food") {
|
||||
const merchant = parsed.details?.merchant;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export const briQris = {
|
||||
kind: "bri.qris.success",
|
||||
source: "bri",
|
||||
fromAddress: "bankbri@bri.co.id",
|
||||
subject: "Pembelian QRIS Berhasil",
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import { briQris } from "./bri-qris.js";
|
||||
import { briTransfer } from "./bri-transfer.js";
|
||||
import { grabEreceipt } from "./grab-ereceipt.js";
|
||||
import { livinQrPayment } from "./livin-qr-payment.js";
|
||||
|
||||
export const rules = [livinQrPayment, grabEreceipt, briTransfer];
|
||||
export const rules = [livinQrPayment, grabEreceipt, briTransfer, briQris];
|
||||
|
||||
@@ -231,3 +231,77 @@ Nominal Rp4.000.000
|
||||
Biaya Admin Rp0
|
||||
*Total* *Rp4.000.000*
|
||||
`;
|
||||
|
||||
export const briQrisDirectBody = `Halo NASYAROBBY NUGRAHA PUTRA,
|
||||
|
||||
Berikut detail transaksi kamu di BRImo:
|
||||
Total Transaksi
|
||||
674.500
|
||||
Tujuan
|
||||
[image: Product.png]
|
||||
|
||||
Shopee Indonesia
|
||||
|
||||
QRIS Bayar
|
||||
|
||||
9360000210106600585
|
||||
Nomor Referensi 185794483464
|
||||
Tanggal Transaksi 30 Jul 2026, 19:21:34 WIB
|
||||
Rekening Sumber Dana 0121 **** **** 500
|
||||
Nama Sumber Dana NASYAROBBY NUGRAHA PUTRA
|
||||
Jenis Transaksi QRIS Bayar
|
||||
Nama Merchant Shopee Indonesia
|
||||
Lokasi Merchant KOTA JAKARTA
|
||||
Nama Penerbit BRI
|
||||
Nama Pengakuisisi Shopeepay
|
||||
Kode PAN Pelanggan 9360000210106600585
|
||||
Merchant PAN 936009180021573212
|
||||
ID Terminal 21573212
|
||||
Catatan -
|
||||
Nominal Rp674.500
|
||||
Biaya Admin Rp0
|
||||
|
||||
*INFORMASI:*
|
||||
`;
|
||||
|
||||
export const briQrisForwardedBody = `Nasyarobby N. Putra
|
||||
Boston University MSSD Program
|
||||
|
||||
---------- Forwarded message ---------
|
||||
From: Bank BRI <BankBRI@bri.co.id>
|
||||
Date: Thu, Jul 30, 2026, 7:22 PM
|
||||
Subject: Pembelian QRIS Berhasil
|
||||
To: <nsrb@bu.edu>
|
||||
|
||||
|
||||
[image: bri-white-logo.png]
|
||||
|
||||
Halo NASYAROBBY NUGRAHA PUTRA,
|
||||
|
||||
Berikut detail transaksi kamu di BRImo:
|
||||
Total Transaksi
|
||||
674.500
|
||||
Tujuan
|
||||
[image: Product.png]
|
||||
|
||||
Shopee Indonesia
|
||||
|
||||
QRIS Bayar
|
||||
|
||||
9360000210106600585
|
||||
Nomor Referensi 185794483464
|
||||
Tanggal Transaksi 30 Jul 2026, 19:21:34 WIB
|
||||
Rekening Sumber Dana 0121 **** **** 500
|
||||
Nama Sumber Dana NASYAROBBY NUGRAHA PUTRA
|
||||
Jenis Transaksi QRIS Bayar
|
||||
Nama Merchant Shopee Indonesia
|
||||
Lokasi Merchant KOTA JAKARTA
|
||||
Nama Penerbit BRI
|
||||
Nama Pengakuisisi Shopeepay
|
||||
Kode PAN Pelanggan 9360000210106600585
|
||||
Merchant PAN 936009180021573212
|
||||
ID Terminal 21573212
|
||||
Catatan -
|
||||
Nominal Rp674.500
|
||||
Biaya Admin Rp0
|
||||
`;
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
} from "../src/lib/email.js";
|
||||
import { matchRule } from "../src/handlers/match-rule.js";
|
||||
import {
|
||||
briQrisDirectBody,
|
||||
briQrisForwardedBody,
|
||||
briTransferDirectBody,
|
||||
briTransferForwardedBody,
|
||||
grabBody,
|
||||
@@ -156,6 +158,32 @@ describe("matchRule", () => {
|
||||
assert.equal(result.pass.rule.kind, "bri.transfer.success");
|
||||
});
|
||||
|
||||
it("matches direct BRI QRIS", async () => {
|
||||
const result = await matchRule({
|
||||
messageId: "bri-qris-direct",
|
||||
log: silentLog,
|
||||
message: {
|
||||
from: "Bank BRI <BankBRI@bri.co.id>",
|
||||
subject: "Pembelian QRIS Berhasil",
|
||||
body: briQrisDirectBody,
|
||||
},
|
||||
});
|
||||
assert.equal(result.pass.rule.kind, "bri.qris.success");
|
||||
});
|
||||
|
||||
it("matches forwarded BRI QRIS via original header", async () => {
|
||||
const result = await matchRule({
|
||||
messageId: "1a09b9031c233407",
|
||||
log: silentLog,
|
||||
message: {
|
||||
from: "Nasyarobby Putra <nsrb@bu.edu>",
|
||||
subject: "Fwd: Pembelian QRIS Berhasil",
|
||||
body: briQrisForwardedBody,
|
||||
},
|
||||
});
|
||||
assert.equal(result.pass.rule.kind, "bri.qris.success");
|
||||
});
|
||||
|
||||
it("breaks on unknown from/subject", async () => {
|
||||
const result = await matchRule({
|
||||
messageId: "x",
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { parseBriTransfer } from "../src/parsers/bri.js";
|
||||
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 { 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 {
|
||||
briQrisDirectBody,
|
||||
briQrisForwardedBody,
|
||||
briTransferDirectBody,
|
||||
briTransferForwardedBody,
|
||||
grabBody,
|
||||
@@ -177,6 +180,56 @@ describe("parseBriTransfer", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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 = {
|
||||
@@ -273,4 +326,24 @@ describe("toSpendingRecord", () => {
|
||||
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));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user