@paid-tw/ubn 0.0.0-stage → 0.1.0
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.
- package/dist/checksum.d.ts +15 -0
- package/dist/checksum.js +31 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +5 -0
- package/dist/lookup.d.ts +52 -0
- package/dist/lookup.js +64 -0
- package/dist/rocDate.d.ts +11 -0
- package/dist/rocDate.js +20 -0
- package/dist/shard.d.ts +14 -0
- package/dist/shard.js +21 -0
- package/dist/sources.d.ts +16 -0
- package/dist/sources.js +41 -0
- package/package.json +36 -4
- package/README.md +0 -3
|
@@ -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;
|
package/dist/checksum.js
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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
package/dist/lookup.d.ts
ADDED
|
@@ -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;
|
package/dist/rocDate.js
ADDED
|
@@ -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
|
+
}
|
package/dist/shard.d.ts
ADDED
|
@@ -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>;
|
package/dist/sources.js
ADDED
|
@@ -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,38 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@paid-tw/ubn",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
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": ["dist"],
|
|
24
|
+
"engines": { "node": ">=18" },
|
|
25
|
+
"publishConfig": { "access": "public", "provenance": true },
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "tsc -p tsconfig.json",
|
|
28
|
+
"test": "vitest run",
|
|
29
|
+
"test:watch": "vitest",
|
|
30
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
31
|
+
"prepublishOnly": "pnpm build"
|
|
32
|
+
},
|
|
33
|
+
"keywords": ["taiwan", "統一編號", "ubn", "ban", "vat", "einvoice", "open-data"],
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"typescript": "^5.6.0",
|
|
36
|
+
"vitest": "^2.1.0"
|
|
37
|
+
}
|
|
38
|
+
}
|
package/README.md
DELETED