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:
@@ -1,4 +1,5 @@
|
||||
import { parseBriQris, parseBriTransfer } from "../parsers/bri.js";
|
||||
import { parseFlipTransfer } from "../parsers/flip.js";
|
||||
import { parseGrabReceipt } from "../parsers/grab.js";
|
||||
import { parseLivinReceipt } from "../parsers/livin.js";
|
||||
import { parseTokopediaOrder } from "../parsers/tokopedia.js";
|
||||
@@ -10,6 +11,7 @@ const PARSERS = {
|
||||
"bri.transfer.success": parseBriTransfer,
|
||||
"bri.qris.success": parseBriQris,
|
||||
"tokopedia.order.completed": parseTokopediaOrder,
|
||||
"flip.transfer.success": parseFlipTransfer,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
captureBlockAfterLabel,
|
||||
parseDayMonthYear,
|
||||
parseIdrAmount,
|
||||
parseTimeWib,
|
||||
toWibIso,
|
||||
} from "../lib/parse-helpers.js";
|
||||
|
||||
/**
|
||||
* First non-empty line after a Flip stacked label (value on the next line).
|
||||
*/
|
||||
function valueAfterLabel(body, label, nextLabels = []) {
|
||||
const lines = captureBlockAfterLabel(body, label, nextLabels);
|
||||
return lines?.[0]?.trim() || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Flip transfer-success body (plain text, stacked label/value lines).
|
||||
* Required: Nominal + ID Transaksi.
|
||||
*/
|
||||
export function parseFlipTransfer(body) {
|
||||
if (!body) return null;
|
||||
|
||||
const referenceRaw = valueAfterLabel(body, "ID Transaksi", [
|
||||
"Waktu Terkirim",
|
||||
"Nama Tujuan",
|
||||
]);
|
||||
const reference = referenceRaw
|
||||
? referenceRaw.replace(/^#/, "").trim()
|
||||
: null;
|
||||
|
||||
const waktuRaw = valueAfterLabel(body, "Waktu Terkirim", [
|
||||
"Nama Tujuan",
|
||||
"Bank Tujuan",
|
||||
]);
|
||||
const dateYmd = parseDayMonthYear(waktuRaw);
|
||||
const timeHms = parseTimeWib(waktuRaw);
|
||||
const occurredAt = toWibIso(dateYmd, timeHms);
|
||||
|
||||
const amount = parseIdrAmount(
|
||||
valueAfterLabel(body, "Nominal", [
|
||||
"Kalau ada pertanyaan",
|
||||
"Makasih",
|
||||
"Salam",
|
||||
]),
|
||||
);
|
||||
|
||||
if (amount == null || !reference) return null;
|
||||
|
||||
const namaTujuan = valueAfterLabel(body, "Nama Tujuan", [
|
||||
"Bank Tujuan",
|
||||
"Nomor Rekening Tujuan",
|
||||
]);
|
||||
const bankTujuan = valueAfterLabel(body, "Bank Tujuan", [
|
||||
"Nomor Rekening Tujuan",
|
||||
"Nominal",
|
||||
]);
|
||||
const nomorRekening = valueAfterLabel(body, "Nomor Rekening Tujuan", [
|
||||
"Nominal",
|
||||
]);
|
||||
|
||||
const details = {};
|
||||
if (namaTujuan) details.namaTujuan = namaTujuan;
|
||||
if (bankTujuan) details.bankTujuan = bankTujuan;
|
||||
if (nomorRekening) details.nomorRekening = nomorRekening;
|
||||
|
||||
return {
|
||||
amount,
|
||||
currency: "IDR",
|
||||
reference,
|
||||
occurredAt,
|
||||
details,
|
||||
};
|
||||
}
|
||||
@@ -24,6 +24,8 @@ export function toSpendingRecord(parsed, rule) {
|
||||
? `${base} tip`
|
||||
: base;
|
||||
}
|
||||
} else if (source === "flip") {
|
||||
description = parsed.details?.namaTujuan || "";
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export const flipTransfer = {
|
||||
kind: "flip.transfer.success",
|
||||
source: "flip",
|
||||
fromAddress: "no-reply@flip.id",
|
||||
subject: "Transfer ke",
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import { briQris } from "./bri-qris.js";
|
||||
import { briTransfer } from "./bri-transfer.js";
|
||||
import { flipTransfer } from "./flip-transfer.js";
|
||||
import { grabEreceipt } from "./grab-ereceipt.js";
|
||||
import { livinQrPayment } from "./livin-qr-payment.js";
|
||||
import { tokopediaOrder } from "./tokopedia-order.js";
|
||||
@@ -10,4 +11,5 @@ export const rules = [
|
||||
briTransfer,
|
||||
briQris,
|
||||
tokopediaOrder,
|
||||
flipTransfer,
|
||||
];
|
||||
|
||||
@@ -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*
|
||||
`;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user