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
@@ -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,
};
/**
+74
View File
@@ -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,
};
}
+2
View File
@@ -24,6 +24,8 @@ export function toSpendingRecord(parsed, rule) {
? `${base} tip`
: base;
}
} else if (source === "flip") {
description = parsed.details?.namaTujuan || "";
}
return {
+6
View File
@@ -0,0 +1,6 @@
export const flipTransfer = {
kind: "flip.transfer.success",
source: "flip",
fromAddress: "no-reply@flip.id",
subject: "Transfer ke",
};
+2
View File
@@ -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,
];