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.
6.3 KiB
Agent notes — gmail-hook
npm workspaces: apps/api, apps/web, apps/watcher. Goal is e-receipt email → parsed spending → PocketBase, not a generic Gmail client.
Layout
| App | Role |
|---|---|
apps/api |
Google OAuth, session cookie, Gmail read API. Tokens in SQLite apps/api/data/app.db. |
apps/web |
React login / inbox UI (Vite). |
apps/watcher |
Gmail Pub/Sub push consumer. Matches receipts, parses JSON, writes spendings. |
Shared MIME helpers live in each app (mime.js). Watcher reuses the same SQLite tokens as the API.
Watcher pipeline
POST /pubsub/gmail → history.list (messageAdded) → per message:
logEvent+fetchAndLogMessage— fetch outer mail; collectemlMessagesif any- Work items: if
.eml/message/rfc822attachments exist → process each attachment only (outer is a wrapper). If none → process the outer message as usual. - For each work item (sequential, in-process — no job queue):
matchRule→parseMatchedMessage→saveSpending
Handlers return { pass: {...} } to merge into ctx or { break: true } to stop. Parse full message.body (and message.html when needed), never the 500-char log bodyPreview.
Attached .eml mails use nested From/Subject from Gmail’s expanded message/rfc822 part. When nested HTML/text is not inlined (body.data missing, only attachmentId), the watcher fetches those attachments (hydrateRfc822Message) before parse. Synthetic message_id looks like outerId#partId; dedupe remains on invoice_id.
Match rules
Resolve original sender:
- If body has
---------- Forwarded message ---------, use its From / Subject / Date. - Else use Gmail envelope.
| kind | From address | Subject |
|---|---|---|
livin.qr_payment.success |
noreply.livin@bankmandiri.co.id |
Pembayaran Berhasil! |
grab.ereceipt.ride |
no-reply@grab.com |
Your Grab E-Receipt |
grab.ereceipt.tip |
same From/Subject as ride; detected from tip body copy | tip invoice_id = {Booking ID}:tip |
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.
Rules: apps/watcher/src/rules/. Parsers: apps/watcher/src/parsers/.
Parsing notes
- Required for a spending row:
amount+reference(invoice id). Fail soft (log + break) if missing — never throw (Pub/Sub must ack). - 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 asmerchant; 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 keepsmessage.htmland merges the first standalone AM/PM time intooccurredAt(WIB). Tip receipts (Your tip goes a long way…, timestamp like13 Sep 26 10:47 +0700) becomegrab.ereceipt.tipwithinvoice_id{bookingId}:tipso they do not collide with the ride. - BRI / BRImo: transfer (
Pemindahan Dana Sesama Rekening BRI) — amount fromNominal,invoice_idfromNomor Referensi,descriptionfromNama Tujuan, datetime fromTanggal. QRIS (Pembelian QRIS Berhasil) — amount fromNominal(fallbackTotal Transaksi),descriptionfromNama Merchant, datetime fromTanggal Transaksi. Other BRI subjects stay out of scope until samples appear. - Tokopedia:
Pesanan Selesai— one row perNo.Invoice; amount fromTotal belanja(not fee lines);description={Toko} - Tokopedia; line items (1+) indetails.items;trx_datefromTanggal Terima(date-only). - Open-ended text (names, products, driver, compliments) is best-effort optional
details.
PocketBase
Env: POCKETBASE_URL, POCKETBASE_USER, POCKETBASE_PASSWORD (a _superusers account). Collection: spendings.
- Unique
invoice_id— duplicate insert → log skip, no throw. - Leave
categoryempty; user fills it in PocketBase admin. - Store flat columns for querying;
detailsfor kind leftovers;parsedfor the full watcher envelope JSON (audit snapshot). Keepmessage_idso the original Gmail message can be re-fetched if needed — do not store raw email body by default. - Schema ensure runs on watcher boot (
ensureSpendingsSchema). - Never commit secrets.
Parse failures and non-duplicate skips (incomplete record, save errors) POST to ntfy (NTFY_URL, default https://n.0dev.web.id/system) with messageId and message body. Duplicates do not notify.
Run
npm install
npm run dev # api + web
# sign in once via UI so SQLite has tokens
npm run dev:watcher
npm test -w watcher # unit tests (no Gmail/Pub/Sub)
Manual / backfill (forwarded old receipts)
Gmail Date on a forward is “today”; trx_date still comes from the receipt body. Duplicates skip on unique invoice_id. Forward-as-attachment (multiple *.eml) is expanded the same way as the watcher.
# List id / from / subject (from Gmail API)
npm run list-messages --
npm run list-messages -- --query 'newer_than:14d has:attachment filename:eml' --max 100
# Dry-run last 30 days of inbox (default query: in:inbox newer_than:30d)
npm run process-messages -- --dry-run
# Process a wrapper mail that has .eml attachments
npm run process-messages -- --ids OUTER_MESSAGE_ID
# Process and save
npm run process-messages -- --query 'newer_than:2d subject:"Pembayaran Berhasil!"'
npm run process-messages -- --ids MESSAGE_ID_1,MESSAGE_ID_2
npm run process-messages -- --user eleven16th@gmail.com --max 100
Adding a provider
- Rule module under
src/rules/(kind, fromAddress, subject, source). - Parser under
src/parsers/; register inparse-matched-message.js. - Fixture + tests under
test/. - Map into spendings in
pocketbase/map.js(source,description,details,parsed).