From fd20ac0ca1301b094e9d32546740f40ce2027eed Mon Sep 17 00:00:00 2001 From: Lawrence Lin Date: Mon, 6 Jul 2026 19:01:23 -0500 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20add=20unrun=20devDependency=20?= =?UTF-8?q?=E2=80=94=20tsdown=20config=20loading=20fails=20on=20fresh=20in?= =?UTF-8?q?stalls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tsdown 0.22 loads tsdown.config.ts via its optional peer "unrun", which a fresh pnpm install never provides — every package's build died with 'Failed to import module "unrun"' on a clean checkout (and typecheck cascaded into missing-dist errors). Declare it explicitly. --- package.json | 1 + pnpm-lock.yaml | 22 ++++++++++++++++++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index f407c71..38b65f4 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "publint": "^0.3.21", "tsdown": "^0.22.3", "typescript": "^5.6.0", + "unrun": "^0.3.1", "vite": "^8.1.0", "vitest": "^4.1.9" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4a72e8d..63751b8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,10 +37,13 @@ importers: version: 0.3.21 tsdown: specifier: ^0.22.3 - version: 0.22.3(@arethetypeswrong/core@0.18.3)(publint@0.3.21)(typescript@5.9.3) + version: 0.22.3(@arethetypeswrong/core@0.18.3)(publint@0.3.21)(typescript@5.9.3)(unrun@0.3.1) typescript: specifier: ^5.6.0 version: 5.9.3 + unrun: + specifier: ^0.3.1 + version: 0.3.1 vite: specifier: ^8.1.0 version: 8.1.0(@types/node@20.19.43)(esbuild@0.27.7) @@ -2106,6 +2109,16 @@ packages: resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} engines: {node: '>= 4.0.0'} + unrun@0.3.1: + resolution: {integrity: sha512-onIck/oNnCaytwths1ZVp1LK2Gq2hPoyFhiHebObuUXqR3S0uHuLLaBK8K6mRRgV7Ptip8AnNvaUsgzwWwBZuA==} + engines: {node: ^22.13.0 || >=24.0.0} + hasBin: true + peerDependencies: + synckit: ^0.11.11 + peerDependenciesMeta: + synckit: + optional: true + until-async@3.0.2: resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==} @@ -3873,7 +3886,7 @@ snapshots: ts-interface-checker@0.1.13: {} - tsdown@0.22.3(@arethetypeswrong/core@0.18.3)(publint@0.3.21)(typescript@5.9.3): + tsdown@0.22.3(@arethetypeswrong/core@0.18.3)(publint@0.3.21)(typescript@5.9.3)(unrun@0.3.1): dependencies: ansis: 4.3.1 cac: 7.0.0 @@ -3894,6 +3907,7 @@ snapshots: '@arethetypeswrong/core': 0.18.3 publint: 0.3.21 typescript: 5.9.3 + unrun: 0.3.1 transitivePeerDependencies: - '@ts-macro/tsc' - '@typescript/native-preview' @@ -3952,6 +3966,10 @@ snapshots: universalify@0.1.2: {} + unrun@0.3.1: + dependencies: + rolldown: 1.1.2 + until-async@3.0.2: {} validate-npm-package-name@5.0.1: {} From 27c02f77ccd06f0e07b07cdcbdce6bf064088490 Mon Sep 17 00:00:00 2001 From: Lawrence Lin Date: Mon, 6 Jul 2026 19:01:23 -0500 Subject: [PATCH 2/2] =?UTF-8?q?feat(amego):=20amegoErrorHint()=20=E2=80=94?= =?UTF-8?q?=20merchant-actionable=20guidance=20for=20account-level=20error?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Amego's setup-level failures (IP allowlist 14, API access not enabled 22, bad App Key 16, UBN mismatch 12, account inactive/suspended 13/19), transient provider-side codes (10/15/18/21), and number-track exhaustion (3040111/3040191) all require the merchant to act in the Amego backend — but rawCode + the terse original message ('IP 錯誤') don't say what to do. amegoErrorHint() maps them to display-ready zh-TW guidance; everything else returns undefined so callers fall back to error.message. Accepts a raw code or an InvoiceError (isInvoiceError-guarded, amego-only). Code 14 behavior verified against a real merchant account: an Amego-side IP allowlist rejects cloud egress IPs unconditionally (2026-07). --- .changeset/amego-error-hints.md | 11 ++++ packages/einvoice-amego/README.en.md | 27 ++++++++++ packages/einvoice-amego/README.md | 24 +++++++++ packages/einvoice-amego/src/hints.test.ts | 60 +++++++++++++++++++++ packages/einvoice-amego/src/hints.ts | 65 +++++++++++++++++++++++ packages/einvoice-amego/src/index.ts | 1 + 6 files changed, 188 insertions(+) create mode 100644 .changeset/amego-error-hints.md create mode 100644 packages/einvoice-amego/src/hints.test.ts create mode 100644 packages/einvoice-amego/src/hints.ts diff --git a/.changeset/amego-error-hints.md b/.changeset/amego-error-hints.md new file mode 100644 index 0000000..bcdc686 --- /dev/null +++ b/.changeset/amego-error-hints.md @@ -0,0 +1,11 @@ +--- +"@paid-tw/einvoice-amego": minor +--- + +Add `amegoErrorHint()` — translate Amego account/setup-level raw error codes +(IP allowlist `14`, API access not enabled `22`, bad App Key `16`, account +disabled/suspended `13`/`19`, UBN mismatch `12`, transient `10`/`15`/`18`/`21`, +number tracks exhausted `3040111`/`3040191`) into actionable zh-TW guidance for +direct merchant display. Accepts a raw code (`"14"` / `14`) or an +`InvoiceError` (guarded via `isInvoiceError`, amego-only); returns `undefined` +for everything else so callers fall back to `error.message`. diff --git a/packages/einvoice-amego/README.en.md b/packages/einvoice-amego/README.en.md index 24d4c02..4aea917 100644 --- a/packages/einvoice-amego/README.en.md +++ b/packages/einvoice-amego/README.en.md @@ -78,6 +78,33 @@ await invoices.validateBan("28080623"); // → boolean (company exists capability) so the two providers are interchangeable; `barcodeQuery()` / `banQuery()` remain for the full raw responses. +### Error hints (opt-in) + +Amego's account/setup-level errors (IP allowlist, API access not enabled, +invoice-number tracks exhausted…) don't tell the merchant *what to do next* +— and they are exactly the ones only the merchant can fix in the Amego +backend. `amegoErrorHint()` translates those raw codes into actionable +zh-TW guidance suitable for direct display; anything else returns +`undefined` so you can fall back to `error.message` (Amego's original text): + +```ts +import { amegoErrorHint } from "@paid-tw/einvoice-amego"; +import { isInvoiceError } from "@paid-tw/einvoice"; + +try { + await invoices.issue(input); +} catch (e) { + const hint = amegoErrorHint(e); // also accepts a raw code: "14" / 14 + showError(hint ?? (isInvoiceError(e) ? e.message : "issue failed")); +} +``` + +Covered: `12`/`13`/`14`/`16`/`19`/`22` (account & API-access setup), +`10`/`15`/`18`/`21` (transient Amego-side), `3040111`/`3040191` (number +tracks exhausted). Notably `14` ("IP 錯誤") is guaranteed to fire when the +Amego backend has an IP allowlist and requests come from cloud egress IPs — +the hint tells the merchant to remove the restriction. + ## Config | Option | Required | Description | diff --git a/packages/einvoice-amego/README.md b/packages/einvoice-amego/README.md index a50d696..2bf3d61 100644 --- a/packages/einvoice-amego/README.md +++ b/packages/einvoice-amego/README.md @@ -76,6 +76,30 @@ await invoices.validateBan("28080623"); // → boolean (company exists `validateMobileBarcode` 與 ezPay 轉接器一致(`CARRIER_VALIDATION` 能力),因此兩個 provider 可互換使用;`barcodeQuery()` / `banQuery()` 則保留以取得完整的原始回應。 +### 錯誤提示(選用) + +光貿的帳號/串接層錯誤(IP 限制、尚未申請 API 串接、字軌用罄…)從原始訊息看不出 +「接下來該做什麼」,而這些恰好都需要商家自己到光貿後台處理。`amegoErrorHint()` +把這類錯誤碼翻成可直接顯示給商家的 zh-TW 行動指引;不屬於此類的錯誤回傳 +`undefined`,請退回顯示 `error.message`(光貿原文): + +```ts +import { amegoErrorHint } from "@paid-tw/einvoice-amego"; +import { isInvoiceError } from "@paid-tw/einvoice"; + +try { + await invoices.issue(input); +} catch (e) { + const hint = amegoErrorHint(e); // 也接受 rawCode:"14" / 14 + showError(hint ?? (isInvoiceError(e) ? e.message : "開立失敗")); +} +``` + +涵蓋:`12`/`13`/`14`/`16`/`19`/`22`(帳號與串接設定)、`10`/`15`/`18`/`21` +(光貿端暫時性錯誤)、`3040111`/`3040191`(字軌用罄)。其中 `14`「IP 錯誤」在 +後台設有 IP 限制、而請求來自雲端(IP 不固定)時必然發生——提示會引導商家移除 +IP 限制。 + ## 設定 | 選項 | 必填 | 說明 | diff --git a/packages/einvoice-amego/src/hints.test.ts b/packages/einvoice-amego/src/hints.test.ts new file mode 100644 index 0000000..635f5fa --- /dev/null +++ b/packages/einvoice-amego/src/hints.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { InvoiceError, InvoiceErrorCode } from "@paid-tw/einvoice"; +import { amegoErrorHint } from "./hints.js"; + +function amegoError(rawCode: string, message = "boom"): InvoiceError { + return new InvoiceError(message, { + provider: "amego", + code: InvoiceErrorCode.AUTH, + rawCode, + }); +} + +describe("amegoErrorHint", () => { + it("maps account/setup-level codes to actionable zh-TW hints", () => { + expect(amegoErrorHint("14")).toContain("IP 限制"); + expect(amegoErrorHint("22")).toContain("API 介接"); + expect(amegoErrorHint("16")).toContain("App Key"); + expect(amegoErrorHint("12")).toContain("統一編號"); + expect(amegoErrorHint("19")).toContain("停權"); + expect(amegoErrorHint("13")).toContain("尚未啟用"); + }); + + it("accepts numeric raw codes", () => { + expect(amegoErrorHint(14)).toBe(amegoErrorHint("14")); + expect(amegoErrorHint(3040111)).toContain("字軌"); + }); + + it("covers transient provider-side codes with a retry hint", () => { + for (const code of ["10", "15", "18", "21"]) { + expect(amegoErrorHint(code)).toContain("稍後再試"); + } + }); + + it("extracts the raw code from an amego InvoiceError", () => { + expect(amegoErrorHint(amegoError("14", "IP 錯誤"))).toContain("IP 限制"); + }); + + it("returns undefined for other providers' errors", () => { + const foreign = new InvoiceError("IP 錯誤", { + provider: "ecpay", + code: InvoiceErrorCode.AUTH, + rawCode: "14", + }); + expect(amegoErrorHint(foreign)).toBeUndefined(); + }); + + it("returns undefined for business errors the caller should handle in context", () => { + expect(amegoErrorHint("3040171")).toBeUndefined(); // OrderId 重複 (CONFLICT) + expect(amegoErrorHint("3050141")).toBeUndefined(); // 已存在折讓單 + expect(amegoErrorHint("71")).toBeUndefined(); // 查無資料 + }); + + it("returns undefined for non-errors and errors without a rawCode", () => { + expect(amegoErrorHint(undefined)).toBeUndefined(); + expect(amegoErrorHint({})).toBeUndefined(); + expect(amegoErrorHint(new Error("plain"))).toBeUndefined(); + const noRaw = new InvoiceError("x", { provider: "amego", code: InvoiceErrorCode.NETWORK }); + expect(amegoErrorHint(noRaw)).toBeUndefined(); + }); +}); diff --git a/packages/einvoice-amego/src/hints.ts b/packages/einvoice-amego/src/hints.ts new file mode 100644 index 0000000..f061471 --- /dev/null +++ b/packages/einvoice-amego/src/hints.ts @@ -0,0 +1,65 @@ +import { isInvoiceError } from "@paid-tw/einvoice"; + +/** + * 商家可行動的錯誤提示(zh-TW)。 + * + * Amego 的帳號/串接層錯誤(IP 限制、尚未申請 API 串接、字軌用罄…)從 + * `InvoiceError` 的 `rawCode` + 原始 `message`(例如「IP 錯誤」)看不出 + * 「接下來該做什麼」——而這幾種恰好都需要商家自己到光貿後台操作,接入方 + * 無法以程式解決。此表把這些碼翻成可以直接顯示給商家的行動指引。 + * + * 刻意只涵蓋「人需要介入」的碼:呼叫端程式錯誤(如參數格式)與一般業務 + * 錯誤(發票不存在、金額超過…)不在此列——那些應該由接入方依自己的 + * 業務語境處理。查表不中時回傳 `undefined`,呼叫端可退回顯示 + * `error.message`(光貿原文)。 + * + * 來源:光貿官方錯誤表(invoice.amego.tw/info_detail?mid=71); + * 14「IP 錯誤」的行為已對真實商家帳號實測驗證(2026-07)。 + */ +const HINTS: Record = { + // 通用錯誤 — 帳號 / 串接設定 + "12": "統一編號與此光貿帳號不符,請確認填寫的是該帳號註冊的統編。", + "13": "光貿帳號尚未啟用,請聯繫光貿客服確認帳號狀態。", + "14": "來源 IP 遭光貿拒絕:後台「API 介接」設有 IP 限制。若請求來自雲端服務(IP 不固定),請至光貿後台移除 IP 限制。", + "16": "App Key 驗證失敗,請確認 App Key 是否貼錯,或已在光貿後台重新產生。", + "19": "光貿帳號已停權,請聯繫光貿客服。", + "22": "此光貿帳號尚未申請 API 串接,請至光貿後台「系統設定 → API 介接」申請並啟用。", + + // 通用錯誤 — 暫時性(光貿端),稍後重試即可 + "10": "光貿系統維護中,請稍後再試。", + "15": "請求時間驗證失敗(時鐘偏差),請稍後再試;若持續發生,可啟用 syncTime 設定。", + "18": "光貿系統忙碌(資料庫連線失敗),請稍後再試。", + "21": "光貿系統使用人數過多,請稍後再試。", + + // 字軌用罄 — 需商家補號 + "3040111": "發票字軌已用罄,無可用號碼。請至光貿後台補充本期字軌後,系統即可繼續開立。", + "3040191": "取號失敗(字軌配號異常),請至光貿後台確認本期字軌狀態。", +}; + +/** + * 依 Amego 原始錯誤碼取得商家行動指引。 + * + * 接受原始碼(`rawCode`,數字或字串皆可)或直接丟入 `InvoiceError` + * (會先以 `isInvoiceError` 判別並確認 `provider === "amego"`)。 + * 非商家可行動的錯誤回傳 `undefined`。 + * + * ```ts + * try { + * await provider.issue(input); + * } catch (e) { + * const hint = amegoErrorHint(e); + * showError(hint ?? (isInvoiceError(e) ? e.message : "開立失敗")); + * } + * ``` + */ +export function amegoErrorHint(rawCode: string | number): string | undefined; +export function amegoErrorHint(error: unknown): string | undefined; +export function amegoErrorHint(input: unknown): string | undefined { + if (typeof input === "string" || typeof input === "number") { + return HINTS[String(input)]; + } + if (isInvoiceError(input) && input.provider === "amego" && input.rawCode !== undefined) { + return HINTS[input.rawCode]; + } + return undefined; +} diff --git a/packages/einvoice-amego/src/index.ts b/packages/einvoice-amego/src/index.ts index 77bd580..e536de2 100644 --- a/packages/einvoice-amego/src/index.ts +++ b/packages/einvoice-amego/src/index.ts @@ -12,6 +12,7 @@ export { export type { TrackStatus, UploadStatus } from "./endpoints.js"; export { sign, mapAmegoErrorCode, clearTimeSyncCache, fetchServerTime } from "./client.js"; export type { AmegoResponse, AmegoTimeResponse } from "./client.js"; +export { amegoErrorHint } from "./hints.js"; export { amegoIssuePayloadSchema, amegoCustomIssuePayloadSchema,