Repository navigation
feat(tools/nat): nat-verify — spot-verify single invoices via offline invNo-filtered jobs - #10
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a single-invoice “spot verification” workflow to the NAT tooling so operators can validate specific invoices against the MoF platform record (via offline invNo-filtered jobs), addressing drift/staleness in gateway exports and local DB snapshots.
Changes:
- Extend offline report job creation to optionally pass through
invNoStart/invNoEndfor invoice-number filtering. - Introduce
nat-verify.tsCLI to run one offline CSV job per invoice (chunked to the API’s ≤2-month limit) and print decoded invoice summaries or JSON. - Document platform behavior findings (online query current-month limitation, token decoding) and add tests for range chunking and token decoding.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| tools/nat/README.md | Adds user-facing usage docs for nat-verify and updates the tools table. |
| tools/nat/nat-verify.ts | Implements the new CLI and its date-range chunking / job polling logic. |
| tools/nat/NAT-PLATFORM.md | Documents verified platform behaviors (history window, token payload fields, invNo job filtering). |
| tools/nat/nat-client.ts | Adds invNoStart/End support to createReportJob and introduces a generic decodeDataToken. |
| tools/nat/tests/nat-verify.test.ts | Adds unit tests for chunkRange, monthEnd, and token decoding delegation. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| export function chunkRange(arg: string): Array<{ from: string; to: string }> { | ||
| const m = arg.match(/^(\d{4}-\d{2})(?:\.\.(\d{4}-\d{2}))?$/); | ||
| if (!m) throw new Error(`bad range "${arg}" — expected YYYY-MM or YYYY-MM..YYYY-MM`); | ||
| const months = monthsBetween(m[1], m[2] ?? m[1]); | ||
| const chunks: Array<{ from: string; to: string }> = []; | ||
| for (let i = 0; i < months.length; i += 2) { | ||
| const last = months[Math.min(i + 1, months.length - 1)]; | ||
| chunks.push({ from: `${months[i]}-01`, to: monthEnd(last) }); | ||
| } | ||
| return chunks; | ||
| } |
There was a problem hiding this comment.
Adopted in b20f15c — chunkRange() now rejects months outside 01-12 and reversed ranges up front with explicit errors (before login). Agreed on the severity: an empty chunk list silently turned a typo into '✗ not found on the MoF platform', which is exactly the wrong answer for a verification tool to fake. Tests added for both paths (26 pass).
… invNo-filtered jobs The online (即時) btb411w query turns out to serve only the CURRENT month (one-day probes on any older month return 0 rows even unfiltered), and its documented invoiceNumberStart/End filter did not match live even for an in-window invoice. The reliable single-invoice path is the 非即時 job with invNoStart == invNoEnd == 發票號碼 — verified live: returns exactly that invoice's M/D rows for months of any age, including invoices transmitted by a different value-added centre. - createReportJob: optional invNoStart/invNoEnd passthrough - NatClient.decodeDataToken: generic btb row/job token decoder (decodeJobToken now delegates); online invoice-row tokens decode to full fields (invoiceNumber, amounts, extStatus, buyer/seller, sender = the VAC, migType, ...) — documented in NAT-PLATFORM.md - nat-verify.ts: CLI — range + invoice numbers -> per-invoice offline CSV job -> parsed M row summary (狀態/金額/parties/傳送方) or --json; seqNo watermark fences off same-month jobs from the same run - NAT-PLATFORM.md: online current-month-only window, row-token field list, verified invNoStart/End notes; README: usage section - tests: chunkRange/monthEnd/decodeDataToken (24 pass) Motivation: a gateway export kept a stale un-voided state for an invoice the MoF had voided (and replaced) weeks later — gateway snapshots drift; the MoF record is the statutory truth worth checking against.
… range bounds - runInvoiceJob: also match queryEndDate month so a concurrent job that shares the start month (e.g. a monthly export running in parallel) can't be picked up as this chunk's result - chunkRange: reject months outside 01-12 and reversed ranges up front — both used to yield zero chunks, making every invoice read as 'not found on the platform', which is a misleading answer for a verification tool - tests for both (26 pass); live smoke re-run OK
70f5679 to
b20f15c
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
Suppressed comments (2)
tools/nat/nat-verify.ts:72
runInvoiceJob()pollslistJobs()with its default apply-date window ("today" computed insidelistJobs()on each call). If the run crosses UTC midnight (e.g. job created near 23:59Z), subsequent polls can switch to the new day and no longer see the job, causing a false failure.
const stamp = Date.now();
await client.createReportJob({ ...opts, fileType: "CSV", invNoStart: opts.invNo, invNoEnd: opts.invNo });
let job: ReportJob | undefined;
for (let i = 0; i < 40 && !job; i++) {
await new Promise((r) => setTimeout(r, 3000));
const jobs = await client.listJobs();
job = jobs
tools/nat/nat-client.ts:434
decodeDataToken()decodes the JWT payload segment using base64, but JWT segments are base64url-encoded (and may be unpadded). This can break decoding for tokens containing-/_or missing padding, and the current code also throws a fairly opaque error if the token is malformed. Consider normalizing base64url + validating the expected shape before JSON parsing.
/** Decode any btb list-row token (JWT → base64 `data` → JSON) — jobs and invoices alike. */
static decodeDataToken<T = Record<string, unknown>>(token: string): T {
const payload = JSON.parse(Buffer.from(token.split(".")[1], "base64").toString("utf8"));
return JSON.parse(Buffer.from(payload.data, "base64").toString("utf8"));
}
What
A single-invoice verification path against the MoF platform, plus the platform findings that shaped it.
Findings (documented in NAT-PLATFORM.md)
invoiceNumberStart/Endfilter did not match live even for an invoice that the same unfiltered query returns — exact param encoding unconfirmed, so the tool does not rely on it.invNoStart/invNoEndworks and is now verified live: a job with both set to one 發票號碼 + a date window returns exactly that invoice's M/D rows, for months of any age, regardless of which value-added centre transmitted it.tokens decode exactly like job tokens (JWT payload → base64data→ JSON) into full invoice fields (invoiceNumber, amounts,extStatus, buyer/seller,senderId/Name= the VAC,migType, …) — field list documented.Changes
createReportJob: optionalinvNoStart/invNoEndpassthrough.NatClient.decodeDataToken: generic btb token decoder;decodeJobTokendelegates to it.nat-verify.ts: CLI —bun run nat-verify.ts <YYYY-MM[..YYYY-MM]> <發票號碼...> [--in] [--json]. One offline CSV job per invoice per ≤2-month chunk, then prints the decoded M-row summary (狀態/日期/金額/parties/傳送方/品項數). A seqNo watermark fences off same-month jobs from the same run so a poll can't pick up the previous invoice's job. Exit 1 when anything is not found.Why
Gateway (加值中心) exports drift: we hit a real invoice that the MoF shows as 作廢已確認 (voided on the platform weeks after issuance, then re-issued at a corrected amount) while the gateway's export still says 是否作廢=否. Any local DB built from gateway exports alone silently overstates sales. The MoF record is the statutory truth — this tool makes checking against it a one-liner.
Test plan
bun run test: 24 pass (17 existing + 7 new:chunkRangesingle/even/odd/malformed,monthEndincl. leap Feb,decodeDataToken/decodeJobTokenround-trip).