feat(watcher): add Tokopedia order parsing and rules

Implement parsing for Tokopedia "Pesanan Selesai" messages, including support for single and multi-item orders. Update rules to recognize Tokopedia order completion and enhance spending record mapping. Add tests to validate new functionality.
This commit is contained in:
2026-09-13 23:58:23 +07:00
parent 0fa0ed3380
commit c446d794a8
9 changed files with 338 additions and 1 deletions
@@ -1,6 +1,7 @@
import { parseBriQris, parseBriTransfer } from "../parsers/bri.js";
import { parseGrabReceipt } from "../parsers/grab.js";
import { parseLivinReceipt } from "../parsers/livin.js";
import { parseTokopediaOrder } from "../parsers/tokopedia.js";
import { notifyReceiptIssue } from "../notify/ntfy.js";
const PARSERS = {
@@ -8,6 +9,7 @@ const PARSERS = {
"grab.ereceipt.ride": parseGrabReceipt,
"bri.transfer.success": parseBriTransfer,
"bri.qris.success": parseBriQris,
"tokopedia.order.completed": parseTokopediaOrder,
};
/**
+126
View File
@@ -0,0 +1,126 @@
import {
captureAfterLabel,
parseDayMonthYear,
parseIdrAmount,
} from "../lib/parse-helpers.js";
const QTY_LINE = /^(\d+)\s*x\s*(Rp[\d.,]+)\s*$/i;
const RP_ONLY = /^(Rp[\d.,]+)\s*$/i;
const URL_LINE = /^(?:https?:\/\/|<https?:\/\/)/i;
function isUrlLine(line) {
return URL_LINE.test(line.trim());
}
/**
* Extract product line items from the Toko…Subtotal block.
* Handles single- and multi-item orders from the same shop.
*/
export function extractTokopediaItems(itemBlock) {
if (!itemBlock) return [];
const lines = itemBlock
.split(/\r?\n/)
.map((l) => l.trim())
.filter((l) => l && !isUrlLine(l));
const items = [];
let nameParts = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const qtyMatch = line.match(QTY_LINE);
if (qtyMatch) {
const qty = Number(qtyMatch[1]);
const unitPrice = parseIdrAmount(qtyMatch[2]);
let lineTotal = unitPrice;
const next = lines[i + 1];
if (next && RP_ONLY.test(next)) {
lineTotal = parseIdrAmount(next);
i += 1;
}
const name = nameParts.join(" ").replace(/\s+/g, " ").trim();
nameParts = [];
if (name && unitPrice != null) {
items.push({
name,
qty,
unitPrice,
lineTotal: lineTotal ?? unitPrice,
});
}
continue;
}
nameParts.push(line);
}
return items;
}
function extractItemBlock(body) {
const tokoIdx = body.search(/Toko\s*:/i);
if (tokoIdx < 0) return null;
const afterToko = body.slice(tokoIdx);
const shopLineEnd = afterToko.search(/\r?\n/);
const rest =
shopLineEnd >= 0 ? afterToko.slice(shopLineEnd + 1) : afterToko;
const endMatch = rest.search(/Subtotal harga produk|Total belanja/i);
return endMatch >= 0 ? rest.slice(0, endMatch) : rest;
}
function parseTotalBelanja(body) {
const m = body.match(/Total\s+belanja\s*\r?\n\s*(Rp[\d.,]+)/i);
if (m) return parseIdrAmount(m[1]);
// Fallback: value on same line
return parseIdrAmount(captureAfterLabel(body, "Total belanja"));
}
/**
* Parse Tokopedia “Pesanan Selesai” body (plain text).
* Required: No.Invoice + Total belanja. One row per invoice.
*/
export function parseTokopediaOrder(body) {
if (!body) return null;
const invoiceMatch = body.match(/No\.?\s*Invoice\s*:?\s*(\d+)/i);
const reference = invoiceMatch?.[1] || null;
const amount = parseTotalBelanja(body);
if (amount == null || !reference) return null;
const cleanRef = reference.trim();
if (!cleanRef) return null;
const shopMatch = body.match(/Toko\s*:\s*([^\n<]+)/i);
const shop = shopMatch
? shopMatch[1].replace(/[<\s]*https?:\/\/.*$/i, "").trim() || null
: null;
const items = extractTokopediaItems(extractItemBlock(body));
const tanggalRaw = captureAfterLabel(body, "Tanggal Terima");
const occurredAt = parseDayMonthYear(tanggalRaw);
const trackingNumber = captureAfterLabel(body, "Nomor Resi");
const shippingService = captureAfterLabel(body, "Layanan Pengiriman");
const details = {};
if (shop) details.shop = shop;
if (items.length) details.items = items;
if (trackingNumber && !isUrlLine(trackingNumber)) {
details.trackingNumber = trackingNumber.trim();
}
if (shippingService && !isUrlLine(shippingService)) {
details.shippingService = shippingService.trim();
}
return {
amount,
currency: "IDR",
reference: cleanRef,
occurredAt,
details,
};
}
+4
View File
@@ -8,6 +8,10 @@ export function toSpendingRecord(parsed, rule) {
description = parsed.details?.merchant || "";
} else if (source === "bri") {
description = parsed.details?.namaTujuan || parsed.details?.merchant || "";
} else if (source === "tokopedia") {
description = parsed.details?.shop
? `${parsed.details.shop} - Tokopedia`
: "Tokopedia";
} else if (source === "grab") {
if (parsed.kind === "grab.ereceipt.food" || parsed.details?.variant === "food") {
const merchant = parsed.details?.merchant;
+8 -1
View File
@@ -2,5 +2,12 @@ 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";
import { tokopediaOrder } from "./tokopedia-order.js";
export const rules = [livinQrPayment, grabEreceipt, briTransfer, briQris];
export const rules = [
livinQrPayment,
grabEreceipt,
briTransfer,
briQris,
tokopediaOrder,
];
@@ -0,0 +1,6 @@
export const tokopediaOrder = {
kind: "tokopedia.order.completed",
source: "tokopedia",
fromAddress: "noreply@tokopedia.com",
subject: "Pesanan Selesai",
};
+105
View File
@@ -305,3 +305,108 @@ Catatan -
Nominal Rp674.500
Biaya Admin Rp0
`;
export const tokopediaSingleForwardedBody = `---------- Forwarded message ---------
From: Tokopedia <noreply@tokopedia.com>
Date: Sat, Sep 12, 2026 at 4:58 AM
Subject: Pesanan Selesai: “Mouse Pad Besar Anime Style...”
To: <nasyarobby@gmail.com>
Hai *Roby,*
Pesananmu selesai.
No.Invoice: 585871780568401286
<https://fapp1.tokopedia.com/example>
Toko: LuxePad.Creations
Mouse Pad Besar Anime Style Cute Black Kitten Premium Laptop Mouse Pad
Keyboard Mat Office Anti-Slip Desk Pad Thickened Locking Edge Anime Gaming
MousePad Long Aesthetic Desk Mat - 800mmX300mmX3mm-T4
1 x Rp88.750
Rp88.750
Subtotal harga produk
Diskon produk dari penjual
Voucher dari platform
Total ongkos kirim
Voucher ongkir platform
Asuransi pengiriman
Biaya jasa aplikasi
Rp88.750
-Rp38.850
-Rp2.495
Rp11.500
-Rp11.500
Rp400
Rp1.000
Total belanja
Rp48.805
Detail Kurir
Layanan Pengiriman
Standard
Nomor Resi
JY1590448249
Tanggal Terima
05 September 2026
Tujuan Pengiriman
Roby
`;
export const tokopediaMultiForwardedBody = `---------- Forwarded message ---------
From: Tokopedia <noreply@tokopedia.com>
Date: Fri, Aug 21, 2026 at 12:39 AM
Subject: Pesanan Selesai: “sandal jepit ANDA pria casual...” dan 1 Item Lainnya
To: <nasyarobby@gmail.com>
Hai *Roby,*
Pesananmu selesai.
No.Invoice: 585494052374283654
<https://fapp1.tokopedia.com/example>
Toko: NNcolection09
sandal jepit ANDA pria casual kombinasi warna simpel tebal anti licin -
ANDA PRIA KOMBINASI BIRU, 41
1 x Rp10.500
Rp10.500
sandal jepit ANDA pria casual kombinasi warna simpel tebal anti licin -
ANDA PRIA KOMBINASI MERAH, 40
1 x Rp10.500
Rp10.500
Subtotal harga produk
Total ongkos kirim
Voucher ongkir platform
Total proteksi produk
Asuransi pengiriman
Biaya jasa aplikasi
Biaya Layanan
Rp21.000
Rp11.500
-Rp11.500
Rp2.800
Rp200
Rp1.000
Rp1.000
Total belanja
Rp26.000
Detail Kurir
Layanan Pengiriman
Standard
Nomor Resi
JY1408468180
Tanggal Terima
14 Agustus 2026
Tujuan Pengiriman
Roby
`;
+29
View File
@@ -16,6 +16,8 @@ import {
grabForwardedBody,
livinDirectBody,
livinForwardedBody,
tokopediaMultiForwardedBody,
tokopediaSingleForwardedBody,
} from "./fixtures.js";
const silentLog = {
@@ -184,6 +186,33 @@ describe("matchRule", () => {
assert.equal(result.pass.rule.kind, "bri.qris.success");
});
it("matches forwarded Tokopedia Pesanan Selesai", async () => {
const result = await matchRule({
messageId: "1a09ba1248c7d2e8",
log: silentLog,
message: {
from: "NSRB <nasyarobby@gmail.com>",
subject: "Fwd: Pesanan Selesai: “Mouse Pad Besar Anime Style...”",
body: tokopediaSingleForwardedBody,
},
});
assert.equal(result.pass.rule.kind, "tokopedia.order.completed");
});
it("matches multi-item Tokopedia subject via original header", async () => {
const result = await matchRule({
messageId: "1a09ba315b65f2a5",
log: silentLog,
message: {
from: "NSRB <nasyarobby@gmail.com>",
subject:
"Fwd: Pesanan Selesai: “sandal jepit ANDA pria casual...” dan 1 Item Lainnya",
body: tokopediaMultiForwardedBody,
},
});
assert.equal(result.pass.rule.kind, "tokopedia.order.completed");
});
it("breaks on unknown from/subject", async () => {
const result = await matchRule({
messageId: "x",
+56
View File
@@ -3,11 +3,13 @@ import { describe, it } from "node:test";
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 { 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 { livinQrPayment } from "../src/rules/livin-qr-payment.js";
import { grabEreceipt } from "../src/rules/grab-ereceipt.js";
import { tokopediaOrder } from "../src/rules/tokopedia-order.js";
import {
briQrisDirectBody,
briQrisForwardedBody,
@@ -20,6 +22,8 @@ import {
grabTipForwardedBody,
livinDirectBody,
livinForwardedBody,
tokopediaMultiForwardedBody,
tokopediaSingleForwardedBody,
} from "./fixtures.js";
describe("splitMerchantLocation", () => {
@@ -346,4 +350,56 @@ describe("toSpendingRecord", () => {
assert.equal(rec.description, "Shopee Indonesia");
assert.equal(rec.trx_date, toPocketBaseDate(parsed.occurredAt));
});
it("maps Tokopedia envelope to spendings fields", () => {
const parsed = {
kind: "tokopedia.order.completed",
messageId: "1a09ba1248c7d2e8",
amount: 48805,
reference: "585871780568401286",
occurredAt: "2026-09-05",
details: { shop: "LuxePad.Creations" },
};
const rec = toSpendingRecord(parsed, tokopediaOrder);
assert.equal(rec.invoice_id, "585871780568401286");
assert.equal(rec.source, "tokopedia");
assert.equal(rec.description, "LuxePad.Creations - Tokopedia");
assert.equal(rec.trx_date, "2026-09-05 00:00:00.000Z");
});
});
describe("parseTokopediaOrder", () => {
it("parses single-item Pesanan Selesai", () => {
const r = parseTokopediaOrder(tokopediaSingleForwardedBody);
assert.ok(r);
assert.equal(r.amount, 48805);
assert.equal(r.reference, "585871780568401286");
assert.equal(r.occurredAt, "2026-09-05");
assert.equal(r.details.shop, "LuxePad.Creations");
assert.equal(r.details.trackingNumber, "JY1590448249");
assert.equal(r.details.shippingService, "Standard");
assert.equal(r.details.items.length, 1);
assert.match(r.details.items[0].name, /Mouse Pad Besar Anime Style/);
assert.equal(r.details.items[0].qty, 1);
assert.equal(r.details.items[0].unitPrice, 88750);
assert.equal(r.details.items[0].lineTotal, 88750);
});
it("parses multi-item order as one invoice", () => {
const r = parseTokopediaOrder(tokopediaMultiForwardedBody);
assert.ok(r);
assert.equal(r.amount, 26000);
assert.equal(r.reference, "585494052374283654");
assert.equal(r.occurredAt, "2026-08-14");
assert.equal(r.details.shop, "NNcolection09");
assert.equal(r.details.items.length, 2);
assert.match(r.details.items[0].name, /KOMBINASI BIRU/);
assert.match(r.details.items[1].name, /KOMBINASI MERAH/);
assert.equal(r.details.items[0].unitPrice, 10500);
assert.equal(r.details.items[1].lineTotal, 10500);
});
it("returns null without invoice/total", () => {
assert.equal(parseTokopediaOrder("Hai Roby\nPesananmu selesai."), null);
});
});