Skip to main content
Glama
sunnypdater
by sunnypdater
README.md
# CheckYod — a headless Thai bank-reconciliation engine

Reads **KBank Live** and **SCB Connect** money-in notifications out of LINE, stores every
transaction in SQLite, and **auto-confirms PromptPay bills** the moment a matching payment arrives.
No payment gateway, no merchant account, no per-transaction fee — it watches the same LINE
notifications a shop owner already gets on their phone.

Headless by design: REST, SSE, signed webhooks and an MCP server. There is no UI in this
repository, and there is deliberately no HTML in `src/` — whatever renders a dashboard, a till or a
customer's payment page is your code talking to these routes.

Built on [`@evex/linejs`](https://jsr.io/@evex/linejs) (LINE SelfBot). The backend holds the LINE
session and never exposes its tokens.

```
LINE (KBank Live / SCB Connect OA) ──FLEX──▶ linejs client  (data/storage.json)
        on("message"), filtered to the OAs you enabled
                 │
                 ▼
          flex-parser ──▶ data/checkyod.db ──▶ reconcile ──┬──▶ SSE  /api/stream
                                                           └──▶ POST your webhook
```

## How the extraction works

A money-in message is a FLEX card whose payload is a JSON string in
`message.contentMetadata.FLEX_JSON`. The two banks lay theirs out completely differently:

| | KBank Live | SCB Connect |
|---|---|---|
| amount | pair `"จำนวนเงิน"` | header text + the text right after it |
| pair layout | box layout `horizontal` | box layout `baseline` ← the one that bites |
| balance | `"ยอดเงินคงเหลือ"` | `"ยอดเงินที่ใช้ได้"` |
| direction | sign of the amount | the header word |

`src/flex-parser.ts` walks the tree for each. Both parsers are strict enough to reject the *other*
bank's cards as well as their own bank's balance replies and marketing — because the failure mode of
a loose parser is not "nothing happens", it is an invented transaction that can mark a real
customer's bill paid for money that never arrived. `npm run verify-parsers` asserts both directions
against real captured cards in `fixtures/`.

## Auto-confirmation, and why amounts are unique

Open a bill for ฿100 and you get a PromptPay QR. When a real money-in of **exactly and uniquely**
฿100 arrives, that bill becomes `paid` and your webhook fires.

That only works if the amount identifies the payment, so `createBill` never issues an amount another
live bill already holds — it walks up a satang at a time until it finds a free slot (฿100.00 →
฿100.01 → ฿100.02). The requested amount is used as-is whenever it is free, so the charged amount is
never *less* than asked and at most ฿0.99 more. `amount_requested` keeps what you asked for.

**Two deadlines, on purpose.** `expires_at` is what the customer's QR counts down to;
`payable_until` is `BILL_GRACE_MINUTES` later, and money keeps confirming until then — because the
bank's notification lands seconds to minutes *after* the transfer. Render the stretch between them
as "scan window closed, still watching for the money", not as expired.

**If two bills somehow share an amount** (rows imported from elsewhere), the payment is ambiguous
and lands in a review queue instead of being attributed by a coin flip. A configured LLM can suggest
which bill it settles; a human confirms.

## Quick start

```sh
npm install
cp .env.example .env     # set ADMIN_TOKEN and PROMPTPAY_ID — see the comments in that file
npm run login            # QR in the terminal; scan with the LINE account that gets the bank alerts
npm start
```

`npm run login` writes a resumable session to `data/storage.json`. After that the server resumes it
with no QR. If attaching a terminal is awkward (a container, a remote box), pair over HTTP instead —
see below.

Then issue yourself an integration key and open a bill:

```sh
curl -X POST localhost:8090/api/keys \
  -H "Authorization: Bearer $ADMIN_TOKEN" -H 'content-type: application/json' \
  -d '{"name":"my POS"}'
# → { "id":1, "name":"my POS", "prefix":"cyd_a1b2c3d4", "key":"cyd_…" }   ← shown ONCE

curl -X POST localhost:8090/api/bills \
  -H "Authorization: Bearer cyd_…" -H 'content-type: application/json' \
  -d '{"amount":1250.00,"description":"table 4"}'
# → { "id":"CYD-4K7QP2", "payload":"00020101…", "share_token":"a3f9…", "status":"pending", … }
#   payload = the PromptPay QR string. Render it as a QR; that is the whole integration.
```

### Docker

```sh
cp .env.example .env
docker compose run --rm checkyod npm run login   # once, to create data/storage.json
docker compose up -d --build
docker compose logs -f checkyod
```

The container binds to `127.0.0.1` — put a reverse proxy with TLS in front of it. The management
credential is a bearer token on the wire; publishing this port without TLS hands it to the network.

## Two credentials, deliberately different widths

| | credential | reaches |
|---|---|---|
| **integration** | `Authorization: Bearer cyd_…` | bills · transactions · summary · SSE stream · `/mcp` |
| **management** | `Authorization: Bearer $ADMIN_TOKEN` | LINE pairing · receiving accounts · key issuance · webhook config · review queue · LLM · usage |

The narrowing is the security model, not a style choice. An integration key that leaks must not be
able to mint another key, run up an LLM bill, redirect your webhooks, or **start a LINE pairing** —
whoever scans a pairing QR owns the LINE account afterwards, and that account is the one reading
your bank's notifications. A valid `cyd_` key gets `403` on every management route, and
`npm run verify-engine` asserts it against a real server.

Keys are stored as sha256 only. The plaintext exists exactly once, in the response that created it.

`ADMIN_TOKEN` empty means the management surface is **closed** (503), never "open".

## Endpoints

| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | `/healthz` | none | liveness — **not** "the bank feed is alive" |
| GET | `/api/pay/:token` | none | public bill status for the customer (the token is the credential) |
| POST | `/api/pay/:token/slip` | none | customer uploads a transfer slip |
| POST · GET | `/api/bills` | key | open / list bills |
| GET · POST | `/api/bills/:id` · `/cancel` | key | fetch / cancel one bill |
| GET | `/api/summary` · `/api/transactions` | key | the ledger |
| GET | `/api/stream` | key | SSE, one `tx` event per captured transaction |
| POST | `/mcp` | key | MCP endpoint (GET answers 405 — nothing to stream) |
| GET | `/api/line-status` | admin | is the bank feed actually alive |
| POST | `/api/line/connect` + GET `…/:id/stream` | admin | pair LINE by QR over HTTP (background job + SSE) |
| POST | `/api/line/banks` · `/api/line/verify-banks` · `/api/line/restart` | admin | which banks to read; re-check; restart |
| GET · POST | `/api/banks` · `/:id` · `/:id/delete` · `/:id/qr` | admin | receiving accounts |
| GET · POST | `/api/keys` · `/:id/revoke` | admin | integration keys |
| POST · GET | `/api/webhook` · `/deliveries` · `/test` | admin | webhook destination, history, one-shot test |
| GET · POST | `/api/review` · `/:id/suggest` · `/slip` · `/resolve` | admin | ambiguous payments |
| GET · POST | `/api/llm-settings` · `/api/llm/read-slip` | admin | LLM config (masked) and a test read |
| GET | `/api/usage` | admin | quota this period |

Full request/response shapes, error codes and the SSE contract are in `docs/integration/` — the same
documents the MCP server serves.

## Pairing LINE over HTTP

For a box where `npm run login` is awkward:

```sh
ATTEMPT=$(curl -sS -X POST localhost:8090/api/line/connect \
  -H "Authorization: Bearer $ADMIN_TOKEN" | jq -r .attemptId)

curl -N "localhost:8090/api/line/connect/$ATTEMPT/stream" \
  -H "Authorization: Bearer $ADMIN_TOKEN"
# event: qr_url  → open that URL on the LINE phone, or render it as a QR
# event: pin     → may never fire (a stored qrCert can skip it); do not block on it
# event: done    → { displayName, kbankFound, scbFound }
```

Events are buffered and replayed on subscribe, so you cannot miss the QR by connecting late.
Only one pairing may run at a time, and beginning one tears down the existing session first — two
clients on one LINE account corrupt its auth token.

## Webhooks

```json
{ "event":"bill.paid", "id":"CYD-4K7QP2", "amount":1250.00,
  "description":"table 4", "paid_at":1785566001390, "tx_id":"…" }
```

Plus `line.down`, `line.up` and `line.expiring`. **Wire those up.** A dead LINE session is silent by
nature: `/healthz` stays green, bills keep opening, and not one of them will ever confirm.

Verify every delivery — reject anything that fails either check:

```js
const ts   = req.get("X-CheckYod-Timestamp");
const mine = crypto.createHmac("sha256", SECRET).update(`${ts}.${rawBody}`).digest("hex");
const ok = crypto.timingSafeEqual(Buffer.from(`sha256=${mine}`),
                                  Buffer.from(req.get("X-CheckYod-Signature")))
        && Math.abs(Date.now() - Number(ts)) < 5 * 60_000;   // reject replays
```

Use the **raw** body, not a re-serialised object. Retries are 3 attempts (2s/8s backoff) on network
errors and 5xx; a 4xx is taken as final. Delivery is fire-and-forget — a dead receiver never delays
or undoes marking a bill paid — and every attempt sequence lands in `webhook_deliveries`.

## MCP

The engine hosts an MCP server at `POST /mcp`, so a coding agent can answer integration questions
from the real docs and exercise the real API in one conversation:

```sh
claude mcp add --transport http checkyod https://your-host/mcp \
  --header "Authorization: Bearer cyd_…"
```

Nine tools. `search_docs` / `get_doc` serve `docs/integration/`; `create_bill`, `get_bill`,
`list_bills`, `cancel_bill`, `get_summary` and `list_transactions` mirror the routes of the same
name; `whoami` is the one with no HTTP equivalent — it answers "how much quota is left, is there a
receiving account, is the bank feed alive, is a webhook configured" in a single call, which is the
whole checklist behind *why did my integration not confirm anything*.

It sits on the same guard as `/api/bills`, so a tool reaches exactly what a key reaches. Bills opened
this way are real bills and consume real quota — point the key at a test instance.

## Checks

```sh
npm run typecheck        # the general gate
npm run verify-parsers   # real FLEX cards, both banks, both directions
npm run verify-engine    # money logic + the guard boundary, against a real server
npm run verify-mcp       # tool schemas, error codes, doc leaks
```

None of them touch `data/`. What they do **not** prove is that LINE capture works end to end — for
that, pair a real account and watch one real payment land.

## Operational notes

- **`data/` is the whole system of record**: the LINE session and the entire ledger. Back it up.
- **One process per LINE account.** Two clients on one account and one device slot race the auth
  token and corrupt it. A dev box sharing an account with a server should set
  `LINE_DEVICE=ANDROIDSECONDARY`.
- **`TZ` is load-bearing.** "Today" on the summary and the quota period are both derived from local
  midnight. A container on UTC puts the last seven hours of a Bangkok day in the wrong bucket.
- **`better-sqlite3` must stay on `^13`.** Older versions abort with
  `Assertion failed: (env) != nullptr` under this workload.

## Status and scope

This is the engine extracted from a working multi-tenant SaaS, reduced to a single account. It does
not include a UI, user accounts, plans, billing, or multi-tenancy — those were the product, this is
the part worth sharing. The invariants that survive here are the ones that cost real money when they
break; `CLAUDE.md` documents them for anyone (or any agent) changing this code.

MIT.