feat(watcher): add Flip transfer receipt provider

Match no-reply@flip.id transfer success mail (direct or forwarded), parse stacked Nominal/ID Transaksi fields, and map spendings with source=flip.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-22 10:09:25 +07:00
co-authored by Cursor
parent f270f531ea
commit 427636b59b
9 changed files with 376 additions and 0 deletions
+76
View File
@@ -411,3 +411,79 @@ Tujuan Pengiriman
Roby
`;
export const flipDirectBody = `BUKTI TRANSFER
Halo, Kak Nasyarobby.
Transfer ke tujuan berhasil diproses. Ini detail transaksinya:
ID Transaksi
#ST260918145355861M3ZG4
Waktu Terkirim
18 Sep 2026 14:53 WIB
Nama Tujuan
Rwen Ibrahim
Bank Tujuan
BCA
Nomor Rekening Tujuan
0020215810
Nominal
Rp40.001
Kalau ada pertanyaan, silakan hubungi Tim Flip lewat menu *Bantuan*
(pilih *Chat
Tim Flip*) atau klik link flip.id/bantuan.
------------------------------
Makasih udah pakai Flip!
Salam hangat,
Tim Flip`;
export const flipForwardedBody = `---------- Forwarded message ---------
From: Flip <no-reply@flip.id>
Date: Fri, Sep 18, 2026 at 2:54 PM
Subject: Transfer ke Rwen Ibrahim berhasil
To: <nasyarobby@gmail.com>
BUKTI TRANSFER
Halo, Kak Nasyarobby.
Transfer ke tujuan berhasil diproses. Ini detail transaksinya:
ID Transaksi
#ST260918145355861M3ZG4
Waktu Terkirim
18 Sep 2026 14:53 WIB
Nama Tujuan
Rwen Ibrahim
Bank Tujuan
BCA
Nomor Rekening Tujuan
0020215810
Nominal
Rp40.001
Kalau ada pertanyaan, silakan hubungi Tim Flip lewat menu *Bantuan*
(pilih *Chat
Tim Flip*) atau klik link flip.id/bantuan.
------------------------------
Makasih udah pakai Flip!
Salam hangat,
Tim Flip
Tidak membuat permintaan ini? Hubungi kami melalui aplikasi lewat ke menu
*Bantuan* (pilih *Chat Tim Flip*) atau klik link flip.id/bantuan.
Copyright © 2025 PT. Fliptech Lentera Inspirasi Pertiwi. All Rights
Reserved.
--
Regards,
*Nasyarobby NP*
`;
+28
View File
@@ -12,6 +12,8 @@ import {
briQrisForwardedBody,
briTransferDirectBody,
briTransferForwardedBody,
flipDirectBody,
flipForwardedBody,
grabBody,
grabForwardedBody,
livinDirectBody,
@@ -223,6 +225,32 @@ describe("matchRule", () => {
assert.equal(result.pass.rule.kind, "tokopedia.order.completed");
});
it("matches direct Flip transfer", async () => {
const result = await matchRule({
messageId: "flip-direct",
log: silentLog,
message: {
from: "Flip <no-reply@flip.id>",
subject: "Transfer ke Rwen Ibrahim berhasil",
body: flipDirectBody,
},
});
assert.equal(result.pass.rule.kind, "flip.transfer.success");
});
it("matches forwarded Flip transfer via original header", async () => {
const result = await matchRule({
messageId: "1a0c704164e0cd54",
log: silentLog,
message: {
from: "NSRB <nasyarobby@gmail.com>",
subject: "Fwd: Transfer ke Rwen Ibrahim berhasil",
body: flipForwardedBody,
},
});
assert.equal(result.pass.rule.kind, "flip.transfer.success");
});
it("breaks on unknown from/subject", async () => {
const result = await matchRule({
messageId: "x",
+56
View File
@@ -1,12 +1,14 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { parseBriQris, parseBriTransfer } from "../src/parsers/bri.js";
import { parseFlipTransfer } from "../src/parsers/flip.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 { flipTransfer } from "../src/rules/flip-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";
@@ -15,6 +17,8 @@ import {
briQrisForwardedBody,
briTransferDirectBody,
briTransferForwardedBody,
flipDirectBody,
flipForwardedBody,
grabBody,
grabFoodForwardedBody,
grabForwardedBody,
@@ -366,6 +370,58 @@ describe("toSpendingRecord", () => {
assert.equal(rec.description, "LuxePad.Creations - Tokopedia");
assert.equal(rec.trx_date, "2026-09-05 00:00:00.000Z");
});
it("maps Flip transfer envelope to spendings fields", () => {
const parsed = {
kind: "flip.transfer.success",
messageId: "1a0c704164e0cd54",
amount: 40001,
reference: "ST260918145355861M3ZG4",
occurredAt: "2026-09-18T14:53:00+07:00",
details: {
namaTujuan: "Rwen Ibrahim",
bankTujuan: "BCA",
nomorRekening: "0020215810",
},
};
const rec = toSpendingRecord(parsed, flipTransfer);
assert.equal(rec.invoice_id, "ST260918145355861M3ZG4");
assert.equal(rec.amount, 40001);
assert.equal(rec.source, "flip");
assert.equal(rec.description, "Rwen Ibrahim");
assert.equal(rec.trx_date, toPocketBaseDate(parsed.occurredAt));
assert.deepEqual(rec.parsed, parsed);
});
});
describe("parseFlipTransfer", () => {
it("parses direct stacked-label body", () => {
const r = parseFlipTransfer(flipDirectBody);
assert.ok(r);
assert.equal(r.amount, 40001);
assert.equal(r.reference, "ST260918145355861M3ZG4");
assert.equal(r.occurredAt, "2026-09-18T14:53:00+07:00");
assert.equal(r.details.namaTujuan, "Rwen Ibrahim");
assert.equal(r.details.bankTujuan, "BCA");
assert.equal(r.details.nomorRekening, "0020215810");
});
it("parses forwarded body", () => {
const r = parseFlipTransfer(flipForwardedBody);
assert.ok(r);
assert.equal(r.amount, 40001);
assert.equal(r.reference, "ST260918145355861M3ZG4");
assert.equal(r.occurredAt, "2026-09-18T14:53:00+07:00");
assert.equal(r.details.namaTujuan, "Rwen Ibrahim");
});
it("returns null when Nominal or ID Transaksi missing", () => {
assert.equal(parseFlipTransfer("BUKTI TRANSFER\nNominal\nRp10.000\n"), null);
assert.equal(
parseFlipTransfer("ID Transaksi\n#ABC\nNama Tujuan\nX\n"),
null,
);
});
});
describe("parseTokopediaOrder", () => {