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:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user