feat: add gmail-hook monorepo with watcher, api, and web
Ship the receipt pipeline (Livin/Grab/BRI), PocketBase spendings, PM2 ecosystem, and a proper .gitignore that excludes secrets, SQLite data, and media dumps. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,327 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
expandWorkItems,
|
||||
listRfc822Parts,
|
||||
messageFromRfc822Part,
|
||||
} from "../src/mime.js";
|
||||
import { processFetchedMessage } from "../src/process-fetched.js";
|
||||
import { matchRule } from "../src/handlers/match-rule.js";
|
||||
import { parseMatchedMessage } from "../src/handlers/parse-matched-message.js";
|
||||
|
||||
const silentLog = {
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
};
|
||||
|
||||
function textPart(text, partId = "0.0") {
|
||||
return {
|
||||
partId,
|
||||
mimeType: "text/plain",
|
||||
body: {
|
||||
data: Buffer.from(text, "utf8")
|
||||
.toString("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, ""),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function htmlPart(html, partId = "0.1") {
|
||||
return {
|
||||
partId,
|
||||
mimeType: "text/html",
|
||||
body: {
|
||||
data: Buffer.from(html, "utf8")
|
||||
.toString("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, ""),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function rfc822Part({
|
||||
partId,
|
||||
filename,
|
||||
from,
|
||||
subject,
|
||||
date,
|
||||
bodyText,
|
||||
bodyHtml,
|
||||
}) {
|
||||
return {
|
||||
partId,
|
||||
mimeType: "message/rfc822",
|
||||
filename,
|
||||
parts: [
|
||||
{
|
||||
partId: `${partId}.0`,
|
||||
mimeType: "multipart/alternative",
|
||||
headers: [
|
||||
{ name: "From", value: from },
|
||||
{ name: "Subject", value: subject },
|
||||
{ name: "Date", value: date },
|
||||
],
|
||||
parts: [
|
||||
textPart(bodyText, `${partId}.0.0`),
|
||||
...(bodyHtml ? [htmlPart(bodyHtml, `${partId}.0.1`)] : []),
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const grabRideBody = `Bike Hemat
|
||||
Hope you enjoyed your ride!
|
||||
Picked up on 13 September 2026
|
||||
Booking ID: A-TESTBOOKING1
|
||||
Total Paid
|
||||
Rp16.600
|
||||
Receipt issued by the driver Mahendra.
|
||||
Passenger
|
||||
Bintang
|
||||
`;
|
||||
|
||||
const grabFoodBody = `Selamat menikmati makanan Anda!
|
||||
TOTAL
|
||||
IDR 49000 TANGGAL | WAKTU
|
||||
11 Sep 26 18:24 +0700
|
||||
Detail Pesanan
|
||||
Jenis Kendaraan:
|
||||
GrabFood
|
||||
Diterbitkan untuk
|
||||
Bintang
|
||||
Pesanan ID
|
||||
00112712674-TESTFOOD1
|
||||
Pesanan Dari:
|
||||
Doner Kebab - Kalibata City
|
||||
Profil:
|
||||
Personal
|
||||
Detail Tagihan
|
||||
Detail Pembayaran:
|
||||
OVO Cash
|
||||
TOTAL (INCL. TAX) IDR 49000
|
||||
`;
|
||||
|
||||
describe("listRfc822Parts", () => {
|
||||
it("finds message/rfc822 attachments and does not treat outer as eml", () => {
|
||||
const payload = {
|
||||
mimeType: "multipart/mixed",
|
||||
parts: [
|
||||
{
|
||||
mimeType: "multipart/alternative",
|
||||
parts: [textPart("wrapper")],
|
||||
},
|
||||
rfc822Part({
|
||||
partId: "1",
|
||||
filename: "Your Grab E-Receipt.eml",
|
||||
from: "Grab <no-reply@grab.com>",
|
||||
subject: "Your Grab E-Receipt",
|
||||
date: "Wed, 9 Sep 2026 06:09:32 +0000",
|
||||
bodyText: grabRideBody,
|
||||
}),
|
||||
rfc822Part({
|
||||
partId: "2",
|
||||
filename: "Your Grab E-Receipt.eml",
|
||||
from: "Grab <no-reply@grab.com>",
|
||||
subject: "Your Grab E-Receipt",
|
||||
date: "Thu, 10 Sep 2026 11:28:24 +0000",
|
||||
bodyText: grabFoodBody,
|
||||
}),
|
||||
],
|
||||
};
|
||||
const parts = listRfc822Parts(payload);
|
||||
assert.equal(parts.length, 2);
|
||||
assert.equal(parts[0].filename, "Your Grab E-Receipt.eml");
|
||||
});
|
||||
});
|
||||
|
||||
describe("messageFromRfc822Part", () => {
|
||||
it("builds synthetic message with nested From/Subject/body", () => {
|
||||
const part = rfc822Part({
|
||||
partId: "1",
|
||||
filename: "Your Grab E-Receipt.eml",
|
||||
from: "Grab <no-reply@grab.com>",
|
||||
subject: "Your Grab E-Receipt",
|
||||
date: "Wed, 9 Sep 2026 06:09:32 +0000",
|
||||
bodyText: grabRideBody,
|
||||
});
|
||||
const msg = messageFromRfc822Part(part, {
|
||||
parentMessageId: "outer123",
|
||||
index: 0,
|
||||
});
|
||||
assert.equal(msg.id, "outer123#1");
|
||||
assert.equal(msg.from, "Grab <no-reply@grab.com>");
|
||||
assert.equal(msg.subject, "Your Grab E-Receipt");
|
||||
assert.match(msg.body, /Booking ID: A-TESTBOOKING1/);
|
||||
assert.equal(msg.parentMessageId, "outer123");
|
||||
assert.equal(msg.attachmentFilename, "Your Grab E-Receipt.eml");
|
||||
});
|
||||
});
|
||||
|
||||
describe("expandWorkItems", () => {
|
||||
it("returns outer only when no eml", () => {
|
||||
const outer = {
|
||||
id: "outer",
|
||||
from: "me@x.com",
|
||||
subject: "hi",
|
||||
body: "x",
|
||||
html: "",
|
||||
};
|
||||
const payload = {
|
||||
mimeType: "multipart/alternative",
|
||||
parts: [textPart("hi")],
|
||||
};
|
||||
const items = expandWorkItems(outer, payload);
|
||||
assert.equal(items.length, 1);
|
||||
assert.equal(items[0], outer);
|
||||
});
|
||||
|
||||
it("returns eml messages when attachments exist", () => {
|
||||
const outer = {
|
||||
id: "outer123",
|
||||
from: "NSRB <nasyarobby@gmail.com>",
|
||||
subject: "Test attachment",
|
||||
body: "Regards",
|
||||
html: "",
|
||||
};
|
||||
const payload = {
|
||||
mimeType: "multipart/mixed",
|
||||
parts: [
|
||||
{ mimeType: "multipart/alternative", parts: [textPart("Regards")] },
|
||||
rfc822Part({
|
||||
partId: "1",
|
||||
filename: "a.eml",
|
||||
from: "Grab <no-reply@grab.com>",
|
||||
subject: "Your Grab E-Receipt",
|
||||
date: "Wed, 9 Sep 2026 06:09:32 +0000",
|
||||
bodyText: grabRideBody,
|
||||
}),
|
||||
],
|
||||
};
|
||||
const items = expandWorkItems(outer, payload);
|
||||
assert.equal(items.length, 1);
|
||||
assert.equal(items[0].from, "Grab <no-reply@grab.com>");
|
||||
assert.equal(items[0].id, "outer123#1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("processFetchedMessage fan-out", () => {
|
||||
it("processes each eml attachment and skips outer wrapper", async () => {
|
||||
const fetched = {
|
||||
notification: { emailAddress: "eleven16th@gmail.com" },
|
||||
log: silentLog,
|
||||
message: {
|
||||
id: "outer123",
|
||||
from: "NSRB <nasyarobby@gmail.com>",
|
||||
subject: "Test attachment",
|
||||
body: "Regards",
|
||||
html: "",
|
||||
},
|
||||
emlMessages: [
|
||||
{
|
||||
id: "outer123#1",
|
||||
from: "Grab <no-reply@grab.com>",
|
||||
subject: "Your Grab E-Receipt",
|
||||
date: "Wed, 9 Sep 2026 06:09:32 +0000",
|
||||
body: grabRideBody,
|
||||
html: "",
|
||||
parentMessageId: "outer123",
|
||||
},
|
||||
{
|
||||
id: "outer123#2",
|
||||
from: "Grab <no-reply@grab.com>",
|
||||
subject: "Your Grab E-Receipt",
|
||||
date: "Thu, 10 Sep 2026 11:28:24 +0000",
|
||||
body: grabFoodBody,
|
||||
html: "",
|
||||
parentMessageId: "outer123",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const results = await processFetchedMessage(fetched, [
|
||||
matchRule,
|
||||
parseMatchedMessage,
|
||||
]);
|
||||
assert.equal(results.length, 2);
|
||||
assert.equal(results[0].parsed?.kind, "grab.ereceipt.ride");
|
||||
assert.equal(results[0].parsed?.reference, "A-TESTBOOKING1");
|
||||
assert.equal(results[1].parsed?.kind, "grab.ereceipt.food");
|
||||
assert.equal(results[1].parsed?.reference, "00112712674-TESTFOOD1");
|
||||
});
|
||||
|
||||
it("processes outer message when no eml attachments", async () => {
|
||||
const fetched = {
|
||||
notification: { emailAddress: "eleven16th@gmail.com" },
|
||||
log: silentLog,
|
||||
message: {
|
||||
id: "direct1",
|
||||
from: `"Livin'" <noreply.livin@bankmandiri.co.id>`,
|
||||
subject: "Pembayaran Berhasil!",
|
||||
date: "Sun, 13 Sep 2026 14:48:36 +0700",
|
||||
body: `Halo TEST,
|
||||
Penerima
|
||||
Shop
|
||||
JAKARTA SELATAN - ID
|
||||
Tanggal 13 Sep 2026
|
||||
Jam 10:00:00 WIB
|
||||
Nominal Transaksi Rp 10.000,00
|
||||
No. Referensi 9999999999999999999`,
|
||||
html: "",
|
||||
},
|
||||
emlMessages: [],
|
||||
};
|
||||
|
||||
const results = await processFetchedMessage(fetched, [
|
||||
matchRule,
|
||||
parseMatchedMessage,
|
||||
]);
|
||||
assert.equal(results.length, 1);
|
||||
assert.equal(results[0].parsed?.kind, "livin.qr_payment.success");
|
||||
});
|
||||
});
|
||||
|
||||
describe("matchRule on synthetic eml", () => {
|
||||
it("matches Grab from nested headers without Fwd", async () => {
|
||||
const result = await matchRule({
|
||||
messageId: "outer#1",
|
||||
log: silentLog,
|
||||
message: {
|
||||
id: "outer#1",
|
||||
from: "Grab <no-reply@grab.com>",
|
||||
subject: "Your Grab E-Receipt",
|
||||
body: grabRideBody,
|
||||
html: "",
|
||||
},
|
||||
});
|
||||
assert.equal(result.pass.rule.kind, "grab.ereceipt.ride");
|
||||
assert.equal(result.pass.original.from, "Grab <no-reply@grab.com>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractMimePartsFromRawEml", () => {
|
||||
it("pulls text/html from a simple multipart eml", async () => {
|
||||
const { extractMimePartsFromRawEml } = await import("../src/mime.js");
|
||||
const raw = [
|
||||
'Content-Type: multipart/alternative; boundary="bnd"',
|
||||
"",
|
||||
"--bnd",
|
||||
"Content-Type: text/plain; charset=UTF-8",
|
||||
"",
|
||||
"Hello plain",
|
||||
"--bnd",
|
||||
"Content-Type: text/html; charset=UTF-8",
|
||||
"",
|
||||
"<b>Hello html</b>",
|
||||
"--bnd--",
|
||||
"",
|
||||
].join("\r\n");
|
||||
const parts = extractMimePartsFromRawEml(raw);
|
||||
assert.match(parts.text, /Hello plain/);
|
||||
assert.match(parts.html, /Hello html/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
export const livinDirectBody = `Livin' by Mandiri
|
||||
|
||||
Pembayaran Berhasil
|
||||
Halo NASYAROBBY NUGRAHA PUTRA,
|
||||
|
||||
Berikut adalah detail transaksi Anda dengan QR:
|
||||
|
||||
Penerima
|
||||
|
||||
SNACK BAROKAHJAKARTA BARAT - ID
|
||||
|
||||
Tanggal13 Sep 2026
|
||||
|
||||
|
||||
Jam14:48:31 WIB
|
||||
|
||||
|
||||
Nominal TransaksiRp 14.000,00
|
||||
|
||||
|
||||
No. Referensi2609131121540314991
|
||||
|
||||
|
||||
No. Ref. QRIS609532729372
|
||||
|
||||
|
||||
Merchant PAN9360091436372513569
|
||||
|
||||
|
||||
Customer PAN9360000822021345851
|
||||
|
||||
|
||||
PengakuisisiGoPay
|
||||
|
||||
|
||||
Terminal ID A01
|
||||
|
||||
Sumber Dana
|
||||
|
||||
Kartu Kredit - Mandiri Platinum ****4585
|
||||
|
||||
Simpan email ini sebagai ref`;
|
||||
|
||||
export const livinForwardedBody = `---------- Forwarded message ---------
|
||||
From: Livin' <noreply.livin@bankmandiri.co.id>
|
||||
Date: Sun, Sep 13, 2026 at 10:44 AM
|
||||
Subject: Pembayaran Berhasil!
|
||||
To: <nasyarobby@gmail.com>
|
||||
|
||||
|
||||
[image: livinbymandiri]
|
||||
Pembayaran Berhasil
|
||||
|
||||
Halo NASYAROBBY NUGRAHA PUTRA,
|
||||
|
||||
Berikut adalah detail transaksi Anda dengan QR:
|
||||
|
||||
Penerima
|
||||
Eleven Chicken Cabang Gah
|
||||
|
||||
JAKARTA SELATAN - ID
|
||||
|
||||
Tanggal 13 Sep 2026
|
||||
|
||||
|
||||
Jam 10:43:51 WIB
|
||||
|
||||
|
||||
Nominal Transaksi Rp 22.000,00
|
||||
|
||||
|
||||
No. Referensi 2609131121539559589`;
|
||||
|
||||
export const grabBody = `Bike Hemat
|
||||
[image: Grab]
|
||||
|
||||
Hope you enjoyed your ride!
|
||||
Picked up on 13 September 2026
|
||||
Booking ID: A-9RXV4K2GWCLOAV
|
||||
Total Paid
|
||||
Rp16.600
|
||||
Receipt issued by the driver Mahendra.
|
||||
5.0 [image: stars]
|
||||
Compliments for driver
|
||||
Layanan Mantap
|
||||
Breakdown
|
||||
Fare
|
||||
16.600
|
||||
Fare
|
||||
16.600
|
||||
Total Paid
|
||||
16.600
|
||||
Passenger
|
||||
Bintang
|
||||
Profile
|
||||
`;
|
||||
|
||||
export const grabForwardedBody = `---------- Forwarded message ---------
|
||||
From: Grab <no-reply@grab.com>
|
||||
Date: Sun, Sep 13, 2026 at 10:58 AM
|
||||
Subject: Your Grab E-Receipt
|
||||
To: <nasyarobby@gmail.com>
|
||||
|
||||
|
||||
${grabBody}`;
|
||||
|
||||
export const grabTipForwardedBody = `---------- Forwarded message ---------
|
||||
From: Grab <no-reply@grab.com>
|
||||
Date: Sun, Sep 13, 2026 at 11:51 AM
|
||||
Subject: Your Grab E-Receipt
|
||||
To: <nasyarobby@gmail.com>
|
||||
|
||||
|
||||
Bike Hemat
|
||||
[image: Grab]
|
||||
|
||||
Thanks! Your tip goes a long way for your driver.
|
||||
13 Sep 26 10:47 +0700
|
||||
Total Paid
|
||||
Rp2.000
|
||||
Tip
|
||||
From: Apartemen Kalibata City, Jl. Kalibata Raya No.1, Rawajati, Pancoran,
|
||||
Kota Jakarta Selatan, DKI Jakarta, Indonesia, 12750
|
||||
Booking ID: A-9RXV4K2GWCLOAV
|
||||
`;
|
||||
|
||||
/** Minimal Grab ride HTML: pickup/dropoff times only exist here, not in plain text. */
|
||||
export const grabRideHtml = `
|
||||
<div>Picked up on 13 September 2026</div>
|
||||
<div>Booking ID: A-9RXV4K2GWCLOAV</div>
|
||||
<table>
|
||||
<tr>
|
||||
<td>
|
||||
<div>Gate II Apartemen Kalibata City</div>
|
||||
<div style="font-weight:300">10:47AM</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<div>Badan Koordinasi Penanaman Modal</div>
|
||||
<div style="font-weight:300">10:58AM</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
`;
|
||||
|
||||
export const grabFoodForwardedBody = `---------- Forwarded message ---------
|
||||
From: <no-reply@grab.com>
|
||||
Date: Fri, Sep 11, 2026 at 7:01 PM
|
||||
Subject: Your Grab E-Receipt
|
||||
To: <nasyarobby@gmail.com>
|
||||
|
||||
|
||||
[image: E-receipt]
|
||||
Selamat menikmati makanan Anda!
|
||||
TOTAL
|
||||
IDR 49000 TANGGAL | WAKTU
|
||||
11 Sep 26 18:24 +0700
|
||||
Detail Pesanan
|
||||
Jenis Kendaraan:
|
||||
GrabFood
|
||||
Diterbitkan untuk
|
||||
Bintang
|
||||
Pesanan ID
|
||||
00112712674-C8EUEZDGEEAZTX
|
||||
Pesanan Dari:
|
||||
Doner Kebab - Kalibata City
|
||||
Kalibata City, Foodcourt Teras Kuliner, Lt. Dasar Units Fb. 23 (Doner
|
||||
Kebab), Jl. Raya Kalibata No. 1, Rawajati, Pancoran, Jakarta Selatan
|
||||
Profil:
|
||||
Personal
|
||||
Detail Tagihan
|
||||
Detail Pembayaran:
|
||||
OVO Cash
|
||||
Detail Pembayaran: Jumlah:
|
||||
1x Fish n Chips IDR 45000
|
||||
|
||||
Subtotal IDR 45000
|
||||
Restaurant packaging charge IDR 4000
|
||||
TOTAL (INCL. TAX) IDR 49000
|
||||
`;
|
||||
|
||||
export const briTransferDirectBody = `Halo,*NASYAROBBY NUGRAHA PUTRA*
|
||||
Berikut ini adalah informasi transaksi yang telah Anda lakukan di Aplikasi
|
||||
BRImo.
|
||||
Transaksi Berhasil
|
||||
Tanggal 13 September 2026 , 19:00:00 WIB
|
||||
Nomor Referensi 212189333864
|
||||
------------------------------
|
||||
Sumber Dana NASYAROBBY NUGRAHA PUTRA
|
||||
0121 **** **** 500
|
||||
Jenis Transaksi Transfer Bank BRI
|
||||
Bank Tujuan BANK BRI
|
||||
Nomor Tujuan 1060 0100 2070 507
|
||||
Nama Tujuan NELLY ARISANTI
|
||||
Catatan -
|
||||
------------------------------
|
||||
Nominal Rp4.000.000
|
||||
Biaya Admin Rp0
|
||||
*Total* *Rp4.000.000*
|
||||
|
||||
|
||||
Semoga informasi ini dapat bermanfaat bagi Anda.`;
|
||||
|
||||
export const briTransferForwardedBody = `Nasyarobby N. Putra
|
||||
Boston University MSSD Program
|
||||
|
||||
---------- Forwarded message ---------
|
||||
From: Bank BRI <BankBRI@bri.co.id>
|
||||
Date: Sun, Sep 13, 2026, 7:00 PM
|
||||
Subject: Pemindahan Dana Sesama Rekening BRI
|
||||
To: <nsrb@bu.edu>
|
||||
|
||||
|
||||
Halo,*NASYAROBBY NUGRAHA PUTRA*
|
||||
Berikut ini adalah informasi transaksi yang telah Anda lakukan di Aplikasi
|
||||
BRImo.
|
||||
Transaksi Berhasil
|
||||
Tanggal 13 September 2026 , 19:00:00 WIB
|
||||
Nomor Referensi 212189333864
|
||||
------------------------------
|
||||
Sumber Dana NASYAROBBY NUGRAHA PUTRA
|
||||
0121 **** **** 500
|
||||
Jenis Transaksi Transfer Bank BRI
|
||||
Bank Tujuan BANK BRI
|
||||
Nomor Tujuan 1060 0100 2070 507
|
||||
Nama Tujuan NELLY ARISANTI
|
||||
Catatan -
|
||||
------------------------------
|
||||
Nominal Rp4.000.000
|
||||
Biaya Admin Rp0
|
||||
*Total* *Rp4.000.000*
|
||||
`;
|
||||
@@ -0,0 +1,171 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import {
|
||||
extractEmail,
|
||||
parseForwardedHeader,
|
||||
resolveOriginal,
|
||||
subjectMatches,
|
||||
} from "../src/lib/email.js";
|
||||
import { matchRule } from "../src/handlers/match-rule.js";
|
||||
import {
|
||||
briTransferDirectBody,
|
||||
briTransferForwardedBody,
|
||||
grabBody,
|
||||
grabForwardedBody,
|
||||
livinDirectBody,
|
||||
livinForwardedBody,
|
||||
} from "./fixtures.js";
|
||||
|
||||
const silentLog = {
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
};
|
||||
|
||||
describe("extractEmail", () => {
|
||||
it("parses angle-bracket addresses", () => {
|
||||
assert.equal(
|
||||
extractEmail(`"Livin'" <noreply.livin@bankmandiri.co.id>`),
|
||||
"noreply.livin@bankmandiri.co.id",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseForwardedHeader", () => {
|
||||
it("reads original From/Subject from a Fwd block", () => {
|
||||
const h = parseForwardedHeader(livinForwardedBody);
|
||||
assert.ok(h);
|
||||
assert.match(h.from, /noreply\.livin@bankmandiri\.co\.id/);
|
||||
assert.equal(h.subject, "Pembayaran Berhasil!");
|
||||
});
|
||||
|
||||
it("returns null when there is no Fwd block", () => {
|
||||
assert.equal(parseForwardedHeader(livinDirectBody), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveOriginal", () => {
|
||||
it("uses envelope for direct mail", () => {
|
||||
const o = resolveOriginal({
|
||||
from: `"Livin'" <noreply.livin@bankmandiri.co.id>`,
|
||||
subject: "Pembayaran Berhasil!",
|
||||
date: "Sun, 13 Sep 2026 14:48:36 +0700 (WIB)",
|
||||
body: livinDirectBody,
|
||||
});
|
||||
assert.equal(o.viaForward, false);
|
||||
assert.equal(extractEmail(o.from), "noreply.livin@bankmandiri.co.id");
|
||||
});
|
||||
|
||||
it("uses Fwd header for forwarded mail", () => {
|
||||
const o = resolveOriginal({
|
||||
from: "NSRB <nasyarobby@gmail.com>",
|
||||
subject: "Fwd: Your Grab E-Receipt",
|
||||
body: grabForwardedBody,
|
||||
});
|
||||
assert.equal(o.viaForward, true);
|
||||
assert.equal(extractEmail(o.from), "no-reply@grab.com");
|
||||
assert.equal(o.subject, "Your Grab E-Receipt");
|
||||
});
|
||||
});
|
||||
|
||||
describe("subjectMatches", () => {
|
||||
it("matches exact and Fwd-prefixed subjects", () => {
|
||||
assert.equal(subjectMatches("Pembayaran Berhasil!", "Pembayaran Berhasil!"), true);
|
||||
assert.equal(subjectMatches("Fwd: Pembayaran Berhasil!", "Pembayaran Berhasil!"), true);
|
||||
assert.equal(subjectMatches("Hello", "Pembayaran Berhasil!"), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("matchRule", () => {
|
||||
it("matches direct Livin", async () => {
|
||||
const result = await matchRule({
|
||||
messageId: "1a099bd61efd234d",
|
||||
log: silentLog,
|
||||
message: {
|
||||
from: `"Livin'" <noreply.livin@bankmandiri.co.id>`,
|
||||
subject: "Pembayaran Berhasil!",
|
||||
date: "Sun, 13 Sep 2026 14:48:36 +0700 (WIB)",
|
||||
body: livinDirectBody,
|
||||
},
|
||||
});
|
||||
assert.equal(result.pass.rule.kind, "livin.qr_payment.success");
|
||||
});
|
||||
|
||||
it("matches forwarded Livin via original header", async () => {
|
||||
const result = await matchRule({
|
||||
messageId: "1a099b0c04c4397a",
|
||||
log: silentLog,
|
||||
message: {
|
||||
from: "NSRB <nasyarobby@gmail.com>",
|
||||
subject: "Fwd: Pembayaran Berhasil!",
|
||||
body: livinForwardedBody,
|
||||
},
|
||||
});
|
||||
assert.equal(result.pass.rule.kind, "livin.qr_payment.success");
|
||||
});
|
||||
|
||||
it("matches direct Grab", async () => {
|
||||
const result = await matchRule({
|
||||
messageId: "grab-direct",
|
||||
log: silentLog,
|
||||
message: {
|
||||
from: "Grab <no-reply@grab.com>",
|
||||
subject: "Your Grab E-Receipt",
|
||||
body: grabBody,
|
||||
},
|
||||
});
|
||||
assert.equal(result.pass.rule.kind, "grab.ereceipt.ride");
|
||||
});
|
||||
|
||||
it("matches forwarded Grab via original header", async () => {
|
||||
const result = await matchRule({
|
||||
messageId: "1a099b8b9d239e5e",
|
||||
log: silentLog,
|
||||
message: {
|
||||
from: "NSRB <nasyarobby@gmail.com>",
|
||||
subject: "Fwd: Your Grab E-Receipt",
|
||||
body: grabForwardedBody,
|
||||
},
|
||||
});
|
||||
assert.equal(result.pass.rule.kind, "grab.ereceipt.ride");
|
||||
});
|
||||
|
||||
it("matches direct BRI transfer", async () => {
|
||||
const result = await matchRule({
|
||||
messageId: "bri-direct",
|
||||
log: silentLog,
|
||||
message: {
|
||||
from: "Bank BRI <BankBRI@bri.co.id>",
|
||||
subject: "Pemindahan Dana Sesama Rekening BRI",
|
||||
body: briTransferDirectBody,
|
||||
},
|
||||
});
|
||||
assert.equal(result.pass.rule.kind, "bri.transfer.success");
|
||||
});
|
||||
|
||||
it("matches forwarded BRI transfer via original header", async () => {
|
||||
const result = await matchRule({
|
||||
messageId: "1a09b6cd9a3bc6bd",
|
||||
log: silentLog,
|
||||
message: {
|
||||
from: "Nasyarobby Putra <nsrb@bu.edu>",
|
||||
subject: "Fwd: Pemindahan Dana Sesama Rekening BRI",
|
||||
body: briTransferForwardedBody,
|
||||
},
|
||||
});
|
||||
assert.equal(result.pass.rule.kind, "bri.transfer.success");
|
||||
});
|
||||
|
||||
it("breaks on unknown from/subject", async () => {
|
||||
const result = await matchRule({
|
||||
messageId: "x",
|
||||
log: silentLog,
|
||||
message: {
|
||||
from: "Someone <other@example.com>",
|
||||
subject: "Hello",
|
||||
body: "hi",
|
||||
},
|
||||
});
|
||||
assert.equal(result.break, true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { formatReceiptAlert } from "../src/notify/ntfy.js";
|
||||
|
||||
describe("formatReceiptAlert", () => {
|
||||
it("includes reason, messageId, and body", () => {
|
||||
const text = formatReceiptAlert({
|
||||
reason: "parse failed",
|
||||
messageId: "abc#1",
|
||||
kind: "grab.ereceipt.ride",
|
||||
message: {
|
||||
from: "Grab <no-reply@grab.com>",
|
||||
subject: "Your Grab E-Receipt",
|
||||
body: "Total Paid\nRp1.000",
|
||||
html: "<b>x</b>",
|
||||
},
|
||||
});
|
||||
assert.match(text, /reason: parse failed/);
|
||||
assert.match(text, /messageId: abc#1/);
|
||||
assert.match(text, /Total Paid/);
|
||||
assert.match(text, /html \(length\)/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { parseDayMonthYear } from "../src/lib/parse-helpers.js";
|
||||
|
||||
describe("parseDayMonthYear", () => {
|
||||
it("parses English months", () => {
|
||||
assert.equal(parseDayMonthYear("13 Sep 2026"), "2026-09-13");
|
||||
assert.equal(parseDayMonthYear("13 September 2026"), "2026-09-13");
|
||||
});
|
||||
|
||||
it("parses Indonesian months", () => {
|
||||
assert.equal(parseDayMonthYear("15 Agu 2026"), "2026-08-15");
|
||||
assert.equal(parseDayMonthYear("15 Agustus 2026"), "2026-08-15");
|
||||
assert.equal(parseDayMonthYear("1 Des 26"), "2026-12-01");
|
||||
assert.equal(parseDayMonthYear("1 Mei 2026"), "2026-05-01");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,276 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { 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 { 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 {
|
||||
briTransferDirectBody,
|
||||
briTransferForwardedBody,
|
||||
grabBody,
|
||||
grabFoodForwardedBody,
|
||||
grabForwardedBody,
|
||||
grabRideHtml,
|
||||
grabTipForwardedBody,
|
||||
livinDirectBody,
|
||||
livinForwardedBody,
|
||||
} 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("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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user