diff --git a/AGENTS.md b/AGENTS.md index 7774a0d..92d6cfa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`. diff --git a/apps/watcher/src/handlers/parse-matched-message.js b/apps/watcher/src/handlers/parse-matched-message.js index 404246d..626011f 100644 --- a/apps/watcher/src/handlers/parse-matched-message.js +++ b/apps/watcher/src/handlers/parse-matched-message.js @@ -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, }; /** diff --git a/apps/watcher/src/parsers/bri.js b/apps/watcher/src/parsers/bri.js index faa42f0..69067c0 100644 --- a/apps/watcher/src/parsers/bri.js +++ b/apps/watcher/src/parsers/bri.js @@ -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, + }; +} diff --git a/apps/watcher/src/pocketbase/map.js b/apps/watcher/src/pocketbase/map.js index 6b10022..02e1d0d 100644 --- a/apps/watcher/src/pocketbase/map.js +++ b/apps/watcher/src/pocketbase/map.js @@ -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; diff --git a/apps/watcher/src/rules/bri-qris.js b/apps/watcher/src/rules/bri-qris.js new file mode 100644 index 0000000..4a4ff03 --- /dev/null +++ b/apps/watcher/src/rules/bri-qris.js @@ -0,0 +1,6 @@ +export const briQris = { + kind: "bri.qris.success", + source: "bri", + fromAddress: "bankbri@bri.co.id", + subject: "Pembelian QRIS Berhasil", +}; diff --git a/apps/watcher/src/rules/index.js b/apps/watcher/src/rules/index.js index e4eb222..e50c528 100644 --- a/apps/watcher/src/rules/index.js +++ b/apps/watcher/src/rules/index.js @@ -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]; diff --git a/apps/watcher/test/fixtures.js b/apps/watcher/test/fixtures.js index 1a41517..aab4d90 100644 --- a/apps/watcher/test/fixtures.js +++ b/apps/watcher/test/fixtures.js @@ -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 +Date: Thu, Jul 30, 2026, 7:22 PM +Subject: Pembelian QRIS Berhasil +To: + + +[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 +`; diff --git a/apps/watcher/test/match.test.js b/apps/watcher/test/match.test.js index 9041dd6..1cd46d0 100644 --- a/apps/watcher/test/match.test.js +++ b/apps/watcher/test/match.test.js @@ -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 ", + 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 ", + 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", diff --git a/apps/watcher/test/parse.test.js b/apps/watcher/test/parse.test.js index dd72392..ed54188 100644 --- a/apps/watcher/test/parse.test.js +++ b/apps/watcher/test/parse.test.js @@ -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)); + }); });