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:
2026-09-13 23:28:59 +07:00
parent 1b880ed554
commit 0fa0ed3380
9 changed files with 266 additions and 6 deletions
@@ -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,
};
/**
+77
View File
@@ -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,
};
}
+1 -1
View File
@@ -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;
+6
View File
@@ -0,0 +1,6 @@
export const briQris = {
kind: "bri.qris.success",
source: "bri",
fromAddress: "bankbri@bri.co.id",
subject: "Pembelian QRIS Berhasil",
};
+2 -1
View File
@@ -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];
+74
View File
@@ -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
`;
+28
View File
@@ -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",
+74 -1
View File
@@ -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));
});
});