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:
@@ -39,6 +39,7 @@ Resolve original sender:
|
|||||||
| `grab.ereceipt.food` | same From/Subject; body has GrabFood / `Selamat menikmati makanan` | `Pesanan ID` as invoice_id |
|
| `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.transfer.success` | `bankbri@bri.co.id` | `Pemindahan Dana Sesama Rekening BRI` |
|
||||||
| `bri.qris.success` | `bankbri@bri.co.id` | `Pembelian QRIS Berhasil` |
|
| `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.
|
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.
|
- **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.
|
- **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.
|
- **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`.
|
- Open-ended text (names, products, driver, compliments) is best-effort optional `details`.
|
||||||
## PocketBase
|
## PocketBase
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { parseBriQris, parseBriTransfer } from "../parsers/bri.js";
|
import { parseBriQris, parseBriTransfer } from "../parsers/bri.js";
|
||||||
import { parseGrabReceipt } from "../parsers/grab.js";
|
import { parseGrabReceipt } from "../parsers/grab.js";
|
||||||
import { parseLivinReceipt } from "../parsers/livin.js";
|
import { parseLivinReceipt } from "../parsers/livin.js";
|
||||||
|
import { parseTokopediaOrder } from "../parsers/tokopedia.js";
|
||||||
import { notifyReceiptIssue } from "../notify/ntfy.js";
|
import { notifyReceiptIssue } from "../notify/ntfy.js";
|
||||||
|
|
||||||
const PARSERS = {
|
const PARSERS = {
|
||||||
@@ -8,6 +9,7 @@ const PARSERS = {
|
|||||||
"grab.ereceipt.ride": parseGrabReceipt,
|
"grab.ereceipt.ride": parseGrabReceipt,
|
||||||
"bri.transfer.success": parseBriTransfer,
|
"bri.transfer.success": parseBriTransfer,
|
||||||
"bri.qris.success": parseBriQris,
|
"bri.qris.success": parseBriQris,
|
||||||
|
"tokopedia.order.completed": parseTokopediaOrder,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -8,6 +8,10 @@ export function toSpendingRecord(parsed, rule) {
|
|||||||
description = parsed.details?.merchant || "";
|
description = parsed.details?.merchant || "";
|
||||||
} else if (source === "bri") {
|
} else if (source === "bri") {
|
||||||
description = parsed.details?.namaTujuan || parsed.details?.merchant || "";
|
description = parsed.details?.namaTujuan || parsed.details?.merchant || "";
|
||||||
|
} else if (source === "tokopedia") {
|
||||||
|
description = parsed.details?.shop
|
||||||
|
? `${parsed.details.shop} - Tokopedia`
|
||||||
|
: "Tokopedia";
|
||||||
} else if (source === "grab") {
|
} else if (source === "grab") {
|
||||||
if (parsed.kind === "grab.ereceipt.food" || parsed.details?.variant === "food") {
|
if (parsed.kind === "grab.ereceipt.food" || parsed.details?.variant === "food") {
|
||||||
const merchant = parsed.details?.merchant;
|
const merchant = parsed.details?.merchant;
|
||||||
|
|||||||
@@ -2,5 +2,12 @@ import { briQris } from "./bri-qris.js";
|
|||||||
import { briTransfer } from "./bri-transfer.js";
|
import { briTransfer } from "./bri-transfer.js";
|
||||||
import { grabEreceipt } from "./grab-ereceipt.js";
|
import { grabEreceipt } from "./grab-ereceipt.js";
|
||||||
import { livinQrPayment } from "./livin-qr-payment.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",
|
||||||
|
};
|
||||||
@@ -305,3 +305,108 @@ Catatan -
|
|||||||
Nominal Rp674.500
|
Nominal Rp674.500
|
||||||
Biaya Admin Rp0
|
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
|
||||||
|
`;
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ import {
|
|||||||
grabForwardedBody,
|
grabForwardedBody,
|
||||||
livinDirectBody,
|
livinDirectBody,
|
||||||
livinForwardedBody,
|
livinForwardedBody,
|
||||||
|
tokopediaMultiForwardedBody,
|
||||||
|
tokopediaSingleForwardedBody,
|
||||||
} from "./fixtures.js";
|
} from "./fixtures.js";
|
||||||
|
|
||||||
const silentLog = {
|
const silentLog = {
|
||||||
@@ -184,6 +186,33 @@ describe("matchRule", () => {
|
|||||||
assert.equal(result.pass.rule.kind, "bri.qris.success");
|
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 () => {
|
it("breaks on unknown from/subject", async () => {
|
||||||
const result = await matchRule({
|
const result = await matchRule({
|
||||||
messageId: "x",
|
messageId: "x",
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ import { describe, it } from "node:test";
|
|||||||
import { parseBriQris, parseBriTransfer } from "../src/parsers/bri.js";
|
import { parseBriQris, parseBriTransfer } from "../src/parsers/bri.js";
|
||||||
import { parseGrabReceipt, extractGrabTripTimesFromHtml } from "../src/parsers/grab.js";
|
import { parseGrabReceipt, extractGrabTripTimesFromHtml } from "../src/parsers/grab.js";
|
||||||
import { parseLivinReceipt, splitMerchantLocation } from "../src/parsers/livin.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 { toSpendingRecord, toPocketBaseDate } from "../src/pocketbase/map.js";
|
||||||
import { briQris } from "../src/rules/bri-qris.js";
|
import { briQris } from "../src/rules/bri-qris.js";
|
||||||
import { briTransfer } from "../src/rules/bri-transfer.js";
|
import { briTransfer } from "../src/rules/bri-transfer.js";
|
||||||
import { livinQrPayment } from "../src/rules/livin-qr-payment.js";
|
import { livinQrPayment } from "../src/rules/livin-qr-payment.js";
|
||||||
import { grabEreceipt } from "../src/rules/grab-ereceipt.js";
|
import { grabEreceipt } from "../src/rules/grab-ereceipt.js";
|
||||||
|
import { tokopediaOrder } from "../src/rules/tokopedia-order.js";
|
||||||
import {
|
import {
|
||||||
briQrisDirectBody,
|
briQrisDirectBody,
|
||||||
briQrisForwardedBody,
|
briQrisForwardedBody,
|
||||||
@@ -20,6 +22,8 @@ import {
|
|||||||
grabTipForwardedBody,
|
grabTipForwardedBody,
|
||||||
livinDirectBody,
|
livinDirectBody,
|
||||||
livinForwardedBody,
|
livinForwardedBody,
|
||||||
|
tokopediaMultiForwardedBody,
|
||||||
|
tokopediaSingleForwardedBody,
|
||||||
} from "./fixtures.js";
|
} from "./fixtures.js";
|
||||||
|
|
||||||
describe("splitMerchantLocation", () => {
|
describe("splitMerchantLocation", () => {
|
||||||
@@ -346,4 +350,56 @@ describe("toSpendingRecord", () => {
|
|||||||
assert.equal(rec.description, "Shopee Indonesia");
|
assert.equal(rec.description, "Shopee Indonesia");
|
||||||
assert.equal(rec.trx_date, toPocketBaseDate(parsed.occurredAt));
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user