@paid-tw/ubn 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Taiwan Unified Business Number (統一編號, UBN/BAN) checksum validation.
3
+ *
4
+ * The 8-digit UBN carries a check digit. The algorithm below was verified
5
+ * empirically against ALL 1,714,969 active UBNs in the FIA tax registry
6
+ * (dataset snapshot 2026-10-27): it matches 100%.
7
+ *
8
+ * History: before 2023 the valid condition was `sum % 10 === 0` (with a
9
+ * special case when the 7th digit is 7). Because the UBN space was running
10
+ * out, the Ministry of Finance broadened the rule to `sum % 5 === 0`, which
11
+ * is a superset and therefore backward compatible. The pre-2023 `% 10` rule
12
+ * only matches ~88% of currently-registered UBNs, so do NOT use it.
13
+ */
14
+ /** Returns true if `ban` is a syntactically valid 8-digit UBN. */
15
+ export declare function validateUBN(ban: string): boolean;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Taiwan Unified Business Number (統一編號, UBN/BAN) checksum validation.
3
+ *
4
+ * The 8-digit UBN carries a check digit. The algorithm below was verified
5
+ * empirically against ALL 1,714,969 active UBNs in the FIA tax registry
6
+ * (dataset snapshot 2026-10-27): it matches 100%.
7
+ *
8
+ * History: before 2023 the valid condition was `sum % 10 === 0` (with a
9
+ * special case when the 7th digit is 7). Because the UBN space was running
10
+ * out, the Ministry of Finance broadened the rule to `sum % 5 === 0`, which
11
+ * is a superset and therefore backward compatible. The pre-2023 `% 10` rule
12
+ * only matches ~88% of currently-registered UBNs, so do NOT use it.
13
+ */
14
+ const WEIGHTS = [1, 2, 1, 2, 1, 2, 4, 1];
15
+ /** Returns true if `ban` is a syntactically valid 8-digit UBN. */
16
+ export function validateUBN(ban) {
17
+ if (!/^\d{8}$/.test(ban))
18
+ return false;
19
+ let sum = 0;
20
+ for (let i = 0; i < 8; i++) {
21
+ const product = Number(ban[i]) * WEIGHTS[i];
22
+ // accumulate the digits of each product (tens + units)
23
+ sum += Math.floor(product / 10) + (product % 10);
24
+ }
25
+ if (sum % 5 === 0)
26
+ return true;
27
+ // Historical exception: when the 7th digit (index 6) is 7, one may be added.
28
+ if (ban[6] === '7' && (sum + 1) % 5 === 0)
29
+ return true;
30
+ return false;
31
+ }
@@ -0,0 +1,6 @@
1
+ export { validateUBN } from './checksum.js';
2
+ export { rocToISO } from './rocDate.js';
3
+ export { shardOf, DEFAULT_SHARDS } from './shard.js';
4
+ export { lookupVAT, InvalidUBNError } from './lookup.js';
5
+ export type { UBNRecord, LookupOptions, RegistryStatus } from './lookup.js';
6
+ export { fiaRegistration, gcisCompany } from './sources.js';
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { validateUBN } from './checksum.js';
2
+ export { rocToISO } from './rocDate.js';
3
+ export { shardOf, DEFAULT_SHARDS } from './shard.js';
4
+ export { lookupVAT, InvalidUBNError } from './lookup.js';
5
+ export { fiaRegistration, gcisCompany } from './sources.js';
@@ -0,0 +1,52 @@
1
+ /** Registry status written onto shards. Older shards omit the field. */
2
+ export type RegistryStatus = 'active' | 'suspended';
3
+ export interface UBNRecord {
4
+ ban: string;
5
+ /** Business name as registered for tax — this is the invoice title (抬頭). */
6
+ name: string;
7
+ /** Registered business address. */
8
+ address?: string;
9
+ /** Whether the entity issues uniform invoices (使用統一發票). */
10
+ useInvoice?: boolean;
11
+ /**
12
+ * `active` or `suspended`, from `BGMOPEN1` / `BGMOPEN1X`.
13
+ *
14
+ * Present on shards built by the header-driven pipeline. Shards published
15
+ * before that omit `status`; a missing value means unknown — do not treat
16
+ * it as active. The suspension date itself is not on the shard; it is only
17
+ * in the full dataset (`ubn.ndjson.gz` / `ubn.sqlite`).
18
+ */
19
+ status?: RegistryStatus;
20
+ }
21
+ export interface LookupOptions {
22
+ /** Base URL serving the dataset (e.g. your CDN in front of R2). */
23
+ baseUrl: string;
24
+ /** Shard count; must match the published manifest.json. Default 8192. */
25
+ shards?: number;
26
+ /** Abort the request after this many ms. Default 2000. */
27
+ timeoutMs?: number;
28
+ /** Inject a fetch implementation (tests / non-browser runtimes). */
29
+ fetchImpl?: typeof fetch;
30
+ /**
31
+ * Version prefix of the published shards (e.g. `"v/20261028"`). When omitted,
32
+ * the client reads `<baseUrl>/latest.json` to discover it. Set this to pin a
33
+ * version, or to `""` to read shards flat at `<baseUrl>/shards/` (e.g. when
34
+ * `baseUrl` already points inside a version).
35
+ */
36
+ version?: string;
37
+ }
38
+ export declare class InvalidUBNError extends Error {
39
+ readonly ban: string;
40
+ constructor(ban: string);
41
+ }
42
+ /**
43
+ * Look up a UBN against the static dataset: one request for its shard, then a
44
+ * local match. Designed for checkout "type UBN -> fill company title".
45
+ *
46
+ * - Validates the checksum first and throws InvalidUBNError before any network.
47
+ * - Returns null when the UBN is well-formed but not in the dataset
48
+ * (e.g. a brand-new registration not yet in the daily snapshot). Callers
49
+ * can then fall back to the live FIA API; see src/sources.ts.
50
+ * - Never let a lookup block checkout: treat a thrown/timeout as "no autofill".
51
+ */
52
+ export declare function lookupVAT(ban: string, opts: LookupOptions): Promise<UBNRecord | null>;
package/dist/lookup.js ADDED
@@ -0,0 +1,64 @@
1
+ import { validateUBN } from './checksum.js';
2
+ import { shardOf, DEFAULT_SHARDS } from './shard.js';
3
+ /**
4
+ * Resolve the directory that holds the shards. The dataset workflow publishes
5
+ * them under a versioned, immutable prefix (`v/<date>/shards/`) with a short-TTL
6
+ * `latest.json` = `{"latest":"v/<date>"}` pointer, so the client must resolve
7
+ * that pointer rather than assume a flat `/shards/`. Falls back to flat when
8
+ * `latest.json` is absent or `version` is given explicitly.
9
+ */
10
+ async function resolveShardsDir(opts, doFetch, signal) {
11
+ const base = opts.baseUrl.replace(/\/$/, '');
12
+ if (opts.version !== undefined) {
13
+ return opts.version ? `${base}/${opts.version}/shards` : `${base}/shards`;
14
+ }
15
+ const res = await doFetch(`${base}/latest.json`, { signal });
16
+ if (res.ok) {
17
+ const { latest } = (await res.json());
18
+ return latest ? `${base}/${latest.replace(/^\/|\/$/g, '')}/shards` : `${base}/shards`;
19
+ }
20
+ // Only an absent pointer (404) means the legacy flat layout. Any other status
21
+ // is an outage: propagate it so the caller sees an error, not a false "not
22
+ // found" (returning null here would mask the outage as a missing record).
23
+ if (res.status === 404)
24
+ return `${base}/shards`;
25
+ throw new Error(`tw-ubn: latest.json resolution failed (HTTP ${res.status})`);
26
+ }
27
+ export class InvalidUBNError extends Error {
28
+ ban;
29
+ constructor(ban) {
30
+ super(`Invalid UBN: ${ban}`);
31
+ this.ban = ban;
32
+ this.name = 'InvalidUBNError';
33
+ }
34
+ }
35
+ /**
36
+ * Look up a UBN against the static dataset: one request for its shard, then a
37
+ * local match. Designed for checkout "type UBN -> fill company title".
38
+ *
39
+ * - Validates the checksum first and throws InvalidUBNError before any network.
40
+ * - Returns null when the UBN is well-formed but not in the dataset
41
+ * (e.g. a brand-new registration not yet in the daily snapshot). Callers
42
+ * can then fall back to the live FIA API; see src/sources.ts.
43
+ * - Never let a lookup block checkout: treat a thrown/timeout as "no autofill".
44
+ */
45
+ export async function lookupVAT(ban, opts) {
46
+ if (!validateUBN(ban))
47
+ throw new InvalidUBNError(ban);
48
+ const shards = opts.shards ?? DEFAULT_SHARDS;
49
+ const bucket = shardOf(ban, shards);
50
+ const doFetch = opts.fetchImpl ?? fetch;
51
+ const controller = new AbortController();
52
+ const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 2000);
53
+ try {
54
+ const dir = await resolveShardsDir(opts, doFetch, controller.signal);
55
+ const res = await doFetch(`${dir}/${bucket}.json`, { signal: controller.signal });
56
+ if (!res.ok)
57
+ return null;
58
+ const rows = (await res.json());
59
+ return rows.find((r) => r.ban === ban) ?? null;
60
+ }
61
+ finally {
62
+ clearTimeout(timer);
63
+ }
64
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Convert a Republic-of-China (民國) date string to ISO (Gregorian).
3
+ *
4
+ * Government registries encode dates as a 7-digit string: 3-digit ROC year,
5
+ * 2-digit month, 2-digit day. ROC year + 1911 = Gregorian year.
6
+ * Example: "0760321" -> "1987-03-21".
7
+ *
8
+ * Returns null for empty / malformed values (month or day "00" included),
9
+ * which the source data uses for "unknown".
10
+ */
11
+ export declare function rocToISO(roc: string | null | undefined): string | null;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Convert a Republic-of-China (民國) date string to ISO (Gregorian).
3
+ *
4
+ * Government registries encode dates as a 7-digit string: 3-digit ROC year,
5
+ * 2-digit month, 2-digit day. ROC year + 1911 = Gregorian year.
6
+ * Example: "0760321" -> "1987-03-21".
7
+ *
8
+ * Returns null for empty / malformed values (month or day "00" included),
9
+ * which the source data uses for "unknown".
10
+ */
11
+ export function rocToISO(roc) {
12
+ if (!roc || !/^\d{7}$/.test(roc))
13
+ return null;
14
+ const year = Number(roc.slice(0, 3)) + 1911;
15
+ const mm = roc.slice(3, 5);
16
+ const dd = roc.slice(5, 7);
17
+ if (mm === '00' || dd === '00')
18
+ return null;
19
+ return `${year}-${mm}-${dd}`;
20
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Static sharding of the dataset by UBN.
3
+ *
4
+ * The published dataset is split into N static JSON shards so a single lookup
5
+ * is one cache-friendly request. The bucket is derived from the UBN with a
6
+ * 32-bit FNV-1a hash (portable, no deps) so the client and the build pipeline
7
+ * agree exactly. `crates/ubn-core` (`shard_of`) and the worker copies MUST keep
8
+ * an identical implementation; shared test vectors guard against drift.
9
+ *
10
+ * The shard count lives in the published manifest.json ("shards"); a client
11
+ * should read it rather than hard-coding, but 8192 is the default.
12
+ */
13
+ export declare const DEFAULT_SHARDS = 8192;
14
+ export declare function shardOf(ban: string, shards?: number): number;
package/dist/shard.js ADDED
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Static sharding of the dataset by UBN.
3
+ *
4
+ * The published dataset is split into N static JSON shards so a single lookup
5
+ * is one cache-friendly request. The bucket is derived from the UBN with a
6
+ * 32-bit FNV-1a hash (portable, no deps) so the client and the build pipeline
7
+ * agree exactly. `crates/ubn-core` (`shard_of`) and the worker copies MUST keep
8
+ * an identical implementation; shared test vectors guard against drift.
9
+ *
10
+ * The shard count lives in the published manifest.json ("shards"); a client
11
+ * should read it rather than hard-coding, but 8192 is the default.
12
+ */
13
+ export const DEFAULT_SHARDS = 8192;
14
+ export function shardOf(ban, shards = DEFAULT_SHARDS) {
15
+ let h = 0x811c9dc5; // FNV offset basis
16
+ for (let i = 0; i < ban.length; i++) {
17
+ h ^= ban.charCodeAt(i);
18
+ h = Math.imul(h, 0x01000193); // FNV prime
19
+ }
20
+ return (h >>> 0) % shards;
21
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Official government sources, for enrichment / validation / fallback.
3
+ * These talk to government servers directly and are NOT the checkout hot path.
4
+ * All endpoints below were verified on 2026-10-27.
5
+ */
6
+ /**
7
+ * FIA (財政部財政資訊中心) per-record tax-registration API.
8
+ * Documented at https://eip.fia.gov.tw/OAI/v2/api-docs
9
+ *
10
+ * CORS: `Access-Control-Allow-Origin: *` (browser-callable), but responses are
11
+ * `Cache-Control: private`. Do NOT call it per checkout — put your own edge
12
+ * cache in front and use it only as a fallback for UBNs missing from the daily
13
+ * bulk snapshot. Returns null on 404 (not found).
14
+ */
15
+ export declare function fiaRegistration(ban: string, fetchImpl?: typeof fetch): Promise<Record<string, unknown> | null>;
16
+ export declare function gcisCompany(ban: string, fetchImpl?: typeof fetch): Promise<Record<string, unknown> | null>;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Official government sources, for enrichment / validation / fallback.
3
+ * These talk to government servers directly and are NOT the checkout hot path.
4
+ * All endpoints below were verified on 2026-10-27.
5
+ */
6
+ /**
7
+ * FIA (財政部財政資訊中心) per-record tax-registration API.
8
+ * Documented at https://eip.fia.gov.tw/OAI/v2/api-docs
9
+ *
10
+ * CORS: `Access-Control-Allow-Origin: *` (browser-callable), but responses are
11
+ * `Cache-Control: private`. Do NOT call it per checkout — put your own edge
12
+ * cache in front and use it only as a fallback for UBNs missing from the daily
13
+ * bulk snapshot. Returns null on 404 (not found).
14
+ */
15
+ export async function fiaRegistration(ban, fetchImpl = fetch) {
16
+ const res = await fetchImpl(`https://eip.fia.gov.tw/OAI/api/businessRegistration/${ban}`);
17
+ if (res.status === 404)
18
+ return null;
19
+ if (!res.ok)
20
+ throw new Error(`FIA API ${res.status}`);
21
+ return (await res.json());
22
+ }
23
+ /**
24
+ * GCIS (經濟部商業司) company-registration OpenData API.
25
+ *
26
+ * Covers COMPANIES ONLY (公司 — 股份/有限公司); sole proprietors and
27
+ * partnerships (商號) are under a different business-registration resource.
28
+ * No CORS headers -> server-side only. Use it to cross-check the legal name
29
+ * and to detect dissolved/revoked status (Company_Status_Desc), which the FIA
30
+ * tax registry does not carry.
31
+ */
32
+ const GCIS_COMPANY_RESOURCE = '5F64D864-61CB-4D0D-8AD9-492047CC1EA6';
33
+ export async function gcisCompany(ban, fetchImpl = fetch) {
34
+ const url = `https://data.gcis.nat.gov.tw/od/data/api/${GCIS_COMPANY_RESOURCE}` +
35
+ `?$format=json&$filter=Business_Accounting_NO eq ${ban}&$skip=0&$top=1`;
36
+ const res = await fetchImpl(url);
37
+ if (!res.ok)
38
+ return null;
39
+ const arr = (await res.json());
40
+ return arr?.[0] ?? null;
41
+ }
package/package.json CHANGED
@@ -1,6 +1,53 @@
1
1
  {
2
2
  "name": "@paid-tw/ubn",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Taiwan business registry (統一編號) open dataset + a tiny checkout VAT-to-title lookup client",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/paid-tw/tw-ubn.git",
10
+ "directory": "client"
11
+ },
12
+ "homepage": "https://github.com/paid-tw/tw-ubn#readme",
13
+ "bugs": "https://github.com/paid-tw/tw-ubn/issues",
14
+ "main": "./dist/index.js",
15
+ "module": "./dist/index.js",
16
+ "types": "./dist/index.d.ts",
17
+ "exports": {
18
+ ".": {
19
+ "types": "./dist/index.d.ts",
20
+ "import": "./dist/index.js"
21
+ }
22
+ },
23
+ "files": [
24
+ "dist"
25
+ ],
26
+ "engines": {
27
+ "node": ">=18"
28
+ },
29
+ "publishConfig": {
30
+ "access": "public",
31
+ "provenance": true
32
+ },
33
+ "scripts": {
34
+ "build": "tsc -p tsconfig.json",
35
+ "test": "vitest run",
36
+ "test:watch": "vitest",
37
+ "typecheck": "tsc -p tsconfig.json --noEmit",
38
+ "prepublishOnly": "pnpm build"
39
+ },
40
+ "keywords": [
41
+ "taiwan",
42
+ "統一編號",
43
+ "ubn",
44
+ "ban",
45
+ "vat",
46
+ "einvoice",
47
+ "open-data"
48
+ ],
49
+ "devDependencies": {
50
+ "typescript": "^5.6.0",
51
+ "vitest": "^2.1.0"
52
+ }
53
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.