From c446d794a8964147abd1c21542d8dfa965855cb9 Mon Sep 17 00:00:00 2001 From: Nasyarobby Putra Date: Sun, 13 Sep 2026 23:58:23 +0700 Subject: [PATCH] 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. --- AGENTS.md | 2 + .../src/handlers/parse-matched-message.js | 2 + apps/watcher/src/parsers/tokopedia.js | 126 ++++++++++++++++++ apps/watcher/src/pocketbase/map.js | 4 + apps/watcher/src/rules/index.js | 9 +- apps/watcher/src/rules/tokopedia-order.js | 6 + apps/watcher/test/fixtures.js | 105 +++++++++++++++ apps/watcher/test/match.test.js | 29 ++++ apps/watcher/test/parse.test.js | 56 ++++++++ 9 files changed, 338 insertions(+), 1 deletion(-) create mode 100644 apps/watcher/src/parsers/tokopedia.js create mode 100644 apps/watcher/src/rules/tokopedia-order.js diff --git a/AGENTS.md b/AGENTS.md index 92d6cfa..883fe7c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,7 @@ Resolve original sender: | `grab.ereceipt.food` | same From/Subject; body has GrabFood / `Selamat menikmati makanan` | `Pesanan ID` as invoice_id | | `bri.transfer.success` | `bankbri@bri.co.id` | `Pemindahan Dana Sesama Rekening BRI` | | `bri.qris.success` | `bankbri@bri.co.id` | `Pembelian QRIS Berhasil` | +| `tokopedia.order.completed` | `noreply@tokopedia.com` | `Pesanan Selesai` | Production path is **direct provider mail**. Forwards still work via the Fwd header. Do **not** match on the forwarder envelope From. @@ -50,6 +51,7 @@ Rules: `apps/watcher/src/rules/`. Parsers: `apps/watcher/src/parsers/`. - **Livin:** support clean forwarded lines and jammed HTML-stripped labels (`Tanggal13 Sep 2026`, `Tanggal15 Agu 2026`). Indonesian month names/abbreviations (`Agu`/`Agustus`, `Des`, …) are accepted. When merchant and city are glued on one line, keep the whole string as `merchant`; only split location when it is already a separate line ending in `- ID`. Do not maintain a city-name list. - **Grab:** plain text often has date-only `Picked up on …`; pickup/dropoff times (`10:47AM`) live in HTML. Watcher keeps `message.html` and merges the first standalone AM/PM time into `occurredAt` (WIB). Tip receipts (`Your tip goes a long way…`, timestamp like `13 Sep 26 10:47 +0700`) become `grab.ereceipt.tip` with `invoice_id` `{bookingId}:tip` so they do not collide with the ride. - **BRI / BRImo:** transfer (`Pemindahan Dana Sesama Rekening BRI`) — amount from `Nominal`, `invoice_id` from `Nomor Referensi`, `description` from `Nama Tujuan`, datetime from `Tanggal`. QRIS (`Pembelian QRIS Berhasil`) — amount from `Nominal` (fallback `Total Transaksi`), `description` from `Nama Merchant`, datetime from `Tanggal Transaksi`. Other BRI subjects stay out of scope until samples appear. +- **Tokopedia:** `Pesanan Selesai` — one row per `No.Invoice`; amount from `Total belanja` (not fee lines); `description` = `{Toko} - Tokopedia`; line items (1+) in `details.items`; `trx_date` from `Tanggal Terima` (date-only). - Open-ended text (names, products, driver, compliments) is best-effort optional `details`. ## PocketBase diff --git a/apps/watcher/src/handlers/parse-matched-message.js b/apps/watcher/src/handlers/parse-matched-message.js index 626011f..413cb6f 100644 --- a/apps/watcher/src/handlers/parse-matched-message.js +++ b/apps/watcher/src/handlers/parse-matched-message.js @@ -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, }; /** diff --git a/apps/watcher/src/parsers/tokopedia.js b/apps/watcher/src/parsers/tokopedia.js new file mode 100644 index 0000000..02c0f64 --- /dev/null +++ b/apps/watcher/src/parsers/tokopedia.js @@ -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?:\/\/| 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, + }; +} diff --git a/apps/watcher/src/pocketbase/map.js b/apps/watcher/src/pocketbase/map.js index 02e1d0d..50d440d 100644 --- a/apps/watcher/src/pocketbase/map.js +++ b/apps/watcher/src/pocketbase/map.js @@ -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; diff --git a/apps/watcher/src/rules/index.js b/apps/watcher/src/rules/index.js index e50c528..8321612 100644 --- a/apps/watcher/src/rules/index.js +++ b/apps/watcher/src/rules/index.js @@ -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, +]; diff --git a/apps/watcher/src/rules/tokopedia-order.js b/apps/watcher/src/rules/tokopedia-order.js new file mode 100644 index 0000000..724c50d --- /dev/null +++ b/apps/watcher/src/rules/tokopedia-order.js @@ -0,0 +1,6 @@ +export const tokopediaOrder = { + kind: "tokopedia.order.completed", + source: "tokopedia", + fromAddress: "noreply@tokopedia.com", + subject: "Pesanan Selesai", +}; diff --git a/apps/watcher/test/fixtures.js b/apps/watcher/test/fixtures.js index aab4d90..8e47df9 100644 --- a/apps/watcher/test/fixtures.js +++ b/apps/watcher/test/fixtures.js @@ -305,3 +305,108 @@ Catatan - Nominal Rp674.500 Biaya Admin Rp0 `; + +export const tokopediaSingleForwardedBody = `---------- Forwarded message --------- +From: Tokopedia +Date: Sat, Sep 12, 2026 at 4:58 AM +Subject: Pesanan Selesai: “Mouse Pad Besar Anime Style...” +To: + +Hai *Roby,* +Pesananmu selesai. + +No.Invoice: 585871780568401286 + +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 +Date: Fri, Aug 21, 2026 at 12:39 AM +Subject: Pesanan Selesai: “sandal jepit ANDA pria casual...” dan 1 Item Lainnya +To: + +Hai *Roby,* +Pesananmu selesai. + +No.Invoice: 585494052374283654 + +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 +`; diff --git a/apps/watcher/test/match.test.js b/apps/watcher/test/match.test.js index 1cd46d0..c932003 100644 --- a/apps/watcher/test/match.test.js +++ b/apps/watcher/test/match.test.js @@ -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 ", + 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 ", + 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", diff --git a/apps/watcher/test/parse.test.js b/apps/watcher/test/parse.test.js index ed54188..3e2eb59 100644 --- a/apps/watcher/test/parse.test.js +++ b/apps/watcher/test/parse.test.js @@ -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); + }); });