From e63ad86f9b507d0b305adac0367d4c29590c9c04 Mon Sep 17 00:00:00 2001 From: YJack0000 Date: Thu, 24 Sep 2026 17:02:18 +0800 Subject: [PATCH 01/27] =?UTF-8?q?[chore]=20=E6=96=B0=E5=A2=9E=E6=AC=84?= =?UTF-8?q?=E4=BD=8D=E5=8A=A0=E5=AF=86=20helper=EF=BC=88AES-GCM,=20FIELD?= =?UTF-8?q?=5FENCRYPTION=5FKEY=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 6 ++++ src/lib/crypto.ts | 82 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 src/lib/crypto.ts diff --git a/.env.example b/.env.example index dbc3610..7f40a71 100644 --- a/.env.example +++ b/.env.example @@ -18,3 +18,9 @@ BETTER_AUTH_URL="http://localhost:3000" # on the project and add the .../auth/calendar scope to the OAuth consent screen. GOOGLE_CLIENT_ID="" GOOGLE_CLIENT_SECRET="" + +# Field-level encryption for integration credentials (Simpany, Wise) and employee +# bank account numbers. 32 random bytes, base64: openssl rand -base64 32 +# Prod: wrangler secret put FIELD_ENCRYPTION_KEY. Losing it makes stored +# credentials unreadable (reconnect integrations, re-enter account numbers). +FIELD_ENCRYPTION_KEY="" diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts new file mode 100644 index 0000000..a805228 --- /dev/null +++ b/src/lib/crypto.ts @@ -0,0 +1,82 @@ +// 欄位層級加密(AES-256-GCM,Web Crypto): +// - 用於外部整合憑證(Simpany 帳密、Wise token)與員工銀行帳號等高敏感欄位。 +// - 金鑰來自 FIELD_ENCRYPTION_KEY(32 bytes 的 base64,`openssl rand -base64 32`), +// 正式環境用 `wrangler secret put FIELD_ENCRYPTION_KEY` 設定。 +// - 密文格式 `v1::`,版本前綴留給日後換金鑰。 +// - 這裡只在 server 端使用;解密後的值不得回傳給 client 或 MCP。 + +const VERSION = "v1"; +const IV_BYTES = 12; + +let cachedKey: Promise | null = null; + +export class EncryptionKeyMissingError extends Error { + constructor() { + super("FIELD_ENCRYPTION_KEY 未設定,無法加解密敏感欄位"); + this.name = "EncryptionKeyMissingError"; + } +} + +function toBase64(bytes: Uint8Array): string { + let s = ""; + for (const b of bytes) s += String.fromCharCode(b); + return btoa(s); +} + +function fromBase64(b64: string): Uint8Array { + const s = atob(b64); + const out = new Uint8Array(s.length); + for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i); + return out; +} + +function getKey(): Promise { + if (cachedKey) return cachedKey; + const raw = process.env.FIELD_ENCRYPTION_KEY?.trim(); + if (!raw) throw new EncryptionKeyMissingError(); + const bytes = fromBase64(raw); + if (bytes.length !== 32) { + throw new Error("FIELD_ENCRYPTION_KEY 必須是 32 bytes 的 base64"); + } + cachedKey = crypto.subtle.importKey("raw", bytes, "AES-GCM", false, [ + "encrypt", + "decrypt", + ]); + return cachedKey; +} + +/** 加密字串,回傳 `v1::`。 */ +export async function encryptField(plain: string): Promise { + const key = await getKey(); + const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES)); + const ct = await crypto.subtle.encrypt( + { name: "AES-GCM", iv }, + key, + new TextEncoder().encode(plain), + ); + return `${VERSION}:${toBase64(iv)}:${toBase64(new Uint8Array(ct))}`; +} + +/** 解密 encryptField 的輸出;格式不符或金鑰錯誤時丟錯,不回傳半成品。 */ +export async function decryptField(enc: string): Promise { + const [version, ivB64, ctB64] = enc.split(":"); + if (version !== VERSION || !ivB64 || !ctB64) { + throw new Error("無法辨識的密文格式"); + } + const key = await getKey(); + const pt = await crypto.subtle.decrypt( + { name: "AES-GCM", iv: fromBase64(ivB64) }, + key, + fromBase64(ctB64), + ); + return new TextDecoder().decode(pt); +} + +/** JSON 物件版本,給整合憑證用。 */ +export async function encryptJson(value: unknown): Promise { + return encryptField(JSON.stringify(value)); +} + +export async function decryptJson(enc: string): Promise { + return JSON.parse(await decryptField(enc)) as T; +} From bb9ec2140be51ec8ab15e1ead669b99c24d63eb8 Mon Sep 17 00:00:00 2001 From: YJack0000 Date: Thu, 24 Sep 2026 17:12:00 +0800 Subject: [PATCH 02/27] =?UTF-8?q?[feature]=20=E6=95=B4=E5=90=88=E6=A1=86?= =?UTF-8?q?=E6=9E=B6=EF=BC=9Aorg=5Fintegrations=20=E8=A1=A8=E8=88=87?= =?UTF-8?q?=E5=8A=A0=E5=AF=86=E6=86=91=E8=AD=89=E5=84=B2=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - migrations/0023:org_integrations(一列 = 一組織一整合,預設關閉,憑證密文) - src/lib/integrations:types / catalog(simpany、wise)/ registry(空,待實作登記)/ store - i18n:integrations namespace、操作紀錄的 integration 實體名稱 --- migrations/0023_org_integrations.sql | 61 +++++ src/app/dashboard/activity/page.tsx | 1 + src/db/schema.ts | 29 ++- src/i18n/messages/activity.ts | 1 + src/i18n/messages/index.ts | 2 + src/i18n/messages/integrations.ts | 152 ++++++++++++ src/lib/integrations/catalog.ts | 36 +++ src/lib/integrations/registry.ts | 41 ++++ src/lib/integrations/store.ts | 350 +++++++++++++++++++++++++++ src/lib/integrations/types.ts | 106 ++++++++ 10 files changed, 778 insertions(+), 1 deletion(-) create mode 100644 migrations/0023_org_integrations.sql create mode 100644 src/i18n/messages/integrations.ts create mode 100644 src/lib/integrations/catalog.ts create mode 100644 src/lib/integrations/registry.ts create mode 100644 src/lib/integrations/store.ts create mode 100644 src/lib/integrations/types.ts diff --git a/migrations/0023_org_integrations.sql b/migrations/0023_org_integrations.sql new file mode 100644 index 0000000..b90dede --- /dev/null +++ b/migrations/0023_org_integrations.sql @@ -0,0 +1,61 @@ +-- 0023: 組織層級的外部整合(Simpany 電子發票、Wise …)。 +-- +-- 目的:接外部服務要存「這個組織的帳密 / token」與「這個整合開了沒」。與其每接一家 +-- 就開一張 xxx_settings 表(像 0018 的 calendar_settings),不如一張通用表,一列 = +-- 一個組織的一個整合,框架(src/lib/integrations)統一處理連接、開關、加密、失效。 +-- +-- 規則: +-- - 預設關閉:owner / admin 先「連接」(輸入憑證,server 端實測通過才寫入),寫入時 +-- enabled = false,要另外手動打開。這樣「接上了」與「開始會對外動作」是兩個決定。 +-- - 憑證一律密文:credentials_enc / token_cache_enc 是 src/lib/crypto.ts 的 +-- encryptJson / encryptField 輸出(`v1::`,金鑰 FIELD_ENCRYPTION_KEY), +-- 絕不存明文,也絕不回傳給 client 或 MCP。 +-- - 中斷連接 = 刪列(不軟刪):留著密文沒有意義,重新連接就是重新輸入。 +-- - Google 日曆不搬進來:它的 token 在 better-auth 的 account 表,設定在 +-- calendar_settings,維持原狀;UI 只是把它列在同一個整合清單裡。 +-- +-- provider 的 CHECK 清單就是「系統認得的整合」,新增一家要改這裡(新 migration) +-- 並在 src/lib/integrations/catalog.ts 補一筆。 +-- Forward-only,全部 additive。Run AFTER 0022_subscription_contract.sql. + +CREATE TABLE org_integrations ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + organization_id text NOT NULL REFERENCES "organization"(id) ON DELETE CASCADE, + provider text NOT NULL, + enabled boolean NOT NULL DEFAULT false, + -- connected 憑證有效,可以使用(enabled 另計) + -- needs_reauth 外部服務拒絕了憑證(密碼改了、token 被撤銷),要重新連接 + -- error 其他持續性錯誤(外部服務異常等),細節在 last_error + status text NOT NULL DEFAULT 'connected', + -- 非機密設定:例如 Simpany 的公司 id、Wise 的 profile id / 帳戶對應。可直接顯示。 + config jsonb NOT NULL DEFAULT '{}'::jsonb, + -- encryptJson(憑證物件)。欄位名與內容由各 provider 的 credentialFields 決定。 + credentials_enc text, + -- provider 自己換來的 session token(例如登入後拿到的 cookie / bearer),也要加密。 + token_cache_enc text, + token_expires_at timestamptz, + last_synced_at timestamptz, + last_error text, + last_error_at timestamptz, + connected_by_user_id text REFERENCES "user"(id) ON DELETE SET NULL, + connected_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT uq_org_integration UNIQUE (organization_id, provider), + CONSTRAINT chk_org_integration_provider + CHECK (provider = ANY (ARRAY['simpany'::text, 'wise'::text])), + CONSTRAINT chk_org_integration_status + CHECK (status = ANY (ARRAY['connected'::text, 'needs_reauth'::text, 'error'::text])) + -- enabled 與 status 刻意不綁 CHECK:憑證失效(needs_reauth)時保留使用者的開關意圖, + -- 重新連接後自動恢復原狀。「能不能用」由程式判斷 enabled AND status = 'connected'。 +); + +COMMENT ON TABLE org_integrations IS '組織層級外部整合(一列 = 一個組織的一個 provider);中斷連接即刪列'; +COMMENT ON COLUMN org_integrations.provider IS '整合代號:simpany / wise;對應 src/lib/integrations/catalog.ts'; +COMMENT ON COLUMN org_integrations.enabled IS '是否開啟;連接後預設 false,需 owner/admin 手動開啟;實際可用 = enabled AND status = connected'; +COMMENT ON COLUMN org_integrations.status IS 'connected / needs_reauth(憑證被拒,需重新連接)/ error(其他持續性錯誤)'; +COMMENT ON COLUMN org_integrations.config IS '非機密設定(公司 id、帳戶對應等),可顯示給成員與 MCP'; +COMMENT ON COLUMN org_integrations.credentials_enc IS 'encryptJson(憑證) 密文(FIELD_ENCRYPTION_KEY);絕不存明文、絕不回傳'; +COMMENT ON COLUMN org_integrations.token_cache_enc IS 'provider 快取的 session token 密文;過期或失效可隨時丟棄重換'; +COMMENT ON COLUMN org_integrations.token_expires_at IS 'token_cache_enc 的到期時間'; +COMMENT ON COLUMN org_integrations.last_synced_at IS '最近一次成功呼叫外部服務的時間'; +COMMENT ON COLUMN org_integrations.last_error IS '最近一次失敗的訊息(給人看,不含憑證)'; diff --git a/src/app/dashboard/activity/page.tsx b/src/app/dashboard/activity/page.tsx index 9158da5..c818af8 100644 --- a/src/app/dashboard/activity/page.tsx +++ b/src/app/dashboard/activity/page.tsx @@ -47,6 +47,7 @@ export default async function ActivityPage() { document: t("entity.document"), payroll_run: t("entity.payroll_run"), payslip: t("entity.payslip"), + integration: t("entity.integration"), }; const { orgId } = await requireOrg(); const rows = await listActivity(orgId, { limit: 300 }); diff --git a/src/db/schema.ts b/src/db/schema.ts index 2fcb818..ecad4d7 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,4 +1,4 @@ -import { pgTable, check, bigint, text, boolean, char, numeric, timestamp, date, unique, integer, foreignKey, index, uniqueIndex } from "drizzle-orm/pg-core" +import { pgTable, check, bigint, text, boolean, char, numeric, timestamp, date, unique, integer, foreignKey, index, uniqueIndex, jsonb } from "drizzle-orm/pg-core" import { sql } from "drizzle-orm" @@ -592,3 +592,30 @@ export const calendarEventLinks = pgTable("calendar_event_links", { index("idx_calendar_event_org").using("btree", table.organizationId.asc().nullsLast().op("text_ops")), check("chk_calendar_event_kind", sql`kind = ANY (ARRAY['due'::text, 'payment'::text, 'invoice'::text])`), ]); + +// ---- 組織層級外部整合(migrations/0023)。一列 = 一個組織的一個 provider, +// 框架在 src/lib/integrations。連接後預設關閉;中斷連接即刪列。---- +// credentials_enc / token_cache_enc 是 src/lib/crypto.ts 的密文,絕不存明文、 +// 絕不回傳給 client 或 MCP —— 讀取一律走 src/lib/integrations/store.ts。 +export const orgIntegrations = pgTable("org_integrations", { + id: bigint({ mode: "number" }).primaryKey().generatedAlwaysAsIdentity({ name: "org_integrations_id_seq", startWith: 1, increment: 1, minValue: 1, cache: 1 }), + organizationId: text("organization_id").notNull(), + provider: text().notNull(), + enabled: boolean().default(false).notNull(), + status: text().default('connected').notNull(), + // 非機密設定(公司 id、帳戶對應等) + config: jsonb().$type>().default({}).notNull(), + credentialsEnc: text("credentials_enc"), + tokenCacheEnc: text("token_cache_enc"), + tokenExpiresAt: timestamp("token_expires_at", { withTimezone: true, mode: 'string' }), + lastSyncedAt: timestamp("last_synced_at", { withTimezone: true, mode: 'string' }), + lastError: text("last_error"), + lastErrorAt: timestamp("last_error_at", { withTimezone: true, mode: 'string' }), + connectedByUserId: text("connected_by_user_id"), + connectedAt: timestamp("connected_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), +}, (table) => [ + unique("uq_org_integration").on(table.organizationId, table.provider), + check("chk_org_integration_provider", sql`provider = ANY (ARRAY['simpany'::text, 'wise'::text])`), + check("chk_org_integration_status", sql`status = ANY (ARRAY['connected'::text, 'needs_reauth'::text, 'error'::text])`), +]); diff --git a/src/i18n/messages/activity.ts b/src/i18n/messages/activity.ts index ad0d0ba..7ffa470 100644 --- a/src/i18n/messages/activity.ts +++ b/src/i18n/messages/activity.ts @@ -37,6 +37,7 @@ const activity = { document: { "zh-TW": "憑證", en: "Document" }, payroll_run: { "zh-TW": "薪資批次", en: "Payroll run" }, payslip: { "zh-TW": "薪資單", en: "Payslip" }, + integration: { "zh-TW": "整合", en: "Integration" }, }, } satisfies Dictionary; diff --git a/src/i18n/messages/index.ts b/src/i18n/messages/index.ts index 8b5d10a..2cf785b 100644 --- a/src/i18n/messages/index.ts +++ b/src/i18n/messages/index.ts @@ -20,6 +20,7 @@ import payroll from "./payroll"; import members from "./members"; import activity from "./activity"; import settings from "./settings"; +import integrations from "./integrations"; import auth from "./auth"; import errors from "./errors"; import lib from "./lib"; @@ -48,6 +49,7 @@ const catalogue = { members, activity, settings, + integrations, auth, errors, lib, diff --git a/src/i18n/messages/integrations.ts b/src/i18n/messages/integrations.ts new file mode 100644 index 0000000..6a34cbe --- /dev/null +++ b/src/i18n/messages/integrations.ts @@ -0,0 +1,152 @@ +import type { Dictionary } from "./dictionary"; + +/** + * 設定 › 整合(/dashboard/settings/integrations)與 src/lib/integrations 共用的字串。 + * + * 新增 provider 時要補:providers.(name / description),以及它每個欄位在 + * fields. 的標籤(catalog.ts 的 labelKey 就是這裡的 key,型別會檢查)。 + */ +const integrations = { + title: { "zh-TW": "整合", en: "Integrations" }, + description: { + "zh-TW": "把這個組織接上外部服務。每個整合預設關閉:先連接(輸入憑證並實測),再手動開啟。", + en: "Connect this organization to external services. Every integration starts off: connect it first (credentials are tested), then switch it on.", + }, + readOnlyNote: { + "zh-TW": "只有組織的擁有者或管理員可以連接、開關或中斷整合。", + en: "Only organization owners or admins can connect, toggle or disconnect integrations.", + }, + providers: { + simpany: { + name: { "zh-TW": "Simpany 電子發票", en: "Simpany e-invoice" }, + description: { + "zh-TW": "用 Simpany 帳號開立與查詢電子發票。", + en: "Issue and look up e-invoices with your Simpany account.", + }, + }, + wise: { + name: { "zh-TW": "Wise", en: "Wise" }, + description: { + "zh-TW": "讀取 Wise 帳戶的餘額與交易,用來對帳。", + en: "Read Wise balances and transactions for reconciliation.", + }, + }, + googleCalendar: { + name: { "zh-TW": "Google 日曆", en: "Google Calendar" }, + description: { + "zh-TW": "把請款、收款與開發票的日期推到專屬日曆,提醒由 Google 發送。", + en: "Pushes billing, payment and invoicing dates to a dedicated calendar; Google sends the reminders.", + }, + }, + }, + fields: { + account: { "zh-TW": "帳號(Email)", en: "Account (email)" }, + password: { "zh-TW": "密碼", en: "Password" }, + apiToken: { "zh-TW": "API Token", en: "API token" }, + }, + status: { + notConnected: { "zh-TW": "未連接", en: "Not connected" }, + connectedOff: { "zh-TW": "已連接 · 已關閉", en: "Connected · off" }, + connected: { "zh-TW": "已連接", en: "Connected" }, + needsReauth: { "zh-TW": "需要重新連接:{error}", en: "Needs reconnecting: {error}" }, + error: { "zh-TW": "發生錯誤:{error}", en: "Error: {error}" }, + unknownError: { "zh-TW": "外部服務拒絕了憑證", en: "the service rejected the credentials" }, + connectedBy: { "zh-TW": "由 {name} 於 {date} 連接", en: "Connected by {name} on {date}" }, + lastSynced: { "zh-TW": "上次成功呼叫:{date}", en: "Last successful call: {date}" }, + calendarConnected: { "zh-TW": "已連接(由 {owner} 連結)", en: "Connected (by {owner})" }, + unknownMember: { "zh-TW": "某位成員", en: "a member" }, + }, + actions: { + connect: { "zh-TW": "連接", en: "Connect" }, + reconnect: { "zh-TW": "重新連接", en: "Reconnect" }, + disconnect: { "zh-TW": "中斷連接", en: "Disconnect" }, + manage: { "zh-TW": "管理", en: "Manage" }, + toggleLabel: { "zh-TW": "開啟 {name}", en: "Enable {name}" }, + }, + notImplemented: { "zh-TW": "尚未開放連接", en: "Not available yet" }, + sheet: { + connectTitle: { "zh-TW": "連接 {name}", en: "Connect {name}" }, + reconnectTitle: { "zh-TW": "重新連接 {name}", en: "Reconnect {name}" }, + description: { + "zh-TW": "按下「測試並連接」後,系統會先用這組憑證實際登入一次,通過才會儲存。連接後預設是關閉的。", + en: "\"Test and connect\" signs in with these credentials first and only saves them if that works. The integration stays off until you switch it on.", + }, + reconnectDescription: { + "zh-TW": "輸入新的憑證並實測;通過後會取代舊的,開關狀態維持不變。", + en: "Enter new credentials to test; if they work they replace the old ones and the on/off state is kept.", + }, + securityNote: { + "zh-TW": "憑證只會加密存在伺服器,之後任何人(包含你)都看不到原值。", + en: "Credentials are stored encrypted on the server; nobody, including you, can view them again.", + }, + submit: { "zh-TW": "測試並連接", en: "Test and connect" }, + submitting: { "zh-TW": "測試中…", en: "Testing…" }, + cancel: { "zh-TW": "取消", en: "Cancel" }, + }, + discard: { + title: { "zh-TW": "放棄輸入的內容?", en: "Discard what you entered?" }, + description: { + "zh-TW": "關閉後剛才輸入的憑證不會保留。", + en: "The credentials you typed will not be kept.", + }, + confirm: { "zh-TW": "放棄", en: "Discard" }, + keepEditing: { "zh-TW": "繼續編輯", en: "Keep editing" }, + }, + disconnectConfirm: { + title: { "zh-TW": "中斷 {name} 的連接?", en: "Disconnect {name}?" }, + description: { + "zh-TW": "會刪除這個組織存的 {name} 憑證與設定,相關功能立即停用。要再使用需重新輸入憑證。", + en: "This deletes the {name} credentials and settings stored for this organization and stops the integration immediately. You will need to re-enter credentials to use it again.", + }, + confirm: { "zh-TW": "中斷連接", en: "Disconnect" }, + cancel: { "zh-TW": "取消", en: "Cancel" }, + }, + toast: { + connected: { + "zh-TW": "{name} 已連接。確認無誤後再打開開關。", + en: "{name} connected. Switch it on when you're ready.", + }, + reconnected: { "zh-TW": "{name} 已重新連接", en: "{name} reconnected" }, + disconnected: { "zh-TW": "已中斷 {name}", en: "{name} disconnected" }, + failed: { "zh-TW": "操作失敗", en: "Operation failed" }, + }, + errors: { + notAllowed: { + "zh-TW": "只有組織的擁有者或管理員可以變更整合", + en: "Only organization owners or admins can change integrations", + }, + unknownProvider: { "zh-TW": "不認得的整合:{provider}", en: "Unknown integration: {provider}" }, + notImplemented: { + "zh-TW": "{name} 整合尚未實作,暫時無法連接", + en: "The {name} integration is not implemented yet", + }, + requiredField: { "zh-TW": "請填寫「{field}」", en: "\"{field}\" is required" }, + testFailed: { "zh-TW": "連線測試失敗:{error}", en: "Connection test failed: {error}" }, + notConnected: { "zh-TW": "{name} 尚未連接", en: "{name} is not connected" }, + cannotEnable: { + "zh-TW": "{name} 需要先重新連接才能開啟", + en: "{name} must be reconnected before it can be switched on", + }, + encryptionKeyMissing: { + "zh-TW": "伺服器尚未設定 FIELD_ENCRYPTION_KEY,無法安全儲存憑證,請聯絡系統管理員", + en: "FIELD_ENCRYPTION_KEY is not configured on the server, so credentials cannot be stored safely. Contact your administrator.", + }, + unavailable: { + "zh-TW": "{name} 整合尚未連接/未開啟,請 owner 或 admin 到 設定 › 整合 開啟", + en: "The {name} integration is not connected or not switched on. An owner or admin can turn it on under Settings › Integrations.", + }, + needsReauth: { + "zh-TW": "{name} 的憑證已失效({error}),請 owner 或 admin 到 設定 › 整合 重新連接", + en: "The {name} credentials no longer work ({error}). An owner or admin can reconnect it under Settings › Integrations.", + }, + }, + activity: { + connected: { "zh-TW": "連接 {name}", en: "{name} connected" }, + reconnected: { "zh-TW": "重新連接 {name}", en: "{name} reconnected" }, + enabled: { "zh-TW": "開啟 {name}", en: "{name} switched on" }, + disabled: { "zh-TW": "關閉 {name}", en: "{name} switched off" }, + disconnected: { "zh-TW": "中斷 {name}", en: "{name} disconnected" }, + }, +} satisfies Dictionary; + +export default integrations; diff --git a/src/lib/integrations/catalog.ts b/src/lib/integrations/catalog.ts new file mode 100644 index 0000000..95c6af2 --- /dev/null +++ b/src/lib/integrations/catalog.ts @@ -0,0 +1,36 @@ +import type { IntegrationCatalogEntry, IntegrationProviderId } from "./types"; + +/** + * 整合的靜態目錄:設定頁要畫出一列所需的全部資訊(logo、要輸入哪些欄位), + * 與「有沒有實作」無關。設定頁會列出這裡的每一筆;還沒在 registry.ts 登記實作的, + * 連接按鈕會停用並註明「尚未開放連接」。 + * + * 名稱與描述不寫在這裡,走 i18n:integrations.providers..name / .description。 + * 欄位標籤同理:integrations.fields.。 + * + * client 與 server 都會 import 這支,不能碰 DB / crypto。 + */ +export const INTEGRATION_CATALOG: Record = { + simpany: { + id: "simpany", + logo: { letter: "S", className: "bg-emerald-600 text-white" }, + credentialFields: [ + { key: "account", labelKey: "account", type: "email", required: true, autoComplete: "username" }, + { key: "password", labelKey: "password", type: "password", required: true, autoComplete: "current-password" }, + ], + }, + wise: { + id: "wise", + logo: { letter: "W", className: "bg-lime-400 text-emerald-950" }, + credentialFields: [ + { key: "apiToken", labelKey: "apiToken", type: "token", required: true, autoComplete: "off" }, + ], + }, +}; + +/** 設定頁的排列順序。 */ +export const INTEGRATION_ORDER: readonly IntegrationProviderId[] = ["simpany", "wise"]; + +export function getCatalogEntry(id: IntegrationProviderId): IntegrationCatalogEntry { + return INTEGRATION_CATALOG[id]; +} diff --git a/src/lib/integrations/registry.ts b/src/lib/integrations/registry.ts new file mode 100644 index 0000000..8b2a898 --- /dev/null +++ b/src/lib/integrations/registry.ts @@ -0,0 +1,41 @@ +import type { IntegrationProvider, IntegrationProviderId } from "./types"; + +/** + * 已實作的整合。目前是空的:框架先上,Simpany / Wise 的實作各自接上來。 + * + * ── 如何新增一個整合的實作 ────────────────────────────────────────────── + * 1. 在 src/lib/integrations/.ts 寫一個 IntegrationProvider: + * + * import type { IntegrationProvider } from "./types"; + * + * export const simpanyProvider: IntegrationProvider = { + * id: "simpany", + * async testConnection(creds, config) { + * // 用 creds.account / creds.password 實際登入一次。 + * // 憑證錯 → return { ok: false, error: "帳號或密碼錯誤" } + * // 成功 → return { ok: true, config: { companyId }, tokenCache: { value, expiresAt } } + * }, + * }; + * + * 2. 在下面的 PROVIDERS 加一行:`simpany: simpanyProvider,` + * (這支只會被 server 端 import:actions、store、MCP 工具。) + * + * 3. 業務邏輯(開發票、抓交易…)寫在同一支或旁邊的檔案,執行前一律先 + * `requireEnabledIntegration(orgId, "simpany")`(MCP 工具用 + * `requireIntegrationForTool`)拿到 { row, credentials },外部呼叫成功後 + * `recordSyncSuccess`,憑證被拒時 `markNeedsReauth`。詳見 docs/integrations.md。 + * + * 顯示用的資料(名稱、欄位、logo)不在這裡,在 catalog.ts 與 i18n。 + * ───────────────────────────────────────────────────────────────────── + */ +const PROVIDERS: Partial> = {}; + +/** 取實作;還沒實作的回 null(設定頁據此停用「連接」)。 */ +export function getProvider(id: IntegrationProviderId): IntegrationProvider | null { + return PROVIDERS[id] ?? null; +} + +/** 有實作的整合代號,給設定頁判斷哪些能連接。 */ +export function implementedProviderIds(): IntegrationProviderId[] { + return (Object.keys(PROVIDERS) as IntegrationProviderId[]).filter((id) => PROVIDERS[id]); +} diff --git a/src/lib/integrations/store.ts b/src/lib/integrations/store.ts new file mode 100644 index 0000000..2fa6461 --- /dev/null +++ b/src/lib/integrations/store.ts @@ -0,0 +1,350 @@ +import { and, eq, sql } from "drizzle-orm"; +import { getTranslations } from "next-intl/server"; +import { getDb } from "@/db"; +import { orgIntegrations } from "@/db/schema"; +import { user } from "@/db/auth-schema"; +import { decryptField, decryptJson, encryptField, encryptJson } from "@/lib/crypto"; +import type { + IntegrationConfig, + IntegrationCredentials, + IntegrationProviderId, + IntegrationStatus, + IntegrationSummary, + TokenCache, +} from "./types"; + +/** + * org_integrations 的唯一存取點(server only —— 會碰 DB 與 FIELD_ENCRYPTION_KEY)。 + * + * 讀:getIntegration / listIntegrations 回傳 IntegrationSummary,型別上就不含密文。 + * 真的要憑證的只有 provider 的實作,走 loadCredentials / requireEnabledIntegration。 + * 解密後的值只能留在 server 記憶體裡:不可回傳給 client、不可放進 MCP 結果、不可寫 log。 + */ + +/** 整合沒連接 / 沒開啟 / 憑證失效時丟這個。message 就是給人看的修法。 */ +export class IntegrationUnavailableError extends Error { + constructor( + readonly provider: IntegrationProviderId, + readonly reason: "not_connected" | "disabled" | "needs_reauth" | "error", + message: string, + ) { + super(message); + this.name = "IntegrationUnavailableError"; + } +} + +const summaryColumns = { + provider: orgIntegrations.provider, + enabled: orgIntegrations.enabled, + status: orgIntegrations.status, + config: orgIntegrations.config, + connectedAt: orgIntegrations.connectedAt, + connectedByUserId: orgIntegrations.connectedByUserId, + connectedByName: sql`coalesce(nullif(${user.name}, ''), ${user.email})`, + tokenExpiresAt: orgIntegrations.tokenExpiresAt, + lastSyncedAt: orgIntegrations.lastSyncedAt, + lastError: orgIntegrations.lastError, + lastErrorAt: orgIntegrations.lastErrorAt, + updatedAt: orgIntegrations.updatedAt, +}; + +type SummaryRow = { + provider: string; + status: string; +} & Omit; + +function toSummary(r: SummaryRow): IntegrationSummary { + return { + ...r, + provider: r.provider as IntegrationProviderId, + status: r.status as IntegrationStatus, + config: r.config ?? {}, + }; +} + +function whereRow(orgId: string, provider: IntegrationProviderId) { + return and( + eq(orgIntegrations.organizationId, orgId), + eq(orgIntegrations.provider, provider), + ); +} + +/** 整合在目前語系下的顯示名稱(integrations.providers..name)。 */ +export async function integrationDisplayName(provider: IntegrationProviderId): Promise { + const t = await getTranslations("integrations"); + return t(`providers.${provider}.name`); +} + +// --------------------------------------------------------------------------- +// 讀(不含秘密) +// --------------------------------------------------------------------------- + +/** 這個組織某個整合的狀態;沒連接回 null。不含任何密文。 */ +export async function getIntegration( + orgId: string, + provider: IntegrationProviderId, +): Promise { + const [row] = await getDb() + .select(summaryColumns) + .from(orgIntegrations) + .leftJoin(user, eq(user.id, orgIntegrations.connectedByUserId)) + .where(whereRow(orgId, provider)) + .limit(1); + return row ? toSummary(row) : null; +} + +/** 這個組織所有已連接的整合。不含任何密文。 */ +export async function listIntegrations(orgId: string): Promise { + const rows = await getDb() + .select(summaryColumns) + .from(orgIntegrations) + .leftJoin(user, eq(user.id, orgIntegrations.connectedByUserId)) + .where(eq(orgIntegrations.organizationId, orgId)); + return rows.map(toSummary); +} + +// --------------------------------------------------------------------------- +// 讀(含秘密 —— 只給 provider 實作用) +// --------------------------------------------------------------------------- + +/** 解密後的憑證;沒連接回 null。只能在 server 端使用,結果不得外傳。 */ +export async function loadCredentials( + orgId: string, + provider: IntegrationProviderId, +): Promise { + const [row] = await getDb() + .select({ credentialsEnc: orgIntegrations.credentialsEnc }) + .from(orgIntegrations) + .where(whereRow(orgId, provider)) + .limit(1); + if (!row?.credentialsEnc) return null; + return decryptJson(row.credentialsEnc); +} + +/** 快取的 session token;沒有或已過期(預留 60 秒緩衝)回 null。 */ +export async function loadTokenCache( + orgId: string, + provider: IntegrationProviderId, +): Promise { + const [row] = await getDb() + .select({ + tokenCacheEnc: orgIntegrations.tokenCacheEnc, + tokenExpiresAt: orgIntegrations.tokenExpiresAt, + }) + .from(orgIntegrations) + .where(whereRow(orgId, provider)) + .limit(1); + if (!row?.tokenCacheEnc || !row.tokenExpiresAt) return null; + const expiresAt = new Date(row.tokenExpiresAt); + if (Number.isNaN(expiresAt.getTime()) || expiresAt.getTime() - 60_000 <= Date.now()) { + return null; + } + return { value: await decryptField(row.tokenCacheEnc), expiresAt }; +} + +/** + * 業務邏輯執行前的關卡:整合必須已連接、已開啟、狀態 connected,否則丟 + * IntegrationUnavailableError(訊息會告訴使用者去哪裡修)。通過則回傳狀態與憑證。 + */ +export async function requireEnabledIntegration( + orgId: string, + provider: IntegrationProviderId, +): Promise<{ row: IntegrationSummary; credentials: IntegrationCredentials }> { + const row = await getIntegration(orgId, provider); + const t = await getTranslations("integrations"); + const name = t(`providers.${provider}.name`); + if (!row) { + throw new IntegrationUnavailableError(provider, "not_connected", t("errors.unavailable", { name })); + } + if (row.status !== "connected") { + throw new IntegrationUnavailableError( + provider, + row.status === "needs_reauth" ? "needs_reauth" : "error", + t("errors.needsReauth", { name, error: row.lastError ?? t("status.unknownError") }), + ); + } + if (!row.enabled) { + throw new IntegrationUnavailableError(provider, "disabled", t("errors.unavailable", { name })); + } + const credentials = await loadCredentials(orgId, provider); + if (!credentials) { + throw new IntegrationUnavailableError(provider, "not_connected", t("errors.unavailable", { name })); + } + return { row, credentials }; +} + +// --------------------------------------------------------------------------- +// 寫:provider 執行期回報 +// --------------------------------------------------------------------------- + +/** 存 provider 換來的 session token(加密)。 */ +export async function saveTokenCache( + orgId: string, + provider: IntegrationProviderId, + value: string, + expiresAt: Date, +): Promise { + await getDb() + .update(orgIntegrations) + .set({ + tokenCacheEnc: await encryptField(value), + tokenExpiresAt: expiresAt.toISOString(), + updatedAt: sql`now()`, + }) + .where(whereRow(orgId, provider)); +} + +/** 丟掉快取的 token(例如外部服務說它失效了,但帳密本身可能還能用)。 */ +export async function clearTokenCache( + orgId: string, + provider: IntegrationProviderId, +): Promise { + await getDb() + .update(orgIntegrations) + .set({ tokenCacheEnc: null, tokenExpiresAt: null, updatedAt: sql`now()` }) + .where(whereRow(orgId, provider)); +} + +/** + * 外部服務拒絕了憑證(密碼改了、token 被撤銷)。狀態改為 needs_reauth、清掉 token, + * 之後 requireEnabledIntegration 會擋下並請使用者重新連接。enabled 不動 —— + * 重新連接後會回到原本的開關狀態。error 會顯示給成員看,不得含憑證。 + */ +export async function markNeedsReauth( + orgId: string, + provider: IntegrationProviderId, + error: string, +): Promise { + await getDb() + .update(orgIntegrations) + .set({ + status: "needs_reauth", + tokenCacheEnc: null, + tokenExpiresAt: null, + lastError: error, + lastErrorAt: sql`now()`, + updatedAt: sql`now()`, + }) + .where(whereRow(orgId, provider)); +} + +/** 一次性失敗(網路、對方 5xx):只記下錯誤,不改狀態。 */ +export async function recordSyncFailure( + orgId: string, + provider: IntegrationProviderId, + error: string, +): Promise { + await getDb() + .update(orgIntegrations) + .set({ lastError: error, lastErrorAt: sql`now()`, updatedAt: sql`now()` }) + .where(whereRow(orgId, provider)); +} + +/** 外部呼叫成功:記下時間並清掉上一次的錯誤。 */ +export async function recordSyncSuccess( + orgId: string, + provider: IntegrationProviderId, +): Promise { + await getDb() + .update(orgIntegrations) + .set({ + lastSyncedAt: sql`now()`, + lastError: null, + lastErrorAt: null, + updatedAt: sql`now()`, + }) + .where(whereRow(orgId, provider)); +} + +/** 淺層合併非機密設定(jsonb ||),回傳合併後的 config;沒連接回 null。 */ +export async function updateConfig( + orgId: string, + provider: IntegrationProviderId, + patch: IntegrationConfig, +): Promise { + const [row] = await getDb() + .update(orgIntegrations) + .set({ + config: sql`${orgIntegrations.config} || ${JSON.stringify(patch)}::jsonb`, + updatedAt: sql`now()`, + }) + .where(whereRow(orgId, provider)) + .returning({ config: orgIntegrations.config }); + return row?.config ?? null; +} + +// --------------------------------------------------------------------------- +// 寫:設定頁的連接 / 開關 / 中斷(權限檢查在 server action,這裡不判斷角色) +// --------------------------------------------------------------------------- + +/** + * 寫入一次成功的連接。新連接 enabled = false;重新連接保留原本的 enabled。 + * config 與既有設定淺層合併(重新連接不會洗掉帳戶對應之類的設定)。 + */ +export async function saveConnection(args: { + orgId: string; + provider: IntegrationProviderId; + userId: string; + credentials: IntegrationCredentials; + config: IntegrationConfig; + tokenCache?: TokenCache; +}): Promise { + const credentialsEnc = await encryptJson(args.credentials); + const tokenCacheEnc = args.tokenCache ? await encryptField(args.tokenCache.value) : null; + const tokenExpiresAt = args.tokenCache ? args.tokenCache.expiresAt.toISOString() : null; + const configJson = JSON.stringify(args.config); + await getDb() + .insert(orgIntegrations) + .values({ + organizationId: args.orgId, + provider: args.provider, + enabled: false, + status: "connected", + config: args.config, + credentialsEnc, + tokenCacheEnc, + tokenExpiresAt, + connectedByUserId: args.userId, + }) + .onConflictDoUpdate({ + target: [orgIntegrations.organizationId, orgIntegrations.provider], + set: { + status: "connected", + config: sql`${orgIntegrations.config} || ${configJson}::jsonb`, + credentialsEnc, + tokenCacheEnc, + tokenExpiresAt, + lastError: null, + lastErrorAt: null, + connectedByUserId: args.userId, + connectedAt: sql`now()`, + updatedAt: sql`now()`, + }, + }); +} + +/** 開 / 關。回傳更新後的列數(0 = 沒連接)。 */ +export async function setIntegrationEnabled( + orgId: string, + provider: IntegrationProviderId, + enabled: boolean, +): Promise { + const rows = await getDb() + .update(orgIntegrations) + .set({ enabled, updatedAt: sql`now()` }) + .where(whereRow(orgId, provider)) + .returning({ id: orgIntegrations.id }); + return rows.length; +} + +/** 中斷連接 = 刪列(連同密文)。回傳刪掉的列數。 */ +export async function deleteIntegration( + orgId: string, + provider: IntegrationProviderId, +): Promise { + const rows = await getDb() + .delete(orgIntegrations) + .where(whereRow(orgId, provider)) + .returning({ id: orgIntegrations.id }); + return rows.length; +} diff --git a/src/lib/integrations/types.ts b/src/lib/integrations/types.ts new file mode 100644 index 0000000..5555046 --- /dev/null +++ b/src/lib/integrations/types.ts @@ -0,0 +1,106 @@ +import type integrationsMessages from "@/i18n/messages/integrations"; + +// 組織層級外部整合的共用型別。client 與 server 都會 import 這支,所以這裡只能有 +// 型別與純常數,不能碰 DB 或 crypto。 + +/** + * 系統認得的整合代號。必須與 migrations/0023 的 chk_org_integration_provider 一致 —— + * 新增一家要同時:寫新 migration 擴充 CHECK、在這裡加代號、在 catalog.ts 補一筆。 + */ +export const INTEGRATION_PROVIDER_IDS = ["simpany", "wise"] as const; +export type IntegrationProviderId = (typeof INTEGRATION_PROVIDER_IDS)[number]; + +export function isIntegrationProviderId(v: unknown): v is IntegrationProviderId { + return ( + typeof v === "string" && (INTEGRATION_PROVIDER_IDS as readonly string[]).includes(v) + ); +} + +/** 對應 org_integrations.status。 */ +export type IntegrationStatus = "connected" | "needs_reauth" | "error"; + +/** + * 欄位的輸入型態。password / token 在 UI 以遮罩輸入、且永遠不回填; + * text / email 是一般輸入。 + */ +export type IntegrationFieldType = "text" | "email" | "password" | "token"; + +/** 欄位標籤的 i18n key(integrations.fields.);新增欄位要先補字串。 */ +export type IntegrationFieldLabelKey = keyof (typeof integrationsMessages)["fields"]; + +export type IntegrationField = { + /** 存進憑證 / config 物件時用的 key。 */ + key: string; + labelKey: IntegrationFieldLabelKey; + type: IntegrationFieldType; + required: boolean; + /** 瀏覽器自動填入提示;帳密類整合填了能讓密碼管理器幫忙。 */ + autoComplete?: string; +}; + +/** 解密後的憑證:key 就是 credentialFields 的 key。只存在 server 記憶體中。 */ +export type IntegrationCredentials = Record; + +/** 非機密設定(公司 id、帳戶對應等)。會顯示給成員與 MCP。 */ +export type IntegrationConfig = Record; + +/** 靜態目錄的一筆:UI 要畫出這個整合所需的一切,不含任何實作。 */ +export type IntegrationCatalogEntry = { + id: IntegrationProviderId; + /** 清單上的 logo 方塊:一個字 + Tailwind 底色 / 字色 class。 */ + logo: { letter: string; className: string }; + /** 連接時要輸入的機密欄位(加密存放)。 */ + credentialFields: readonly IntegrationField[]; + /** 連接時可一併輸入的非機密設定(明文存 config)。沒有就省略。 */ + configFields?: readonly IntegrationField[]; + /** 服務本身的網站,給使用者參考。 */ + website?: string; +}; + +export type TokenCache = { value: string; expiresAt: Date }; + +export type TestConnectionResult = + | { + ok: true; + /** 測試時順便發現的非機密設定(例如公司 id),會合併進 config。 */ + config?: IntegrationConfig; + /** 登入換來的 session token,會加密存進 token_cache_enc。 */ + tokenCache?: TokenCache; + } + | { ok: false; error: string }; + +/** + * 一個整合的「實作」。顯示用的資料(名稱、欄位)在 catalog.ts;這裡只放會打外部 + * 服務的邏輯。實作放在 src/lib/integrations/.ts,並在 registry.ts 登記一行。 + * + * 規則: + * - testConnection 不得拋錯表達「憑證錯」,要回 { ok: false, error }(error 給人看, + * 不得含憑證)。網路錯等非預期狀況可以拋,框架會轉成錯誤訊息。 + * - 不得把 creds 寫進 log、錯誤訊息或回傳值。 + */ +export interface IntegrationProvider { + id: IntegrationProviderId; + testConnection( + creds: IntegrationCredentials, + config: IntegrationConfig, + ): Promise; +} + +/** + * 給 client 與 MCP 看的整合狀態。刻意不含任何密文或憑證欄位 —— + * 型別上就拿不到,避免哪天有人 `...row` 一路傳到前端。 + */ +export type IntegrationSummary = { + provider: IntegrationProviderId; + enabled: boolean; + status: IntegrationStatus; + config: IntegrationConfig; + connectedAt: string; + connectedByUserId: string | null; + connectedByName: string | null; + tokenExpiresAt: string | null; + lastSyncedAt: string | null; + lastError: string | null; + lastErrorAt: string | null; + updatedAt: string; +}; From 62a9c9f8e6fe0eda128343529340daaf48dd67a6 Mon Sep 17 00:00:00 2001 From: YJack0000 Date: Thu, 24 Sep 2026 17:12:12 +0800 Subject: [PATCH 03/27] =?UTF-8?q?[feature]=20=E5=93=A1=E5=B7=A5=E5=A4=9A?= =?UTF-8?q?=E5=B8=B3=E6=88=B6=EF=BC=9Amigration=200024=20=E8=88=87=20Drizz?= =?UTF-8?q?le=20schema=EF=BC=88employee=5Fbank=5Faccounts=E3=80=81national?= =?UTF-8?q?=5Fid=5Fenc=E3=80=81=E5=8C=AF=E5=85=A5=E5=B8=B3=E6=88=B6?= =?UTF-8?q?=E6=AC=84=E4=BD=8D=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- migrations/0024_employee_bank_accounts.sql | 90 ++++++++++++++++++++++ src/db/schema.ts | 60 +++++++++++++++ 2 files changed, 150 insertions(+) create mode 100644 migrations/0024_employee_bank_accounts.sql diff --git a/migrations/0024_employee_bank_accounts.sql b/migrations/0024_employee_bank_accounts.sql new file mode 100644 index 0000000..a25a32a --- /dev/null +++ b/migrations/0024_employee_bank_accounts.sql @@ -0,0 +1,90 @@ +-- 0024: 員工多帳戶(薪轉 / 報銷撥款的收款帳戶)+ 身分證字號加密欄位。 +-- +-- 問題:employees.salary_account 是一格自由文字,一位員工只能記一個帳戶,而且 +-- 帳號與身分證字號都以明文存在資料庫裡;遮罩只發生在 MCP 的輸出層,網頁端任何 +-- 組織成員都拿得到完整值。發薪與撥款也無從記錄「錢匯到員工的哪個帳戶」。 +-- +-- 做法: +-- (1) 新表 employee_bank_accounts:一位員工可有多個帳戶。帳號本體只存密文 +-- (src/lib/crypto.ts 的 encryptField,AES-256-GCM,金鑰為部署 secret +-- FIELD_ENCRYPTION_KEY),另存末 5 碼供列表與遮罩顯示,不需解密。 +-- 「薪資預設 / 報銷預設」各自以 partial unique index 保證同一位員工最多一個。 +-- (2) payslips.paid_to_account_id、transactions.settle_to_account_id:記錄這筆 +-- 薪資 / 撥款匯入員工的哪個帳戶(選填)。與既有的 from_account_id(公司自己的 +-- 帳本帳戶 bank_accounts)是兩回事,互不取代。 +-- (3) employees.national_id_enc:身分證字號的密文。寫入時改寫這欄並清空明文 +-- national_id;讀取時優先讀密文,沒有才退回舊的明文欄位。 +-- +-- 舊欄位(national_id / salary_account)本次不刪:既有資料的搬移由 +-- scripts/migrate-employee-pii.ts 另外執行(需要加密金鑰,SQL 做不到),確認 +-- 搬完之後再用後續 migration 移除。Forward-only,全部 additive。 +-- Run AFTER 0023. + +-- (1) 員工收款帳戶 +-- kind: +-- bank 台灣的銀行 / 郵局帳戶,bank_code 必填(3 碼),帳號只能是數字 +-- wise Wise 等跨境收款帳戶 +-- other 其他(無法歸類的舊資料也放這裡) +CREATE TABLE employee_bank_accounts ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + organization_id text, + employee_id bigint NOT NULL REFERENCES employees(id), + kind text NOT NULL DEFAULT 'bank', + bank_code text, + branch_code text, + bank_name text, + account_holder text, + account_number_enc text NOT NULL, + account_last5 text NOT NULL, + currency text NOT NULL DEFAULT 'TWD', + label text, + default_for_salary boolean NOT NULL DEFAULT false, + default_for_reimbursement boolean NOT NULL DEFAULT false, + is_active boolean NOT NULL DEFAULT true, + note text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT chk_emp_acct_kind + CHECK (kind = ANY (ARRAY['bank'::text, 'wise'::text, 'other'::text])), + -- 銀行帳戶一定要有 3 碼銀行代碼;其他種類可留空,但有填就得是 3 碼數字。 + CONSTRAINT chk_emp_acct_bank_code + CHECK ((kind <> 'bank' OR bank_code IS NOT NULL) AND (bank_code IS NULL OR bank_code ~ '^[0-9]{3}$')), + CONSTRAINT chk_emp_acct_branch_code + CHECK (branch_code IS NULL OR branch_code ~ '^[0-9]{4}$'), + CONSTRAINT chk_emp_acct_last5 + CHECK (char_length(account_last5) BETWEEN 1 AND 5), + CONSTRAINT chk_emp_acct_currency + CHECK (currency ~ '^[A-Z]{3}$') +); + +COMMENT ON TABLE employee_bank_accounts IS '員工的收款帳戶(薪轉 / 報銷撥款),一位員工可有多個'; +COMMENT ON COLUMN employee_bank_accounts.bank_code IS '銀行代碼 3 碼(例 807 永豐);kind = bank 時必填'; +COMMENT ON COLUMN employee_bank_accounts.branch_code IS '分行代碼 4 碼,選填'; +COMMENT ON COLUMN employee_bank_accounts.account_number_enc IS '完整帳號的密文(encryptField,v1::);明文只在 owner/admin 明確「顯示完整帳號」時於 server 端解密,並寫入 activity_log'; +COMMENT ON COLUMN employee_bank_accounts.account_last5 IS '帳號末 5 碼(去掉空白與連字號後),列表與遮罩顯示用,不需解密'; +COMMENT ON COLUMN employee_bank_accounts.default_for_salary IS '發薪時預設匯入這個帳戶;同一位員工最多一個'; +COMMENT ON COLUMN employee_bank_accounts.default_for_reimbursement IS '報銷撥款時預設匯入這個帳戶;同一位員工最多一個'; + +CREATE INDEX idx_emp_acct_employee ON employee_bank_accounts (employee_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_emp_acct_org ON employee_bank_accounts (organization_id) WHERE deleted_at IS NULL; +CREATE UNIQUE INDEX uq_emp_acct_default_salary ON employee_bank_accounts (employee_id) + WHERE default_for_salary AND deleted_at IS NULL; +CREATE UNIQUE INDEX uq_emp_acct_default_reimbursement ON employee_bank_accounts (employee_id) + WHERE default_for_reimbursement AND deleted_at IS NULL; + +-- (2) 薪資單 / 交易記錄匯入的員工帳戶 +ALTER TABLE payslips ADD COLUMN paid_to_account_id bigint REFERENCES employee_bank_accounts(id); +ALTER TABLE transactions ADD COLUMN settle_to_account_id bigint REFERENCES employee_bank_accounts(id); +CREATE INDEX idx_payslip_paid_to ON payslips (paid_to_account_id); +CREATE INDEX idx_txn_settle_to ON transactions (settle_to_account_id); + +COMMENT ON COLUMN payslips.paid_to_account_id IS '薪資匯入的員工帳戶(employee_bank_accounts),選填'; +COMMENT ON COLUMN transactions.settle_to_account_id IS '撥款 / 薪資匯入的員工帳戶(employee_bank_accounts),選填;與 from_account_id(公司帳本帳戶)無關'; + +-- (3) 身分證字號密文 +ALTER TABLE employees ADD COLUMN national_id_enc text; + +COMMENT ON COLUMN employees.national_id_enc IS '身分證字號 / 統編的密文(encryptField);讀取時優先於明文 national_id'; +COMMENT ON COLUMN employees.national_id IS '已淘汰:明文身分證字號,改存 national_id_enc。scripts/migrate-employee-pii.ts 搬完後清空,後續 migration 移除'; +COMMENT ON COLUMN employees.salary_account IS '已淘汰:單一自由文字的薪轉帳戶,改用 employee_bank_accounts。scripts/migrate-employee-pii.ts 搬完後清空,後續 migration 移除'; diff --git a/src/db/schema.ts b/src/db/schema.ts index 2fcb818..119c7c4 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -70,7 +70,10 @@ export const employees = pgTable("employees", { deletedAt: timestamp("deleted_at", { withTimezone: true, mode: 'string' }), organizationId: text("organization_id"), name: text().notNull(), + // 已淘汰:明文身分證字號。新寫入一律改存 nationalIdEnc 並清空這欄(migrations/0024)。 nationalId: text("national_id"), + // 身分證字號密文(src/lib/crypto.ts encryptField)。讀取走 src/db/employee-accounts.ts 的 readNationalId。 + nationalIdEnc: text("national_id_enc"), employmentType: text("employment_type").default('full_time').notNull(), hasLaborInsurance: boolean("has_labor_insurance").default(true).notNull(), hasHealthInsurance: boolean("has_health_insurance").default(true).notNull(), @@ -79,6 +82,7 @@ export const employees = pgTable("employees", { // 勞健保投保薪資(記錄保多少,先不試算保費);是否有勞退看 hasPension laborInsuredSalary: numeric("labor_insured_salary", { precision: 18, scale: 2 }), healthInsuredSalary: numeric("health_insured_salary", { precision: 18, scale: 2 }), + // 已淘汰:單一自由文字的薪轉帳戶,改用 employee_bank_accounts(migrations/0024)。 salaryAccount: text("salary_account"), startDate: date("start_date"), endDate: date("end_date"), @@ -96,6 +100,46 @@ export const employees = pgTable("employees", { check("chk_emp_type", sql`employment_type = ANY (ARRAY['full_time'::text, 'part_time'::text, 'freelancer'::text, 'contractor'::text])`), ]); +// ---- 員工收款帳戶(migrations/0024):薪轉 / 報銷撥款匯入的帳戶,一位員工可有多個。 +// 帳號只存密文(accountNumberEnc),列表一律用末 5 碼;完整帳號只在 owner/admin +// 明確「顯示完整帳號」時於 server 端解密,並寫入 activity_log。---- +export const employeeBankAccounts = pgTable("employee_bank_accounts", { + id: bigint({ mode: "number" }).primaryKey().generatedAlwaysAsIdentity({ name: "employee_bank_accounts_id_seq", startWith: 1, increment: 1, minValue: 1, cache: 1 }), + organizationId: text("organization_id"), + employeeId: bigint("employee_id", { mode: "number" }).notNull(), + kind: text().default('bank').notNull(), + bankCode: text("bank_code"), + branchCode: text("branch_code"), + bankName: text("bank_name"), + accountHolder: text("account_holder"), + accountNumberEnc: text("account_number_enc").notNull(), + accountLast5: text("account_last5").notNull(), + currency: text().default('TWD').notNull(), + label: text(), + defaultForSalary: boolean("default_for_salary").default(false).notNull(), + defaultForReimbursement: boolean("default_for_reimbursement").default(false).notNull(), + isActive: boolean("is_active").default(true).notNull(), + note: text(), + createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), + deletedAt: timestamp("deleted_at", { withTimezone: true, mode: 'string' }), +}, (table) => [ + foreignKey({ + columns: [table.employeeId], + foreignColumns: [employees.id], + name: "employee_bank_accounts_employee_id_fkey" + }), + index("idx_emp_acct_employee").using("btree", table.employeeId.asc().nullsLast().op("int8_ops")).where(sql`deleted_at IS NULL`), + index("idx_emp_acct_org").using("btree", table.organizationId.asc().nullsLast().op("text_ops")).where(sql`deleted_at IS NULL`), + uniqueIndex("uq_emp_acct_default_salary").using("btree", table.employeeId.asc().nullsLast().op("int8_ops")).where(sql`default_for_salary AND deleted_at IS NULL`), + uniqueIndex("uq_emp_acct_default_reimbursement").using("btree", table.employeeId.asc().nullsLast().op("int8_ops")).where(sql`default_for_reimbursement AND deleted_at IS NULL`), + check("chk_emp_acct_kind", sql`kind = ANY (ARRAY['bank'::text, 'wise'::text, 'other'::text])`), + check("chk_emp_acct_bank_code", sql`((kind <> 'bank'::text) OR (bank_code IS NOT NULL)) AND ((bank_code IS NULL) OR (bank_code ~ '^[0-9]{3}$'::text))`), + check("chk_emp_acct_branch_code", sql`(branch_code IS NULL) OR (branch_code ~ '^[0-9]{4}$'::text)`), + check("chk_emp_acct_last5", sql`(char_length(account_last5) >= 1) AND (char_length(account_last5) <= 5)`), + check("chk_emp_acct_currency", sql`currency ~ '^[A-Z]{3}$'::text`), +]); + export const payrollRuns = pgTable("payroll_runs", { // You can use { mode: "bigint" } if numbers are exceeding js number limitations id: bigint({ mode: "number" }).primaryKey().generatedAlwaysAsIdentity({ name: "payroll_runs_id_seq", startWith: 1, increment: 1, minValue: 1, cache: 1 }), @@ -127,7 +171,15 @@ export const payslips = pgTable("payslips", { // You can use { mode: "bigint" } if numbers are exceeding js number limitations paidTransactionId: bigint("paid_transaction_id", { mode: "number" }), note: text(), + // 薪資匯入的員工帳戶(migrations/0024),選填 + paidToAccountId: bigint("paid_to_account_id", { mode: "number" }), }, (table) => [ + index("idx_payslip_paid_to").using("btree", table.paidToAccountId.asc().nullsLast().op("int8_ops")), + foreignKey({ + columns: [table.paidToAccountId], + foreignColumns: [employeeBankAccounts.id], + name: "payslips_paid_to_account_id_fkey" + }), foreignKey({ columns: [table.payrollRunId], foreignColumns: [payrollRuns.id], @@ -251,7 +303,15 @@ export const transactions = pgTable("transactions", { // 請款項目綁定(選填):把 income 交易掛到某一筆 billing_items,讓該期「已收多少」 // 自動算出來。FK 在 DB 端(migrations/0017)建立,這裡只放欄位避免宣告順序衝突。 billingItemId: bigint("billing_item_id", { mode: "number" }), + // 撥款 / 薪資匯入的員工帳戶(migrations/0024),選填。與 fromAccountId(公司帳本帳戶)無關。 + settleToAccountId: bigint("settle_to_account_id", { mode: "number" }), }, (table) => [ + index("idx_txn_settle_to").using("btree", table.settleToAccountId.asc().nullsLast().op("int8_ops")), + foreignKey({ + columns: [table.settleToAccountId], + foreignColumns: [employeeBankAccounts.id], + name: "transactions_settle_to_account_id_fkey" + }), index("idx_txn_book").using("btree", table.book.asc().nullsLast().op("text_ops")), index("idx_txn_category").using("btree", table.categoryId.asc().nullsLast().op("int8_ops")), index("idx_txn_date").using("btree", table.txnDate.asc().nullsLast().op("date_ops")), From 058860b20477fdbc802e996815205a8ca549f0bf Mon Sep 17 00:00:00 2001 From: YJack0000 Date: Thu, 24 Sep 2026 17:15:55 +0800 Subject: [PATCH 04/27] =?UTF-8?q?[feature]=20=E8=A8=AD=E5=AE=9A=20?= =?UTF-8?q?=E2=80=BA=20=E6=95=B4=E5=90=88=EF=BC=9A=E6=AC=A1=E7=B4=9A?= =?UTF-8?q?=E5=B0=8E=E8=A6=BD=E3=80=81=E6=95=B4=E5=90=88=E6=B8=85=E5=96=AE?= =?UTF-8?q?=E3=80=81=E9=80=A3=E6=8E=A5=20Sheet=20=E8=88=87=E9=96=8B?= =?UTF-8?q?=E9=97=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 設定區加左側次級導覽(基本資料 / 整合 / MCP) - /dashboard/settings/integrations:每個整合一列(狀態、開關、連接/重新連接/中斷) - 連接用 Sheet:測試並連接、錯誤就地顯示、未存修改關閉前確認;中斷用 AlertDialog 確認 - server actions 限 owner/admin,回傳值不含任何憑證 - Google 日曆設定搬進整合頁(#google-calendar),清單上以一列顯示狀態 - 新增 shadcn Switch 元件 --- .../billing/sync-calendar-button.tsx | 4 +- .../settings/integrations/actions.ts | 219 +++++++++ .../{ => integrations}/calendar-actions.ts | 6 +- .../calendar-settings-client.tsx | 4 +- .../integrations/integrations-client.tsx | 438 ++++++++++++++++++ .../dashboard/settings/integrations/page.tsx | 74 +++ src/app/dashboard/settings/layout.tsx | 16 + src/app/dashboard/settings/page.tsx | 32 +- src/app/dashboard/settings/settings-nav.tsx | 46 ++ src/components/app-sidebar.tsx | 13 +- src/components/header-breadcrumb.tsx | 1 + src/components/ui/switch.tsx | 35 ++ src/i18n/messages/common.ts | 1 + src/i18n/messages/lib.ts | 2 +- src/i18n/messages/settings.ts | 8 +- 15 files changed, 862 insertions(+), 37 deletions(-) create mode 100644 src/app/dashboard/settings/integrations/actions.ts rename src/app/dashboard/settings/{ => integrations}/calendar-actions.ts (97%) rename src/app/dashboard/settings/{ => integrations}/calendar-settings-client.tsx (97%) create mode 100644 src/app/dashboard/settings/integrations/integrations-client.tsx create mode 100644 src/app/dashboard/settings/integrations/page.tsx create mode 100644 src/app/dashboard/settings/layout.tsx create mode 100644 src/app/dashboard/settings/settings-nav.tsx create mode 100644 src/components/ui/switch.tsx diff --git a/src/app/dashboard/billing/sync-calendar-button.tsx b/src/app/dashboard/billing/sync-calendar-button.tsx index 7c1be19..657a641 100644 --- a/src/app/dashboard/billing/sync-calendar-button.tsx +++ b/src/app/dashboard/billing/sync-calendar-button.tsx @@ -7,7 +7,7 @@ import { useTranslations } from "next-intl"; import { toast } from "sonner"; import { CalendarPlus, RefreshCw } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { syncCalendar } from "../settings/calendar-actions"; +import { syncCalendar } from "../settings/integrations/calendar-actions"; /** * 尚未連結時直接指向設定頁,而不是按了才報錯 —— 讓「還沒設定」這件事在按下去之前 @@ -21,7 +21,7 @@ export function SyncCalendarButton({ connected }: Readonly<{ connected: boolean if (!connected) { return ( diff --git a/src/app/dashboard/settings/integrations/actions.ts b/src/app/dashboard/settings/integrations/actions.ts new file mode 100644 index 0000000..b50ddcf --- /dev/null +++ b/src/app/dashboard/settings/integrations/actions.ts @@ -0,0 +1,219 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { getTranslations } from "next-intl/server"; +import { canManageOrg, requireOrgWithRole } from "@/lib/session"; +import { logWeb } from "@/db/activity"; +import { EncryptionKeyMissingError } from "@/lib/crypto"; +import { getCatalogEntry } from "@/lib/integrations/catalog"; +import { getProvider } from "@/lib/integrations/registry"; +import { + deleteIntegration, + getIntegration, + saveConnection, + setIntegrationEnabled, +} from "@/lib/integrations/store"; +import { + isIntegrationProviderId, + type IntegrationConfig, + type IntegrationCredentials, + type IntegrationField, + type IntegrationProviderId, +} from "@/lib/integrations/types"; + +/** + * 設定 › 整合 的 server actions。全部限 owner / admin —— + * 隱藏按鈕擋得住誤按,擋不住直接呼叫 action,所以每一支都在這裡再檢查一次角色。 + * + * 回傳值永遠不含憑證:失敗只回錯誤訊息,成功只回 ok。畫面上的狀態靠 revalidatePath + * 重新渲染 server component 取得(那邊讀的是 IntegrationSummary,型別上就沒有密文)。 + */ + +export type IntegrationActionState = { + ok: boolean; + error?: string; +}; + +const PAGE = "/dashboard/settings/integrations"; + +type Manager = { orgId: string; userId: string }; + +async function requireManager(): Promise { + const t = await getTranslations("integrations"); + const { orgId, userId, role } = await requireOrgWithRole(); + if (!canManageOrg(role)) return { error: t("errors.notAllowed") }; + return { orgId, userId }; +} + +/** 只收目錄裡宣告過的欄位;其他 key 一律丟掉,不讓 client 塞東西進憑證或 config。 */ +function pickFields( + fields: readonly IntegrationField[] | undefined, + values: Record, +): Record { + const out: Record = {}; + for (const f of fields ?? []) { + const raw = values[f.key]; + if (typeof raw !== "string") continue; + // 密碼不修剪(前後空白可能是密碼的一部分);其餘欄位修掉貼上時夾帶的空白。 + const v = f.type === "password" ? raw : raw.trim(); + if (v !== "") out[f.key] = v; + } + return out; +} + +async function connectOrReconnect( + providerArg: string, + values: Record, + mode: "connect" | "reconnect", +): Promise { + const t = await getTranslations("integrations"); + // 在 try 外面:未登入時 requireOrg() 會 redirect,那個例外不能被吞掉。 + const me = await requireManager(); + if ("error" in me) return { ok: false, error: me.error }; + try { + if (!isIntegrationProviderId(providerArg)) { + return { ok: false, error: t("errors.unknownProvider", { provider: String(providerArg) }) }; + } + const provider: IntegrationProviderId = providerArg; + const name = t(`providers.${provider}.name`); + const impl = getProvider(provider); + if (!impl) return { ok: false, error: t("errors.notImplemented", { name }) }; + + const entry = getCatalogEntry(provider); + const credentials: IntegrationCredentials = pickFields(entry.credentialFields, values); + const configInput: IntegrationConfig = pickFields(entry.configFields, values); + for (const f of [...entry.credentialFields, ...(entry.configFields ?? [])]) { + if (f.required && !(f.key in credentials) && !(f.key in configInput)) { + return { ok: false, error: t("errors.requiredField", { field: t(`fields.${f.labelKey}`) }) }; + } + } + + // 重新連接時把既有 config 一起給 provider:有些 provider 需要之前發現的 id 才能測。 + const existing = await getIntegration(me.orgId, provider); + const config: IntegrationConfig = { ...(existing?.config ?? {}), ...configInput }; + + let result; + try { + result = await impl.testConnection(credentials, config); + } catch (e) { + // provider 自己沒處理好的例外(網路錯之類)。訊息照樣給人看,provider 有責任 + // 不把憑證放進錯誤訊息裡。 + const msg = e instanceof Error ? e.message : String(e); + return { ok: false, error: t("errors.testFailed", { error: msg }) }; + } + if (!result.ok) return { ok: false, error: t("errors.testFailed", { error: result.error }) }; + + await saveConnection({ + orgId: me.orgId, + provider, + userId: me.userId, + credentials, + config: { ...configInput, ...(result.config ?? {}) }, + tokenCache: result.tokenCache, + }); + + const isNew = !existing; + await logWeb( + me.orgId, + isNew ? "create" : "update", + "integration", + null, + isNew || mode === "connect" + ? t("activity.connected", { name }) + : t("activity.reconnected", { name }), + ); + revalidatePath(PAGE); + return { ok: true }; + } catch (e) { + if (e instanceof EncryptionKeyMissingError) { + return { ok: false, error: t("errors.encryptionKeyMissing") }; + } + return { ok: false, error: e instanceof Error ? e.message : t("toast.failed") }; + } +} + +/** + * 連接:驗必填 → provider.testConnection 實測 → 加密存入,enabled = false。 + * 若這個組織其實已經連接過(兩個分頁同時操作),行為等同重新連接:保留開關狀態。 + */ +export async function connectIntegration( + provider: string, + values: Record, +): Promise { + return connectOrReconnect(provider, values, "connect"); +} + +/** 重新連接:同連接,但保留原本的 enabled,並把狀態恢復為 connected。 */ +export async function reconnectIntegration( + provider: string, + values: Record, +): Promise { + return connectOrReconnect(provider, values, "reconnect"); +} + +/** 開 / 關。開啟只允許在 status = connected 時;關閉永遠允許。 */ +export async function setIntegrationEnabledAction( + providerArg: string, + enabled: boolean, +): Promise { + const t = await getTranslations("integrations"); + // 在 try 外面:未登入時 requireOrg() 會 redirect,那個例外不能被吞掉。 + const me = await requireManager(); + if ("error" in me) return { ok: false, error: me.error }; + try { + if (!isIntegrationProviderId(providerArg)) { + return { ok: false, error: t("errors.unknownProvider", { provider: String(providerArg) }) }; + } + const provider: IntegrationProviderId = providerArg; + const name = t(`providers.${provider}.name`); + const row = await getIntegration(me.orgId, provider); + if (!row) return { ok: false, error: t("errors.notConnected", { name }) }; + if (enabled && row.status !== "connected") { + return { ok: false, error: t("errors.cannotEnable", { name }) }; + } + if (row.enabled !== enabled) { + await setIntegrationEnabled(me.orgId, provider, enabled); + await logWeb( + me.orgId, + "update", + "integration", + null, + enabled ? t("activity.enabled", { name }) : t("activity.disabled", { name }), + ); + } + revalidatePath(PAGE); + return { ok: true }; + } catch (e) { + return { ok: false, error: e instanceof Error ? e.message : t("toast.failed") }; + } +} + +/** 中斷連接 = 刪列(連同加密憑證與 token)。 */ +export async function disconnectIntegration( + providerArg: string, +): Promise { + const t = await getTranslations("integrations"); + // 在 try 外面:未登入時 requireOrg() 會 redirect,那個例外不能被吞掉。 + const me = await requireManager(); + if ("error" in me) return { ok: false, error: me.error }; + try { + if (!isIntegrationProviderId(providerArg)) { + return { ok: false, error: t("errors.unknownProvider", { provider: String(providerArg) }) }; + } + const provider: IntegrationProviderId = providerArg; + const removed = await deleteIntegration(me.orgId, provider); + if (removed > 0) { + await logWeb( + me.orgId, + "delete", + "integration", + null, + t("activity.disconnected", { name: t(`providers.${provider}.name`) }), + ); + } + revalidatePath(PAGE); + return { ok: true }; + } catch (e) { + return { ok: false, error: e instanceof Error ? e.message : t("toast.failed") }; + } +} diff --git a/src/app/dashboard/settings/calendar-actions.ts b/src/app/dashboard/settings/integrations/calendar-actions.ts similarity index 97% rename from src/app/dashboard/settings/calendar-actions.ts rename to src/app/dashboard/settings/integrations/calendar-actions.ts index 4591c85..ae1f116 100644 --- a/src/app/dashboard/settings/calendar-actions.ts +++ b/src/app/dashboard/settings/integrations/calendar-actions.ts @@ -61,7 +61,7 @@ export async function connectCalendar(): Promise { await setCalendarOwner(orgId, userId); const result = await syncBillingCalendar(orgId, await activeOrgName(orgId)); await logWeb(orgId, "update", "calendar", null, tRec("activity.calendarConnected")); - revalidatePath("/dashboard/settings"); + revalidatePath("/dashboard/settings/integrations"); revalidatePath("/dashboard/billing"); return { ok: true, result }; } catch (e) { @@ -100,7 +100,7 @@ export async function disconnectCalendarAction(): Promise { const { calendarRemoved } = await disconnectCalendar(orgId); await logWeb(orgId, "update", "calendar", null, tRec("activity.calendarDisconnected")); - revalidatePath("/dashboard/settings"); + revalidatePath("/dashboard/settings/integrations"); revalidatePath("/dashboard/billing"); return { ok: true, @@ -122,7 +122,7 @@ export async function updateReminderDays(days: number): Promise { const { error } = await authClient.oauth2.link({ providerId: "google-calendar", - callbackURL: "/dashboard/settings", + callbackURL: "/dashboard/settings/integrations", }); if (error) toast.error(error.message ?? t("calendar.toast.authFailed")); }); diff --git a/src/app/dashboard/settings/integrations/integrations-client.tsx b/src/app/dashboard/settings/integrations/integrations-client.tsx new file mode 100644 index 0000000..b8c3de4 --- /dev/null +++ b/src/app/dashboard/settings/integrations/integrations-client.tsx @@ -0,0 +1,438 @@ +"use client"; + +import { useOptimistic, useState, useTransition } from "react"; +import Link from "next/link"; +import { toast } from "sonner"; +import { useTranslations } from "next-intl"; +import { Info, Link2, RefreshCw, Unlink } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { Field } from "@/components/form-field"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { cn } from "@/lib/utils"; +import type { + IntegrationCatalogEntry, + IntegrationField, + IntegrationProviderId, + IntegrationStatus, +} from "@/lib/integrations/types"; +import { + connectIntegration, + disconnectIntegration, + reconnectIntegration, + setIntegrationEnabledAction, +} from "./actions"; + +/** server 交給 client 的一列:只有能顯示的東西,沒有任何憑證或密文。 */ +export type IntegrationRowData = { + id: IntegrationProviderId; + logo: IntegrationCatalogEntry["logo"]; + credentialFields: IntegrationField[]; + configFields: IntegrationField[]; + /** registry 裡有沒有實作;沒有的話不能連接。 */ + implemented: boolean; + connection: { + enabled: boolean; + status: IntegrationStatus; + /** 已格式化的日期字串。 */ + connectedAt: string; + connectedByName: string | null; + lastSyncedAt: string | null; + lastError: string | null; + } | null; +}; + +type SheetTarget = { row: IntegrationRowData; mode: "connect" | "reconnect"; nonce: number }; + +export function IntegrationsList({ + rows, + canManage, + calendar, +}: Readonly<{ + rows: IntegrationRowData[]; + canManage: boolean; + calendar: { connected: boolean; ownerLabel: string | null }; +}>) { + const t = useTranslations("integrations"); + const [target, setTarget] = useState(null); + const [sheetOpen, setSheetOpen] = useState(false); + + function openSheet(row: IntegrationRowData, mode: SheetTarget["mode"]) { + // nonce 當 key:每次打開都是一張乾淨的表單,關閉時元件留著讓收合動畫跑完。 + setTarget((prev) => ({ row, mode, nonce: (prev?.nonce ?? 0) + 1 })); + setSheetOpen(true); + } + + return ( +
+ {canManage ? null : ( +

+ + {t("readOnlyNote")} +

+ )} +
    + {rows.map((row) => ( + openSheet(row, mode)} + /> + ))} + +
+ {target ? ( + + ) : null} +
+ ); +} + +function LogoTile({ letter, className }: Readonly<{ letter: string; className: string }>) { + return ( +
+ {letter} +
+ ); +} + +function IntegrationRow({ + row, + canManage, + onConnect, +}: Readonly<{ + row: IntegrationRowData; + canManage: boolean; + onConnect: (mode: SheetTarget["mode"]) => void; +}>) { + const t = useTranslations("integrations"); + const name = t(`providers.${row.id}.name`); + const c = row.connection; + const [pending, start] = useTransition(); + const [optimisticEnabled, setOptimisticEnabled] = useOptimistic(c?.enabled ?? false); + const [confirmDisconnect, setConfirmDisconnect] = useState(false); + + function toggle(next: boolean) { + start(async () => { + setOptimisticEnabled(next); + const res = await setIntegrationEnabledAction(row.id, next); + if (!res.ok) toast.error(res.error ?? t("toast.failed")); + }); + } + + function disconnect() { + start(async () => { + const res = await disconnectIntegration(row.id); + if (!res.ok) { + toast.error(res.error ?? t("toast.failed")); + return; + } + setConfirmDisconnect(false); + }); + } + + let statusLine: React.ReactNode; + if (!c) { + statusLine = {t("status.notConnected")}; + } else if (c.status === "needs_reauth") { + statusLine = ( + + {t("status.needsReauth", { error: c.lastError ?? t("status.unknownError") })} + + ); + } else if (c.status === "error") { + statusLine = ( + + {t("status.error", { error: c.lastError ?? t("status.unknownError") })} + + ); + } else { + statusLine = {optimisticEnabled ? t("status.connected") : t("status.connectedOff")}; + } + + const meta: string[] = []; + if (c) { + meta.push( + t("status.connectedBy", { + name: c.connectedByName ?? t("status.unknownMember"), + date: c.connectedAt, + }), + ); + if (c.lastSyncedAt) meta.push(t("status.lastSynced", { date: c.lastSyncedAt })); + } + + return ( +
  • +
    + +
    +

    {name}

    +

    {statusLine}

    + {meta.length > 0 ? ( +

    {meta.join(" · ")}

    + ) : ( +

    {t(`providers.${row.id}.description`)}

    + )} +
    +
    + +
    + + {canManage && !c ? ( +
    + + {row.implemented ? null : ( + {t("notImplemented")} + )} +
    + ) : null} + {canManage && c ? ( + <> + + + + ) : null} +
    + + !pending && setConfirmDisconnect(o)}> + + + {t("disconnectConfirm.title", { name })} + {t("disconnectConfirm.description", { name })} + + + {t("disconnectConfirm.cancel")} + { + // 等 server 回來再關,失敗時對話框留著。 + e.preventDefault(); + disconnect(); + }} + > + {t("disconnectConfirm.confirm")} + + + + +
  • + ); +} + +/** + * Google 日曆不走 org_integrations(token 在 better-auth 的 account 表、設定在 + * calendar_settings),這裡只反映狀態,管理介面在同頁下方的 #google-calendar。 + */ +function CalendarRow({ + connected, + ownerLabel, +}: Readonly<{ connected: boolean; ownerLabel: string | null }>) { + const t = useTranslations("integrations"); + return ( +
  • +
    + +
    +

    {t("providers.googleCalendar.name")}

    +

    + {connected + ? t("status.calendarConnected", { owner: ownerLabel ?? t("status.unknownMember") }) + : t("status.notConnected")} +

    +

    {t("providers.googleCalendar.description")}

    +
    +
    +
    + +
    +
  • + ); +} + +function inputType(type: IntegrationField["type"]): string { + if (type === "email") return "email"; + if (type === "password" || type === "token") return "password"; + return "text"; +} + +function ConnectSheet({ + target, + open, + onOpenChange, +}: Readonly<{ + target: SheetTarget; + open: boolean; + onOpenChange: (open: boolean) => void; +}>) { + const t = useTranslations("integrations"); + const { row, mode } = target; + const name = t(`providers.${row.id}.name`); + const fields = [...row.credentialFields, ...row.configFields]; + const [values, setValues] = useState>({}); + const [error, setError] = useState(null); + const [confirmDiscard, setConfirmDiscard] = useState(false); + const [pending, start] = useTransition(); + const dirty = Object.values(values).some((v) => v !== ""); + + function requestClose(next: boolean) { + if (next) return; + if (pending) return; + if (dirty) { + setConfirmDiscard(true); + return; + } + onOpenChange(false); + } + + function submit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + start(async () => { + const res = + mode === "connect" + ? await connectIntegration(row.id, values) + : await reconnectIntegration(row.id, values); + if (!res.ok) { + setError(res.error ?? t("toast.failed")); + return; + } + toast.success(mode === "connect" ? t("toast.connected", { name }) : t("toast.reconnected", { name })); + onOpenChange(false); + }); + } + + return ( + <> + + + + + + {mode === "connect" ? t("sheet.connectTitle", { name }) : t("sheet.reconnectTitle", { name })} + + + {mode === "connect" ? t("sheet.description") : t("sheet.reconnectDescription")} + + +
    +
    + {fields.map((f) => { + const id = `integration-${row.id}-${f.key}`; + return ( + + { + const v = ev.target.value; + setValues((prev) => ({ ...prev, [f.key]: v })); + }} + disabled={pending} + /> + + ); + })} +

    {t("sheet.securityNote")}

    + {error ? ( +

    + {error} +

    + ) : null} +
    + + + + +
    +
    +
    + + + + + {t("discard.title")} + {t("discard.description")} + + + {t("discard.keepEditing")} + { + setConfirmDiscard(false); + onOpenChange(false); + }} + > + {t("discard.confirm")} + + + + + + ); +} diff --git a/src/app/dashboard/settings/integrations/page.tsx b/src/app/dashboard/settings/integrations/page.tsx new file mode 100644 index 0000000..b8c13b3 --- /dev/null +++ b/src/app/dashboard/settings/integrations/page.tsx @@ -0,0 +1,74 @@ +import { getTranslations } from "next-intl/server"; +import { PageHeader } from "@/components/page-header"; +import { canManageOrg, requireOrgWithRole } from "@/lib/session"; +import { + getCalendarOwnerLabel, + getCalendarSettings, + hasCalendarGrant, +} from "@/lib/google-calendar"; +import { formatDate, formatDateTime } from "@/lib/format"; +import { INTEGRATION_CATALOG, INTEGRATION_ORDER } from "@/lib/integrations/catalog"; +import { getProvider } from "@/lib/integrations/registry"; +import { listIntegrations } from "@/lib/integrations/store"; +import { IntegrationsList, type IntegrationRowData } from "./integrations-client"; +import { CalendarSettingsClient } from "./calendar-settings-client"; + +export const dynamic = "force-dynamic"; + +export default async function IntegrationsPage() { + const t = await getTranslations("integrations"); + const { orgId, userId, role } = await requireOrgWithRole(); + const canManage = canManageOrg(role); + const [summaries, calendar, granted, calendarOwner] = await Promise.all([ + listIntegrations(orgId), + getCalendarSettings(orgId), + hasCalendarGrant(userId), + getCalendarOwnerLabel(orgId), + ]); + + // 只把「可以顯示」的東西交給 client:IntegrationSummary 本來就不含密文,這裡再把 + // 日期先格式化好(server 與 client 時區不同,交給 client 格式化會 hydration mismatch)。 + const rows: IntegrationRowData[] = INTEGRATION_ORDER.map((id) => { + const s = summaries.find((x) => x.provider === id) ?? null; + return { + id, + logo: INTEGRATION_CATALOG[id].logo, + credentialFields: INTEGRATION_CATALOG[id].credentialFields.map((f) => ({ ...f })), + configFields: (INTEGRATION_CATALOG[id].configFields ?? []).map((f) => ({ ...f })), + implemented: getProvider(id) !== null, + connection: s + ? { + enabled: s.enabled, + status: s.status, + connectedAt: formatDate(s.connectedAt), + connectedByName: s.connectedByName, + lastSyncedAt: s.lastSyncedAt ? formatDateTime(s.lastSyncedAt) : null, + lastError: s.lastError, + } + : null, + }; + }); + + const calendarConnected = Boolean(calendar?.ownerUserId && calendar?.googleCalendarId); + + return ( + <> + + +
    + +
    + + ); +} diff --git a/src/app/dashboard/settings/layout.tsx b/src/app/dashboard/settings/layout.tsx new file mode 100644 index 0000000..fb0fcea --- /dev/null +++ b/src/app/dashboard/settings/layout.tsx @@ -0,0 +1,16 @@ +import { SettingsNav } from "./settings-nav"; + +/** + * 設定區的外框:左邊一條次級導覽(基本資料 / 整合 / MCP),右邊是各分頁自己的內容。 + * 窄螢幕時導覽改成頂端一排可橫向捲動的分頁,不佔垂直空間。 + */ +export default function SettingsLayout({ + children, +}: Readonly<{ children: React.ReactNode }>) { + return ( +
    + +
    {children}
    +
    + ); +} diff --git a/src/app/dashboard/settings/page.tsx b/src/app/dashboard/settings/page.tsx index c5af8ac..8952d28 100644 --- a/src/app/dashboard/settings/page.tsx +++ b/src/app/dashboard/settings/page.tsx @@ -4,30 +4,20 @@ import { PageHeader } from "@/components/page-header"; import { canManageOrg, requireOrgWithRole } from "@/lib/session"; import { getDb } from "@/db"; import { organization } from "@/db/auth-schema"; -import { - getCalendarOwnerLabel, - getCalendarSettings, - hasCalendarGrant, -} from "@/lib/google-calendar"; import { OrgSettingsClient } from "./org-settings-client"; -import { CalendarSettingsClient } from "./calendar-settings-client"; export const dynamic = "force-dynamic"; +// Google 日曆的設定已搬到 設定 › 整合(./integrations),與其他外部整合列在一起。 export default async function SettingsPage() { const t = await getTranslations("settings"); // Guard: redirects to /login or /onboarding when needed. - const { orgId, userId, role } = await requireOrgWithRole(); - const [calendar, granted, ownerLabel, orgRow] = await Promise.all([ - getCalendarSettings(orgId), - hasCalendarGrant(userId), - getCalendarOwnerLabel(orgId), - getDb() - .select({ name: organization.name }) - .from(organization) - .where(eq(organization.id, orgId)) - .limit(1), - ]); + const { orgId, role } = await requireOrgWithRole(); + const orgRow = await getDb() + .select({ name: organization.name }) + .from(organization) + .where(eq(organization.id, orgId)) + .limit(1); return ( <> @@ -37,14 +27,6 @@ export default async function SettingsPage() { orgName={orgRow[0]?.name ?? ""} canEdit={canManageOrg(role)} /> - ); } diff --git a/src/app/dashboard/settings/settings-nav.tsx b/src/app/dashboard/settings/settings-nav.tsx new file mode 100644 index 0000000..00211d2 --- /dev/null +++ b/src/app/dashboard/settings/settings-nav.tsx @@ -0,0 +1,46 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { Building2, Plug, Puzzle } from "lucide-react"; +import { cn } from "@/lib/utils"; + +const sections = [ + { key: "general", href: "/dashboard/settings", icon: Building2 }, + { key: "integrations", href: "/dashboard/settings/integrations", icon: Puzzle }, + { key: "mcp", href: "/dashboard/settings/mcp", icon: Plug }, +] as const; + +export function SettingsNav() { + const t = useTranslations("settings.nav"); + const pathname = usePathname(); + + return ( + + ); +} diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx index 40a96b4..203c804 100644 --- a/src/components/app-sidebar.tsx +++ b/src/components/app-sidebar.tsx @@ -67,6 +67,7 @@ export type NavItemKey = | "members" | "activity" | "mcp" + | "integrations" | "settings"; const daily = [ @@ -129,9 +130,15 @@ export function AppSidebar({ const isActive = (href: string) => { if (href === "/dashboard") return pathname === "/dashboard"; - // /settings is a prefix of /settings/mcp — match it exactly so only the - // specific sub-page (e.g. MCP) highlights, not both. - if (href === "/dashboard/settings") return pathname === "/dashboard/settings"; + // /settings is a prefix of /settings/mcp — MCP has its own sidebar entry, so + // exclude it here so only one item highlights. Other settings sub-pages + // (e.g. integrations) have no entry of their own and light up "settings". + if (href === "/dashboard/settings") { + return ( + pathname.startsWith("/dashboard/settings") && + !pathname.startsWith("/dashboard/settings/mcp") + ); + } return pathname.startsWith(href); }; diff --git a/src/components/header-breadcrumb.tsx b/src/components/header-breadcrumb.tsx index 267be71..78dc521 100644 --- a/src/components/header-breadcrumb.tsx +++ b/src/components/header-breadcrumb.tsx @@ -39,6 +39,7 @@ const routeKeys: Record = { "/dashboard/members": "members", "/dashboard/settings": "settings", "/dashboard/settings/mcp": "mcp", + "/dashboard/settings/integrations": "integrations", }; export function HeaderBreadcrumb() { diff --git a/src/components/ui/switch.tsx b/src/components/ui/switch.tsx new file mode 100644 index 0000000..8baa844 --- /dev/null +++ b/src/components/ui/switch.tsx @@ -0,0 +1,35 @@ +"use client" + +import * as React from "react" +import { Switch as SwitchPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Switch({ + className, + size = "default", + ...props +}: React.ComponentProps & { + size?: "sm" | "default" +}) { + return ( + + + + ) +} + +export { Switch } diff --git a/src/i18n/messages/common.ts b/src/i18n/messages/common.ts index bf960be..71b15e1 100644 --- a/src/i18n/messages/common.ts +++ b/src/i18n/messages/common.ts @@ -39,6 +39,7 @@ const common = { members: { "zh-TW": "成員", en: "Members" }, activity: { "zh-TW": "操作紀錄", en: "Activity log" }, mcp: { "zh-TW": "MCP", en: "MCP" }, + integrations: { "zh-TW": "整合", en: "Integrations" }, settings: { "zh-TW": "組織設定", en: "Organization settings" }, }, }, diff --git a/src/i18n/messages/lib.ts b/src/i18n/messages/lib.ts index dd1e910..279389e 100644 --- a/src/i18n/messages/lib.ts +++ b/src/i18n/messages/lib.ts @@ -25,7 +25,7 @@ const lib = { }, calendar: { notConnected: { "zh-TW": "尚未連結 Google 日曆", en: "Google Calendar is not connected" }, - grantExpired: { "zh-TW": "Google 日曆授權已失效,請到組織設定重新連結", en: "The Google Calendar authorization has expired. Reconnect it in organization settings." }, + grantExpired: { "zh-TW": "Google 日曆授權已失效,請到 設定 › 整合 重新連結", en: "The Google Calendar authorization has expired. Reconnect it under Settings › Integrations." }, title: { "zh-TW": "請款提醒 · {org}", en: "Billing reminders · {org}" }, client: { "zh-TW": "客戶", en: "client" }, link: { "zh-TW": "請款看板:/billing", en: "Billing board: /billing" }, diff --git a/src/i18n/messages/settings.ts b/src/i18n/messages/settings.ts index b7237a0..f6c4a18 100644 --- a/src/i18n/messages/settings.ts +++ b/src/i18n/messages/settings.ts @@ -2,7 +2,13 @@ import type { Dictionary } from "./dictionary"; const settings = { title: { "zh-TW": "組織設定", en: "Organization settings" }, - description: { "zh-TW": "管理這個組織的基本資料與整合", en: "Manage this organization's basic info and integrations" }, + description: { "zh-TW": "管理這個組織的基本資料", en: "Manage this organization's basic info" }, + nav: { + label: { "zh-TW": "設定分區", en: "Settings sections" }, + general: { "zh-TW": "基本資料", en: "General" }, + integrations: { "zh-TW": "整合", en: "Integrations" }, + mcp: { "zh-TW": "MCP", en: "MCP" }, + }, org: { title: { "zh-TW": "基本資料", en: "Basic info" }, descriptionEditable: { "zh-TW": "修改組織名稱", en: "Edit the organization name" }, From 7817e5de49223907c05d9a62e441bff1dc8d1595 Mon Sep 17 00:00:00 2001 From: YJack0000 Date: Thu, 24 Sep 2026 17:20:26 +0800 Subject: [PATCH 05/27] =?UTF-8?q?[feature]=20MCP=EF=BC=9Alist=5Fintegratio?= =?UTF-8?q?ns=20=E8=88=87=E6=95=B4=E5=90=88=E5=B7=A5=E5=85=B7=E5=85=B1?= =?UTF-8?q?=E7=94=A8=E9=97=9C=E5=8D=A1=E3=80=81=E7=A8=BD=E6=A0=B8=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tools-integrations.ts:list_integrations(不含任何憑證) - requireIntegrationForTool / auditIntegrationCall 給之後的 provider 工具用 - SERVER_VERSION 1.3.0 → 1.4.0 --- src/lib/mcp/handler.ts | 2 +- src/lib/mcp/tools-billing.ts | 2 +- src/lib/mcp/tools-integrations.ts | 141 ++++++++++++++++++++++++++++++ src/lib/mcp/tools.ts | 2 + 4 files changed, 145 insertions(+), 2 deletions(-) create mode 100644 src/lib/mcp/tools-integrations.ts diff --git a/src/lib/mcp/handler.ts b/src/lib/mcp/handler.ts index d2ec17b..8c3aebf 100644 --- a/src/lib/mcp/handler.ts +++ b/src/lib/mcp/handler.ts @@ -72,7 +72,7 @@ function deriveMcpAudit(name: string, out: unknown): McpAudit | null { /** Bump on every published change to tools, schemas or instructions. Clients * (and OpenAI's plugin "Scan Tools") key their cached snapshot off this. */ -export const SERVER_VERSION = "1.3.0"; +export const SERVER_VERSION = "1.4.0"; /** Public base URL of this deployment; doubles as the OAuth issuer. * Keep in sync with the `resource` passed to `mcp()` in src/lib/auth.ts. */ diff --git a/src/lib/mcp/tools-billing.ts b/src/lib/mcp/tools-billing.ts index b16da74..6d0d7f0 100644 --- a/src/lib/mcp/tools-billing.ts +++ b/src/lib/mcp/tools-billing.ts @@ -327,7 +327,7 @@ export const billingItemTools: Record = { sync_billing_calendar: { description: - "[write] Push the current billing board to the organization's Google Calendar (creates/updates/removes reminders). Idempotent. Fails with a clear message if nobody has connected Google Calendar yet — connect it from 組織設定 in the web app. Normally unnecessary: the sync runs automatically whenever billing data changes.", + "[write] Push the current billing board to the organization's Google Calendar (creates/updates/removes reminders). Idempotent. Fails with a clear message if nobody has connected Google Calendar yet — connect it from 組織設定 › 整合 (Settings › Integrations) in the web app. Normally unnecessary: the sync runs automatically whenever billing data changes.", inputSchema: { type: "object", properties: { ...ORG_ARG }, diff --git a/src/lib/mcp/tools-integrations.ts b/src/lib/mcp/tools-integrations.ts new file mode 100644 index 0000000..966de45 --- /dev/null +++ b/src/lib/mcp/tools-integrations.ts @@ -0,0 +1,141 @@ +import { getTranslations } from "next-intl/server"; +import { logMcp, type ActivityAction } from "@/db/activity"; +import { INTEGRATION_ORDER } from "@/lib/integrations/catalog"; +import { getProvider } from "@/lib/integrations/registry"; +import { listIntegrations, requireEnabledIntegration } from "@/lib/integrations/store"; +import type { + IntegrationCredentials, + IntegrationProviderId, + IntegrationSummary, +} from "@/lib/integrations/types"; +import { INTEGRATION_PROVIDER_IDS } from "@/lib/integrations/types"; +import { + listResult, + listSchema, + ORG_ARG, + resolveOrg, + rowSchema, + type ToolContext, + type ToolDef, +} from "./shared"; + +// ---- 外部整合(org_integrations)---- +// +// 這裡只有「看狀態」的 list_integrations。連接 / 開關 / 中斷一律在 web 的 +// 設定 › 整合 做:要輸入憑證,而憑證不該經過 AI 對話。 +// +// 各 provider 的業務工具(開發票、抓 Wise 交易…)放在各自的 tools-.ts, +// 並遵守同一套規則: +// - tools/list 是靜態的 —— 整合沒連接時工具照樣列出,execute 時才用 +// requireIntegrationForTool() 擋下,丟出清楚的中文錯誤(比照 sync_billing_calendar)。 +// - 每一次打到外部服務都用 auditIntegrationCall() 記一筆操作紀錄。 +// - 回傳值永遠不含憑證、token 或密文。 + +/** ISO 8601;DB 讀回來的 timestamptz 字串(`2026-09-24 10:00:00+00`)統一轉掉。 */ +function iso(v: string | null): string | null { + if (!v) return null; + const d = new Date(v); + return Number.isNaN(d.getTime()) ? v : d.toISOString(); +} + +/** + * 給其他 tools-*.ts 用的關卡:整合必須已連接、已開啟、狀態正常,否則丟錯,訊息會 + * 告訴使用者請 owner / admin 到 設定 › 整合 處理。通過則回傳狀態與解密後的憑證 —— + * 憑證只能拿去打外部服務,絕不可放進工具的回傳值。 + */ +export async function requireIntegrationForTool( + orgId: string, + provider: IntegrationProviderId, +): Promise<{ row: IntegrationSummary; credentials: IntegrationCredentials }> { + return requireEnabledIntegration(orgId, provider); +} + +/** + * 記錄一次對外部服務的呼叫(操作紀錄,channel = mcp,entity = integration)。 + * + * handler 的 deriveMcpAudit 只會依工具名稱記「寫入了哪個 entity」;整合工具真正要追的 + * 是「代表這個組織打了哪個外部服務、做了什麼」,所以由工具自己在呼叫成功(或失敗)後 + * 明確記一筆。detail 會原樣顯示在操作紀錄,不得含憑證、token 或完整個資。 + * 記錄失敗不影響工具結果(logMcp 自己吞錯)。 + */ +export async function auditIntegrationCall( + ctx: ToolContext, + orgId: string, + provider: IntegrationProviderId, + action: ActivityAction, + detail: string, +): Promise { + await logMcp(orgId, ctx.userId, action, "integration", null, `${provider}: ${detail}`); +} + +const INTEGRATION_ROW = rowSchema({ + provider: { type: "string", enum: [...INTEGRATION_PROVIDER_IDS] }, + name: { type: "string", description: "Display name." }, + available: { + type: "boolean", + description: "Whether this server has an implementation for the provider yet. False means it cannot be connected at all for now.", + }, + connected: { type: "boolean", description: "Credentials are stored for this organization." }, + enabled: { type: "boolean", description: "Switched on by an owner/admin. New connections start off." }, + usable: { + type: "boolean", + description: "connected AND enabled AND status = connected — integration tools will run. Otherwise they fail with a message telling an owner/admin what to fix in 設定 › 整合.", + }, + status: { + type: ["string", "null"], + enum: ["connected", "needs_reauth", "error", null], + description: "null when not connected. needs_reauth = the service rejected the stored credentials; reconnect in the web app.", + }, + config: { + type: "object", + description: "Non-secret settings (e.g. a discovered company id). Never contains credentials.", + }, + connectedAt: { type: ["string", "null"], description: "ISO 8601 timestamp." }, + connectedBy: { type: ["string", "null"], description: "Name or email of the member who connected it." }, + tokenExpiresAt: { + type: ["string", "null"], + description: "ISO 8601; when the cached session token expires (it is refreshed automatically).", + }, + lastSyncedAt: { type: ["string", "null"], description: "ISO 8601; last successful call to the service." }, + lastError: { type: ["string", "null"], description: "Most recent failure message, if any." }, +}); + +export const integrationTools: Record = { + list_integrations: { + description: + "List this organization's external integrations (e.g. Simpany e-invoice, Wise) and whether each is connected, switched on and healthy. Never returns credentials. Connecting, switching on/off and disconnecting are done by an owner/admin in the web app under 設定 › 整合 (Settings › Integrations) — credentials are never entered over MCP.", + inputSchema: { + type: "object", + properties: { ...ORG_ARG }, + additionalProperties: false, + }, + outputSchema: listSchema(INTEGRATION_ROW), + execute: async (args, ctx) => { + const orgId = await resolveOrg(args, ctx); + const [summaries, t] = await Promise.all([ + listIntegrations(orgId), + getTranslations("integrations"), + ]); + return listResult( + INTEGRATION_ORDER.map((provider) => { + const s = summaries.find((x) => x.provider === provider); + return { + provider, + name: t(`providers.${provider}.name`), + available: getProvider(provider) !== null, + connected: Boolean(s), + enabled: s?.enabled ?? false, + usable: Boolean(s && s.enabled && s.status === "connected"), + status: s?.status ?? null, + config: s?.config ?? {}, + connectedAt: iso(s?.connectedAt ?? null), + connectedBy: s?.connectedByName ?? null, + tokenExpiresAt: iso(s?.tokenExpiresAt ?? null), + lastSyncedAt: iso(s?.lastSyncedAt ?? null), + lastError: s?.lastError ?? null, + }; + }), + ); + }, + }, +}; diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts index 7a44c7b..3663429 100644 --- a/src/lib/mcp/tools.ts +++ b/src/lib/mcp/tools.ts @@ -31,6 +31,7 @@ import { clientTools } from "./tools-client"; import { hrTools } from "./tools-hr"; import { billingItemTools } from "./tools-billing"; import { orgTools } from "./tools-org"; +import { integrationTools } from "./tools-integrations"; export type { ToolContext, ToolDef } from "./shared"; @@ -715,4 +716,5 @@ export const tools: Record = { ...transactionTools, ...clientTools, ...hrTools, + ...integrationTools, }; From d900bd9a5a7bf491823b7a38b7df057916de83b3 Mon Sep 17 00:00:00 2001 From: YJack0000 Date: Thu, 24 Sep 2026 17:20:26 +0800 Subject: [PATCH 06/27] =?UTF-8?q?[docs]=20=E6=95=B4=E5=90=88=E6=A1=86?= =?UTF-8?q?=E6=9E=B6=E8=AA=AA=E6=98=8E=E8=88=87=20FIELD=5FENCRYPTION=5FKEY?= =?UTF-8?q?=20=E9=83=A8=E7=BD=B2=E8=A8=AD=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/deployment.md | 4 +- docs/integrations.md | 148 +++++++++++++++++++++++++++++++++++++++++++ docs/mcp.md | 21 ++++-- 3 files changed, 168 insertions(+), 5 deletions(-) create mode 100644 docs/integrations.md diff --git a/docs/deployment.md b/docs/deployment.md index bd8561b..bf0c933 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -30,7 +30,8 @@ This document describes two paths: | Postgres | Any Postgres. Schema is introspect-only (`bun run db:pull`); migrations under [`migrations/`](../migrations) are plain forward-only SQL. | | A Postgres driver that matches your runtime | On serverless/edge you need an **HTTP** driver (e.g. Neon). On a normal Node server you can use a regular TCP driver (`pg`). See [Database driver](#database-driver). | | Object storage *(only for document uploads)* | Cloudflare R2 by default. Swappable — see [Storage portability](#storage-portability). | -| Env vars | `DATABASE_URL`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`, `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`. See [`.env.example`](../.env.example). | +| Env vars | `DATABASE_URL`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`, `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `FIELD_ENCRYPTION_KEY`. See [`.env.example`](../.env.example). | +| `FIELD_ENCRYPTION_KEY` | 32 random bytes, base64 (`openssl rand -base64 32`). AES-256-GCM key for field-level encryption of integration credentials (Simpany, Wise — see [integrations.md](integrations.md)) and other high-sensitivity fields. Without it, connecting an integration fails with a clear error. **Losing or rotating it makes stored credentials unreadable** — every integration must be reconnected. | --- @@ -110,6 +111,7 @@ Local dev reads `.env.local`. For the deployed Worker, set secrets with wrangler echo "$BETTER_AUTH_SECRET" | bunx wrangler secret put BETTER_AUTH_SECRET echo "$DATABASE_URL" | bunx wrangler secret put DATABASE_URL echo "$GOOGLE_CLIENT_ID" | bunx wrangler secret put GOOGLE_CLIENT_ID +echo "$FIELD_ENCRYPTION_KEY" | bunx wrangler secret put FIELD_ENCRYPTION_KEY # …and GOOGLE_CLIENT_SECRET, BETTER_AUTH_URL ``` diff --git a/docs/integrations.md b/docs/integrations.md new file mode 100644 index 0000000..31b291e --- /dev/null +++ b/docs/integrations.md @@ -0,0 +1,148 @@ +# Integrations framework + +Per-organization connections to external services (Simpany e-invoice, Wise, …). +Every integration is **off by default**: an owner/admin *connects* it (enters +credentials, which are tested server-side before being stored), then *switches it +on* separately. Disconnecting deletes the row, credentials included. + +Google Calendar predates this framework and is **not** stored here (its token lives +in better-auth's `account` table, its settings in `calendar_settings`). The +settings page just lists it alongside the others. + +## Pieces + +| Where | What | +| --- | --- | +| `migrations/0023_org_integrations.sql` / `orgIntegrations` in `src/db/schema.ts` | One row per (organization, provider). `credentials_enc` / `token_cache_enc` are ciphertext from `src/lib/crypto.ts` (`FIELD_ENCRYPTION_KEY`). `config` is non-secret jsonb. | +| `src/lib/integrations/types.ts` | Provider ids, field/catalog/provider types, `IntegrationSummary` (the secret-free view). Client-safe. | +| `src/lib/integrations/catalog.ts` | Static catalog: logo + credential/config fields per provider. Drives the settings UI. Client-safe. | +| `src/lib/integrations/registry.ts` | Map of **implementations** (`testConnection`). Empty until a provider lands. Server only. | +| `src/lib/integrations/store.ts` | The only code that reads/writes `org_integrations`. Server only. | +| `src/app/dashboard/settings/integrations/` | Settings page, server actions (owner/admin only), connect Sheet. | +| `src/lib/mcp/tools-integrations.ts` | `list_integrations`, plus `requireIntegrationForTool` and `auditIntegrationCall` for provider tools. | + +## Lifecycle + +``` +(no row) --connect: testConnection ok--> connected, enabled=false +connected --toggle--> enabled=true/false +any call gets 401/invalid creds --markNeedsReauth--> needs_reauth (enabled kept) +needs_reauth --reconnect: testConnection ok--> connected (enabled restored as it was) +any --disconnect--> (row deleted) +``` + +"Usable" means `enabled AND status = 'connected'`. `requireEnabledIntegration` +enforces exactly that and throws `IntegrationUnavailableError` whose message tells +the user what to do (e.g. 「Simpany 電子發票 整合尚未連接/未開啟,請 owner 或 admin 到 設定 › 整合 開啟」). + +## Store API (`src/lib/integrations/store.ts`) + +```ts +// secret-free reads +getIntegration(orgId, provider): Promise +listIntegrations(orgId): Promise +integrationDisplayName(provider): Promise + +// secret reads — server memory only, never return/log them +loadCredentials(orgId, provider): Promise +loadTokenCache(orgId, provider): Promise // null if missing/expired (60s skew) +requireEnabledIntegration(orgId, provider): Promise<{ row: IntegrationSummary; credentials: IntegrationCredentials }> + +// runtime reporting from provider code +saveTokenCache(orgId, provider, value: string, expiresAt: Date): Promise +clearTokenCache(orgId, provider): Promise +markNeedsReauth(orgId, provider, error: string): Promise // credentials rejected +recordSyncFailure(orgId, provider, error: string): Promise // transient failure, status unchanged +recordSyncSuccess(orgId, provider): Promise // sets last_synced_at, clears last_error +updateConfig(orgId, provider, patch): Promise // shallow jsonb merge + +// used by the settings actions (they do the role check) +saveConnection({ orgId, provider, userId, credentials, config, tokenCache? }): Promise +setIntegrationEnabled(orgId, provider, enabled): Promise +deleteIntegration(orgId, provider): Promise +``` + +## Adding a provider + +Simpany and Wise are already in the DB `CHECK`, in `INTEGRATION_PROVIDER_IDS`, in +the catalog (Simpany: `account` email + `password`; Wise: `apiToken`) and in i18n. +To bring one to life: + +1. **Implementation** — `src/lib/integrations/.ts`: + + ```ts + import type { IntegrationProvider } from "./types"; + + export const wiseProvider: IntegrationProvider = { + id: "wise", + async testConnection(creds, config) { + const res = await fetch("https://api.wise.com/v2/profiles", { + headers: { Authorization: `Bearer ${creds.apiToken}` }, + }); + if (res.status === 401) return { ok: false, error: "API token 無效或已撤銷" }; + if (!res.ok) return { ok: false, error: `Wise 回應 ${res.status}` }; + const profiles = await res.json(); + return { ok: true, config: { profileId: profiles[0]?.id } }; + }, + }; + ``` + + - Return `{ ok: false, error }` for bad credentials; `error` is shown to the user + and must never contain the credentials. Throwing is reserved for unexpected + failures (the framework turns it into a message). + - Return discovered non-secret settings in `config`; return a session token in + `tokenCache` if the service hands one out (it is encrypted). + - Use `fetch` only — this runs on Cloudflare Workers. + +2. **Register** it: one line in `PROVIDERS` in `registry.ts` + (`wise: wiseProvider,`). The Connect button on 設定 › 整合 turns on + automatically. + +3. **Business logic** (web actions or MCP tools): + + ```ts + const { row, credentials } = await requireEnabledIntegration(orgId, "wise"); + try { + const data = await callWise(credentials, row.config); + await recordSyncSuccess(orgId, "wise"); + } catch (e) { + if (isAuthError(e)) await markNeedsReauth(orgId, "wise", "Wise token 已失效"); + else await recordSyncFailure(orgId, "wise", String(e)); + throw e; + } + ``` + + For session-token providers (Simpany): try `loadTokenCache`, fall back to logging + in with `credentials`, then `saveTokenCache`; on a rejected token + `clearTokenCache` and retry once before `markNeedsReauth`. + +4. **MCP tools** — a new `src/lib/mcp/tools-.ts`, spread into `tools` in + `tools.ts`, and bump `SERVER_VERSION` in `handler.ts`: + - Tools are always listed; in `execute` call + `requireIntegrationForTool(orgId, "")` first so a disconnected/disabled + integration fails with the clear zh-TW message (same as `sync_billing_calendar`). + - Log each external call with `auditIntegrationCall(ctx, orgId, "", action, detail)` + (`detail` must not contain credentials or full PII). + - Anything that reaches the third party must get `openWorldHint: true` in + `OPENWORLD_OVERRIDES` (handler.ts); add title/destructive overrides as needed. + - Never put credentials, tokens or ciphertext in a tool result. + +5. **Extra settings** — declare `configFields` in the catalog entry (same shape as + `credentialFields`, stored in plain `config`) and add labels under + `integrations.fields` in `src/i18n/messages/integrations.ts` (zh-TW + en). + +### A brand-new provider (not simpany/wise) + +Also: a new migration extending `chk_org_integration_provider` (and the matching +`check(...)` in `schema.ts`), the id in `INTEGRATION_PROVIDER_IDS`, a catalog entry ++ `INTEGRATION_ORDER`, and `integrations.providers..name/description` in i18n. + +## Security rules + +- Credentials are encrypted with `FIELD_ENCRYPTION_KEY` (see + [deployment.md](deployment.md)). Losing or rotating the key means every + integration has to be reconnected. +- Nothing that reaches a client — server-action results, page props, MCP results — + may contain credentials, tokens or ciphertext. `IntegrationSummary` has no such + fields by construction; don't bypass it with a raw select. +- Credentials are only entered in the web UI by owners/admins, never over MCP. diff --git a/docs/mcp.md b/docs/mcp.md index 4b9e02c..13f4624 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -117,8 +117,8 @@ the `tools-*.ts` modules): - `_meta["openai/toolInvocation/invoking" | "invoked"]`, the status line ChatGPT shows while a call is in flight. -**Output schemas.** Every tool declares an `outputSchema` — all 70 of them, as of -server version 1.3.0. When a tool declares one the handler additionally returns +**Output schemas.** Every tool declares an `outputSchema` — all 71 of them, as of +server version 1.4.0. When a tool declares one the handler additionally returns the result as MCP `structuredContent` (the JSON text block stays, per MCP's back-compat recommendation), which is what ChatGPT and Codex prefer over parsing JSON out of text. `list_organizations` remains the reference implementation. @@ -219,10 +219,23 @@ reconciliations: `list_reconciliations` + `create`/`update`/`delete`; accountant notices: `list_accountant_notices`, `mark_accountant_notified`, `unmark_accountant_notified`. +**Integrations** — `list_integrations` shows, per external integration +(Simpany e-invoice, Wise), whether it is available on this server, connected, +switched on, and healthy (`status`, `lastError`, `lastSyncedAt`, +`tokenExpiresAt`) plus its non-secret `config`. It never returns credentials. +Integration business tools live in their own `tools-.ts` and stay in +`tools/list` whether or not the org has connected the integration; at call time +they go through `requireIntegrationForTool()` and fail with a clear zh-TW message +telling an owner/admin to fix it in 設定 › 整合. Every call to the external +service is logged with `auditIntegrationCall()`. See +[`integrations.md`](integrations.md). + **Not exposed (do in the app):** creating an organization, uploading invoice/receipt **files** (R2), multi-currency FX entry, and *connecting* Google -Calendar (the OAuth consent needs a browser — do it once in 組織設定, after which -`sync_billing_calendar` works over MCP). These need file handling or extra UI. +Calendar (the OAuth consent needs a browser — do it once in 設定 › 整合, after which +`sync_billing_calendar` works over MCP), and connecting / switching / disconnecting +integrations (credentials must not pass through an AI conversation). These need +file handling or extra UI. Deletes that would break references return a clear error suggesting deactivation/archiving instead. From 5cd965ff5985057ee626a26a8ef758bf9ee04f4f Mon Sep 17 00:00:00 2001 From: YJack0000 Date: Thu, 24 Sep 2026 17:23:21 +0800 Subject: [PATCH 07/27] =?UTF-8?q?[feature]=20=E5=93=A1=E5=B7=A5=E5=A4=9A?= =?UTF-8?q?=E5=B8=B3=E6=88=B6=EF=BC=9A=E8=B3=87=E6=96=99=E5=B1=A4=E3=80=81?= =?UTF-8?q?=E7=B6=B2=E9=A0=81=E5=B8=B3=E6=88=B6=E5=8D=80=E5=A1=8A=E3=80=81?= =?UTF-8?q?=E7=99=BC=E8=96=AA/=E6=92=A5=E6=AC=BE=E8=A8=98=E9=8C=84?= =?UTF-8?q?=E5=8C=AF=E5=85=A5=E5=B8=B3=E6=88=B6=E3=80=81=E5=93=A1=E5=B7=A5?= =?UTF-8?q?=E5=AF=AB=E5=85=A5=E9=99=90=20owner/admin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/dashboard/advances/page.tsx | 8 + .../advances/record-reimbursement-dialog.tsx | 21 + .../dashboard/employees/account-actions.ts | 157 ++++++ .../employees/edit-employee-form.tsx | 26 +- .../employees/employee-accounts-section.tsx | 510 ++++++++++++++++++ .../dashboard/employees/employee-fields.tsx | 36 +- src/app/dashboard/employees/page.tsx | 50 +- .../dashboard/employees/pay-salary-dialog.tsx | 22 + src/app/dashboard/payroll/page.tsx | 16 +- src/app/dashboard/transactions/page.tsx | 12 + src/components/edit-form.tsx | 23 +- src/db/employee-accounts.ts | 415 ++++++++++++++ src/db/mutations.ts | 81 ++- src/db/queries.ts | 12 + src/i18n/messages/advances.ts | 2 + src/i18n/messages/employees.ts | 64 ++- src/i18n/messages/errors.ts | 18 + src/i18n/messages/lib.ts | 1 + src/lib/employee-accounts.ts | 212 ++++++++ 19 files changed, 1651 insertions(+), 35 deletions(-) create mode 100644 src/app/dashboard/employees/account-actions.ts create mode 100644 src/app/dashboard/employees/employee-accounts-section.tsx create mode 100644 src/db/employee-accounts.ts create mode 100644 src/lib/employee-accounts.ts diff --git a/src/app/dashboard/advances/page.tsx b/src/app/dashboard/advances/page.tsx index 90c1617..938eebd 100644 --- a/src/app/dashboard/advances/page.tsx +++ b/src/app/dashboard/advances/page.tsx @@ -28,6 +28,7 @@ import { listOutstandingAdvances, listBankAccounts } from "@/db/queries"; import { formatCurrency, formatDate } from "@/lib/format"; import { RecordReimbursementDialog } from "./record-reimbursement-dialog"; import { requireOrg } from "@/lib/session"; +import { groupAccountsByEmployee, listEmployeeAccounts } from "@/db/employee-accounts"; export const dynamic = "force-dynamic"; @@ -38,6 +39,9 @@ export default async function AdvancesPage() { listOutstandingAdvances(orgId), listBankAccounts(orgId), ]); + // 只撈有未還代墊的員工的帳戶(遮罩後),給「匯入帳戶」下拉用 + const settleIds = [...new Set(rows.map((r) => r.settleEmployeeId).filter((id): id is number => id != null))]; + const accountsByEmployee = groupAccountsByEmployee(await listEmployeeAccounts(orgId, settleIds)); const total = rows.reduce((s, r) => s + Number(r.amountTwd ?? r.amount ?? 0), 0); const accountOpts = accounts.map((a) => ({ id: a.id, name: a.name, currency: a.currency })); @@ -111,6 +115,10 @@ export default async function AdvancesPage() { vendorName: r.vendorName ?? "", }} accounts={accountOpts} + employeeAccounts={(r.settleEmployeeId == null + ? [] + : (accountsByEmployee.get(r.settleEmployeeId) ?? []) + ).filter((a) => a.isActive)} /> diff --git a/src/app/dashboard/advances/record-reimbursement-dialog.tsx b/src/app/dashboard/advances/record-reimbursement-dialog.tsx index 6babae4..46b6e9f 100644 --- a/src/app/dashboard/advances/record-reimbursement-dialog.tsx +++ b/src/app/dashboard/advances/record-reimbursement-dialog.tsx @@ -19,15 +19,19 @@ import { } from "@/components/ui/dialog"; import { DatePicker } from "@/components/date-picker"; import { submitAction } from "@/lib/form-action"; +import { formatAccountShort, type MaskedEmployeeAccount } from "@/lib/employee-accounts"; const initial: ActionState = { ok: false }; export function RecordReimbursementDialog({ advance, accounts, + employeeAccounts, }: Readonly<{ advance: { id: number; settleName: string; amount: string; currency: string; vendorName: string }; accounts: { id: number; name: string; currency: string }[]; + /** 代墊人啟用中的收款帳戶(遮罩後),選「匯入帳戶」用 */ + employeeAccounts: MaskedEmployeeAccount[]; }>) { const t = useTranslations("advances"); const [open, setOpen] = useState(false); @@ -100,6 +104,23 @@ export function RecordReimbursementDialog({ : t("dialog.fromAccountHint", { currency: advance.currency })}

    + {employeeAccounts.length > 0 ? ( + a.defaultForReimbursement)?.id ?? "none", + )} + > + {t("dialog.toAccountNone")} + {employeeAccounts.map((a) => ( + + {formatAccountShort(a)} + {a.currency === advance.currency ? "" : ` · ${a.currency}`} + + ))} + + ) : null}
    {formatCurrency(advance.amount, advance.currency)} diff --git a/src/app/dashboard/employees/account-actions.ts b/src/app/dashboard/employees/account-actions.ts new file mode 100644 index 0000000..4f47432 --- /dev/null +++ b/src/app/dashboard/employees/account-actions.ts @@ -0,0 +1,157 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { getTranslations } from "next-intl/server"; +import { canManageOrg, requireOrgWithRole } from "@/lib/session"; +import { logWeb } from "@/db/activity"; +import { + convertLegacySalaryAccount, + createEmployeeAccount, + getEmployeeAccount, + revealEmployeeAccountNumber, + softDeleteEmployeeAccount, + updateEmployeeAccount, +} from "@/db/employee-accounts"; +import { EmployeeAccountError, formatAccountShort } from "@/lib/employee-accounts"; +import type { ActionState } from "@/db/mutations"; + +/** + * 員工收款帳戶的網頁端 action。全部只給 owner / admin: + * 隱藏按鈕只擋得住誤按,擋不住直接呼叫 action,所以每一支都在 server 端重驗角色。 + * + * 帳戶區塊放在員工編輯表單「裡面」(不能巢狀
    ),所以這些 action 收的是 + * 一般物件而不是 FormData,由區塊自己的「儲存」按鈕呼叫。 + */ + +export type EmployeeAccountFormInput = { + /** 有值 = 更新;沒有 = 新增 */ + id?: number | null; + employeeId: number; + kind: string; + bankCode: string; + branchCode: string; + bankName: string; + accountHolder: string; + /** 更新時留空 = 不改帳號 */ + accountNumber: string; + currency: string; + label: string; + defaultForSalary: boolean; + defaultForReimbursement: boolean; + isActive: boolean; + note: string; +}; + +async function requireManager(): Promise<{ orgId: string } | { error: string }> { + const ctx = await requireOrgWithRole(); + if (!canManageOrg(ctx.role)) { + const t = await getTranslations("errors"); + return { error: t("forbidden.manageEmployees") }; + } + return { orgId: ctx.orgId }; +} + +/** EmployeeAccountError → 已翻譯字串;其他錯誤照舊回傳訊息。 */ +async function accountErrorMessage(e: unknown, fallbackKey: "create" | "update" | "delete") { + const t = await getTranslations("errors"); + if (e instanceof EmployeeAccountError) return t(`employeeAccount.${e.code}`); + return e instanceof Error ? e.message : t(`failed.${fallbackKey}`); +} + +function revalidateEmployees() { + revalidatePath("/dashboard/employees"); + revalidatePath("/dashboard/payroll"); + revalidatePath("/dashboard/advances"); +} + +export async function saveEmployeeAccount(input: EmployeeAccountFormInput): Promise { + const auth = await requireManager(); + if ("error" in auth) return { ok: false, error: auth.error }; + const { orgId } = auth; + const isUpdate = input.id != null; + try { + const fields = { + kind: input.kind, + bankCode: input.bankCode, + branchCode: input.branchCode, + bankName: input.bankName, + accountHolder: input.accountHolder, + accountNumber: input.accountNumber, + currency: input.currency, + label: input.label, + defaultForSalary: input.defaultForSalary, + defaultForReimbursement: input.defaultForReimbursement, + isActive: input.isActive, + note: input.note, + }; + const saved = isUpdate + ? await updateEmployeeAccount(orgId, input.id as number, fields) + : await createEmployeeAccount(orgId, input.employeeId, fields); + // 摘要只放遮罩後的字樣,完整帳號絕不進 activity_log。 + await logWeb(orgId, isUpdate ? "update" : "create", "employee_bank_account", saved.id, formatAccountShort(saved)); + revalidateEmployees(); + return { ok: true }; + } catch (e) { + return { ok: false, error: await accountErrorMessage(e, isUpdate ? "update" : "create") }; + } +} + +export async function deleteEmployeeAccount(id: number): Promise { + const auth = await requireManager(); + if ("error" in auth) return { ok: false, error: auth.error }; + try { + const removed = await softDeleteEmployeeAccount(auth.orgId, id); + await logWeb(auth.orgId, "delete", "employee_bank_account", id, formatAccountShort(removed)); + revalidateEmployees(); + return { ok: true }; + } catch (e) { + return { ok: false, error: await accountErrorMessage(e, "delete") }; + } +} + +/** + * 顯示完整帳號:唯一會把明文送到瀏覽器的路徑。只給 owner / admin,每一次都寫 + * activity_log(action = read),事後查得到誰在什麼時候看過哪個帳戶。 + */ +export async function revealEmployeeAccount( + id: number, +): Promise<{ ok: true; accountNumber: string } | { ok: false; error: string }> { + const auth = await requireManager(); + if ("error" in auth) return { ok: false, error: auth.error }; + const t = await getTranslations("errors"); + const tRec = await getTranslations("lib"); + try { + const accountNumber = await revealEmployeeAccountNumber(auth.orgId, id); + const acct = await getEmployeeAccount(auth.orgId, id); + // 摘要只記遮罩後的帳戶,不記完整帳號。 + await logWeb( + auth.orgId, + "read", + "employee_bank_account", + id, + tRec("activity.accountRevealed", { account: acct ? formatAccountShort(acct) : `#${id}` }), + ); + return { ok: true, accountNumber }; + } catch (e) { + if (e instanceof EmployeeAccountError) return { ok: false, error: t(`employeeAccount.${e.code}`) }; + return { ok: false, error: t("employeeAccount.revealFailed") }; + } +} + +/** 把舊的 salary_account 自由文字轉成一個「薪資預設」帳戶,並清空舊欄位。 */ +export async function convertLegacySalaryAccountAction(employeeId: number): Promise { + const auth = await requireManager(); + if ("error" in auth) return { ok: false, error: auth.error }; + try { + const created = await convertLegacySalaryAccount(auth.orgId, employeeId); + if (!created) { + const t = await getTranslations("errors"); + return { ok: false, error: t("employeeAccount.nothingToConvert") }; + } + await logWeb(auth.orgId, "create", "employee_bank_account", created.id, formatAccountShort(created)); + revalidateEmployees(); + return { ok: true }; + } catch (e) { + return { ok: false, error: await accountErrorMessage(e, "create") }; + } +} diff --git a/src/app/dashboard/employees/edit-employee-form.tsx b/src/app/dashboard/employees/edit-employee-form.tsx index 6ad3f17..a8a2258 100644 --- a/src/app/dashboard/employees/edit-employee-form.tsx +++ b/src/app/dashboard/employees/edit-employee-form.tsx @@ -8,6 +8,8 @@ import { type EmployeeFormValues, type MemberOption, } from "./employee-fields"; +import { EmployeeAccountsSection } from "./employee-accounts-section"; +import type { MaskedEmployeeAccount } from "@/lib/employee-accounts"; export type { MemberOption } from "./employee-fields"; @@ -16,10 +18,19 @@ type Employee = EmployeeFormValues & { id: number }; export function EditEmployeeForm({ employee, members, + accounts, + legacySalaryAccount, + canManage, footer, }: Readonly<{ employee: Employee; members: MemberOption[]; + /** 這位員工的收款帳戶(遮罩後) */ + accounts: MaskedEmployeeAccount[]; + /** 舊 salary_account(遮罩後),沒有就是 null */ + legacySalaryAccount: string | null; + /** owner / admin 才能改;成員看到的是唯讀表單 */ + canManage: boolean; footer?: React.ReactNode; }>) { const t = useTranslations("employees"); @@ -32,9 +43,22 @@ export function EditEmployeeForm({ submitLabel={t("form.saveChanges")} submittingLabel={t("form.saving")} footer={footer} + readOnly={!canManage} > - + + } + /> ); } diff --git a/src/app/dashboard/employees/employee-accounts-section.tsx b/src/app/dashboard/employees/employee-accounts-section.tsx new file mode 100644 index 0000000..a364130 --- /dev/null +++ b/src/app/dashboard/employees/employee-accounts-section.tsx @@ -0,0 +1,510 @@ +"use client"; + +import { useMemo, useState, useTransition } from "react"; +import { toast } from "sonner"; +import { useTranslations } from "next-intl"; +import { Eye, EyeOff, Pencil, Plus, Trash2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Combobox } from "@/components/combobox"; +import { CopyButton } from "@/components/copy-button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { CURRENCIES } from "@/lib/currency"; +import { + EMPLOYEE_ACCOUNT_KINDS, + TW_BANKS, + bankNameForCode, + type MaskedEmployeeAccount, +} from "@/lib/employee-accounts"; +import { + convertLegacySalaryAccountAction, + deleteEmployeeAccount, + revealEmployeeAccount, + saveEmployeeAccount, + type EmployeeAccountFormInput, +} from "./account-actions"; + +/** + * 員工編輯表單裡的「帳戶」區塊。 + * + * 這個區塊渲染在員工表單()裡面,而 HTML 不能巢狀 ,所以: + * - 輸入框一律不給 name,不會混進員工表單送出的 FormData + * - 帳戶有自己的「儲存」按鈕(type="button"),直接用物件呼叫 server action + * - 在帳戶輸入框按 Enter 不會觸發員工表單送出 + * + * 列表只拿得到遮罩後的資料;完整帳號只有 owner / admin 按「顯示完整帳號」才會 + * 向 server 要一次(每次都會寫入操作紀錄),也只存在這個元件的 state 裡。 + */ + +const BANK_OPTIONS = TW_BANKS.map((b) => `${b.code} ${b.name}`); + +type Draft = Omit & { bankInput: string }; + +function emptyDraft(holder: string, isFirst: boolean): Draft { + return { + id: null, + kind: "bank", + bankInput: "", + bankCode: "", + branchCode: "", + bankName: "", + accountHolder: holder, + accountNumber: "", + currency: "TWD", + label: "", + // 第一個帳戶預設就是薪資與報銷的預設帳戶,省得再勾 + defaultForSalary: isFirst, + defaultForReimbursement: isFirst, + isActive: true, + note: "", + }; +} + +function draftFrom(a: MaskedEmployeeAccount): Draft { + return { + id: a.id, + kind: a.kind, + bankInput: a.bankCode ? `${a.bankCode} ${a.bankName ?? ""}`.trim() : "", + bankCode: a.bankCode ?? "", + branchCode: a.branchCode ?? "", + bankName: a.bankName ?? "", + accountHolder: a.accountHolder ?? "", + accountNumber: "", + currency: a.currency, + label: a.label ?? "", + defaultForSalary: a.defaultForSalary, + defaultForReimbursement: a.defaultForReimbursement, + isActive: a.isActive, + note: a.note ?? "", + }; +} + +/** 銀行欄位的自由輸入 → 代碼與名稱。開頭 3 碼數字就是代碼,其餘文字當名稱。 */ +function parseBankInput(v: string): { bankCode: string; bankName: string } { + const m = /^\s*(\d{3})\s*(.*)$/.exec(v); + if (!m) return { bankCode: "", bankName: v.trim() }; + return { bankCode: m[1], bankName: m[2].trim() || (bankNameForCode(m[1]) ?? "") }; +} + +/** 在帳戶輸入框按 Enter 不要送出外層的員工表單。 */ +function swallowEnter(e: React.KeyboardEvent) { + if (e.key === "Enter" && (e.target as HTMLElement).tagName === "INPUT") e.preventDefault(); +} + +export function EmployeeAccountsSection({ + employeeId, + employeeName, + accounts, + canManage, + legacySalaryAccount, +}: Readonly<{ + employeeId: number; + employeeName: string; + accounts: MaskedEmployeeAccount[]; + canManage: boolean; + /** 舊 salary_account(已遮罩);只有還沒有任何帳戶時才會傳進來 */ + legacySalaryAccount: string | null; +}>) { + const t = useTranslations("employees.accounts"); + const [draft, setDraft] = useState(null); + const [pending, start] = useTransition(); + + function save() { + if (!draft) return; + const { bankInput, ...rest } = draft; + const bank = draft.kind === "bank" ? parseBankInput(bankInput) : { bankCode: "", bankName: draft.bankName }; + start(async () => { + const res = await saveEmployeeAccount({ ...rest, ...bank, employeeId }); + if (res.ok) { + toast.success(draft.id ? t("toast.updated") : t("toast.created")); + setDraft(null); + } else if (res.error) { + toast.error(res.error); + } + }); + } + + function convertLegacy() { + start(async () => { + const res = await convertLegacySalaryAccountAction(employeeId); + if (res.ok) toast.success(t("toast.converted")); + else if (res.error) toast.error(res.error); + }); + } + + return ( +
    +
    +
    {t("title")}
    + {canManage && !draft ? ( + + ) : null} +
    + + {legacySalaryAccount && accounts.length === 0 ? ( +
    + + {t("legacy.notice", { value: legacySalaryAccount })} + + {canManage ? ( + + ) : null} +
    + ) : null} + + {accounts.length === 0 && !legacySalaryAccount ? ( +

    {t("empty")}

    + ) : null} + +
      + {accounts.map((a) => ( + setDraft(draftFrom(a))} + /> + ))} +
    + + {draft ? ( + setDraft(null)} + /> + ) : null} +
    + ); +} + +function AccountRow({ + account: a, + canManage, + editing, + onEdit, +}: Readonly<{ + account: MaskedEmployeeAccount; + canManage: boolean; + editing: boolean; + onEdit: () => void; +}>) { + const t = useTranslations("employees.accounts"); + const [revealed, setRevealed] = useState(null); + const [pending, start] = useTransition(); + + function reveal() { + start(async () => { + const res = await revealEmployeeAccount(a.id); + if (res.ok) setRevealed(res.accountNumber); + else toast.error(res.error); + }); + } + + // 「永豐銀行 807 · 0180 分行 · •••• 90123 · TWD」 + const head = [a.bankName ?? t(`kind.${a.kind as "bank" | "wise" | "other"}`), a.bankCode] + .filter(Boolean) + .join(" "); + const parts = [ + head, + a.branchCode ? t("branchSuffix", { code: a.branchCode }) : null, + `•••• ${a.accountLast5}`, + a.currency, + ].filter(Boolean); + + return ( +
  • +
    + + {parts.join(" · ")} + + {a.defaultForSalary ? {t("chips.salary")} : null} + {a.defaultForReimbursement ? ( + {t("chips.reimbursement")} + ) : null} + {a.isActive ? null : {t("chips.inactive")}} +
    + {a.accountHolder || a.label || a.note ? ( +
    + {[a.accountHolder, a.label, a.note].filter(Boolean).join(" · ")} +
    + ) : null} + {revealed ? ( +
    + {revealed} + + +
    + ) : null} + {canManage ? ( +
    + {revealed ? null : ( + + )} + + +
    + ) : null} +
  • + ); +} + +/** + * 刪除確認。不用共用的 DeleteButton:那顆刪除成功後會把整個員工編輯面板關掉, + * 這裡只是刪掉面板裡的一列。 + */ +function DeleteAccountButton({ id }: Readonly<{ id: number }>) { + const t = useTranslations("employees.accounts"); + const [open, setOpen] = useState(false); + const [pending, start] = useTransition(); + + function onConfirm() { + start(async () => { + const res = await deleteEmployeeAccount(id); + if (res.ok) { + setOpen(false); + toast.success(t("toast.deleted")); + } else if (res.error) { + toast.error(res.error); + } + }); + } + + return ( + + + + + + + {t("deleteConfirm.title")} + {t("deleteConfirm.description")} + + + {t("cancel")} + { + e.preventDefault(); + onConfirm(); + }} + disabled={pending} + > + {t("delete")} + + + + + ); +} + +function AccountForm({ + draft, + setDraft, + pending, + onSave, + onCancel, +}: Readonly<{ + draft: Draft; + setDraft: (d: Draft) => void; + pending: boolean; + onSave: () => void; + onCancel: () => void; +}>) { + const t = useTranslations("employees.accounts"); + const set = (k: K, v: Draft[K]) => setDraft({ ...draft, [k]: v }); + const isBank = draft.kind === "bank"; + const currencyCodes = useMemo(() => { + const codes = CURRENCIES.map((c) => c.code); + return codes.includes(draft.currency) ? codes : [draft.currency, ...codes]; + }, [draft.currency]); + + return ( + // 攔 Enter:這塊在員工表單裡面,按 Enter 會把整張員工表單送出去 +
    +
    {draft.id ? t("form.editTitle") : t("form.addTitle")}
    +
    + + + + {isBank ? ( + + set("bankInput", v)} + placeholder={t("form.bankPlaceholder")} + emptyText={t("form.bankFreeEntry")} + /> + + ) : ( + + set("bankName", e.target.value)} + placeholder={draft.kind === "wise" ? "Wise" : t("form.optional")} + /> + + )} + {isBank ? ( + + set("branchCode", e.target.value)} + inputMode="numeric" + maxLength={4} + placeholder={t("form.branchPlaceholder")} + /> + + ) : null} + + set("accountHolder", e.target.value)} /> + + + set("accountNumber", e.target.value)} + inputMode={isBank ? "numeric" : "text"} + autoComplete="off" + placeholder={draft.id ? t("form.numberKeep") : t("form.numberPlaceholder")} + /> + + + + + + set("label", e.target.value)} + placeholder={t("form.labelPlaceholder")} + /> + + + set("note", e.target.value)} placeholder={t("form.optional")} /> + +
    +
    + set("defaultForSalary", v)} + label={t("form.defaultForSalary")} + disabled={!draft.isActive} + /> + set("defaultForReimbursement", v)} + label={t("form.defaultForReimbursement")} + disabled={!draft.isActive} + /> + {draft.id ? ( + set("isActive", v)} label={t("form.isActive")} /> + ) : null} +
    +
    + + +
    +
    + ); +} + +function FormRow({ + label, + required, + children, +}: Readonly<{ label: string; required?: boolean; children: React.ReactNode }>) { + return ( + // 用 div 而不是
    + + + + + + + + ); +} diff --git a/src/app/dashboard/settings/integrations/page.tsx b/src/app/dashboard/settings/integrations/page.tsx index b8c13b3..fdf12b0 100644 --- a/src/app/dashboard/settings/integrations/page.tsx +++ b/src/app/dashboard/settings/integrations/page.tsx @@ -12,6 +12,8 @@ import { getProvider } from "@/lib/integrations/registry"; import { listIntegrations } from "@/lib/integrations/store"; import { IntegrationsList, type IntegrationRowData } from "./integrations-client"; import { CalendarSettingsClient } from "./calendar-settings-client"; +import { WiseMappingSection } from "./wise-mapping-client"; +import { loadWiseMappingView } from "./wise-mapping-data"; export const dynamic = "force-dynamic"; @@ -50,6 +52,8 @@ export default async function IntegrationsPage() { }); const calendarConnected = Boolean(calendar?.ownerUserId && calendar?.googleCalendarId); + const wiseSummary = summaries.find((x) => x.provider === "wise") ?? null; + const wiseView = wiseSummary ? await loadWiseMappingView(orgId, wiseSummary) : null; return ( <> @@ -59,6 +63,18 @@ export default async function IntegrationsPage() { canManage={canManage} calendar={{ connected: calendarConnected, ownerLabel: calendarOwner }} /> + {wiseView ? ( +
    + +
    + ) : null}
    { + const t = await getTranslations("wise"); + const { orgId, role } = await requireOrgWithRole(); + if (!canManageOrg(role)) return { error: t("errors.notAllowed") }; + return { orgId }; +} + +function toInt(v: unknown): number | null { + const n = typeof v === "number" ? v : typeof v === "string" && v.trim() !== "" ? Number(v) : NaN; + return Number.isInteger(n) ? n : null; +} + +export async function saveWiseMappingsAction( + rows: { profileId: number; balanceId: number; bankAccountId: number | null; syncFrom: string | null }[], +): Promise { + const t = await getTranslations("wise"); + const me = await requireManager(); + if ("error" in me) return { ok: false, error: me.error }; + try { + if (!Array.isArray(rows)) return { ok: false, error: t("errors.failed") }; + const input: MappingInput[] = []; + for (const r of rows) { + const profileId = toInt(r?.profileId); + const balanceId = toInt(r?.balanceId); + if (profileId === null || balanceId === null) return { ok: false, error: t("errors.failed") }; + const syncFrom = typeof r.syncFrom === "string" && r.syncFrom.trim() ? r.syncFrom.trim() : null; + input.push({ profileId, balanceId, bankAccountId: toInt(r.bankAccountId), syncFrom }); + } + const res = await saveWiseMappings(me.orgId, input); + if ("error" in res) return { ok: false, error: res.error }; + await logWeb(me.orgId, "update", "integration", null, t("mapping.activity.saved")); + revalidatePath(PAGE); + revalidatePath("/dashboard/bank-accounts"); + return { ok: true }; + } catch (e) { + return { ok: false, error: e instanceof Error ? e.message : t("errors.failed") }; + } +} + +export async function refreshWiseBalancesAction(): Promise { + const t = await getTranslations("wise"); + const me = await requireManager(); + if ("error" in me) return { ok: false, error: me.error }; + try { + await refreshWiseBalances(me.orgId); + revalidatePath(PAGE); + return { ok: true }; + } catch (e) { + revalidatePath(PAGE); + return { ok: false, error: e instanceof Error ? e.message : t("errors.failed") }; + } +} diff --git a/src/app/dashboard/settings/integrations/wise-mapping-client.tsx b/src/app/dashboard/settings/integrations/wise-mapping-client.tsx new file mode 100644 index 0000000..4296786 --- /dev/null +++ b/src/app/dashboard/settings/integrations/wise-mapping-client.tsx @@ -0,0 +1,228 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { toast } from "sonner"; +import { useTranslations } from "next-intl"; +import { ArrowLeftRight, RefreshCw, Save } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { CurrencyFlag } from "@/components/currency-flag"; +import { formatCurrency } from "@/lib/currency"; +import { refreshWiseBalancesAction, saveWiseMappingsAction } from "./wise-actions"; + +/** 一個 Wise 餘額 + 目前的對應(server 已整理好,不含任何憑證)。 */ +export type WiseBalanceRow = { + profileId: number; + profileName: string; + balanceId: number; + currency: string; + amount: number | null; + /** 已格式化的時間字串。 */ + fetchedAt: string | null; + bankAccountId: number | null; + syncFrom: string | null; +}; + +export type WiseLedgerAccount = { id: number; name: string; currency: string }; + +const NONE = "none"; + +export function WiseMappingSection({ + balances, + accounts, + suggestions, + canManage, + enabled, +}: Readonly<{ + balances: WiseBalanceRow[]; + accounts: WiseLedgerAccount[]; + /** 帳本帳戶 id → 建議切換日。 */ + suggestions: Record; + canManage: boolean; + /** 整合是否已開啟(重新整理餘額要打 Wise,必須開啟)。 */ + enabled: boolean; +}>) { + const t = useTranslations("wise"); + const [pending, start] = useTransition(); + const [rows, setRows] = useState(() => + balances.map((b) => ({ + key: `${b.profileId}:${b.balanceId}`, + bankAccountId: b.bankAccountId, + syncFrom: b.syncFrom ?? "", + })), + ); + const dirty = rows.some((r, i) => { + const b = balances[i]; + return r.bankAccountId !== b.bankAccountId || r.syncFrom !== (b.syncFrom ?? ""); + }); + + function update(i: number, patch: Partial<(typeof rows)[number]>) { + setRows((prev) => prev.map((r, j) => (j === i ? { ...r, ...patch } : r))); + } + + function save() { + start(async () => { + const res = await saveWiseMappingsAction( + balances.map((b, i) => ({ + profileId: b.profileId, + balanceId: b.balanceId, + bankAccountId: rows[i].bankAccountId, + syncFrom: rows[i].syncFrom || null, + })), + ); + if (!res.ok) toast.error(res.error ?? t("errors.failed")); + else toast.success(t("mapping.saved")); + }); + } + + function refresh() { + start(async () => { + const res = await refreshWiseBalancesAction(); + if (!res.ok) toast.error(res.error ?? t("errors.failed")); + else toast.success(t("mapping.refreshed")); + }); + } + + return ( + + + + + {t("mapping.title")} + + + +

    {t("mapping.description")}

    + {canManage ? null : ( +

    {t("mapping.readOnly")}

    + )} + + {balances.length === 0 ? ( +

    {t("mapping.empty")}

    + ) : ( +
    + + + + + + + + + + {balances.map((b, i) => { + const r = rows[i]; + const options = accounts.filter( + (a) => a.currency.trim().toUpperCase() === b.currency, + ); + const suggestion = r.bankAccountId ? suggestions[r.bankAccountId] : undefined; + return ( + + + + + + ); + })} + +
    {t("mapping.columns.balance")}{t("mapping.columns.account")}{t("mapping.columns.syncFrom")}
    +
    + + {b.profileName} · {b.currency} +
    +
    + {b.amount === null ? "—" : formatCurrency(b.amount, b.currency)} + {b.fetchedAt ? ` · ${t("mapping.asOf", { date: b.fetchedAt })}` : null} +
    +
    + {options.length === 0 ? ( + + {t("mapping.noAccounts", { currency: b.currency })} + + ) : ( + + )} + + update(i, { syncFrom: e.target.value })} + disabled={!canManage || pending || !r.bankAccountId} + aria-label={t("mapping.columns.syncFrom")} + /> + {suggestion && suggestion !== r.syncFrom ? ( + + ) : null} +
    +
    + )} +

    {t("mapping.suggestionHint")}

    + + {canManage ? ( +
    + {enabled ? null : ( + + {t("mapping.refreshNeedsEnabled")} + + )} + + +
    + ) : null} +
    +
    + ); +} diff --git a/src/app/dashboard/settings/integrations/wise-mapping-data.ts b/src/app/dashboard/settings/integrations/wise-mapping-data.ts new file mode 100644 index 0000000..42036fc --- /dev/null +++ b/src/app/dashboard/settings/integrations/wise-mapping-data.ts @@ -0,0 +1,39 @@ +import { listBankAccounts } from "@/db/queries"; +import { formatDateTime } from "@/lib/format"; +import type { IntegrationSummary } from "@/lib/integrations/types"; +import { parseWiseConfig, suggestSyncFrom } from "@/lib/wise-sync"; +import type { WiseBalanceRow, WiseLedgerAccount } from "./wise-mapping-client"; + +/** 設定頁 Wise 帳戶對應區塊要的資料(只有非機密的 config 與本組織的帳戶)。 */ +export async function loadWiseMappingView(orgId: string, summary: IntegrationSummary) { + const cfg = parseWiseConfig(summary.config); + const accountsAll = await listBankAccounts(orgId); + const accounts: WiseLedgerAccount[] = accountsAll + .filter((a) => a.isActive) + .map((a) => ({ id: a.id, name: a.name, currency: a.currency.trim().toUpperCase() })); + const currencies = new Set(cfg.balances.map((b) => b.currency)); + const candidateIds = accounts.filter((a) => currencies.has(a.currency)).map((a) => a.id); + const suggestionMap = await suggestSyncFrom(orgId, candidateIds); + const profileName = (id: number) => cfg.profiles.find((p) => p.id === id)?.name ?? String(id); + const balances: WiseBalanceRow[] = cfg.balances.map((b) => { + const m = cfg.accountMappings.find((x) => x.balanceId === b.balanceId); + return { + profileId: b.profileId, + profileName: profileName(b.profileId), + balanceId: b.balanceId, + currency: b.currency, + amount: b.amount, + fetchedAt: b.fetchedAt ? formatDateTime(b.fetchedAt) : null, + bankAccountId: m?.bankAccountId ?? null, + syncFrom: m?.syncFrom ?? null, + }; + }); + return { + // 餘額清單或已存的對應變了(重新整理 / 儲存)就重掛元件,讓表單回到存檔後的狀態。 + key: JSON.stringify([cfg.balances.map((b) => b.balanceId), cfg.accountMappings]), + balances, + accounts, + suggestions: Object.fromEntries(suggestionMap) as Record, + enabled: summary.enabled && summary.status === "connected", + }; +} diff --git a/src/app/dashboard/transactions/page.tsx b/src/app/dashboard/transactions/page.tsx index 503806d..15fa891 100644 --- a/src/app/dashboard/transactions/page.tsx +++ b/src/app/dashboard/transactions/page.tsx @@ -1,4 +1,5 @@ import { Fragment } from "react"; +import Link from "next/link"; import { PageHeader } from "@/components/page-header"; import { BookBadge } from "@/components/book-badge"; import { Badge } from "@/components/ui/badge"; @@ -69,6 +70,8 @@ function TransactionRow({ editDialogTitle, editDialogDescription, uncategorizedLabel, + needsReviewLabel, + needsReviewHint, categories, parties, employees, @@ -83,6 +86,9 @@ function TransactionRow({ editDialogTitle: string; editDialogDescription: string; uncategorizedLabel: string; + needsReviewLabel: string; + /** 待確認 chip 的說明;{source} 會換成來源(wise)。 */ + needsReviewHint: (source: string) => string; categories: Opt[]; parties: Opt[]; employees: Opt[]; @@ -108,9 +114,20 @@ function TransactionRow({
    {t.partyName ?? t.settleName ?? "—"} - - {typeLabel[t.type] ?? t.type} - +
    + + {typeLabel[t.type] ?? t.type} + + {t.needsReview ? ( + + {needsReviewLabel} + + ) : null} +
    @@ -180,6 +197,8 @@ export default async function TransactionsPage({ account?: string; period?: string; page?: string; + /** review=1:只看自動匯入、待確認的列。 */ + review?: string; }>; }>) { const { orgId } = await requireOrg(); @@ -202,15 +221,16 @@ export default async function TransactionsPage({ { label: tr("columns.lastUpdated"), width: "w-36" }, { label: tr("columns.amount"), width: "w-32", align: "right" }, ]; - const { book, category, account, period, page: pageParam } = await searchParams; + const { book, category, account, period, page: pageParam, review } = await searchParams; const active = (["internal", "external", "both"].includes(book ?? "") ? book : undefined) as | Book | undefined; const categoryId = category && Number.isFinite(Number(category)) ? Number(category) : undefined; const accountId = account && Number.isFinite(Number(account)) ? Number(account) : undefined; const page = Math.max(1, Math.trunc(Number(pageParam)) || 1); - const filters = { book: active, categoryId, accountId, period }; - const [rows, total, accounts, categories, parties, employees, projects, contracts, months] = + const needsReview = review === "1" ? true : undefined; + const filters = { book: active, categoryId, accountId, period, needsReview }; + const [rows, total, accounts, categories, parties, employees, projects, contracts, months, reviewCount] = await Promise.all([ listTransactions(orgId, filters, PAGE_SIZE, (page - 1) * PAGE_SIZE), countTransactions(orgId, filters), @@ -221,6 +241,7 @@ export default async function TransactionsPage({ listProjects(orgId), listContractOptions(orgId), listTransactionMonths(orgId), + countTransactions(orgId, { needsReview: true }), ]); const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); const [docsMap, auditMap] = await Promise.all([ @@ -261,6 +282,29 @@ export default async function TransactionsPage({ period={period} /> + {reviewCount > 0 || needsReview ? ( +
    + + {tr("review.count", { count: reviewCount })} + + v !== undefined), + ), + }} + > + {needsReview ? tr("review.showAll") : tr("review.showOnly")} + +
    + ) : null} + {groups.length === 0 ? ( @@ -304,6 +348,8 @@ export default async function TransactionsPage({ editDialogTitle={tr("editDialog.title")} editDialogDescription={tr("editDialog.description")} uncategorizedLabel={tr("table.uncategorized")} + needsReviewLabel={tr("table.needsReview")} + needsReviewHint={(source) => tr("table.needsReviewHint", { source })} categories={categories} parties={partyOpts} employees={employeeOpts} @@ -326,7 +372,7 @@ export default async function TransactionsPage({ totalPages={totalPages} total={total} basePath="/dashboard/transactions" - params={{ book, category, account, period }} + params={{ book, category, account, period, review }} /> ); diff --git a/src/i18n/messages/index.ts b/src/i18n/messages/index.ts index 2cf785b..7d805d8 100644 --- a/src/i18n/messages/index.ts +++ b/src/i18n/messages/index.ts @@ -21,6 +21,7 @@ import members from "./members"; import activity from "./activity"; import settings from "./settings"; import integrations from "./integrations"; +import wise from "./wise"; import auth from "./auth"; import errors from "./errors"; import lib from "./lib"; @@ -50,6 +51,7 @@ const catalogue = { activity, settings, integrations, + wise, auth, errors, lib, diff --git a/src/i18n/messages/transactions.ts b/src/i18n/messages/transactions.ts index e732c01..d527ea7 100644 --- a/src/i18n/messages/transactions.ts +++ b/src/i18n/messages/transactions.ts @@ -19,6 +19,16 @@ const transactions = { monthLabel: { "zh-TW": "{year} 年 {month} 月", en: "{month}/{year}" }, rowsCount: { "zh-TW": "{count} 筆", en: "{count} entries" }, uncategorized: { "zh-TW": "未分類", en: "Uncategorized" }, + needsReview: { "zh-TW": "待確認", en: "To review" }, + needsReviewHint: { + "zh-TW": "自動匯入({source}),還沒有人確認。指定分類後會清掉。", + en: "Imported automatically ({source}) and not yet reviewed. Choosing a category clears it.", + }, + }, + review: { + count: { "zh-TW": "{count} 筆自動匯入待確認", en: "{count} imported entries to review" }, + showOnly: { "zh-TW": "只看待確認", en: "Show only these" }, + showAll: { "zh-TW": "顯示全部", en: "Show all" }, }, empty: { noData: { "zh-TW": "尚無交易", en: "No transactions yet" }, diff --git a/src/i18n/messages/wise.ts b/src/i18n/messages/wise.ts new file mode 100644 index 0000000..ae7b569 --- /dev/null +++ b/src/i18n/messages/wise.ts @@ -0,0 +1,118 @@ +import type { Dictionary } from "./dictionary"; + +/** + * Wise 整合的畫面字串:設定 › 整合 的「帳戶對應」區塊,以及帳戶頁的「從 Wise 同步」。 + * 整合本身的名稱 / 描述仍在 integrations.providers.wise。 + */ +const wise = { + mapping: { + title: { "zh-TW": "Wise 帳戶對應", en: "Wise account mapping" }, + description: { + "zh-TW": + "把每個 Wise 餘額對應到一個同幣別的帳本帳戶,並設定切換日:切換日以前的 Wise 交易視為已手動入帳,永遠不會同步。沒有對應的餘額不會同步。同步只讀 Wise、只寫本系統的帳本,不會動到任何錢。", + en: "Map each Wise balance to a ledger account in the same currency and set a cutover date: Wise transactions before it are treated as already booked by hand and are never synced. Unmapped balances are skipped. Syncing only reads Wise and only writes this ledger — it never moves money.", + }, + columns: { + balance: { "zh-TW": "Wise 餘額", en: "Wise balance" }, + account: { "zh-TW": "帳本帳戶", en: "Ledger account" }, + syncFrom: { "zh-TW": "切換日", en: "Cutover date" }, + }, + asOf: { "zh-TW": "{date} 的餘額", en: "Balance as of {date}" }, + unmapped: { "zh-TW": "不同步", en: "Don't sync" }, + noAccounts: { + "zh-TW": "沒有 {currency} 帳本帳戶,請先到 帳戶 新增", + en: "No {currency} ledger account yet — add one under Accounts", + }, + suggestion: { "zh-TW": "建議:{date}", en: "Suggested: {date}" }, + suggestionHint: { + "zh-TW": "建議值 = 該帳戶最後一筆手動交易的下個月 1 號。留空會自動套用建議值。", + en: "Suggested = first day of the month after the account's latest hand-entered transaction. Leave blank to use it.", + }, + empty: { + "zh-TW": "還沒有發現任何 Wise 餘額。按「重新整理餘額」向 Wise 讀取。", + en: "No Wise balances discovered yet. Press “Refresh balances” to read them from Wise.", + }, + refresh: { "zh-TW": "重新整理餘額", en: "Refresh balances" }, + refreshNeedsEnabled: { + "zh-TW": "要先開啟 Wise 整合才能向 Wise 讀取餘額。", + en: "Switch the Wise integration on to read balances from Wise.", + }, + save: { "zh-TW": "儲存對應", en: "Save mapping" }, + saving: { "zh-TW": "儲存中…", en: "Saving…" }, + saved: { "zh-TW": "已儲存 Wise 帳戶對應", en: "Wise mapping saved" }, + refreshed: { "zh-TW": "已更新 Wise 餘額", en: "Wise balances refreshed" }, + readOnly: { + "zh-TW": "只有擁有者或管理員可以修改對應。", + en: "Only owners or admins can change the mapping.", + }, + activity: { + saved: { "zh-TW": "更新 Wise 帳戶對應", en: "Updated the Wise account mapping" }, + }, + }, + sync: { + button: { "zh-TW": "從 Wise 同步", en: "Sync from Wise" }, + title: { "zh-TW": "從 Wise 同步交易", en: "Sync transactions from Wise" }, + description: { + "zh-TW": + "以下是試算結果,尚未寫入。確認無誤再按寫入:新交易記在內帳、分類留空並標為「待確認」;已同步過的交易不會重複寫入。", + en: "This is a preview — nothing has been written yet. New entries are booked to the internal book, uncategorized and marked “To review”; entries synced before are never written twice.", + }, + loading: { "zh-TW": "正在讀取 Wise 對帳單…", en: "Reading Wise statements…" }, + retry: { "zh-TW": "重試", en: "Retry" }, + columns: { + account: { "zh-TW": "帳戶", en: "Account" }, + range: { "zh-TW": "期間", en: "Range" }, + fetched: { "zh-TW": "讀到", en: "Read" }, + existing: { "zh-TW": "已存在", en: "Existing" }, + beforeCutover: { "zh-TW": "切換日前", en: "Before cutover" }, + toCreate: { "zh-TW": "將新增", en: "To add" }, + date: { "zh-TW": "日期", en: "Date" }, + party: { "zh-TW": "對象", en: "Counterparty" }, + description: { "zh-TW": "說明", en: "Description" }, + amount: { "zh-TW": "金額", en: "Amount" }, + }, + sampleTitle: { + "zh-TW": "將新增的交易(前 {count} 筆)", + en: "Entries to add (first {count})", + }, + skipped: { + "zh-TW": "略過的 Wise 餘額:{list}", + en: "Skipped Wise balances: {list}", + }, + reason: { + unmapped: { "zh-TW": "未對應", en: "unmapped" }, + no_sync_from: { "zh-TW": "未設切換日", en: "no cutover date" }, + account_missing: { "zh-TW": "帳本帳戶不存在", en: "ledger account missing" }, + currency_mismatch: { "zh-TW": "幣別不符", en: "currency mismatch" }, + }, + nothing: { "zh-TW": "沒有新的交易需要寫入。", en: "Nothing new to write." }, + apply: { "zh-TW": "寫入 {count} 筆", en: "Write {count} entries" }, + applying: { "zh-TW": "寫入中…", en: "Writing…" }, + cancel: { "zh-TW": "關閉", en: "Close" }, + applied: { + "zh-TW": "已從 Wise 寫入 {count} 筆交易", + en: "Wrote {count} entries from Wise", + }, + type: { + income: { "zh-TW": "收入", en: "Income" }, + expense: { "zh-TW": "支出", en: "Expense" }, + transfer: { "zh-TW": "換匯", en: "Conversion" }, + }, + mappedBadge: { "zh-TW": "Wise", en: "Wise" }, + activity: { + applied: { + "zh-TW": "從 Wise 同步 {count} 筆交易", + en: "Synced {count} entries from Wise", + }, + }, + }, + errors: { + notAllowed: { + "zh-TW": "只有組織的擁有者或管理員可以操作 Wise 同步", + en: "Only organization owners or admins can use the Wise sync", + }, + failed: { "zh-TW": "操作失敗", en: "Something went wrong" }, + }, +} satisfies Dictionary; + +export default wise; From 44be502a3e543f298dc356084ab9bb0c2e08faab Mon Sep 17 00:00:00 2001 From: YJack0000 Date: Thu, 24 Sep 2026 17:36:43 +0800 Subject: [PATCH 15/27] =?UTF-8?q?[feature]=20Simpany=20=E6=95=B4=E5=90=88?= =?UTF-8?q?=EF=BC=9Ainvoices=20=E5=90=8C=E6=AD=A5=E6=AC=84=E4=BD=8D?= =?UTF-8?q?=E8=88=87=20invoice=5Fdrafts=20=E8=A1=A8=EF=BC=88migration=2000?= =?UTF-8?q?25=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- migrations/0025_invoice_simpany_sync.sql | 93 ++++++++++++++++++++++++ src/db/schema.ts | 45 ++++++++++++ 2 files changed, 138 insertions(+) create mode 100644 migrations/0025_invoice_simpany_sync.sql diff --git a/migrations/0025_invoice_simpany_sync.sql b/migrations/0025_invoice_simpany_sync.sql new file mode 100644 index 0000000..6100154 --- /dev/null +++ b/migrations/0025_invoice_simpany_sync.sql @@ -0,0 +1,93 @@ +-- 0025: 發票與 Simpany 的 API 同步 / 開立。 +-- +-- 0019 把 Simpany 當成「人工開票 + 上傳匯出檔對帳」的外部系統;0023 建好整合框架之後, +-- 改用 Simpany 會員網頁背後的(非公開)API 直接同步與開立(src/lib/integrations/simpany.ts、 +-- src/lib/simpany-sync.ts、src/lib/simpany-issue.ts)。xlsx 對帳頁保留,API 同步取代它。 +-- +-- 本 migration: +-- (1) invoices 補上 Simpany 發票的完整事實:課稅別、零稅率原因、外幣與匯率、B2B/B2C、 +-- Simpany 的 R… id、作廢時間與原因、買受人 email、訂閱期別綁定、最後同步時間。 +-- 這些欄位也就是 docs/export-vat-tracking-design.md §4 提議過的那組(twd_sales_amount +-- 不另開欄位:Simpany 開出的發票金額本來就是台幣銷售額,即 amount_gross)。 +-- (2) invoice_drafts:開立前的「預覽草稿」。MCP / web 先 preview 產生一筆草稿(內含要送給 +-- Simpany 的完整 request body),使用者明確確認後才以 draftId 開立 —— 開立只接受 +-- draftId,不接受任何其他內容,確保「看過的」就是「送出去的」。 +-- +-- 既有語意不變:external_ref = 發票號碼(對帳鍵),external_status = pending/issued/void/n_a。 +-- Forward-only,全部 additive。Run AFTER 0023_org_integrations.sql(0024 保留給同期的 Wise 分支)。 + +-- (1) invoices +ALTER TABLE invoices ADD COLUMN tax_treatment text NOT NULL DEFAULT 'taxable'; +ALTER TABLE invoices ADD CONSTRAINT chk_invoice_tax_treatment + CHECK (tax_treatment = ANY (ARRAY['taxable'::text, 'zero_rated'::text, 'exempt'::text])); + +-- Simpany 的零稅率原因代碼('71' 外銷貨物、'72' 外銷勞務 …);tax_treatment = zero_rated 時填 +ALTER TABLE invoices ADD COLUMN zero_rate_reason text; + +-- 外幣收款開零稅率發票時的換算依據:匯率(取自銀行水單)、外幣幣別與金額。 +-- amount_gross 仍是發票上的台幣金額(= round(foreign_amount × exchange_rate))。 +ALTER TABLE invoices ADD COLUMN exchange_rate numeric(12,6); +ALTER TABLE invoices ADD COLUMN foreign_currency text; +ALTER TABLE invoices ADD COLUMN foreign_amount numeric(14,2); + +ALTER TABLE invoices ADD COLUMN invoice_type text; +ALTER TABLE invoices ADD CONSTRAINT chk_invoice_type + CHECK (invoice_type IS NULL OR invoice_type = ANY (ARRAY['B2B'::text, 'B2C'::text])); + +-- Simpany 的發票 id('R260903181235059' 這種),API 取明細 / 作廢都要用它,不是發票號碼 +ALTER TABLE invoices ADD COLUMN external_id text; +ALTER TABLE invoices ADD COLUMN voided_at timestamptz; +ALTER TABLE invoices ADD COLUMN void_reason text; +ALTER TABLE invoices ADD COLUMN buyer_emails text[]; + +-- 訂閱期別綁定:訂閱期別不物化(見 0014 / 0017),所以只存 subscription_id + 期別起日, +-- 與 transactions.subscription_id / subscription_period 同一套。 +ALTER TABLE invoices ADD COLUMN subscription_id bigint REFERENCES subscriptions(id); +ALTER TABLE invoices ADD COLUMN subscription_period date; + +ALTER TABLE invoices ADD COLUMN external_synced_at timestamptz; + +CREATE UNIQUE INDEX uq_invoice_external_id ON invoices (organization_id, external_id) + WHERE external_id IS NOT NULL; +CREATE INDEX idx_invoice_subscription ON invoices (subscription_id, subscription_period) + WHERE subscription_id IS NOT NULL; + +COMMENT ON COLUMN invoices.tax_treatment IS '課稅別:taxable(應稅)/ zero_rated(零稅率)/ exempt(免稅)'; +COMMENT ON COLUMN invoices.zero_rate_reason IS 'Simpany 零稅率原因代碼(71 外銷貨物、72 外銷勞務 …)'; +COMMENT ON COLUMN invoices.exchange_rate IS '外幣換算台幣的匯率(取自銀行水單)'; +COMMENT ON COLUMN invoices.foreign_currency IS '外幣收款的幣別(USD 等)'; +COMMENT ON COLUMN invoices.foreign_amount IS '外幣金額;amount_gross = round(foreign_amount × exchange_rate)'; +COMMENT ON COLUMN invoices.invoice_type IS 'B2B(有統編)/ B2C'; +COMMENT ON COLUMN invoices.external_id IS 'Simpany 發票 id(R…),API 用;external_ref 仍是發票號碼'; +COMMENT ON COLUMN invoices.voided_at IS 'Simpany 作廢時間'; +COMMENT ON COLUMN invoices.void_reason IS 'Simpany 作廢原因'; +COMMENT ON COLUMN invoices.buyer_emails IS '開立通知寄送的買受人 email'; +COMMENT ON COLUMN invoices.subscription_id IS '這張發票對應的訂閱(與 subscription_period 一起用)'; +COMMENT ON COLUMN invoices.subscription_period IS '對應的訂閱期別起日'; +COMMENT ON COLUMN invoices.external_synced_at IS '最後一次從 Simpany API 同步此列的時間'; + +-- (2) invoice_drafts +CREATE TABLE invoice_drafts ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + organization_id text NOT NULL REFERENCES "organization"(id) ON DELETE CASCADE, + created_by_user_id text REFERENCES "user"(id) ON DELETE SET NULL, + -- { type: 'b2b' | 'b2c', body: <送給 Simpany 的 request body 原樣> } + payload jsonb NOT NULL, + -- 給人看的預覽:買受人、品項、未稅 / 稅額 / 總額、課稅別、警示、外幣換算 + summary jsonb NOT NULL DEFAULT '{}'::jsonb, + -- 開立後要回寫的綁定:transactionIds、billingItemId、subscriptionId + subscriptionPeriod、 + -- contractId、partyId + links jsonb NOT NULL DEFAULT '{}'::jsonb, + status text NOT NULL DEFAULT 'pending', + issued_invoice_id bigint REFERENCES invoices(id), + expires_at timestamptz NOT NULL DEFAULT (now() + interval '2 hours'), + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT chk_invoice_draft_status + CHECK (status = ANY (ARRAY['pending'::text, 'issued'::text, 'cancelled'::text, 'expired'::text])) +); + +CREATE INDEX idx_invoice_draft_org ON invoice_drafts (organization_id, created_at DESC); + +COMMENT ON TABLE invoice_drafts IS 'Simpany 開立前的預覽草稿;開立只接受 draft id(看過的 = 送出的),2 小時過期'; +COMMENT ON COLUMN invoice_drafts.payload IS '{ type, body }:送給 Simpany POST receipts/{type} 的原樣內容'; +COMMENT ON COLUMN invoice_drafts.status IS 'pending / issued / cancelled(送出失敗或結果不明)/ expired'; diff --git a/src/db/schema.ts b/src/db/schema.ts index ecad4d7..e40e837 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -55,6 +55,24 @@ export const invoices = pgTable("invoices", { // 來源,本系統只負責「該開什麼」與「開了沒」,差異用對帳頁呈現而非硬要同步。 externalStatus: text("external_status").default('pending').notNull(), externalRef: text("external_ref"), + // Simpany API 同步 / 開立(migrations/0025)。external_ref 仍是發票號碼;external_id 是 + // Simpany 的 R… id(取明細、作廢要用)。amount_gross 是發票上的台幣金額,外幣收款的 + // 換算依據另存 foreign_* 與 exchange_rate(水單匯率)。 + taxTreatment: text("tax_treatment").default('taxable').notNull(), + zeroRateReason: text("zero_rate_reason"), + exchangeRate: numeric("exchange_rate", { precision: 12, scale: 6 }), + foreignCurrency: text("foreign_currency"), + foreignAmount: numeric("foreign_amount", { precision: 14, scale: 2 }), + invoiceType: text("invoice_type"), + externalId: text("external_id"), + voidedAt: timestamp("voided_at", { withTimezone: true, mode: 'string' }), + voidReason: text("void_reason"), + buyerEmails: text("buyer_emails").array(), + // 訂閱期別綁定(期別不物化,同 transactions.subscription_id / subscription_period)。 + // FK 在 DB 端建立,這裡只放欄位避免與 subscriptions 的宣告順序衝突。 + subscriptionId: bigint("subscription_id", { mode: "number" }), + subscriptionPeriod: date("subscription_period"), + externalSyncedAt: timestamp("external_synced_at", { withTimezone: true, mode: 'string' }), }, (table) => [ index("idx_invoice_party").using("btree", table.partyId.asc().nullsLast().op("int8_ops")), index("idx_invoice_billing_item").using("btree", table.billingItemId.asc().nullsLast().op("int8_ops")), @@ -62,6 +80,10 @@ export const invoices = pgTable("invoices", { check("chk_invoice_direction", sql`direction = ANY (ARRAY['issued'::text, 'received'::text])`), check("chk_invoice_status", sql`status = ANY (ARRAY['valid'::text, 'void'::text, 'allowance'::text])`), check("chk_invoice_external_status", sql`external_status = ANY (ARRAY['pending'::text, 'issued'::text, 'void'::text, 'n_a'::text])`), + check("chk_invoice_tax_treatment", sql`tax_treatment = ANY (ARRAY['taxable'::text, 'zero_rated'::text, 'exempt'::text])`), + check("chk_invoice_type", sql`invoice_type IS NULL OR invoice_type = ANY (ARRAY['B2B'::text, 'B2C'::text])`), + uniqueIndex("uq_invoice_external_id").on(table.organizationId, table.externalId).where(sql`external_id IS NOT NULL`), + index("idx_invoice_subscription").using("btree", table.subscriptionId.asc().nullsLast().op("int8_ops"), table.subscriptionPeriod.asc().nullsLast().op("date_ops")).where(sql`subscription_id IS NOT NULL`), ]); export const employees = pgTable("employees", { @@ -619,3 +641,26 @@ export const orgIntegrations = pgTable("org_integrations", { check("chk_org_integration_provider", sql`provider = ANY (ARRAY['simpany'::text, 'wise'::text])`), check("chk_org_integration_status", sql`status = ANY (ARRAY['connected'::text, 'needs_reauth'::text, 'error'::text])`), ]); + +// ---- Simpany 開立前的預覽草稿(migrations/0025)。preview 寫一列,issue 只收 draft id, +// 確保「使用者看過的」就是「送出去的」。2 小時過期。---- +export const invoiceDrafts = pgTable("invoice_drafts", { + id: bigint({ mode: "number" }).primaryKey().generatedAlwaysAsIdentity({ name: "invoice_drafts_id_seq", startWith: 1, increment: 1, minValue: 1, cache: 1 }), + organizationId: text("organization_id").notNull(), + createdByUserId: text("created_by_user_id"), + payload: jsonb().$type>().notNull(), + summary: jsonb().$type>().default({}).notNull(), + links: jsonb().$type>().default({}).notNull(), + status: text().default('pending').notNull(), + issuedInvoiceId: bigint("issued_invoice_id", { mode: "number" }), + expiresAt: timestamp("expires_at", { withTimezone: true, mode: 'string' }).default(sql`(now() + '02:00:00'::interval)`).notNull(), + createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), +}, (table) => [ + index("idx_invoice_draft_org").using("btree", table.organizationId.asc().nullsLast().op("text_ops"), table.createdAt.desc().nullsFirst().op("timestamptz_ops")), + foreignKey({ + columns: [table.issuedInvoiceId], + foreignColumns: [invoices.id], + name: "invoice_drafts_issued_invoice_id_fkey" + }), + check("chk_invoice_draft_status", sql`status = ANY (ARRAY['pending'::text, 'issued'::text, 'cancelled'::text, 'expired'::text])`), +]); From 1407d404ce51fe3c0b27ca1a31af94ff4bfa4bf2 Mon Sep 17 00:00:00 2001 From: YJack0000 Date: Thu, 24 Sep 2026 17:36:43 +0800 Subject: [PATCH 16/27] =?UTF-8?q?[feature]=20Simpany=20=E6=95=B4=E5=90=88?= =?UTF-8?q?=EF=BC=9Aprovider=20=E8=88=87=20SimpanyClient=EF=BC=88=E7=99=BB?= =?UTF-8?q?=E5=85=A5=E3=80=81token=20=E5=BF=AB=E5=8F=96=E3=80=81=E8=87=AA?= =?UTF-8?q?=E5=8B=95=E9=87=8D=E6=96=B0=E7=99=BB=E5=85=A5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/i18n/messages/integrations.ts | 1 + src/lib/integrations/catalog.ts | 5 + src/lib/integrations/registry.ts | 7 +- src/lib/integrations/simpany.ts | 749 ++++++++++++++++++++++++++++++ 4 files changed, 760 insertions(+), 2 deletions(-) create mode 100644 src/lib/integrations/simpany.ts diff --git a/src/i18n/messages/integrations.ts b/src/i18n/messages/integrations.ts index 6a34cbe..0809514 100644 --- a/src/i18n/messages/integrations.ts +++ b/src/i18n/messages/integrations.ts @@ -43,6 +43,7 @@ const integrations = { account: { "zh-TW": "帳號(Email)", en: "Account (email)" }, password: { "zh-TW": "密碼", en: "Password" }, apiToken: { "zh-TW": "API Token", en: "API token" }, + companyId: { "zh-TW": "公司 ID(選填,帳號有多家公司時才需要)", en: "Company ID (optional; only if the account has several companies)" }, }, status: { notConnected: { "zh-TW": "未連接", en: "Not connected" }, diff --git a/src/lib/integrations/catalog.ts b/src/lib/integrations/catalog.ts index 95c6af2..33fde06 100644 --- a/src/lib/integrations/catalog.ts +++ b/src/lib/integrations/catalog.ts @@ -18,6 +18,11 @@ export const INTEGRATION_CATALOG: Record.ts 寫一個 IntegrationProvider: @@ -28,7 +29,9 @@ import type { IntegrationProvider, IntegrationProviderId } from "./types"; * 顯示用的資料(名稱、欄位、logo)不在這裡,在 catalog.ts 與 i18n。 * ───────────────────────────────────────────────────────────────────── */ -const PROVIDERS: Partial> = {}; +const PROVIDERS: Partial> = { + simpany: simpanyProvider, +}; /** 取實作;還沒實作的回 null(設定頁據此停用「連接」)。 */ export function getProvider(id: IntegrationProviderId): IntegrationProvider | null { diff --git a/src/lib/integrations/simpany.ts b/src/lib/integrations/simpany.ts new file mode 100644 index 0000000..14385df --- /dev/null +++ b/src/lib/integrations/simpany.ts @@ -0,0 +1,749 @@ +import { + clearTokenCache, + loadTokenCache, + markNeedsReauth, + recordSyncFailure, + recordSyncSuccess, + requireEnabledIntegration, + saveTokenCache, + updateConfig, +} from "./store"; +import type { + IntegrationConfig, + IntegrationCredentials, + IntegrationProvider, + TokenCache, +} from "./types"; + +/** + * Simpany(simpany.co)電子發票加值中心。 + * + * ⚠️ Simpany 沒有公開 API。這裡用的是它會員網頁背後的私有 REST API(讀前端 bundle + * 與實際唯讀呼叫確認過形狀),隨時可能改版。因此: + * - 所有回應都當成 unknown 防禦式解析,認不得就把 Simpany 的原始錯誤訊息(截短)丟回去, + * 不猜。 + * - 帳密(account / password)與 JWT 只存在 server 記憶體,不寫 log、不進錯誤訊息、 + * 不進任何回傳值。 + * + * 兩個 host: + * - api.simpany.co/v1 登入、/me(使用者與公司清單) + * - member2.simpany.co/api/v1/c/{companyId}/ 電子發票(receipts) + */ + +const AUTH_BASE = "https://api.simpany.co/v1"; +const EINVOICE_BASE = "https://member2.simpany.co/api/v1/c"; + +/** 取不到 JWT exp 時的保守效期。 */ +const FALLBACK_TOKEN_TTL_MS = 24 * 60 * 60 * 1000; + +const BASE_HEADERS: Record = { + Accept: "application/json", + "X-Requested-With": "XMLHttpRequest", +}; + +// --------------------------------------------------------------------------- +// Types (only the fields we rely on; everything else passes through as unknown) +// --------------------------------------------------------------------------- + +export type SimpanyCompany = { id: number; name: string; regId: string | null }; + +export type SimpanyReceiptType = "B2B" | "B2C"; +export type SimpanyTaxType = "TAXABLE" | "ZERO_TAX_RATE" | "EXEMPTION"; + +export type SimpanyReceiptListItem = { + id: string; + invoiceNumber: string | null; + type: string; + status: string; + buyerVat: string | null; + buyerName: string | null; + buyerAddress: string | null; + totalAmount: number; + issuedAt: string | null; + invalidatedAt: string | null; + invalidReason: string | null; + /** Simpany 說這張現在能不能作廢;回應沒有這個欄位時為 null。 */ + canInvalidate: boolean | null; + allowances: unknown[]; +}; + +export type SimpanyReceiptItem = { + name: string; + quantity: number; + price: number; + amount: number; +}; + +export type SimpanyReceiptDetail = SimpanyReceiptListItem & { + uploadStatus: string | null; + printStatus: string | null; + randomNumber: string | null; + buyerEmails: string[]; + taxType: string | null; + customsClearanceType: string | null; + zeroTaxRateReason: { code: string; name: string } | null; + taxRate: number | null; + isTaxIncluded: boolean | null; + taxAmount: number; + untaxedAmount: number; + remark: string | null; + carrierType: string | null; + items: SimpanyReceiptItem[]; +}; + +export type SimpanyListParams = { + status: "ALL" | "INVALID"; + startDate: string; + endDate: string; + query?: string; + page?: number; + limit?: number; +}; + +export type SimpanyPage = { + data: T[]; + currentPage: number; + lastPage: number; + total: number; +}; + +export type SimpanyZeroTaxReason = { code: string; name: string }; + +/** POST receipts/{b2b|b2c} 的 body,照 Simpany 會員網頁組的樣子。 */ +export type SimpanyCreateBody = { + customId: null; + customer: { vat?: string; name: string; address: string; emails: string[] }; + taxType: SimpanyTaxType; + customsClearanceType: "NOT_VIA_CUSTOMS" | "VIA_CUSTOMS" | null; + remark: string; + isTaxIncluded: boolean; + shouldAdjustTaxAmount: false; + carrier: { type: string | null; number: string | null }; + npoBan: null; + items: { name: string; quantity: number; price: number; subTotal: number }[]; + autocompleteSelectedIsVender: false; + zeroTaxRateReasonCode: string | null; +}; + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +export type SimpanyErrorKind = "auth" | "validation" | "business" | "http" | "network" | "config"; + +/** 給人看的錯誤。message 永遠不含帳密或 token。 */ +export class SimpanyError extends Error { + constructor( + readonly kind: SimpanyErrorKind, + message: string, + readonly status: number | null = null, + ) { + super(message); + this.name = "SimpanyError"; + } +} + +// --------------------------------------------------------------------------- +// Parsing helpers +// --------------------------------------------------------------------------- + +function isObj(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +function str(v: unknown): string | null { + if (typeof v === "string") return v; + if (typeof v === "number" && Number.isFinite(v)) return String(v); + return null; +} + +function num(v: unknown): number { + if (typeof v === "number" && Number.isFinite(v)) return v; + if (typeof v === "string" && v.trim() !== "" && Number.isFinite(Number(v))) return Number(v); + return 0; +} + +function numOrNull(v: unknown): number | null { + if (typeof v === "number") return Number.isFinite(v) ? v : null; + if (typeof v === "string" && v.trim() !== "" && Number.isFinite(Number(v))) return Number(v); + return null; +} + +function bool(v: unknown): boolean | null { + return typeof v === "boolean" ? v : null; +} + +/** 回應 body 截短後的字串,錯誤訊息用。 */ +function snippet(body: unknown): string { + let s: string; + try { + s = typeof body === "string" ? body : JSON.stringify(body); + } catch { + s = String(body); + } + s = s.replace(/\s+/g, " ").trim(); + return s.length > 400 ? `${s.slice(0, 399)}…` : s; +} + +/** 從 Simpany 的各種錯誤形狀裡抽出人看得懂的訊息。 */ +export function simpanyErrorMessage(body: unknown): string | null { + if (!isObj(body)) return typeof body === "string" && body.trim() ? snippet(body) : null; + // 驗證錯誤:{ errors: { field: [msg] } } + if (isObj(body.errors)) { + const parts: string[] = []; + for (const [field, msgs] of Object.entries(body.errors)) { + const list = Array.isArray(msgs) ? msgs.map((m) => str(m) ?? snippet(m)) : [snippet(msgs)]; + parts.push(`${field}: ${list.join("、")}`); + } + if (parts.length) return parts.join(";"); + } + // 業務錯誤:{ status: "error", error: { title, details } } / { error: { code } } + if (isObj(body.error)) { + const e = body.error; + const title = str(e.title) ?? str(e.message); + const details = str(e.details) ?? (e.details === undefined ? null : snippet(e.details)); + const code = str(e.code); + const text = [title, details].filter(Boolean).join(":"); + if (text) return code ? `${text}(${code})` : text; + if (code) return `錯誤代碼 ${code}`; + } + const message = str(body.message); + if (message) return message; + return snippet(body); +} + +/** JWT 的 exp(秒)→ Date;解不出來回 null。只讀 payload,不驗簽(那是 Simpany 的事)。 */ +export function jwtExpiry(token: string): Date | null { + const parts = token.split("."); + if (parts.length < 2) return null; + try { + let b64 = parts[1].replaceAll("-", "+").replaceAll("_", "/"); + while (b64.length % 4) b64 += "="; + const payload: unknown = JSON.parse(atob(b64)); + if (isObj(payload) && typeof payload.exp === "number") { + const d = new Date(payload.exp * 1000); + return Number.isNaN(d.getTime()) ? null : d; + } + } catch { + // fall through + } + return null; +} + +function parseCompany(v: unknown): SimpanyCompany | null { + if (!isObj(v)) return null; + const id = numOrNull(v.id); + if (id == null) return null; + return { id, name: str(v.name) ?? String(id), regId: str(v.reg_id) ?? str(v.regId) }; +} + +export function parseListItem(v: unknown): SimpanyReceiptListItem | null { + if (!isObj(v)) return null; + const id = str(v.id); + if (!id) return null; + return { + id, + invoiceNumber: str(v.invoiceNumber), + type: str(v.type) ?? "", + status: str(v.status) ?? "", + buyerVat: str(v.buyerVat), + buyerName: str(v.buyerName), + buyerAddress: str(v.buyerAddress), + totalAmount: num(v.totalAmount), + issuedAt: str(v.issuedAt), + invalidatedAt: str(v.invalidatedAt), + invalidReason: str(v.invalidReason), + canInvalidate: bool(v.canInvalidate), + allowances: Array.isArray(v.allowances) ? v.allowances : [], + }; +} + +export function parseDetail(v: unknown): SimpanyReceiptDetail | null { + const base = parseListItem(v); + if (!base || !isObj(v)) return null; + const reason = isObj(v.zeroTaxRateReason) + ? { + code: str(v.zeroTaxRateReason.code) ?? "", + name: str(v.zeroTaxRateReason.name) ?? "", + } + : null; + const items: SimpanyReceiptItem[] = Array.isArray(v.items) + ? v.items.filter(isObj).map((it) => ({ + name: str(it.name) ?? "", + quantity: num(it.quantity), + price: num(it.price), + amount: num(it.amount), + })) + : []; + return { + ...base, + uploadStatus: str(v.uploadStatus), + printStatus: str(v.printStatus), + randomNumber: str(v.randomNumber), + buyerEmails: Array.isArray(v.buyerEmails) + ? v.buyerEmails.map((e) => str(e)).filter((e): e is string => Boolean(e)) + : [], + taxType: str(v.taxType), + customsClearanceType: str(v.customsClearanceType), + zeroTaxRateReason: reason?.code ? reason : null, + taxRate: numOrNull(v.taxRate), + isTaxIncluded: bool(v.isTaxIncluded), + taxAmount: num(v.taxAmount), + untaxedAmount: num(v.untaxedAmount), + remark: str(v.remark), + carrierType: str(v.carrierType), + items, + }; +} + +/** `{ data: {...} }` 或直接是物件,兩種都接受。 */ +function unwrapData(body: unknown): unknown { + return isObj(body) && "data" in body ? body.data : body; +} + +// --------------------------------------------------------------------------- +// Raw HTTP (no DB side effects) — shared by testConnection and the client +// --------------------------------------------------------------------------- + +async function readBody(res: Response): Promise { + const text = await res.text(); + if (!text) return null; + try { + return JSON.parse(text); + } catch { + return text; + } +} + +async function safeFetch(url: string, init: RequestInit): Promise { + try { + return await fetch(url, init); + } catch (e) { + // 網路層錯誤的訊息不會含 headers,但保險起見只取 message。 + const msg = e instanceof Error ? e.message : String(e); + throw new SimpanyError("network", `無法連線到 Simpany:${msg}`); + } +} + +/** 登入換 JWT。帳密錯誤丟 kind = "auth"。 */ +async function login(creds: IntegrationCredentials): Promise { + const account = creds.account?.trim(); + const password = creds.password; + if (!account || !password) { + throw new SimpanyError("auth", "Simpany 帳號或密碼未設定"); + } + const res = await safeFetch(`${AUTH_BASE}/login`, { + method: "POST", + headers: { ...BASE_HEADERS, "Content-Type": "application/json" }, + body: JSON.stringify({ account, password }), + }); + const body = await readBody(res); + const token = isObj(body) && isObj(body.data) ? str(body.data.token) : null; + if (res.ok && token && isObj(body) && (body.status === "ok" || body.status === undefined)) { + const expiresAt = jwtExpiry(token) ?? new Date(Date.now() + FALLBACK_TOKEN_TTL_MS); + return { value: token, expiresAt }; + } + const code = + isObj(body) && isObj(body.error) ? numOrNull(body.error.code) : null; + if (res.status === 401 || code === 401 || code === 404) { + throw new SimpanyError("auth", "Simpany 帳號或密碼錯誤", 401); + } + if (res.status >= 500) { + throw new SimpanyError("http", `Simpany 登入失敗(HTTP ${res.status})`, res.status); + } + throw new SimpanyError( + "http", + `Simpany 登入失敗:${simpanyErrorMessage(body) ?? `HTTP ${res.status}`}`, + res.status, + ); +} + +async function fetchCompanies(token: string): Promise { + const res = await safeFetch(`${AUTH_BASE}/me`, { + headers: { ...BASE_HEADERS, Authorization: `Bearer ${token}` }, + }); + const body = await readBody(res); + if (res.status === 401) throw new SimpanyError("auth", "Simpany 登入已失效", 401); + if (!res.ok) { + throw new SimpanyError( + "http", + `讀取 Simpany 公司清單失敗:${simpanyErrorMessage(body) ?? `HTTP ${res.status}`}`, + res.status, + ); + } + const data = unwrapData(body); + const companies = isObj(data) && Array.isArray(data.companies) ? data.companies : []; + return companies.map(parseCompany).filter((c): c is SimpanyCompany => c !== null); +} + +/** + * 決定要用哪一家公司。有指定 companyId 就驗證它在清單內;只有一家就自動選; + * 多家且沒指定就要求使用者填。 + */ +function resolveCompany( + companies: SimpanyCompany[], + wanted: unknown, +): { ok: true; company: SimpanyCompany } | { ok: false; error: string } { + if (companies.length === 0) { + return { ok: false, error: "這個 Simpany 帳號底下沒有任何公司" }; + } + const wantedId = numOrNull(typeof wanted === "string" ? wanted.trim() : wanted); + if (wantedId != null) { + const hit = companies.find((c) => c.id === wantedId); + if (hit) return { ok: true, company: hit }; + return { + ok: false, + error: `找不到公司 ID ${wantedId}。這個帳號可用的公司:${companies + .map((c) => `${c.name}(${c.id})`) + .join("、")}`, + }; + } + if (companies.length === 1) return { ok: true, company: companies[0] }; + return { + ok: false, + error: `這個 Simpany 帳號有多家公司,請在「公司 ID」欄位填入要使用的那一家:${companies + .map((c) => `${c.name}(${c.id})`) + .join("、")}`, + }; +} + +// --------------------------------------------------------------------------- +// Provider (settings › integrations: connect / reconnect) +// --------------------------------------------------------------------------- + +export const simpanyProvider: IntegrationProvider = { + id: "simpany", + async testConnection(creds, config) { + let token: TokenCache; + try { + token = await login(creds); + } catch (e) { + if (e instanceof SimpanyError && e.kind === "auth") return { ok: false, error: e.message }; + throw e; + } + const companies = await fetchCompanies(token.value); + const resolved = resolveCompany(companies, config.companyId); + if (!resolved.ok) return { ok: false, error: resolved.error }; + return { + ok: true, + config: { companyId: resolved.company.id, companyName: resolved.company.name }, + tokenCache: token, + }; + }, +}; + +// --------------------------------------------------------------------------- +// Runtime client (business code) +// --------------------------------------------------------------------------- + +/** + * 用帳密重新登入並把新 token 加密存回快取。帳密被拒 → markNeedsReauth 並丟清楚的中文錯誤; + * 其他失敗 → recordSyncFailure 後原樣丟出。 + */ +async function loginAndCache(orgId: string, credentials: IntegrationCredentials): Promise { + try { + const fresh = await login(credentials); + await saveTokenCache(orgId, "simpany", fresh.value, fresh.expiresAt); + return fresh.value; + } catch (e) { + if (e instanceof SimpanyError && e.kind === "auth") { + await markNeedsReauth(orgId, "simpany", "Simpany 帳號或密碼已失效"); + throw new SimpanyError( + "auth", + "Simpany 拒絕了儲存的帳號密碼(可能改過密碼)。請 owner 或 admin 到 設定 › 整合 重新連接 Simpany。", + 401, + ); + } + const msg = e instanceof Error ? e.message : String(e); + await recordSyncFailure(orgId, "simpany", msg.slice(0, 500)); + throw e; + } +} + +type RequestOptions = { + method?: "GET" | "POST" | "DELETE"; + query?: Record; + body?: unknown; +}; + +/** + * 已登入、已選定公司的 Simpany client。用 getSimpanyClient(orgId) 取得。 + * + * Token 流程:先用快取的 JWT;收到 401 就丟掉快取、用帳密重新登入、重試一次; + * 重新登入本身被拒(帳密錯)→ markNeedsReauth,整合轉為「需要重新連接」。 + * 網路錯 / 5xx → recordSyncFailure(狀態不變)。成功 → recordSyncSuccess(每個 client 只記一次)。 + */ +export class SimpanyClient { + private token: string | null; + private successRecorded = false; + + constructor( + private readonly orgId: string, + private readonly credentials: IntegrationCredentials, + readonly companyId: number, + readonly companyName: string | null, + cached: TokenCache | null, + ) { + this.token = cached?.value ?? null; + } + + // ---- token ---- + + private async relogin(): Promise { + this.token = await loginAndCache(this.orgId, this.credentials); + return this.token; + } + + private async failure(e: unknown): Promise { + const msg = e instanceof Error ? e.message : String(e); + await recordSyncFailure(this.orgId, "simpany", msg.slice(0, 500)); + } + + private async success(): Promise { + if (this.successRecorded) return; + this.successRecorded = true; + await recordSyncSuccess(this.orgId, "simpany"); + } + + // ---- HTTP ---- + + private url(path: string, query?: RequestOptions["query"]): string { + const u = new URL(`${EINVOICE_BASE}/${this.companyId}/${path}`); + for (const [k, v] of Object.entries(query ?? {})) { + if (v !== undefined && v !== "") u.searchParams.set(k, String(v)); + } + return u.toString(); + } + + private async send(token: string, path: string, opts: RequestOptions): Promise { + const headers: Record = { ...BASE_HEADERS, Authorization: `Bearer ${token}` }; + if (opts.body !== undefined) headers["Content-Type"] = "application/json"; + return safeFetch(this.url(path, opts.query), { + method: opts.method ?? "GET", + headers, + body: opts.body === undefined ? undefined : JSON.stringify(opts.body), + }); + } + + /** 發一個 e-invoice API 請求,回傳解析後的 body。錯誤一律丟 SimpanyError。 */ + async request(path: string, opts: RequestOptions = {}): Promise { + let res: Response; + try { + const token = this.token ?? (await this.relogin()); + res = await this.send(token, path, opts); + if (res.status === 401) { + // 快取的 token 過期或被撤銷:重新登入、重試一次。 + await clearTokenCache(this.orgId, "simpany"); + this.token = null; + const fresh = await this.relogin(); + res = await this.send(fresh, path, opts); + if (res.status === 401) { + await markNeedsReauth(this.orgId, "simpany", "Simpany 拒絕了新登入的 token"); + throw new SimpanyError( + "auth", + "Simpany 重新登入後仍拒絕存取。請 owner 或 admin 到 設定 › 整合 重新連接 Simpany。", + 401, + ); + } + } + } catch (e) { + if (e instanceof SimpanyError && e.kind === "network") await this.failure(e); + throw e; + } + + const body = await readBody(res); + if (res.ok) { + // 業務錯誤有時仍是 2xx:{ status: "error", error: {...} } + if (isObj(body) && body.status === "error") { + throw new SimpanyError( + "business", + `Simpany 回應錯誤:${simpanyErrorMessage(body) ?? "未知錯誤"}`, + res.status, + ); + } + await this.success(); + return body; + } + if (res.status >= 500 || res.status === 429) { + const err = new SimpanyError( + "http", + `Simpany 暫時無法處理(HTTP ${res.status}):${simpanyErrorMessage(body) ?? "無訊息"}`, + res.status, + ); + await this.failure(err); + throw err; + } + const kind: SimpanyErrorKind = res.status === 422 || (isObj(body) && isObj(body.errors)) + ? "validation" + : "business"; + throw new SimpanyError( + kind, + `Simpany 拒絕了這個請求(HTTP ${res.status}):${simpanyErrorMessage(body) ?? "無訊息"}`, + res.status, + ); + } + + // ---- receipts ---- + + async listReceipts(params: SimpanyListParams): Promise> { + const body = await this.request("receipts", { + query: { + status: params.status, + startDate: params.startDate, + endDate: params.endDate, + page: params.page ?? 1, + limit: params.limit ?? 25, + query: params.query, + }, + }); + const data = isObj(body) && Array.isArray(body.data) ? body.data : []; + const meta = isObj(body) && isObj(body.meta) ? body.meta : {}; + return { + data: data.map(parseListItem).filter((r): r is SimpanyReceiptListItem => r !== null), + currentPage: num(meta.current_page) || params.page || 1, + lastPage: num(meta.last_page) || 1, + total: num(meta.total), + }; + } + + /** 走完所有分頁。maxPages 是保險絲(Workers 的 subrequest 上限)。 */ + async listAllReceipts( + params: Omit, + maxPages = 20, + ): Promise<{ items: SimpanyReceiptListItem[]; truncated: boolean }> { + const items: SimpanyReceiptListItem[] = []; + let page = 1; + for (;;) { + const res = await this.listReceipts({ ...params, page, limit: 100 }); + items.push(...res.data); + if (page >= res.lastPage || res.data.length === 0) return { items, truncated: false }; + if (page >= maxPages) return { items, truncated: true }; + page++; + } + } + + async getReceipt(id: string): Promise { + if (!/^[A-Za-z0-9_-]+$/.test(id)) throw new SimpanyError("config", `不合法的 Simpany 發票 id:${id}`); + const body = await this.request(`receipts/${encodeURIComponent(id)}`); + const detail = parseDetail(unwrapData(body)); + if (!detail) { + throw new SimpanyError("business", `Simpany 回傳的發票明細格式無法辨識:${snippet(body)}`); + } + return detail; + } + + /** + * 開立發票(POST receipts/{b2b|b2c})。**會產生正式的電子發票並上傳財政部、寄信給買受人。** + * 只能由 simpany-issue.ts 以使用者確認過的草稿呼叫。 + * 回傳 Simpany 的回應 data(形狀未經實測,呼叫端應再以 getReceipt 取完整明細)。 + */ + async createReceipt(type: SimpanyReceiptType, payload: SimpanyCreateBody): Promise { + const body = await this.request(`receipts/${type.toLowerCase()}`, { + method: "POST", + body: payload, + }); + return unwrapData(body); + } + + /** 作廢(DELETE receipts/{id})。不可復原,Simpany 會通知買受人。 */ + async invalidateReceipt(id: string, reason: string): Promise { + if (!/^[A-Za-z0-9_-]+$/.test(id)) throw new SimpanyError("config", `不合法的 Simpany 發票 id:${id}`); + const body = await this.request(`receipts/${encodeURIComponent(id)}`, { + method: "DELETE", + body: { reason, emails: [] }, + }); + return body; + } + + async getZeroTaxReasons(): Promise { + const body = await this.request("receipts/zero-tax-rate-reasons"); + const data = unwrapData(body); + const list = Array.isArray(data) ? data : []; + return list + .filter(isObj) + .map((r) => ({ code: str(r.code) ?? "", name: str(r.name) ?? "" })) + .filter((r) => r.code !== ""); + } + + /** + * 今年(民國年)字軌剩餘號碼數。回應形狀未經驗證 —— 解析不出來就回 null, + * 呼叫端只能拿來提示,不可據此擋開立。 + */ + async getRemainingTrackNumbers(date = new Date()): Promise { + const rocYear = Number( + new Intl.DateTimeFormat("en-US", { timeZone: "Asia/Taipei", year: "numeric" }).format(date), + ) - 1911; + try { + const body = await this.request("track-numbers", { query: { year: rocYear } }); + return sumRemaining(unwrapData(body)); + } catch { + return null; + } + } +} + +/** 在未知形狀裡找「剩餘」類欄位加總;找不到回 null。 */ +function sumRemaining(data: unknown): number | null { + const KEYS = ["remaining", "remainingCount", "remaining_count", "availableCount", "available", "unusedCount", "remain"]; + let found = false; + let total = 0; + const visit = (v: unknown, depth: number) => { + if (depth > 4) return; + if (Array.isArray(v)) { + for (const x of v) visit(x, depth + 1); + return; + } + if (!isObj(v)) return; + for (const k of KEYS) { + const n = numOrNull(v[k]); + if (n != null) { + found = true; + total += n; + return; + } + } + for (const x of Object.values(v)) if (typeof x === "object") visit(x, depth + 1); + }; + visit(data, 0); + return found ? total : null; +} + +/** + * 業務程式碼的入口:確認整合可用、決定公司、帶上快取 token。 + * 整合沒連接 / 沒開 / 需要重新連接時丟 IntegrationUnavailableError(訊息告訴使用者怎麼修)。 + */ +export async function getSimpanyClient(orgId: string): Promise { + const { row, credentials } = await requireEnabledIntegration(orgId, "simpany"); + let cached = await loadTokenCache(orgId, "simpany"); + const config: IntegrationConfig = row.config ?? {}; + const companyId = numOrNull(config.companyId); + const companyName = typeof config.companyName === "string" ? config.companyName : null; + if (companyId != null) { + return new SimpanyClient(orgId, credentials, companyId, companyName, cached); + } + + // 舊連接沒存公司:查一次 /me 並寫回 config。 + let companies: SimpanyCompany[]; + try { + const token = cached?.value ?? (await loginAndCache(orgId, credentials)); + companies = await fetchCompanies(token); + } catch (e) { + if (!(e instanceof SimpanyError && e.kind === "auth" && cached)) throw e; + await clearTokenCache(orgId, "simpany"); + cached = null; + companies = await fetchCompanies(await loginAndCache(orgId, credentials)); + } + const resolved = resolveCompany(companies, config.companyId); + if (!resolved.ok) throw new SimpanyError("config", resolved.error); + await updateConfig(orgId, "simpany", { + companyId: resolved.company.id, + companyName: resolved.company.name, + }); + return new SimpanyClient( + orgId, + credentials, + resolved.company.id, + resolved.company.name, + cached ?? (await loadTokenCache(orgId, "simpany")), + ); +} From 4387e18dca47e87dabfa312af437f316af81d927 Mon Sep 17 00:00:00 2001 From: YJack0000 Date: Thu, 24 Sep 2026 17:36:43 +0800 Subject: [PATCH 17/27] =?UTF-8?q?[feature]=20Simpany=20=E6=95=B4=E5=90=88?= =?UTF-8?q?=EF=BC=9A=E7=99=BC=E7=A5=A8=E5=90=8C=E6=AD=A5=E3=80=81=E8=87=AA?= =?UTF-8?q?=E5=8B=95=E7=B6=81=E5=AE=9A=E8=88=87=E9=A0=90=E8=A6=BD=20/=20?= =?UTF-8?q?=E9=96=8B=E7=AB=8B=20/=20=E4=BD=9C=E5=BB=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/simpany-issue.ts | 913 ++++++++++++++++++++++++++++++++++++ src/lib/simpany-sync.ts | 988 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 1901 insertions(+) create mode 100644 src/lib/simpany-issue.ts create mode 100644 src/lib/simpany-sync.ts diff --git a/src/lib/simpany-issue.ts b/src/lib/simpany-issue.ts new file mode 100644 index 0000000..d1e4951 --- /dev/null +++ b/src/lib/simpany-issue.ts @@ -0,0 +1,913 @@ +import { addDays, format, parseISO } from "date-fns"; +import { and, desc, eq, gt, isNotNull, isNull, ne, or, sql } from "drizzle-orm"; +import { getDb } from "@/db"; +import { + billingItems, + contracts, + invoiceDrafts, + invoices, + parties, + subscriptions, + transactions, +} from "@/db/schema"; +import { getSubscriptionSchedule } from "@/db/queries"; +import { isValidEmail } from "@/lib/pii"; +import { + getSimpanyClient, + parseDetail, + SimpanyError, + type SimpanyClient, + type SimpanyCreateBody, + type SimpanyReceiptDetail, + type SimpanyReceiptType, + type SimpanyZeroTaxReason, +} from "@/lib/integrations/simpany"; +import { + applyInvoiceLinks, + clearLinksForVoidedInvoice, + realVat, + simpanyTaxTypeOf, + taipeiDate, + upsertSimpanyReceipt, + type InvoiceLinks, + type TaxTreatment, + type VoidCleanup, +} from "@/lib/simpany-sync"; + +/** + * 在 Simpany 開立 / 作廢電子發票(migrations/0025)。MCP 工具(tools-simpany.ts)與 + * web 的 server actions(dashboard/invoices/simpany-actions.ts)共用這一支。 + * + * 開立一定是兩段式: + * 1. previewSimpanyInvoice —— 從本系統資料預填、驗證、算好金額,寫一筆 invoice_drafts + * (內含要送出的 request body 原樣),**不呼叫 Simpany 的開立 API**。 + * 2. issueSimpanyDraft(draftId) —— 使用者明確確認預覽後,才把那筆草稿原封不動送出。 + * 開立只接受 draftId,所以「使用者看過的」就是「送出去的」。 + * + * ⚠️ 開立會產生正式電子發票、上傳財政部並寄信給買受人;作廢不可復原。 + */ + +const DRAFT_TTL_MS = 2 * 60 * 60 * 1000; +const DUPLICATE_LOOKBACK_DAYS = 60; +const LOW_TRACK_NUMBERS = 20; +/** 財政部 MIG 作廢原因欄位上限。 */ +const VOID_REASON_MAX = 20; + +export const TAX_TREATMENT_LABEL: Record = { + taxable: "應稅", + zero_rated: "零稅率", + exempt: "免稅", +}; + +/** Simpany 拿不到原因清單時的後備(只列確定的兩個;其他代碼仍可用,但會警示未驗證)。 */ +export const KNOWN_ZERO_TAX_REASONS: SimpanyZeroTaxReason[] = [ + { code: "71", name: "外銷貨物" }, + { code: "72", name: "外銷勞務" }, +]; + +export class SimpanyPreviewError extends Error { + constructor(message: string) { + super(message); + this.name = "SimpanyPreviewError"; + } +} + +// --------------------------------------------------------------------------- +// Amounts +// --------------------------------------------------------------------------- + +export type InvoiceAmounts = { untaxed: number; tax: number; total: number }; + +/** + * Simpany 會員網頁的算法:應稅含稅 tax = round(sum − sum/1.05);應稅未稅 tax = round(sum × 0.05); + * 零稅率 / 免稅 tax = 0。 + */ +export function computeAmounts( + sum: number, + treatment: TaxTreatment, + isTaxIncluded: boolean, +): InvoiceAmounts { + if (treatment !== "taxable") return { untaxed: sum, tax: 0, total: sum }; + if (isTaxIncluded) { + const tax = Math.round(sum - sum / 1.05); + return { untaxed: sum - tax, tax, total: sum }; + } + const tax = Math.round(sum * 0.05); + return { untaxed: sum, tax, total: sum + tax }; +} + +function round2(n: number): number { + return Math.round(n * 100) / 100; +} + +// --------------------------------------------------------------------------- +// Preview +// --------------------------------------------------------------------------- + +export type PreviewItemInput = { name: string; quantity: number; price: number }; + +export type PreviewInput = { + transactionId?: number; + billingItemId?: number; + subscriptionId?: number; + subscriptionPeriod?: string; + type?: SimpanyReceiptType; + buyer?: { vat?: string | null; name?: string | null; address?: string | null; emails?: string[] | null }; + taxTreatment?: TaxTreatment; + zeroRateReason?: string; + customsClearance?: "NOT_VIA_CUSTOMS" | "VIA_CUSTOMS"; + items?: PreviewItemInput[]; + isTaxIncluded?: boolean; + remark?: string; + foreignCurrency?: string; + foreignAmount?: number; + exchangeRate?: number; +}; + +export type DraftLinks = InvoiceLinks & { + transactionIds: number[]; +}; + +export type ForeignInfo = { + currency: string; + amount: number; + exchangeRate: number; + twdAmount: number; +}; + +export type InvoicePreview = { + draftId: number; + expiresAt: string; + type: SimpanyReceiptType; + buyer: { vat: string | null; name: string; address: string; emails: string[] }; + taxTreatment: TaxTreatment; + taxTreatmentLabel: string; + zeroRateReason: SimpanyZeroTaxReason | null; + customsClearanceType: "NOT_VIA_CUSTOMS" | "VIA_CUSTOMS" | null; + isTaxIncluded: boolean; + items: { name: string; quantity: number; price: number; subTotal: number }[]; + amounts: InvoiceAmounts; + foreign: ForeignInfo | null; + remark: string; + links: DraftLinks; + warnings: string[]; + trackNumbersRemaining: number | null; + summary: string; +}; + +type Source = { + partyId: number | null; + amount: number | null; + currency: string; + itemName: string | null; +}; + +function extractEmails(text: string | null | undefined): string[] { + if (!text) return []; + const found = text.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi) ?? []; + return [...new Set(found.map((e) => e.toLowerCase()))].filter(isValidEmail); +} + +/** Simpany 的品名不能有半形冒號(他們的 UI 會換成全形)。 */ +export function sanitizeItemName(name: string): string { + return name.replaceAll(":", ":").replace(/\s+/g, " ").trim(); +} + +async function loadSource( + orgId: string, + input: PreviewInput, + links: DraftLinks, + warnings: string[], +): Promise { + const db = getDb(); + const src: Source = { partyId: null, amount: null, currency: "TWD", itemName: null }; + + if (input.transactionId != null) { + const [txn] = await db + .select({ + id: transactions.id, + type: transactions.type, + amount: transactions.amount, + currency: transactions.currency, + description: transactions.description, + partyId: transactions.partyId, + invoiceId: transactions.invoiceId, + billingItemId: transactions.billingItemId, + subscriptionId: transactions.subscriptionId, + subscriptionPeriod: transactions.subscriptionPeriod, + contractId: transactions.contractId, + }) + .from(transactions) + .where( + and( + eq(transactions.organizationId, orgId), + eq(transactions.id, input.transactionId), + isNull(transactions.deletedAt), + ), + ) + .limit(1); + if (!txn) throw new SimpanyPreviewError(`找不到交易 #${input.transactionId}`); + if (txn.type !== "income") throw new SimpanyPreviewError(`交易 #${txn.id} 不是收入,不能拿來開發票`); + if (txn.invoiceId != null) warnings.push(`交易 #${txn.id} 已經綁定發票 #${txn.invoiceId},可能重複開立`); + links.transactionIds.push(txn.id); + links.billingItemId ??= txn.billingItemId; + if (txn.subscriptionId != null && txn.subscriptionPeriod) { + links.subscriptionId ??= txn.subscriptionId; + links.subscriptionPeriod ??= txn.subscriptionPeriod; + } + links.contractId ??= txn.contractId; + src.partyId = txn.partyId; + src.amount = Number(txn.amount); + src.currency = txn.currency; + src.itemName = txn.description; + } + + const billingItemId = input.billingItemId ?? null; + if (billingItemId != null) { + const [it] = await db + .select({ + id: billingItems.id, + customerPartyId: billingItems.customerPartyId, + contractId: billingItems.contractId, + contractTitle: contracts.title, + title: billingItems.title, + amount: billingItems.amount, + currency: billingItems.currency, + invoicedOn: billingItems.invoicedOn, + }) + .from(billingItems) + .leftJoin(contracts, eq(contracts.id, billingItems.contractId)) + .where( + and( + eq(billingItems.organizationId, orgId), + eq(billingItems.id, billingItemId), + isNull(billingItems.deletedAt), + ), + ) + .limit(1); + if (!it) throw new SimpanyPreviewError(`找不到請款項目 #${billingItemId}`); + if (it.invoicedOn) warnings.push(`請款項目「${it.title}」已標記開發票日 ${it.invoicedOn},可能重複開立`); + links.billingItemId = it.id; + links.contractId ??= it.contractId; + src.partyId ??= it.customerPartyId; + // 請款項目的金額優先於交易(交易可能扣了手續費)。 + src.amount = Number(it.amount); + src.currency = it.currency; + src.itemName = it.contractTitle ? `${it.contractTitle} ${it.title}` : it.title; + } + + if (input.subscriptionId != null) { + if (!input.subscriptionPeriod || !/^\d{4}-\d{2}-\d{2}$/.test(input.subscriptionPeriod)) { + throw new SimpanyPreviewError("指定訂閱時要一併給 subscriptionPeriod(該期起日 YYYY-MM-DD)"); + } + const [sub] = await db + .select({ customerPartyId: subscriptions.customerPartyId, contractId: subscriptions.contractId }) + .from(subscriptions) + .where( + and( + eq(subscriptions.organizationId, orgId), + eq(subscriptions.id, input.subscriptionId), + isNull(subscriptions.deletedAt), + ), + ) + .limit(1); + if (!sub) throw new SimpanyPreviewError(`找不到訂閱 #${input.subscriptionId}`); + const schedule = await getSubscriptionSchedule(orgId, input.subscriptionId); + const period = schedule?.periods.find((p) => p.periodStart === input.subscriptionPeriod); + if (!schedule || !period) { + throw new SimpanyPreviewError( + `訂閱 #${input.subscriptionId} 沒有 ${input.subscriptionPeriod} 這一期(期別起日要對得上 get_subscription_schedule)`, + ); + } + if (period.invoicedOn) { + warnings.push(`訂閱這一期已標記開發票日 ${period.invoicedOn},可能重複開立`); + } + links.subscriptionId = input.subscriptionId; + links.subscriptionPeriod = input.subscriptionPeriod; + links.contractId ??= sub.contractId; + src.partyId ??= sub.customerPartyId; + src.amount = period.expected; + src.currency = schedule.currency; + src.itemName = `${schedule.name}(${period.periodLabel})`; + } + + links.partyId = src.partyId; + return src; +} + +async function duplicateWarnings( + orgId: string, + client: SimpanyClient, + links: DraftLinks, + buyer: { vat: string | null; name: string }, + total: number, +): Promise { + const db = getDb(); + const out: string[] = []; + const notVoid = and( + eq(invoices.organizationId, orgId), + isNull(invoices.deletedAt), + ne(invoices.status, "void"), + ); + if (links.billingItemId != null) { + const rows = await db + .select({ id: invoices.id, number: invoices.invoiceNumber }) + .from(invoices) + .where(and(notVoid, eq(invoices.billingItemId, links.billingItemId))); + for (const r of rows) out.push(`這個請款項目已經有發票 ${r.number ?? `#${r.id}`}`); + } + if (links.subscriptionId != null && links.subscriptionPeriod) { + const rows = await db + .select({ id: invoices.id, number: invoices.invoiceNumber }) + .from(invoices) + .where( + and( + notVoid, + eq(invoices.subscriptionId, links.subscriptionId), + eq(invoices.subscriptionPeriod, links.subscriptionPeriod), + ), + ); + for (const r of rows) out.push(`這個訂閱期別已經有發票 ${r.number ?? `#${r.id}`}`); + } + + const today = taipeiDate(); + const since = format(addDays(parseISO(today), -DUPLICATE_LOOKBACK_DAYS), "yyyy-MM-dd"); + if (links.partyId != null) { + const rows = await db + .select({ id: invoices.id, number: invoices.invoiceNumber, date: invoices.invoiceDate }) + .from(invoices) + .where( + and( + notVoid, + eq(invoices.direction, "issued"), + eq(invoices.partyId, links.partyId), + sql`${invoices.amountGross} = ${total}`, + sql`${invoices.invoiceDate} >= ${since}`, + ), + ); + for (const r of rows) { + out.push(`本系統 ${DUPLICATE_LOOKBACK_DAYS} 天內已有同客戶、同金額的發票 ${r.number ?? `#${r.id}`}(${r.date ?? "無日期"}),請確認不是重複開立`); + } + } + try { + const res = await client.listReceipts({ + status: "ALL", + startDate: since, + endDate: today, + query: buyer.vat ?? buyer.name, + limit: 50, + }); + for (const r of res.data) { + if (r.status.toUpperCase() === "INVALID") continue; + if (Math.abs(r.totalAmount - total) >= 0.005) continue; + const sameBuyer = buyer.vat ? realVat(r.buyerVat) === buyer.vat : r.buyerName?.trim() === buyer.name; + if (!sameBuyer) continue; + const msg = `Simpany ${DUPLICATE_LOOKBACK_DAYS} 天內已有同買受人、同金額的發票 ${r.invoiceNumber ?? r.id}(${r.issuedAt?.slice(0, 10) ?? "?"}),可能重複開立`; + if (!out.some((w) => w.includes(r.invoiceNumber ?? r.id))) out.push(msg); + } + } catch (e) { + out.push(`無法向 Simpany 檢查是否重複開立:${e instanceof Error ? e.message : String(e)}`); + } + return out; +} + +/** + * 產生開立預覽並存成草稿。會讀 Simpany(原因清單、字軌、重複檢查),**不會開立**。 + * 驗證失敗丟 SimpanyPreviewError(訊息給人看)。 + */ +export async function previewSimpanyInvoice( + orgId: string, + userId: string, + input: PreviewInput, + client?: SimpanyClient, +): Promise { + const simpany = client ?? (await getSimpanyClient(orgId)); + const db = getDb(); + const warnings: string[] = []; + const links: DraftLinks = { transactionIds: [] }; + const src = await loadSource(orgId, input, links, warnings); + + // ---- buyer ---- + let party: { name: string; taxId: string | null; contact: string | null } | null = null; + if (src.partyId != null) { + [party] = await db + .select({ name: parties.name, taxId: parties.taxId, contact: parties.contact }) + .from(parties) + .where(and(eq(parties.organizationId, orgId), eq(parties.id, src.partyId))) + .limit(1); + } + const vatInput = input.buyer?.vat; + const vatRaw = vatInput === undefined ? party?.taxId ?? null : vatInput; + const vatTrimmed = vatRaw?.trim() ? vatRaw.trim() : null; + const vat = realVat(vatTrimmed); + if (vatTrimmed && !vat) { + throw new SimpanyPreviewError(`統一編號「${vatTrimmed}」不是 8 碼數字`); + } + const buyerName = (input.buyer?.name ?? party?.name ?? "").trim(); + if (!buyerName) throw new SimpanyPreviewError("缺少買受人名稱(buyer.name)"); + const address = (input.buyer?.address ?? "").trim(); + const emails = (input.buyer?.emails ?? extractEmails(party?.contact)).map((e) => e.trim()).filter(Boolean); + for (const e of emails) { + if (!isValidEmail(e)) throw new SimpanyPreviewError(`Email 格式不正確:${e}`); + } + if (emails.length === 0) { + warnings.push("沒有買受人 email:Simpany 不會寄開立通知,請確認這樣可以"); + } + + // ---- foreign currency ---- + const srcForeign = src.currency.toUpperCase() !== "TWD" ? src.currency.toUpperCase() : null; + const foreignCurrency = (input.foreignCurrency?.trim().toUpperCase() || srcForeign) ?? null; + let foreign: ForeignInfo | null = null; + if (foreignCurrency && foreignCurrency !== "TWD") { + if (!/^[A-Z]{3}$/.test(foreignCurrency)) { + throw new SimpanyPreviewError(`幣別「${foreignCurrency}」不是 3 碼代號`); + } + const foreignAmount = + input.foreignAmount ?? (srcForeign === foreignCurrency ? src.amount ?? undefined : undefined); + if (foreignAmount == null || !(foreignAmount > 0)) { + throw new SimpanyPreviewError("外幣收款要提供外幣金額(foreignAmount)"); + } + const rate = input.exchangeRate; + if (rate == null || !(rate > 0)) { + throw new SimpanyPreviewError( + `這是 ${foreignCurrency} 收款:要提供匯率(exchangeRate),而且必須取自銀行的匯入匯款水單,不可自行估算。台幣銷售額 = round(外幣金額 × 匯率)。`, + ); + } + foreign = { + currency: foreignCurrency, + amount: foreignAmount, + exchangeRate: rate, + twdAmount: Math.round(foreignAmount * rate), + }; + } + const baseTwd = foreign ? foreign.twdAmount : src.amount; + + // ---- type / tax treatment ---- + const type: SimpanyReceiptType = input.type ?? (vat ? "B2B" : "B2C"); + if (type === "B2B" && !vat) { + throw new SimpanyPreviewError("B2B 發票需要 8 碼統一編號;海外買方沒有台灣統編時請開 B2C(零稅率)"); + } + if (type === "B2C" && vat) { + throw new SimpanyPreviewError(`B2C 發票不帶統編;買方有統編 ${vat} 就應開 B2B`); + } + const taxTreatment: TaxTreatment = + input.taxTreatment ?? (foreign && !vat ? "zero_rated" : "taxable"); + if (foreign && taxTreatment === "taxable") { + warnings.push("這是外幣收款:外銷勞務通常應開零稅率(原因 72、非經海關),確認真的要開應稅?"); + } + if (taxTreatment === "exempt") { + warnings.push("免稅只適用法定免稅項目;外銷勞務應開「零稅率 72」而不是免稅(FW10873800 就是這樣開錯而作廢的)"); + } + if (taxTreatment !== "zero_rated" && input.zeroRateReason) { + throw new SimpanyPreviewError("只有零稅率發票才能指定零稅率原因"); + } + + let zeroRateReason: SimpanyZeroTaxReason | null = null; + let customsClearanceType: InvoicePreview["customsClearanceType"] = null; + if (taxTreatment === "zero_rated") { + if (foreign == null && !input.items && src.currency.toUpperCase() !== "TWD") { + throw new SimpanyPreviewError("零稅率外幣收款需要匯率(exchangeRate,取自水單)"); + } + const code = (input.zeroRateReason ?? "72").trim(); + let reasons: SimpanyZeroTaxReason[] = []; + try { + reasons = await simpany.getZeroTaxReasons(); + } catch { + reasons = []; + } + if (reasons.length) { + const hit = reasons.find((r) => r.code === code); + if (!hit) { + throw new SimpanyPreviewError( + `零稅率原因「${code}」不在 Simpany 的清單內:${reasons.map((r) => `${r.code} ${r.name}`).join("、")}`, + ); + } + zeroRateReason = hit; + } else { + if (!/^7\d$/.test(code)) throw new SimpanyPreviewError(`零稅率原因「${code}」格式不正確(應為 71–79)`); + zeroRateReason = KNOWN_ZERO_TAX_REASONS.find((r) => r.code === code) ?? { code, name: "" }; + warnings.push("無法從 Simpany 取得零稅率原因清單,原因代碼未經驗證"); + } + customsClearanceType = input.customsClearance ?? "NOT_VIA_CUSTOMS"; + if (code === "72" && customsClearanceType !== "NOT_VIA_CUSTOMS") { + warnings.push("外銷勞務(72)通常是「非經海關出口」(NOT_VIA_CUSTOMS)"); + } + } + + // ---- items ---- + const rawItems: PreviewItemInput[] = + input.items && input.items.length > 0 + ? input.items + : baseTwd != null + ? [{ name: src.itemName ?? "服務費", quantity: 1, price: baseTwd }] + : []; + if (rawItems.length === 0) { + throw new SimpanyPreviewError("沒有品項也沒有可預填的金額:請給 items,或指定 transactionId / billingItemId / subscriptionId"); + } + const items = rawItems.map((it, i) => { + const name = sanitizeItemName(String(it.name ?? "")); + if (!name) throw new SimpanyPreviewError(`第 ${i + 1} 個品項沒有品名`); + if (name.length > 256) throw new SimpanyPreviewError(`第 ${i + 1} 個品項品名過長`); + const quantity = Number(it.quantity); + const price = Number(it.price); + if (!(quantity > 0)) throw new SimpanyPreviewError(`第 ${i + 1} 個品項數量要大於 0`); + if (!(price > 0)) throw new SimpanyPreviewError(`第 ${i + 1} 個品項單價要大於 0`); + return { name, quantity, price, subTotal: round2(quantity * price) }; + }); + const sum = round2(items.reduce((s, it) => s + it.subTotal, 0)); + if (!Number.isInteger(sum)) { + throw new SimpanyPreviewError(`品項合計 ${sum} 不是整數台幣;發票金額必須是整數`); + } + if (foreign && input.items && sum !== foreign.twdAmount) { + warnings.push(`品項合計 ${sum} 與外幣換算的台幣銷售額 ${foreign.twdAmount} 不一致`); + } + const isTaxIncluded = input.isTaxIncluded ?? true; + const amounts = computeAmounts(sum, taxTreatment, isTaxIncluded); + if (!(amounts.total > 0)) throw new SimpanyPreviewError("發票總額必須大於 0"); + + // ---- checks against Simpany / internal ---- + warnings.push(...(await duplicateWarnings(orgId, simpany, links, { vat, name: buyerName }, amounts.total))); + const trackNumbersRemaining = await simpany.getRemainingTrackNumbers(); + if (trackNumbersRemaining != null && trackNumbersRemaining < LOW_TRACK_NUMBERS) { + warnings.push(`字軌剩餘號碼只剩 ${trackNumbersRemaining} 個`); + } + + const remark = (input.remark ?? "").trim(); + const body: SimpanyCreateBody = { + customId: null, + customer: + type === "B2B" + ? { vat: vat as string, name: buyerName, address, emails } + : { name: buyerName, address, emails }, + taxType: simpanyTaxTypeOf(taxTreatment), + customsClearanceType, + remark, + isTaxIncluded, + shouldAdjustTaxAmount: false, + carrier: { type: type === "B2C" ? "MEMBERSHIP" : null, number: null }, + npoBan: null, + items, + autocompleteSelectedIsVender: false, + zeroTaxRateReasonCode: zeroRateReason?.code ?? null, + }; + + const summaryLine = [ + `${type} ${buyerName}${vat ? `(${vat})` : ""}`, + `${TAX_TREATMENT_LABEL[taxTreatment]}${zeroRateReason ? ` ${zeroRateReason.code}${zeroRateReason.name ? ` ${zeroRateReason.name}` : ""}` : ""}`, + `未稅 ${amounts.untaxed} + 稅 ${amounts.tax} = 總計 NT$${amounts.total}`, + foreign ? `${foreign.currency} ${foreign.amount} × ${foreign.exchangeRate}` : null, + `${items.length} 個品項`, + ] + .filter(Boolean) + .join("|"); + + const expiresAt = new Date(Date.now() + DRAFT_TTL_MS).toISOString(); + const preview: Omit = { + expiresAt, + type, + buyer: { vat, name: buyerName, address, emails }, + taxTreatment, + taxTreatmentLabel: TAX_TREATMENT_LABEL[taxTreatment], + zeroRateReason, + customsClearanceType, + isTaxIncluded, + items, + amounts, + foreign, + remark, + links, + warnings, + trackNumbersRemaining, + summary: summaryLine, + }; + const [draft] = await db + .insert(invoiceDrafts) + .values({ + organizationId: orgId, + createdByUserId: userId, + payload: { type, body }, + summary: preview as unknown as Record, + links: links as unknown as Record, + status: "pending", + expiresAt, + }) + .returning({ id: invoiceDrafts.id }); + return { draftId: draft.id, ...preview }; +} + +// --------------------------------------------------------------------------- +// Issue +// --------------------------------------------------------------------------- + +export type IssueResult = { + invoiceId: number; + invoiceNumber: string | null; + externalId: string; + type: string; + buyer: string | null; + total: number; + uploadStatus: string | null; + issuedAt: string | null; + draftId: number; +}; + +function isObj(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +async function explainUnavailableDraft(orgId: string, draftId: number): Promise { + const [d] = await getDb() + .select({ + status: invoiceDrafts.status, + expiresAt: invoiceDrafts.expiresAt, + issuedInvoiceId: invoiceDrafts.issuedInvoiceId, + }) + .from(invoiceDrafts) + .where(and(eq(invoiceDrafts.organizationId, orgId), eq(invoiceDrafts.id, draftId))) + .limit(1); + if (!d) throw new SimpanyPreviewError(`找不到草稿 #${draftId}`); + if (d.status === "issued") { + throw new SimpanyPreviewError( + `草稿 #${draftId} 已經開立過了${d.issuedInvoiceId ? `(發票 #${d.issuedInvoiceId})` : ""},不會重複開立`, + ); + } + if (d.status === "cancelled") { + throw new SimpanyPreviewError(`草稿 #${draftId} 已取消(先前送出失敗或結果不明),請重新預覽`); + } + if (d.status === "pending" && new Date(d.expiresAt).getTime() <= Date.now()) { + await getDb() + .update(invoiceDrafts) + .set({ status: "expired" }) + .where(and(eq(invoiceDrafts.id, draftId), eq(invoiceDrafts.status, "pending"))); + } + throw new SimpanyPreviewError(`草稿 #${draftId} 已過期(2 小時),請重新預覽`); +} + +async function markDraft( + draftId: number, + status: "pending" | "cancelled", + note?: string, +): Promise { + await getDb() + .update(invoiceDrafts) + .set({ + status, + ...(note + ? { summary: sql`${invoiceDrafts.summary} || ${JSON.stringify({ lastError: note })}::jsonb` } + : {}), + }) + .where(eq(invoiceDrafts.id, draftId)); +} + +/** 開立成功但回應沒有 id 時的後備:今天同買受人、同金額、最新的那張。 */ +async function findJustIssued( + client: SimpanyClient, + body: SimpanyCreateBody, + total: number, +): Promise { + const today = taipeiDate(); + const res = await client.listReceipts({ + status: "ALL", + startDate: today, + endDate: today, + query: body.customer.vat ?? body.customer.name, + limit: 25, + }); + const hits = res.data + .filter((r) => Math.abs(r.totalAmount - total) < 0.005 && r.status.toUpperCase() !== "INVALID") + .sort((a, b) => (b.issuedAt ?? "").localeCompare(a.issuedAt ?? "")); + return hits[0] ? client.getReceipt(hits[0].id) : null; +} + +/** + * 開立一筆預覽過的草稿。**會在 Simpany 產生正式電子發票、上傳財政部、寄信給買受人。** + * 只能在使用者明確確認預覽之後呼叫。 + */ +export async function issueSimpanyDraft( + orgId: string, + draftId: number, + opts: { notifyEmails?: string[] } = {}, +): Promise { + const db = getDb(); + if (opts.notifyEmails) { + for (const e of opts.notifyEmails) { + if (!isValidEmail(e.trim())) throw new SimpanyPreviewError(`Email 格式不正確:${e}`); + } + } + // 先搶下草稿(pending → issued):同一份草稿被按兩次,第二次會搶不到,不會開兩張。 + const [draft] = await db + .update(invoiceDrafts) + .set({ status: "issued" }) + .where( + and( + eq(invoiceDrafts.organizationId, orgId), + eq(invoiceDrafts.id, draftId), + eq(invoiceDrafts.status, "pending"), + gt(invoiceDrafts.expiresAt, sql`now()`), + ), + ) + .returning(); + if (!draft) return explainUnavailableDraft(orgId, draftId); + + const payload = draft.payload as { type?: unknown; body?: unknown }; + const type = payload.type === "B2B" || payload.type === "B2C" ? payload.type : null; + if (!type || !isObj(payload.body)) { + await markDraft(draftId, "cancelled", "草稿內容損毀"); + throw new SimpanyPreviewError(`草稿 #${draftId} 內容損毀,請重新預覽`); + } + const body = structuredClone(payload.body) as unknown as SimpanyCreateBody; + if (opts.notifyEmails) body.customer.emails = opts.notifyEmails.map((e) => e.trim()); + const summary = draft.summary as Partial; + const links = (draft.links ?? {}) as DraftLinks; + const total = summary.amounts?.total ?? 0; + + let client: SimpanyClient; + try { + client = await getSimpanyClient(orgId); + } catch (e) { + await markDraft(draftId, "pending"); + throw e; + } + + let created: unknown; + try { + created = await client.createReceipt(type, body); + } catch (e) { + const definite = + e instanceof SimpanyError && (e.kind === "validation" || e.kind === "business" || e.kind === "auth" || e.kind === "config"); + if (definite) { + // Simpany 明確拒絕 → 沒開出來,草稿退回可再送(修正後通常要重新預覽)。 + await markDraft(draftId, "pending", e.message); + throw e; + } + // 網路中斷 / 5xx:不知道到底開了沒有。草稿作廢,避免盲目重送造成重複開立。 + const msg = e instanceof Error ? e.message : String(e); + await markDraft(draftId, "cancelled", msg); + throw new SimpanyError( + "http", + `送出後沒有收到明確結果(${msg})。發票可能已開出也可能沒有:請先執行「從 Simpany 同步」確認,確定沒開出再重新預覽開立。`, + ); + } + + // 取完整明細:回應形狀未經實測,一律再 GET 一次;拿不到 id 就用買受人 + 金額找今天最新的一張。 + let detail: SimpanyReceiptDetail | null = null; + const createdId = isObj(created) && typeof created.id === "string" ? created.id : null; + try { + if (createdId) detail = await client.getReceipt(createdId); + else detail = parseDetail(created) ?? (await findJustIssued(client, body, total)); + } catch { + detail = parseDetail(created); + } + if (!detail) { + throw new SimpanyError( + "business", + `Simpany 已接受開立,但無法解析回應取得發票號碼。請到 Simpany 確認,並執行「從 Simpany 同步」把它拉回本系統(草稿 #${draftId} 已標記為已開立,不會重複送出)。`, + ); + } + + const outcome = await upsertSimpanyReceipt(orgId, detail); + await applyInvoiceLinks(orgId, outcome.invoiceId, outcome.invoiceDate, links); + if (summary.foreign) { + await db + .update(invoices) + .set({ + foreignCurrency: summary.foreign.currency, + foreignAmount: String(summary.foreign.amount), + exchangeRate: String(summary.foreign.exchangeRate), + }) + .where(and(eq(invoices.organizationId, orgId), eq(invoices.id, outcome.invoiceId))); + } + await db + .update(invoiceDrafts) + .set({ issuedInvoiceId: outcome.invoiceId }) + .where(eq(invoiceDrafts.id, draftId)); + + return { + invoiceId: outcome.invoiceId, + invoiceNumber: detail.invoiceNumber, + externalId: detail.id, + type: detail.type, + buyer: detail.buyerName, + total: detail.totalAmount, + uploadStatus: detail.uploadStatus, + issuedAt: detail.issuedAt, + draftId, + }; +} + +// --------------------------------------------------------------------------- +// Lookup & void +// --------------------------------------------------------------------------- + +/** 發票號碼(FW12345678)或 Simpany id(R…)→ Simpany id。 */ +export async function resolveSimpanyReceiptId( + orgId: string, + client: SimpanyClient, + ref: string, +): Promise { + const r = ref.trim(); + if (/^R\d+$/i.test(r)) return r.toUpperCase(); + const number = r.toUpperCase().replace(/[\s-]/g, ""); + if (!/^[A-Z]{2}\d{8}$/.test(number)) { + throw new SimpanyPreviewError(`「${ref}」不是發票號碼(兩個英文字母 + 8 碼數字)也不是 Simpany id(R 開頭)`); + } + const [local] = await getDb() + .select({ externalId: invoices.externalId }) + .from(invoices) + .where( + and( + eq(invoices.organizationId, orgId), + isNotNull(invoices.externalId), + or(eq(invoices.externalRef, number), eq(invoices.invoiceNumber, number)), + ), + ) + .orderBy(desc(invoices.id)) + .limit(1); + if (local?.externalId) return local.externalId; + const today = taipeiDate(); + const res = await client.listReceipts({ + status: "ALL", + startDate: format(addDays(parseISO(today), -400), "yyyy-MM-dd"), + endDate: today, + query: number, + limit: 25, + }); + const hit = res.data.find((x) => x.invoiceNumber?.toUpperCase() === number); + if (!hit) throw new SimpanyPreviewError(`在 Simpany 找不到發票 ${number}(查了最近 400 天)`); + return hit.id; +} + +export type VoidResult = { + invoiceId: number; + invoiceNumber: string | null; + externalId: string; + voidedAt: string | null; + reason: string; + cleanup: VoidCleanup | null; +}; + +/** 作廢。**不可復原,Simpany 會通知買受人。** 之後重新同步這張並清掉開發票日 / 交易綁定。 */ +export async function voidSimpanyInvoice( + orgId: string, + ref: string, + reason: string, +): Promise { + const why = reason.trim(); + if (!why) throw new SimpanyPreviewError("作廢一定要填原因"); + if (why.length > VOID_REASON_MAX) { + throw new SimpanyPreviewError(`作廢原因最多 ${VOID_REASON_MAX} 個字(財政部欄位上限),目前 ${why.length} 字`); + } + const client = await getSimpanyClient(orgId); + const id = await resolveSimpanyReceiptId(orgId, client, ref); + const before = await client.getReceipt(id); + if (before.status.toUpperCase() === "INVALID") { + throw new SimpanyPreviewError(`發票 ${before.invoiceNumber ?? id} 已經是作廢狀態`); + } + if (before.canInvalidate === false) { + throw new SimpanyPreviewError( + `Simpany 表示發票 ${before.invoiceNumber ?? id} 目前不能作廢(可能已跨申報期,或已有折讓;需改開折讓單或洽 Simpany)`, + ); + } + // 先確保本系統有這張(之前沒同步過的話,作廢後的清理才找得到綁定)。 + await upsertSimpanyReceipt(orgId, before); + await client.invalidateReceipt(id, why); + const after = await client.getReceipt(id); + const outcome = await upsertSimpanyReceipt(orgId, after); + const cleanup = outcome.becameVoid ? await clearLinksForVoidedInvoice(orgId, outcome.invoiceId) : null; + if (after.status.toUpperCase() !== "INVALID") { + throw new SimpanyError( + "business", + `已送出作廢,但 Simpany 回報的狀態仍是 ${after.status}。請到 Simpany 確認。`, + ); + } + return { + invoiceId: outcome.invoiceId, + invoiceNumber: after.invoiceNumber, + externalId: after.id, + voidedAt: after.invalidatedAt, + reason: why, + cleanup, + }; +} + +/** Simpany 的零稅率原因清單(拿不到就回已知的後備清單)。 */ +export async function listZeroTaxReasons(orgId: string): Promise { + const client = await getSimpanyClient(orgId); + return client.getZeroTaxReasons(); +} + +/** 取消還沒開立的草稿(使用者在預覽後決定不開)。 */ +export async function cancelSimpanyDraft(orgId: string, draftId: number): Promise { + const rows = await getDb() + .update(invoiceDrafts) + .set({ status: "cancelled" }) + .where( + and( + eq(invoiceDrafts.organizationId, orgId), + eq(invoiceDrafts.id, draftId), + eq(invoiceDrafts.status, "pending"), + ), + ) + .returning({ id: invoiceDrafts.id }); + return rows.length > 0; +} diff --git a/src/lib/simpany-sync.ts b/src/lib/simpany-sync.ts new file mode 100644 index 0000000..5a83734 --- /dev/null +++ b/src/lib/simpany-sync.ts @@ -0,0 +1,988 @@ +import { addDays, differenceInCalendarDays, format, parseISO } from "date-fns"; +import { and, eq, inArray, isNotNull, isNull, ne, or, sql } from "drizzle-orm"; +import { getDb } from "@/db"; +import { + billingItems, + invoices, + parties, + subscriptionPeriods, + transactions, +} from "@/db/schema"; +import { listBillingBoard } from "@/db/queries"; +import { + getSimpanyClient, + type SimpanyClient, + type SimpanyReceiptDetail, + type SimpanyReceiptListItem, +} from "@/lib/integrations/simpany"; + +/** + * Simpany → internal 的發票同步(migrations/0025)。 + * + * Simpany 是銷項發票的真相來源:這裡把它的發票拉回 invoices(direction = issued), + * 以 (organization_id, external_id) 為冪等鍵。之後嘗試把發票「掛」到本系統該開票的東西上: + * + * - 收入交易(transactions.invoice_id) + * - 請款項目(billing_items.invoiced_on)或訂閱期別(subscription_periods.invoiced_on) + * + * 規則:同一客戶、金額完全相同、日期相差 ±45 天內,且**只有一個候選**才自動綁; + * 有多個候選(或同一候選被多張發票搶)就不綁,回報在 needsReview 讓人決定。 + * + * 發票在 Simpany 被作廢時,清掉它當初回填的 invoiced_on 與交易綁定,讓那一期重新出現在 + * 「待開發票」—— 除非同一期已經有另一張有效發票。 + * + * xlsx 匯出檔對帳(src/lib/simpany-export.ts)保留作為沒開整合時的後備。 + */ + +const LINK_WINDOW_DAYS = 45; +/** 一次同步最多抓幾張明細(每張一個 subrequest);超過就回 incomplete,再跑一次會接著做。 */ +const MAX_DETAIL_FETCHES = 80; + +export type TaxTreatment = "taxable" | "zero_rated" | "exempt"; + +export function taxTreatmentOf(taxType: string | null | undefined): TaxTreatment { + if (taxType === "ZERO_TAX_RATE") return "zero_rated"; + if (taxType === "EXEMPTION") return "exempt"; + return "taxable"; +} + +export function simpanyTaxTypeOf(t: TaxTreatment): "TAXABLE" | "ZERO_TAX_RATE" | "EXEMPTION" { + if (t === "zero_rated") return "ZERO_TAX_RATE"; + if (t === "exempt") return "EXEMPTION"; + return "TAXABLE"; +} + +/** B2C 的買受人統編在 Simpany 是 '0000000000';只有 8 碼數字才算真的統編。 */ +export function realVat(v: string | null | undefined): string | null { + const s = v?.trim() ?? ""; + return /^\d{8}$/.test(s) ? s : null; +} + +/** 台北時區的 YYYY-MM-DD。 */ +export function taipeiDate(d: Date = new Date()): string { + return new Intl.DateTimeFormat("en-CA", { + timeZone: "Asia/Taipei", + year: "numeric", + month: "2-digit", + day: "2-digit", + }).format(d); +} + +/** Simpany 的 issuedAt(ISO,+08:00)→ 開立日期(台北日期)。 */ +export function dateOfIssued(iso: string | null): string | null { + if (!iso) return null; + if (/^\d{4}-\d{2}-\d{2}T.*\+08:00$/.test(iso)) return iso.slice(0, 10); + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? null : taipeiDate(d); +} + +function isVoid(status: string): boolean { + return status.toUpperCase() === "INVALID"; +} + +function sameMoney(a: number | string | null | undefined, b: number | string | null | undefined) { + if (a == null || b == null) return false; + return Math.abs(Number(a) - Number(b)) < 0.005; +} + +function withinWindow(a: string | null, b: string | null): boolean { + if (!a || !b) return false; + return Math.abs(differenceInCalendarDays(parseISO(a), parseISO(b))) <= LINK_WINDOW_DAYS; +} + +/** 品項摘要,存在 note(本系統沒有發票品項表)。 */ +export function itemsSummary(d: SimpanyReceiptDetail): string | null { + const lines = d.items.map((it) => { + const qty = it.quantity && it.quantity !== 1 ? ` × ${it.quantity}` : ""; + return `${it.name}${qty}:${it.amount || it.price * (it.quantity || 1)}`; + }); + const parts = []; + if (lines.length) parts.push(`品項:${lines.join(";")}`); + if (d.remark) parts.push(`備註:${d.remark}`); + return parts.length ? parts.join("\n") : null; +} + +// --------------------------------------------------------------------------- +// Upsert one receipt +// --------------------------------------------------------------------------- + +type ExistingInvoice = { + id: number; + externalId: string | null; + externalRef: string | null; + invoiceNumber: string | null; + status: string; + externalStatus: string; + externalSyncedAt: string | null; + partyId: number | null; + billingItemId: number | null; + subscriptionId: number | null; + subscriptionPeriod: string | null; + invoiceDate: string | null; + amountGross: string | null; + note: string | null; +}; + +const existingColumns = { + id: invoices.id, + externalId: invoices.externalId, + externalRef: invoices.externalRef, + invoiceNumber: invoices.invoiceNumber, + status: invoices.status, + externalStatus: invoices.externalStatus, + externalSyncedAt: invoices.externalSyncedAt, + partyId: invoices.partyId, + billingItemId: invoices.billingItemId, + subscriptionId: invoices.subscriptionId, + subscriptionPeriod: invoices.subscriptionPeriod, + invoiceDate: invoices.invoiceDate, + amountGross: invoices.amountGross, + note: invoices.note, +}; + +type PartyLite = { id: number; name: string; taxId: string | null }; + +async function loadParties(orgId: string): Promise { + return getDb() + .select({ id: parties.id, name: parties.name, taxId: parties.taxId }) + .from(parties) + .where(and(eq(parties.organizationId, orgId), isNull(parties.deletedAt))); +} + +/** 先比統編,再比完全相同的名稱;各自只有唯一一筆才算數。 */ +export function matchPartyId( + all: PartyLite[], + vat: string | null, + name: string | null, +): number | null { + const v = realVat(vat); + if (v) { + const byVat = all.filter((p) => p.taxId?.trim() === v); + if (byVat.length === 1) return byVat[0].id; + } + const n = name?.trim(); + if (n) { + const byName = all.filter((p) => p.name.trim() === n); + if (byName.length === 1) return byName[0].id; + } + return null; +} + +export type UpsertOutcome = { + invoiceId: number; + action: "created" | "updated" | "adopted"; + /** 這次同步才變成作廢(之前不是)。 */ + becameVoid: boolean; + partyId: number | null; + invoiceDate: string | null; + amountGross: number; + hasBillingLink: boolean; +}; + +/** + * 把一張 Simpany 發票寫進 invoices。找既有列的順序: + * 1. 同 external_id + * 2. 同發票號碼(external_ref / invoice_number)、還沒有 external_id 的銷項發票(手動或 xlsx 對帳建的) + * 3. 「待開立」(pending、沒號碼)的銷項發票,同客戶、同金額、±45 天且唯一 —— 舊流程先在本系統建草稿、 + * 再到 Simpany 手開的那種 + * 都沒有才新增。既有列的綁定(客戶、合約、請款項目、訂閱期別、外幣資訊)不覆寫。 + */ +export async function upsertSimpanyReceipt( + orgId: string, + d: SimpanyReceiptDetail, + ctx: { parties?: PartyLite[]; existing?: ExistingInvoice | null } = {}, +): Promise { + const db = getDb(); + const allParties = ctx.parties ?? (await loadParties(orgId)); + const voided = isVoid(d.status); + const invoiceDate = dateOfIssued(d.issuedAt); + const partyId = matchPartyId(allParties, d.buyerVat, d.buyerName); + + let existing: ExistingInvoice | null | undefined = ctx.existing; + let action: UpsertOutcome["action"] = "updated"; + if (existing === undefined) { + [existing] = await db + .select(existingColumns) + .from(invoices) + .where(and(eq(invoices.organizationId, orgId), eq(invoices.externalId, d.id))) + .limit(1); + } + if (!existing && d.invoiceNumber) { + const [byNumber] = await db + .select(existingColumns) + .from(invoices) + .where( + and( + eq(invoices.organizationId, orgId), + eq(invoices.direction, "issued"), + isNull(invoices.externalId), + isNull(invoices.deletedAt), + or(eq(invoices.externalRef, d.invoiceNumber), eq(invoices.invoiceNumber, d.invoiceNumber)), + ), + ) + .limit(1); + if (byNumber) { + existing = byNumber; + action = "adopted"; + } + } + if (!existing && partyId != null && invoiceDate) { + const pending = await db + .select(existingColumns) + .from(invoices) + .where( + and( + eq(invoices.organizationId, orgId), + eq(invoices.direction, "issued"), + eq(invoices.partyId, partyId), + eq(invoices.externalStatus, "pending"), + isNull(invoices.externalId), + isNull(invoices.deletedAt), + ), + ); + const hits = pending.filter( + (p) => sameMoney(p.amountGross, d.totalAmount) && withinWindow(p.invoiceDate, invoiceDate), + ); + if (hits.length === 1) { + existing = hits[0]; + action = "adopted"; + } + } + + const facts = { + direction: "issued", + invoiceNumber: d.invoiceNumber, + externalRef: d.invoiceNumber, + externalId: d.id, + invoiceDate, + counterpartyName: d.buyerName, + counterpartyTaxId: realVat(d.buyerVat), + amountNet: String(d.untaxedAmount), + tax: String(d.taxAmount), + amountGross: String(d.totalAmount), + currency: "TWD", + status: voided ? "void" : "valid", + externalStatus: voided ? "void" : "issued", + taxTreatment: taxTreatmentOf(d.taxType), + zeroRateReason: d.zeroTaxRateReason?.code ?? null, + invoiceType: d.type === "B2B" || d.type === "B2C" ? d.type : null, + voidedAt: voided ? d.invalidatedAt : null, + voidReason: voided ? d.invalidReason : null, + buyerEmails: d.buyerEmails, + externalSyncedAt: new Date().toISOString(), + }; + + if (existing) { + await db + .update(invoices) + .set({ + ...facts, + partyId: existing.partyId ?? partyId, + note: existing.note ?? itemsSummary(d), + }) + .where(eq(invoices.id, existing.id)); + return { + invoiceId: existing.id, + action, + becameVoid: voided && existing.status !== "void", + partyId: existing.partyId ?? partyId, + invoiceDate, + amountGross: d.totalAmount, + hasBillingLink: existing.billingItemId != null || existing.subscriptionId != null, + }; + } + + const [inserted] = await db + .insert(invoices) + .values({ organizationId: orgId, ...facts, partyId, note: itemsSummary(d) }) + .onConflictDoUpdate({ + target: [invoices.organizationId, invoices.externalId], + targetWhere: sql`external_id IS NOT NULL`, + set: facts, + }) + .returning({ id: invoices.id }); + return { + invoiceId: inserted.id, + action: "created", + becameVoid: voided, + partyId, + invoiceDate, + amountGross: d.totalAmount, + hasBillingLink: false, + }; +} + +// --------------------------------------------------------------------------- +// Links +// --------------------------------------------------------------------------- + +export type InvoiceLinks = { + transactionIds?: number[]; + billingItemId?: number | null; + subscriptionId?: number | null; + subscriptionPeriod?: string | null; + contractId?: number | null; + partyId?: number | null; +}; + +/** 回填訂閱某期的開發票日(期別不物化,沒有列就新建一列只帶日期)。 */ +async function setSubscriptionPeriodInvoiced( + orgId: string, + subscriptionId: number, + periodStart: string, + date: string | null, + onlyIfEmpty: boolean, +): Promise { + const db = getDb(); + const [row] = await db + .select({ id: subscriptionPeriods.id, invoicedOn: subscriptionPeriods.invoicedOn }) + .from(subscriptionPeriods) + .where( + and( + eq(subscriptionPeriods.organizationId, orgId), + eq(subscriptionPeriods.subscriptionId, subscriptionId), + eq(subscriptionPeriods.periodStart, periodStart), + isNull(subscriptionPeriods.deletedAt), + ), + ) + .limit(1); + if (row) { + if (onlyIfEmpty && row.invoicedOn) return; + await db + .update(subscriptionPeriods) + .set({ invoicedOn: date }) + .where(eq(subscriptionPeriods.id, row.id)); + } else if (date) { + await db.insert(subscriptionPeriods).values({ + organizationId: orgId, + subscriptionId, + periodStart, + invoicedOn: date, + }); + } +} + +/** + * 把發票綁到交易 / 請款項目 / 訂閱期別,並回填開發票日(已有日期不覆蓋)。 + * 呼叫端負責確認這些 id 屬於同一個 org(這裡每個 update 也都帶 org 條件)。 + */ +export async function applyInvoiceLinks( + orgId: string, + invoiceId: number, + invoiceDate: string | null, + links: InvoiceLinks, +): Promise { + const db = getDb(); + const patch: Partial = {}; + if (links.partyId != null) patch.partyId = links.partyId; + if (links.contractId != null) patch.contractId = links.contractId; + if (links.billingItemId != null) patch.billingItemId = links.billingItemId; + if (links.subscriptionId != null && links.subscriptionPeriod) { + patch.subscriptionId = links.subscriptionId; + patch.subscriptionPeriod = links.subscriptionPeriod; + } + if (Object.keys(patch).length) { + await db + .update(invoices) + .set(patch) + .where(and(eq(invoices.organizationId, orgId), eq(invoices.id, invoiceId))); + } + const txnIds = (links.transactionIds ?? []).filter((n) => Number.isFinite(n)); + if (txnIds.length) { + await db + .update(transactions) + .set({ invoiceId }) + .where( + and( + eq(transactions.organizationId, orgId), + inArray(transactions.id, txnIds), + isNull(transactions.invoiceId), + ), + ); + } + if (links.billingItemId != null && invoiceDate) { + await db + .update(billingItems) + .set({ invoicedOn: invoiceDate }) + .where( + and( + eq(billingItems.organizationId, orgId), + eq(billingItems.id, links.billingItemId), + isNull(billingItems.invoicedOn), + ), + ); + } + if (links.subscriptionId != null && links.subscriptionPeriod && invoiceDate) { + await setSubscriptionPeriodInvoiced( + orgId, + links.subscriptionId, + links.subscriptionPeriod, + invoiceDate, + true, + ); + } +} + +export type VoidCleanup = { + invoiceId: number; + invoiceNumber: string | null; + clearedBillingItemId: number | null; + clearedSubscription: { subscriptionId: number; periodStart: string } | null; + unlinkedTransactionIds: number[]; +}; + +/** + * 發票作廢後:清掉它回填的開發票日(同一期若還有別張有效發票就不清),解除交易綁定。 + * 發票列本身的綁定欄位保留,當作歷史。 + */ +export async function clearLinksForVoidedInvoice( + orgId: string, + invoiceId: number, +): Promise { + const db = getDb(); + const [inv] = await db + .select({ + invoiceNumber: invoices.invoiceNumber, + billingItemId: invoices.billingItemId, + subscriptionId: invoices.subscriptionId, + subscriptionPeriod: invoices.subscriptionPeriod, + }) + .from(invoices) + .where(and(eq(invoices.organizationId, orgId), eq(invoices.id, invoiceId))) + .limit(1); + const out: VoidCleanup = { + invoiceId, + invoiceNumber: inv?.invoiceNumber ?? null, + clearedBillingItemId: null, + clearedSubscription: null, + unlinkedTransactionIds: [], + }; + if (!inv) return out; + + const otherValid = (cond: ReturnType) => + db + .select({ id: invoices.id }) + .from(invoices) + .where( + and( + eq(invoices.organizationId, orgId), + ne(invoices.id, invoiceId), + ne(invoices.status, "void"), + isNull(invoices.deletedAt), + cond, + ), + ) + .limit(1); + + if (inv.billingItemId != null) { + const [other] = await otherValid(and(eq(invoices.billingItemId, inv.billingItemId))); + if (!other) { + await db + .update(billingItems) + .set({ invoicedOn: null }) + .where(and(eq(billingItems.organizationId, orgId), eq(billingItems.id, inv.billingItemId))); + out.clearedBillingItemId = inv.billingItemId; + } + } + if (inv.subscriptionId != null && inv.subscriptionPeriod) { + const [other] = await otherValid( + and( + eq(invoices.subscriptionId, inv.subscriptionId), + eq(invoices.subscriptionPeriod, inv.subscriptionPeriod), + ), + ); + if (!other) { + await setSubscriptionPeriodInvoiced(orgId, inv.subscriptionId, inv.subscriptionPeriod, null, false); + out.clearedSubscription = { + subscriptionId: inv.subscriptionId, + periodStart: inv.subscriptionPeriod, + }; + } + } + const unlinked = await db + .update(transactions) + .set({ invoiceId: null }) + .where(and(eq(transactions.organizationId, orgId), eq(transactions.invoiceId, invoiceId))) + .returning({ id: transactions.id }); + out.unlinkedTransactionIds = unlinked.map((r) => r.id); + return out; +} + +// --------------------------------------------------------------------------- +// Auto-link candidates +// --------------------------------------------------------------------------- + +type Candidate = + | { kind: "transaction"; id: number; label: string; billingItemId: number | null } + | { kind: "billing_item"; id: number; label: string; contractId: number | null } + | { + kind: "subscription_period"; + id: number; + periodStart: string; + label: string; + contractId: number | null; + }; + +function candidateKey(c: Candidate): string { + return c.kind === "subscription_period" ? `sub:${c.id}:${c.periodStart}` : `${c.kind}:${c.id}`; +} + +type CandidatePools = { + txns: { + id: number; + partyId: number | null; + txnDate: string; + amount: string; + currency: string; + amountTwd: string | null; + description: string | null; + billingItemId: number | null; + }[]; + items: { + id: number; + customerPartyId: number; + contractId: number | null; + title: string; + amount: string; + currency: string; + anchor: string | null; + }[]; + periods: { + subscriptionId: number; + periodStart: string; + customerPartyId: number | null; + contractId: number | null; + title: string; + expected: number; + currency: string; + anchor: string | null; + }[]; + /** 已被某張有效發票綁走的請款項目 / 訂閱期別。 */ + takenBilling: Set; +}; + +async function loadCandidatePools(orgId: string, from: string, to: string): Promise { + const db = getDb(); + const lo = format(addDays(parseISO(from), -LINK_WINDOW_DAYS), "yyyy-MM-dd"); + const hi = format(addDays(parseISO(to), LINK_WINDOW_DAYS), "yyyy-MM-dd"); + const [txns, items, board, taken] = await Promise.all([ + db + .select({ + id: transactions.id, + partyId: transactions.partyId, + txnDate: transactions.txnDate, + amount: transactions.amount, + currency: transactions.currency, + amountTwd: transactions.amountTwd, + description: transactions.description, + billingItemId: transactions.billingItemId, + }) + .from(transactions) + .where( + and( + eq(transactions.organizationId, orgId), + eq(transactions.type, "income"), + isNull(transactions.invoiceId), + isNull(transactions.deletedAt), + isNotNull(transactions.partyId), + sql`${transactions.txnDate} between ${lo} and ${hi}`, + ), + ), + db + .select({ + id: billingItems.id, + customerPartyId: billingItems.customerPartyId, + contractId: billingItems.contractId, + title: billingItems.title, + amount: billingItems.amount, + currency: billingItems.currency, + billedOn: billingItems.billedOn, + dueDate: billingItems.dueDate, + paidOn: billingItems.paidOn, + }) + .from(billingItems) + .where( + and( + eq(billingItems.organizationId, orgId), + isNull(billingItems.deletedAt), + isNull(billingItems.invoicedOn), + eq(billingItems.needsInvoice, true), + ne(billingItems.status, "cancelled"), + ), + ), + listBillingBoard(orgId, { includeAllHistory: true }), + db + .select({ + billingItemId: invoices.billingItemId, + subscriptionId: invoices.subscriptionId, + subscriptionPeriod: invoices.subscriptionPeriod, + }) + .from(invoices) + .where( + and( + eq(invoices.organizationId, orgId), + isNull(invoices.deletedAt), + ne(invoices.status, "void"), + or(isNotNull(invoices.billingItemId), isNotNull(invoices.subscriptionId)), + ), + ), + ]); + const takenBilling = new Set(); + for (const t of taken) { + if (t.billingItemId != null) takenBilling.add(`billing_item:${t.billingItemId}`); + if (t.subscriptionId != null && t.subscriptionPeriod) { + takenBilling.add(`sub:${t.subscriptionId}:${t.subscriptionPeriod}`); + } + } + return { + txns, + items: items.map((it) => ({ + id: it.id, + customerPartyId: it.customerPartyId, + contractId: it.contractId, + title: it.title, + amount: it.amount, + currency: it.currency, + anchor: it.billedOn ?? it.dueDate ?? it.paidOn, + })), + periods: board + .filter((r) => r.source === "subscription" && r.subscriptionId != null && r.periodStart && !r.invoicedOn) + .map((r) => ({ + subscriptionId: r.subscriptionId as number, + periodStart: r.periodStart as string, + customerPartyId: r.customerPartyId, + contractId: r.contractId, + title: r.title, + expected: r.expected, + currency: r.currency, + anchor: r.billedOn ?? r.dueDate ?? r.periodStart, + })), + takenBilling, + }; +} + +type LinkTarget = { invoiceId: number; partyId: number; invoiceDate: string; gross: number }; + +function findCandidates( + pools: CandidatePools, + inv: LinkTarget, + want: { txn: boolean; billing: boolean }, +): { txn: Candidate[]; billing: Candidate[] } { + const txn: Candidate[] = want.txn + ? pools.txns + .filter( + (t) => + t.partyId === inv.partyId && + withinWindow(t.txnDate, inv.invoiceDate) && + ((t.currency === "TWD" && sameMoney(t.amount, inv.gross)) || sameMoney(t.amountTwd, inv.gross)), + ) + .map((t) => ({ + kind: "transaction" as const, + id: t.id, + billingItemId: t.billingItemId, + label: `${t.txnDate} ${t.description ?? "收入"} ${t.currency} ${Number(t.amount)}`, + })) + : []; + const billing: Candidate[] = []; + if (want.billing) { + for (const it of pools.items) { + if ( + it.customerPartyId === inv.partyId && + it.currency === "TWD" && + sameMoney(it.amount, inv.gross) && + withinWindow(it.anchor, inv.invoiceDate) && + !pools.takenBilling.has(`billing_item:${it.id}`) + ) { + billing.push({ + kind: "billing_item", + id: it.id, + contractId: it.contractId, + label: `請款項目「${it.title}」TWD ${Number(it.amount)}`, + }); + } + } + for (const p of pools.periods) { + if ( + p.customerPartyId === inv.partyId && + p.currency === "TWD" && + sameMoney(p.expected, inv.gross) && + withinWindow(p.anchor, inv.invoiceDate) && + !pools.takenBilling.has(`sub:${p.subscriptionId}:${p.periodStart}`) + ) { + billing.push({ + kind: "subscription_period", + id: p.subscriptionId, + periodStart: p.periodStart, + contractId: p.contractId, + label: `訂閱「${p.title}」TWD ${p.expected}`, + }); + } + } + } + return { txn, billing }; +} + +// --------------------------------------------------------------------------- +// Sync +// --------------------------------------------------------------------------- + +export type NeedsReviewItem = { + invoiceId: number; + invoiceNumber: string | null; + buyer: string | null; + amount: number; + reason: string; + candidates: { kind: string; id: number; periodStart?: string; label: string }[]; +}; + +export type SyncResult = { + startDate: string; + endDate: string; + seen: number; + created: number; + updated: number; + unchanged: number; + voided: number; + autoLinked: { invoiceNumber: string | null; invoiceId: number; linkedTo: string[] }[]; + voidCleanups: VoidCleanup[]; + needsReview: NeedsReviewItem[]; + /** true = 這次沒做完(明細太多或分頁太多),再跑一次會接著做(冪等)。 */ + incomplete: boolean; +}; + +/** + * 同步一段日期區間的 Simpany 發票進 invoices,並嘗試自動綁定。冪等:同一張發票重跑只會更新。 + * 已同步過、狀態沒變的發票不會重抓明細。 + */ +export async function syncSimpanyInvoices( + orgId: string, + range: { startDate: string; endDate: string }, + client?: SimpanyClient, +): Promise { + const simpany = client ?? (await getSimpanyClient(orgId)); + const db = getDb(); + + // ALL 應該已含作廢的;另外抓一次 INVALID 以防 ALL 不含(兩邊以 id 去重)。 + const [all, invalid] = await Promise.all([ + simpany.listAllReceipts({ status: "ALL", ...range }), + simpany.listAllReceipts({ status: "INVALID", ...range }), + ]); + const byId = new Map(); + for (const r of all.items) byId.set(r.id, r); + for (const r of invalid.items) byId.set(r.id, r); + const list = [...byId.values()]; + + const result: SyncResult = { + ...range, + seen: list.length, + created: 0, + updated: 0, + unchanged: 0, + voided: 0, + autoLinked: [], + voidCleanups: [], + needsReview: [], + incomplete: all.truncated || invalid.truncated, + }; + if (list.length === 0) return result; + + const [allParties, existingRows] = await Promise.all([ + loadParties(orgId), + db + .select(existingColumns) + .from(invoices) + .where( + and( + eq(invoices.organizationId, orgId), + inArray( + invoices.externalId, + list.map((r) => r.id), + ), + ), + ), + ]); + const existingById = new Map(existingRows.map((r) => [r.externalId as string, r])); + + const touched: (UpsertOutcome & { invoiceNumber: string | null; buyer: string | null })[] = []; + let fetched = 0; + for (const item of list) { + const existing = existingById.get(item.id) ?? null; + const statusNow = isVoid(item.status) ? "void" : "valid"; + if ( + existing?.externalSyncedAt && + existing.status === statusNow && + existing.invoiceNumber === item.invoiceNumber && + sameMoney(existing.amountGross, item.totalAmount) + ) { + result.unchanged++; + if (statusNow === "valid" && existing.invoiceDate) { + touched.push({ + invoiceId: existing.id, + action: "updated", + becameVoid: false, + partyId: existing.partyId, + invoiceDate: existing.invoiceDate, + amountGross: item.totalAmount, + hasBillingLink: existing.billingItemId != null || existing.subscriptionId != null, + invoiceNumber: existing.invoiceNumber, + buyer: item.buyerName, + }); + } + continue; + } + if (fetched >= MAX_DETAIL_FETCHES) { + result.incomplete = true; + continue; + } + fetched++; + const detail = await simpany.getReceipt(item.id); + const outcome = await upsertSimpanyReceipt(orgId, detail, { parties: allParties, existing }); + if (outcome.action === "created") result.created++; + else result.updated++; + if (outcome.becameVoid) { + result.voided++; + if (outcome.action !== "created") { + result.voidCleanups.push(await clearLinksForVoidedInvoice(orgId, outcome.invoiceId)); + } + } + if (!isVoid(detail.status)) { + touched.push({ ...outcome, invoiceNumber: detail.invoiceNumber, buyer: detail.buyerName }); + } + } + + await autoLink(orgId, range, touched, result); + return result; +} + +async function autoLink( + orgId: string, + range: { startDate: string; endDate: string }, + touched: (UpsertOutcome & { invoiceNumber: string | null; buyer: string | null })[], + result: SyncResult, +): Promise { + if (touched.length === 0) return; + const db = getDb(); + const linkedTxn = new Set( + ( + await db + .select({ invoiceId: transactions.invoiceId }) + .from(transactions) + .where( + and( + eq(transactions.organizationId, orgId), + isNull(transactions.deletedAt), + inArray( + transactions.invoiceId, + touched.map((t) => t.invoiceId), + ), + ), + ) + ).map((r) => r.invoiceId), + ); + const pools = await loadCandidatePools(orgId, range.startDate, range.endDate); + + // 第一輪:每張發票各自的候選。 + const plans: { + t: (typeof touched)[number]; + txn: Candidate[]; + billing: Candidate[]; + }[] = []; + for (const t of touched) { + const wantTxn = !linkedTxn.has(t.invoiceId); + const wantBilling = !t.hasBillingLink; + if (!wantTxn && !wantBilling) continue; + if (t.partyId == null || !t.invoiceDate) { + if (t.action === "created") { + result.needsReview.push({ + invoiceId: t.invoiceId, + invoiceNumber: t.invoiceNumber, + buyer: t.buyer, + amount: t.amountGross, + reason: "找不到對應的客戶(統編與名稱都對不上),無法自動綁定收款或請款", + candidates: [], + }); + } + continue; + } + const c = findCandidates( + pools, + { invoiceId: t.invoiceId, partyId: t.partyId, invoiceDate: t.invoiceDate, gross: t.amountGross }, + { txn: wantTxn, billing: wantBilling }, + ); + plans.push({ t, ...c }); + } + + // 第二輪:同一個候選若是多張發票的唯一候選,也算模稜兩可。 + const soleClaims = new Map(); + for (const p of plans) { + for (const list of [p.txn, p.billing]) { + if (list.length === 1) { + const k = candidateKey(list[0]); + soleClaims.set(k, (soleClaims.get(k) ?? 0) + 1); + } + } + } + + for (const { t, txn, billing } of plans) { + const linkedTo: string[] = []; + const links: InvoiceLinks = {}; + const review = (reason: string, cands: Candidate[]) => + result.needsReview.push({ + invoiceId: t.invoiceId, + invoiceNumber: t.invoiceNumber, + buyer: t.buyer, + amount: t.amountGross, + reason, + candidates: cands.map((c) => ({ + kind: c.kind, + id: c.id, + ...(c.kind === "subscription_period" ? { periodStart: c.periodStart } : {}), + label: c.label, + })), + }); + + if (txn.length === 1 && (soleClaims.get(candidateKey(txn[0])) ?? 0) === 1) { + links.transactionIds = [txn[0].id]; + linkedTo.push(`交易 #${txn[0].id}`); + } else if (txn.length > 0) { + review( + txn.length > 1 + ? "有多筆可能對應的收入交易,未自動綁定" + : "對應的收入交易同時也可能屬於另一張發票,未自動綁定", + txn, + ); + } + + const b = billing[0]; + if (billing.length === 1 && b && (soleClaims.get(candidateKey(b)) ?? 0) === 1) { + if (b.kind === "billing_item") { + links.billingItemId = b.id; + links.contractId = b.contractId; + linkedTo.push(`請款項目 #${b.id}`); + } else if (b.kind === "subscription_period") { + links.subscriptionId = b.id; + links.subscriptionPeriod = b.periodStart; + links.contractId = b.contractId; + linkedTo.push(`訂閱 #${b.id} ${b.periodStart} 期`); + } + } else if (billing.length > 0) { + review( + billing.length > 1 + ? "有多個可能對應的請款項目 / 訂閱期別,未自動綁定" + : "對應的請款項目 / 訂閱期別同時也可能屬於另一張發票,未自動綁定", + billing, + ); + } + + if (linkedTo.length) { + await applyInvoiceLinks(orgId, t.invoiceId, t.invoiceDate, links); + result.autoLinked.push({ invoiceNumber: t.invoiceNumber, invoiceId: t.invoiceId, linkedTo }); + } + } +} + +/** 預設同步區間:最近 90 天(台北日期)。 */ +export function defaultSyncRange(): { startDate: string; endDate: string } { + const end = taipeiDate(); + return { startDate: format(addDays(parseISO(end), -90), "yyyy-MM-dd"), endDate: end }; +} From 5a0294a7f20edeef30eaebe116130f2c5047a806 Mon Sep 17 00:00:00 2001 From: YJack0000 Date: Thu, 24 Sep 2026 17:36:43 +0800 Subject: [PATCH 18/27] =?UTF-8?q?[feature]=20Simpany=20=E6=95=B4=E5=90=88?= =?UTF-8?q?=EF=BC=9AMCP=20=E5=B7=A5=E5=85=B7=EF=BC=88list/get/sync/preview?= =?UTF-8?q?/issue/void/zero-rate=20reasons=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/mcp/handler.ts | 20 +- src/lib/mcp/tools-accounting.ts | 18 ++ src/lib/mcp/tools-simpany.ts | 453 ++++++++++++++++++++++++++++++++ src/lib/mcp/tools.ts | 2 + 4 files changed, 492 insertions(+), 1 deletion(-) create mode 100644 src/lib/mcp/tools-simpany.ts diff --git a/src/lib/mcp/handler.ts b/src/lib/mcp/handler.ts index 8c3aebf..384ae7f 100644 --- a/src/lib/mcp/handler.ts +++ b/src/lib/mcp/handler.ts @@ -72,7 +72,7 @@ function deriveMcpAudit(name: string, out: unknown): McpAudit | null { /** Bump on every published change to tools, schemas or instructions. Clients * (and OpenAI's plugin "Scan Tools") key their cached snapshot off this. */ -export const SERVER_VERSION = "1.4.0"; +export const SERVER_VERSION = "1.5.0"; /** Public base URL of this deployment; doubles as the OAuth issuer. * Keep in sync with the `resource` passed to `mcp()` in src/lib/auth.ts. */ @@ -260,11 +260,22 @@ function toolAnnotations(name: string, explicit?: ToolAnnotations): ToolAnnotati // Reaches outside our own database, so it cannot claim a closed world. const OPENWORLD_OVERRIDES: Record> = { sync_billing_calendar: { openWorldHint: true }, + // Simpany e-invoice (src/lib/mcp/tools-simpany.ts): every tool calls Simpany's + // API; issue/void create or cancel legal e-invoices and email the buyer. + simpany_list_invoices: { openWorldHint: true }, + simpany_get_invoice: { openWorldHint: true }, + simpany_sync_invoices: { openWorldHint: true }, + simpany_preview_invoice: { openWorldHint: true }, + simpany_issue_invoice: { openWorldHint: true }, + simpany_void_invoice: { openWorldHint: true }, + simpany_list_zero_rate_reasons: { openWorldHint: true }, }; // Writes that are irreversible from MCP even though the verb isn't "delete". // (Irreversible as a *bookkeeping entry* — no tool here moves real money.) const DESTRUCTIVE_OVERRIDES: Record> = { + // Voids a legal e-invoice at the Ministry of Finance; cannot be undone. + simpany_void_invoice: { destructiveHint: true }, // Writes the payslip AND the matching salary-expense ledger entry; the month // cannot be recorded twice and there is no tool that reverses the entry. pay_employee_salary: { destructiveHint: true }, @@ -307,6 +318,13 @@ const TITLE_OVERRIDES: Record = { mark_accountant_notified: "Mark as sent to the accountant", pay_employee_salary: "Record a salary payslip", sync_billing_calendar: "Sync the billing calendar", + simpany_get_invoice: "Simpany e-invoice detail", + simpany_issue_invoice: "Issue a Simpany e-invoice", + simpany_list_invoices: "List Simpany e-invoices", + simpany_list_zero_rate_reasons: "Simpany zero-rate reasons", + simpany_preview_invoice: "Preview a Simpany e-invoice", + simpany_sync_invoices: "Sync invoices from Simpany", + simpany_void_invoice: "Void a Simpany e-invoice", unmark_accountant_notified: "Unmark as sent to the accountant", }; diff --git a/src/lib/mcp/tools-accounting.ts b/src/lib/mcp/tools-accounting.ts index 976aef3..8a889ba 100644 --- a/src/lib/mcp/tools-accounting.ts +++ b/src/lib/mcp/tools-accounting.ts @@ -140,6 +140,24 @@ const INVOICE_ROW_PROPS = { description: "Issuing state in Simpany, the external invoicing system.", }, externalRef: { type: ["string", "null"] }, + // Simpany API sync / issue (migrations/0025). + taxTreatment: { + type: "string", + enum: ["taxable", "zero_rated", "exempt"], + description: "應稅 / 零稅率 / 免稅.", + }, + zeroRateReason: { type: ["string", "null"], description: "Simpany zero-rate reason code, e.g. 72 外銷勞務." }, + exchangeRate: { type: ["string", "null"], description: "Decimal as a string; FX rate from the bank remittance slip." }, + foreignCurrency: { type: ["string", "null"] }, + foreignAmount: { type: ["string", "null"], description: "Decimal as a string." }, + invoiceType: { type: ["string", "null"], enum: ["B2B", "B2C", null] }, + externalId: { type: ["string", "null"], description: "Simpany receipt id (R…)." }, + voidedAt: { type: ["string", "null"] }, + voidReason: { type: ["string", "null"] }, + buyerEmails: { type: ["array", "null"], items: { type: "string" } }, + subscriptionId: { type: ["number", "null"] }, + subscriptionPeriod: { type: ["string", "null"], description: "YYYY-MM-DD." }, + externalSyncedAt: { type: ["string", "null"], description: "Last synced from Simpany." }, } as const; const INVOICE_ROW: JsonSchemaObject = { diff --git a/src/lib/mcp/tools-simpany.ts b/src/lib/mcp/tools-simpany.ts new file mode 100644 index 0000000..a266592 --- /dev/null +++ b/src/lib/mcp/tools-simpany.ts @@ -0,0 +1,453 @@ +import { addDays, format, parseISO } from "date-fns"; +import { canManageOrg, getOrgRole } from "@/lib/session"; +import { + issueSimpanyDraft, + listZeroTaxReasons, + previewSimpanyInvoice, + resolveSimpanyReceiptId, + voidSimpanyInvoice, + type PreviewInput, + type PreviewItemInput, +} from "@/lib/simpany-issue"; +import { + defaultSyncRange, + syncSimpanyInvoices, + taipeiDate, + type TaxTreatment, +} from "@/lib/simpany-sync"; +import { getSimpanyClient } from "@/lib/integrations/simpany"; +import { auditIntegrationCall, requireIntegrationForTool } from "./tools-integrations"; +import { + listResult, + listSchema, + optBoolean, + optDate, + optNumber, + optString, + ORG_ARG, + requireNumber, + requireString, + resolveOrg, + rowSchema, + type JsonSchemaObject, + type ToolContext, + type ToolDef, +} from "./shared"; + +// ---- Simpany 電子發票(src/lib/integrations/simpany.ts)---- +// +// 每支工具 execute 第一步都是 requireIntegrationForTool:整合沒連接 / 沒開 / 需要重新 +// 連接時,丟出清楚的中文錯誤告訴使用者請 owner / admin 到 設定 › 整合 處理。 +// 對 Simpany 或本系統帳本有寫入的(同步、開立、作廢)限 owner / admin,並用 +// auditIntegrationCall 記操作紀錄(不含帳密、token 或完整個資)。 +// +// 開立一定是兩段式:simpany_preview_invoice 產生草稿 → 使用者在對話中明確同意 → +// simpany_issue_invoice 只收 draftId。 + +const TAX_TREATMENTS = ["taxable", "zero_rated", "exempt"] as const; + +async function requireManager(orgId: string, ctx: ToolContext, what: string): Promise { + const role = await getOrgRole(orgId, ctx.userId); + if (!canManageOrg(role)) { + throw new Error(`只有組織的擁有者或管理員可以${what},請找 owner 或 admin 操作。`); + } +} + +function rangeArgs(args: Record, defaultDays: number) { + const end = optDate(args, "endDate") ?? taipeiDate(); + const start = + optDate(args, "startDate") ?? format(addDays(parseISO(end), -defaultDays), "yyyy-MM-dd"); + if (start > end) throw new Error('"startDate" must be on or before "endDate".'); + return { startDate: start, endDate: end }; +} + +function optObject(args: Record, key: string): Record | undefined { + const v = args[key]; + if (v === undefined || v === null) return undefined; + if (typeof v !== "object" || Array.isArray(v)) throw new Error(`"${key}" must be an object.`); + return v as Record; +} + +function optStringArray(v: unknown, key: string): string[] | undefined { + if (v === undefined || v === null) return undefined; + if (!Array.isArray(v) || v.some((x) => typeof x !== "string")) { + throw new Error(`"${key}" must be an array of strings.`); + } + return (v as string[]).map((s) => s.trim()).filter(Boolean); +} + +function parseItems(v: unknown): PreviewItemInput[] | undefined { + if (v === undefined || v === null) return undefined; + if (!Array.isArray(v)) throw new Error('"items" must be an array.'); + return v.map((raw, i) => { + if (!raw || typeof raw !== "object") throw new Error(`items[${i}] must be an object.`); + const it = raw as Record; + return { + name: requireString(it, "name"), + quantity: optNumber(it, "quantity") ?? 1, + price: requireNumber(it, "price"), + }; + }); +} + +const BUYER_SCHEMA = { + type: "object", + properties: { + vat: { type: "string", description: "8-digit Taiwan 統一編號. Omit (or empty) for a buyer without one." }, + name: { type: "string" }, + address: { type: "string" }, + emails: { type: "array", items: { type: "string" }, description: "Where Simpany emails the e-invoice notice." }, + }, + additionalProperties: false, +} as const; + +const LIST_ROW: JsonSchemaObject = rowSchema({ + id: { type: "string", description: "Simpany receipt id (R…); use with simpany_get_invoice / simpany_void_invoice." }, + invoiceNumber: { type: ["string", "null"] }, + type: { type: "string", description: "B2B or B2C." }, + status: { type: "string", description: "ISSUED or INVALID (voided)." }, + buyerVat: { type: ["string", "null"], description: "null for B2C." }, + buyerName: { type: ["string", "null"] }, + total: { type: "number", description: "TWD incl. tax." }, + issuedAt: { type: ["string", "null"] }, + voidedAt: { type: ["string", "null"] }, + voidReason: { type: ["string", "null"] }, + allowanceCount: { type: "number" }, +}); + +const LOOSE_OBJECT: JsonSchemaObject = { type: "object", additionalProperties: true }; + +export const simpanyTools: Record = { + simpany_list_invoices: { + description: + "[read] List e-invoices issued in Simpany (the company's e-invoice provider) for a date range, straight from Simpany. Unofficial API: if Simpany changes it this may fail with Simpany's raw error. Requires the Simpany integration to be connected and switched on (設定 › 整合).", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, + inputSchema: { + type: "object", + properties: { + startDate: { type: "string", description: "YYYY-MM-DD; default 90 days before endDate." }, + endDate: { type: "string", description: "YYYY-MM-DD; default today (Taipei)." }, + status: { type: "string", enum: ["all", "void"], description: "all (default) or only voided ones." }, + query: { type: "string", description: "Keyword Simpany matches (invoice number, buyer name or tax id)." }, + ...ORG_ARG, + }, + additionalProperties: false, + }, + outputSchema: { + ...listSchema(LIST_ROW), + properties: { + ...listSchema(LIST_ROW).properties, + truncated: { type: "boolean", description: "More pages existed than were fetched; narrow the range." }, + }, + required: ["items", "count", "truncated"], + }, + execute: async (args, ctx) => { + const orgId = await resolveOrg(args, ctx); + await requireIntegrationForTool(orgId, "simpany"); + const status = optString(args, "status") ?? "all"; + if (status !== "all" && status !== "void") throw new Error('"status" must be all or void.'); + const client = await getSimpanyClient(orgId); + const { items, truncated } = await client.listAllReceipts( + { + status: status === "void" ? "INVALID" : "ALL", + ...rangeArgs(args, 90), + query: optString(args, "query"), + }, + 5, + ); + return { + ...listResult( + items.map((r) => ({ + id: r.id, + invoiceNumber: r.invoiceNumber, + type: r.type, + status: r.status, + buyerVat: /^\d{8}$/.test(r.buyerVat ?? "") ? r.buyerVat : null, + buyerName: r.buyerName, + total: r.totalAmount, + issuedAt: r.issuedAt, + voidedAt: r.invalidatedAt, + voidReason: r.invalidReason, + allowanceCount: r.allowances.length, + })), + ), + truncated, + }; + }, + }, + + simpany_get_invoice: { + description: + "[read] Full detail of one Simpany e-invoice by invoice number (e.g. FW10873802) or Simpany id (R…): items, tax type, zero-rate reason, buyer emails, upload status to the Ministry of Finance, void info.", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, + inputSchema: { + type: "object", + properties: { + invoice: { type: "string", description: "Invoice number (two letters + 8 digits) or Simpany id (R…)." }, + ...ORG_ARG, + }, + required: ["invoice"], + additionalProperties: false, + }, + outputSchema: LOOSE_OBJECT, + execute: async (args, ctx) => { + const orgId = await resolveOrg(args, ctx); + await requireIntegrationForTool(orgId, "simpany"); + const client = await getSimpanyClient(orgId); + const id = await resolveSimpanyReceiptId(orgId, client, requireString(args, "invoice")); + const d = await client.getReceipt(id); + return { + id: d.id, + invoiceNumber: d.invoiceNumber, + randomNumber: d.randomNumber, + type: d.type, + status: d.status, + uploadStatus: d.uploadStatus, + printStatus: d.printStatus, + buyer: { + vat: /^\d{8}$/.test(d.buyerVat ?? "") ? d.buyerVat : null, + name: d.buyerName, + address: d.buyerAddress, + emails: d.buyerEmails, + }, + taxType: d.taxType, + customsClearanceType: d.customsClearanceType, + zeroTaxRateReason: d.zeroTaxRateReason, + taxRate: d.taxRate, + isTaxIncluded: d.isTaxIncluded, + untaxedAmount: d.untaxedAmount, + taxAmount: d.taxAmount, + totalAmount: d.totalAmount, + remark: d.remark, + carrierType: d.carrierType, + items: d.items, + issuedAt: d.issuedAt, + voidedAt: d.invalidatedAt, + voidReason: d.invalidReason, + canInvalidate: d.canInvalidate, + allowanceCount: d.allowances.length, + }; + }, + }, + + simpany_sync_invoices: { + description: + "[write] Pull Simpany e-invoices for a date range into this organization's invoice records (idempotent; writes only to these books, never to Simpany). Matches the buyer to a party by tax id then exact name, and auto-links each invoice to an unlinked income transaction and/or a billing item / subscription period of the same party when the gross amount is equal and the date is within ±45 days and there is exactly one candidate — ambiguous ones are returned in needsReview, not linked. Voided invoices have the 開發票日 they had filled cleared so the charge shows up as needing an invoice again. Owner/admin only.", + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true }, + inputSchema: { + type: "object", + properties: { + startDate: { type: "string", description: "YYYY-MM-DD; default 90 days ago." }, + endDate: { type: "string", description: "YYYY-MM-DD; default today (Taipei)." }, + ...ORG_ARG, + }, + additionalProperties: false, + }, + outputSchema: LOOSE_OBJECT, + execute: async (args, ctx) => { + const orgId = await resolveOrg(args, ctx); + await requireIntegrationForTool(orgId, "simpany"); + await requireManager(orgId, ctx, "同步 Simpany 發票"); + const range = + args.startDate || args.endDate ? rangeArgs(args, 90) : defaultSyncRange(); + const res = await syncSimpanyInvoices(orgId, range); + await auditIntegrationCall( + ctx, + orgId, + "simpany", + "update", + `sync ${range.startDate}~${range.endDate}: +${res.created} ~${res.updated} void ${res.voided} linked ${res.autoLinked.length}`, + ); + return res; + }, + }, + + simpany_preview_invoice: { + description: + "Prepare (but do NOT issue) a Simpany e-invoice. Prefills from internal data when given transactionId (an income entry), billingItemId (a planned charge) or subscriptionId + subscriptionPeriod, and/or takes explicit fields. Validates (B2B needs an 8-digit tax id; a foreign buyer without a Taiwan tax id is B2C + zero_rated with reason 72 外銷勞務 + NOT_VIA_CUSTOMS; zero-rated foreign-currency income REQUIRES exchangeRate taken from the bank's remittance slip 水單 — TWD amount = round(foreignAmount × exchangeRate)), computes untaxed/tax/total exactly as Simpany does, checks for possible duplicates, and stores a draft valid for 2 hours. Returns draftId plus the full preview and warnings. Show the preview (buyer, items, tax type, amounts, warnings) to the user and get explicit approval before calling simpany_issue_invoice with the draftId. Only writes the draft row; nothing is sent to Simpany or the buyer.", + inputSchema: { + type: "object", + properties: { + transactionId: { type: "number", description: "Income transaction to invoice; see list_transactions." }, + billingItemId: { type: "number", description: "Planned charge to invoice; see list_billing_status." }, + subscriptionId: { type: "number", description: "Subscription to invoice (with subscriptionPeriod)." }, + subscriptionPeriod: { type: "string", description: "Period start date YYYY-MM-DD; see get_subscription_schedule." }, + type: { type: "string", enum: ["B2B", "B2C"], description: "Default: B2B when the buyer has a Taiwan tax id, else B2C." }, + buyer: BUYER_SCHEMA, + taxTreatment: { + type: "string", + enum: [...TAX_TREATMENTS], + description: "taxable 應稅 (5%), zero_rated 零稅率, exempt 免稅. Default: zero_rated for foreign-currency income from a buyer without a Taiwan tax id, else taxable. Export services are zero_rated, never exempt.", + }, + zeroRateReason: { type: "string", description: "Simpany reason code when zero_rated; default 72 外銷勞務. See simpany_list_zero_rate_reasons." }, + customsClearance: { type: "string", enum: ["NOT_VIA_CUSTOMS", "VIA_CUSTOMS"], description: "Zero-rated only; default NOT_VIA_CUSTOMS." }, + items: { + type: "array", + description: "Line items. Default: one item named after the source, priced at its amount. Names may not contain ':' (converted to ':').", + items: { + type: "object", + properties: { + name: { type: "string" }, + quantity: { type: "number", description: "Default 1." }, + price: { type: "number", description: "Unit price in TWD (tax-inclusive when isTaxIncluded)." }, + }, + required: ["name", "price"], + additionalProperties: false, + }, + }, + isTaxIncluded: { type: "boolean", description: "Whether item prices include 5% tax. Default true." }, + remark: { type: "string", description: "Printed remark, e.g. a quote number." }, + foreignCurrency: { type: "string", description: "e.g. USD; defaults to the source's currency when not TWD." }, + foreignAmount: { type: "number", description: "Amount in foreignCurrency; defaults to the source amount." }, + exchangeRate: { type: "number", description: "TWD per 1 unit of foreignCurrency, from the bank's remittance slip (水單). Required for foreign-currency income." }, + ...ORG_ARG, + }, + additionalProperties: false, + }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false }, + outputSchema: LOOSE_OBJECT, + execute: async (args, ctx) => { + const orgId = await resolveOrg(args, ctx); + await requireIntegrationForTool(orgId, "simpany"); + const buyer = optObject(args, "buyer"); + const taxTreatment = optString(args, "taxTreatment"); + if (taxTreatment && !TAX_TREATMENTS.includes(taxTreatment as TaxTreatment)) { + throw new Error(`"taxTreatment" must be one of: ${TAX_TREATMENTS.join(", ")}.`); + } + const type = optString(args, "type"); + if (type && type !== "B2B" && type !== "B2C") throw new Error('"type" must be B2B or B2C.'); + const customs = optString(args, "customsClearance"); + if (customs && customs !== "NOT_VIA_CUSTOMS" && customs !== "VIA_CUSTOMS") { + throw new Error('"customsClearance" must be NOT_VIA_CUSTOMS or VIA_CUSTOMS.'); + } + const input: PreviewInput = { + transactionId: optNumber(args, "transactionId"), + billingItemId: optNumber(args, "billingItemId"), + subscriptionId: optNumber(args, "subscriptionId"), + subscriptionPeriod: optDate(args, "subscriptionPeriod"), + type: type as PreviewInput["type"], + buyer: buyer + ? { + vat: buyer.vat === undefined ? undefined : optString(buyer, "vat") ?? null, + name: optString(buyer, "name"), + address: optString(buyer, "address"), + emails: optStringArray(buyer.emails, "buyer.emails"), + } + : undefined, + taxTreatment: taxTreatment as TaxTreatment | undefined, + zeroRateReason: optString(args, "zeroRateReason"), + customsClearance: customs as PreviewInput["customsClearance"], + items: parseItems(args.items), + isTaxIncluded: optBoolean(args, "isTaxIncluded"), + remark: optString(args, "remark"), + foreignCurrency: optString(args, "foreignCurrency"), + foreignAmount: optNumber(args, "foreignAmount"), + exchangeRate: optNumber(args, "exchangeRate"), + }; + const preview = await previewSimpanyInvoice(orgId, ctx.userId, input); + return { + ...preview, + nextStep: + "Show this preview to the user (buyer, items, tax type, untaxed/tax/total, warnings). Only after they explicitly approve it in this conversation, call simpany_issue_invoice({ draftId }). The draft expires at expiresAt.", + }; + }, + }, + + simpany_issue_invoice: { + description: + "Issue a previewed draft as a real e-invoice in Simpany. THIS CREATES A LEGAL TAX DOCUMENT: Simpany uploads it to Taiwan's Ministry of Finance and emails the buyer; it can only be undone by voiding. Only call after the user has explicitly approved the preview from simpany_preview_invoice in this conversation. Takes ONLY the draftId (the exact previewed content is sent; a draft can be issued once and expires after 2 hours). On success the invoice is saved to this organization's invoices and linked to the previewed transaction / billing item / subscription period. Owner/admin only.", + inputSchema: { + type: "object", + properties: { + draftId: { type: "number", description: "From simpany_preview_invoice." }, + notifyEmails: { + type: "array", + items: { type: "string" }, + description: "Optional: replace the buyer notification emails shown in the preview.", + }, + ...ORG_ARG, + }, + required: ["draftId"], + additionalProperties: false, + }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false }, + outputSchema: LOOSE_OBJECT, + execute: async (args, ctx) => { + const orgId = await resolveOrg(args, ctx); + await requireIntegrationForTool(orgId, "simpany"); + await requireManager(orgId, ctx, "在 Simpany 開立發票"); + const draftId = requireNumber(args, "draftId"); + try { + const res = await issueSimpanyDraft(orgId, draftId, { + notifyEmails: optStringArray(args.notifyEmails, "notifyEmails"), + }); + await auditIntegrationCall( + ctx, + orgId, + "simpany", + "create", + `issue draft #${draftId} → ${res.invoiceNumber ?? res.externalId} NT$${res.total}`, + ); + return res; + } catch (e) { + await auditIntegrationCall( + ctx, + orgId, + "simpany", + "create", + `issue draft #${draftId} failed: ${(e instanceof Error ? e.message : String(e)).slice(0, 200)}`, + ); + throw e; + } + }, + }, + + simpany_void_invoice: { + description: + "Void (作廢) an e-invoice in Simpany. CANNOT BE UNDONE: the void is reported to the Ministry of Finance and Simpany notifies the buyer. Only call after the user explicitly confirmed voiding this specific invoice. Afterwards the invoice is re-synced here and the 開發票日 it filled is cleared so the charge shows as needing an invoice again. Only possible within the current filing period and before any allowance; otherwise Simpany refuses. Owner/admin only.", + inputSchema: { + type: "object", + properties: { + invoice: { type: "string", description: "Invoice number (e.g. FW10873800) or Simpany id (R…)." }, + reason: { type: "string", description: "Required 作廢原因, max 20 characters (e.g. 課稅別開立錯誤)." }, + ...ORG_ARG, + }, + required: ["invoice", "reason"], + additionalProperties: false, + }, + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false }, + outputSchema: LOOSE_OBJECT, + execute: async (args, ctx) => { + const orgId = await resolveOrg(args, ctx); + await requireIntegrationForTool(orgId, "simpany"); + await requireManager(orgId, ctx, "作廢 Simpany 發票"); + const ref = requireString(args, "invoice"); + const res = await voidSimpanyInvoice(orgId, ref, requireString(args, "reason")); + await auditIntegrationCall( + ctx, + orgId, + "simpany", + "delete", + `void ${res.invoiceNumber ?? res.externalId}: ${res.reason}`, + ); + return res; + }, + }, + + simpany_list_zero_rate_reasons: { + description: + "[read] Simpany's list of zero-tax-rate reason codes (e.g. 71 外銷貨物, 72 外銷勞務) for zero_rated invoices.", + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }, + inputSchema: { + type: "object", + properties: { ...ORG_ARG }, + additionalProperties: false, + }, + outputSchema: listSchema(rowSchema({ code: { type: "string" }, name: { type: "string" } })), + execute: async (args, ctx) => { + const orgId = await resolveOrg(args, ctx); + await requireIntegrationForTool(orgId, "simpany"); + return listResult(await listZeroTaxReasons(orgId)); + }, + }, +}; diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts index 3663429..514f150 100644 --- a/src/lib/mcp/tools.ts +++ b/src/lib/mcp/tools.ts @@ -32,6 +32,7 @@ import { hrTools } from "./tools-hr"; import { billingItemTools } from "./tools-billing"; import { orgTools } from "./tools-org"; import { integrationTools } from "./tools-integrations"; +import { simpanyTools } from "./tools-simpany"; export type { ToolContext, ToolDef } from "./shared"; @@ -717,4 +718,5 @@ export const tools: Record = { ...clientTools, ...hrTools, ...integrationTools, + ...simpanyTools, }; From 7c67e3caba6cf3ff7aa0b5954282d66f795f29a6 Mon Sep 17 00:00:00 2001 From: YJack0000 Date: Thu, 24 Sep 2026 17:37:11 +0800 Subject: [PATCH 19/27] =?UTF-8?q?[docs]=20Wise=20=E6=95=B4=E5=90=88?= =?UTF-8?q?=EF=BC=9A=E5=94=AF=E8=AE=80=E4=BF=9D=E8=AD=89=E3=80=81=E5=88=87?= =?UTF-8?q?=E6=8F=9B=E6=97=A5=E8=88=87=E5=B8=B3=E6=88=B6=E5=B0=8D=E6=87=89?= =?UTF-8?q?=E3=80=81MCP=20=E5=B7=A5=E5=85=B7=E6=B8=85=E5=96=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/integrations.md | 84 +++++++++++++++++++++++++++++++++++++++++++- docs/mcp.md | 29 +++++++++++---- 2 files changed, 106 insertions(+), 7 deletions(-) diff --git a/docs/integrations.md b/docs/integrations.md index 31b291e..cbd3844 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -16,7 +16,7 @@ settings page just lists it alongside the others. | `migrations/0023_org_integrations.sql` / `orgIntegrations` in `src/db/schema.ts` | One row per (organization, provider). `credentials_enc` / `token_cache_enc` are ciphertext from `src/lib/crypto.ts` (`FIELD_ENCRYPTION_KEY`). `config` is non-secret jsonb. | | `src/lib/integrations/types.ts` | Provider ids, field/catalog/provider types, `IntegrationSummary` (the secret-free view). Client-safe. | | `src/lib/integrations/catalog.ts` | Static catalog: logo + credential/config fields per provider. Drives the settings UI. Client-safe. | -| `src/lib/integrations/registry.ts` | Map of **implementations** (`testConnection`). Empty until a provider lands. Server only. | +| `src/lib/integrations/registry.ts` | Map of **implementations** (`testConnection`). Wise is registered. Server only. | | `src/lib/integrations/store.ts` | The only code that reads/writes `org_integrations`. Server only. | | `src/app/dashboard/settings/integrations/` | Settings page, server actions (owner/admin only), connect Sheet. | | `src/lib/mcp/tools-integrations.ts` | `list_integrations`, plus `requireIntegrationForTool` and `auditIntegrationCall` for provider tools. | @@ -137,6 +137,88 @@ Also: a new migration extending `chk_org_integration_provider` (and the matching `check(...)` in `schema.ts`), the id in `INTEGRATION_PROVIDER_IDS`, a catalog entry + `INTEGRATION_ORDER`, and `integrations.providers..name/description` in i18n. +## Wise(唯讀交易同步) + +Wise 是**唯讀**整合:只把 Wise 對帳單的交易匯入本組織的帳本,讓 Wise 的帳不用再每月手動彙總。 + +### 唯讀保證 + +- 所有對 Wise 的請求都走 `src/lib/integrations/wise.ts` 的 `wiseGet()`:method 寫死 GET, + `assertReadOnly()` 會拒絕任何非 GET,且 path 必須符合唯讀白名單 + (`/v2/profiles`、`/v4/profiles/{id}/balances`、`/v1/profiles/{id}/balance-statements/{balanceId}/statement.json`), + 否則不發請求直接丟錯。**沒有任何建立 quote / transfer / conversion 的程式碼路徑。** +- 同步唯一會寫的是本組織的 `transactions`(內帳),不會寫 Wise。 +- Wise 回 401 → `markNeedsReauth`;回 403 且帶 `x-2fa-approval` header → 丟出「需要 SCA」的清楚錯誤 + (本系統不實作 SCA 簽章);其他錯誤 → `recordSyncFailure`。成功 → `recordSyncSuccess`。 + +### 連接與帳戶對應 + +1. 設定 › 整合 › Wise → 連接,貼上 API token。`testConnection` 呼叫 `GET /v2/profiles` 與各 profile 的 + STANDARD 餘額,把 `profiles` / `balances`(含當下餘額與讀取時間)寫進 `config`。 +2. 開啟整合後,同頁下方的「Wise 帳戶對應」把每個 Wise 餘額對應到**同幣別**的帳本帳戶,並設定切換日 + (`syncFrom`)。沒對應的餘額不同步。一個帳本帳戶只能對應一個 Wise 餘額;帳戶必須屬於本組織且幣別相同 + (`saveWiseMappings` 會驗)。 +3. 「重新整理餘額」再向 Wise 讀一次 profile 與餘額(需整合已開啟)。 + +`config` 形狀(非機密、成員與 MCP 看得到): + +```jsonc +{ + "profiles": [{ "id": 73990862, "type": "BUSINESS", "name": "Cerana Technology" }], + "balances": [{ "profileId": 73990862, "balanceId": 129476973, "currency": "USD", "amount": 1234.5, "fetchedAt": "…" }], + "accountMappings": [{ "profileId": 73990862, "balanceId": 129476973, "currency": "USD", "bankAccountId": 3, "syncFrom": "2026-10-01" }], + "syncFrom": null // 選填:全域切換日,對應本身沒設時用 +} +``` + +### 切換日(cutover) + +過去的 Wise 支出是手動以「月彙總」入帳(對象「Cerana Wise card (彙總)」,book = internal), +所以同步必須從某一天之後才開始,否則會重複記帳: + +- **切換日之前的 Wise 交易永遠不同步**(依台北日期判斷)。 +- 建議值 = 該帳本帳戶上最後一筆**非 Wise 同步**交易所在月份的下個月 1 號(帳戶沒交易時為本月 1 號)。 + 設定頁會顯示建議值;有選帳戶但切換日留空時,儲存會直接套用建議值。 +- 每次同步的起點 = max(切換日, 該帳戶最後一筆 Wise 同步交易日 − 3 天),抓到現在,按台北日曆月切塊 + (Wise 單次上限 469 天)。MCP 的 `startDate` 可以覆寫起點,但不能早於切換日。 + +### 交易對應規則(`src/lib/wise-sync.ts`) + +| Wise | 帳本 | +| --- | --- | +| CREDIT | `income`,入帳到對應帳戶 | +| DEBIT | `expense`,從對應帳戶支出 | +| 金額 | `abs(amount.value)`,餘額幣別;Wise 的金額已含手續費,手續費另記在說明與 `external_meta` | +| 日期 | `date` 換成台北日期 | +| 對象 | `merchant.name` → `senderName` → `recipient.name` → `details.description`(查無就新建 party) | +| 說明 | `details.description`(+ 原幣金額、+ `fee X`) | +| 分類 / 待確認 | 分類留空(未分類)、`needs_review = true` | +| book | `internal`(與過去手動輸入的 Wise 列一致) | +| `external_*` | `external_source = 'wise'`、`external_ref = referenceNumber`、`external_meta` = 商家、原幣金額、匯率、手續費、卡號末四碼、持卡人、Wise 分類 | + +**換匯(CONVERSION)**:帳本一列只有一個幣別,不支援跨幣轉帳。所以換匯的兩腳各記一列: + +- 另一腳的餘額**也有對應**時,每腳記成「單腳轉帳」(`type = transfer`,只填自己這邊的 from / to 帳戶), + 不進損益、兩邊帳戶餘額都正確,`needs_review = false`。 +- 另一腳**沒有對應**時,退回 income / expense(對象「Wise 換匯」)並標 `needs_review`。 +- 兩腳共用同一個 referenceNumber,所以 `external_ref` 加幣別後綴(`BALANCE-123:USD`)。 +- 單腳轉帳在 web 的編輯表單存檔時會被「轉帳需要兩個帳戶」擋下;要改請先刪掉再手動記。 + +**去重**:`(organization_id, external_source, external_ref)` 有部分唯一索引(migration 0026),寫入用 +`ON CONFLICT DO NOTHING`。已存在的列(包括已軟刪除的)永遠不改、不重寫 —— 刪掉一筆同步進來的交易, +下次同步也不會再長回來。同一次抓回來的資料裡鍵重複時只取第一筆,並列在結果的 `duplicateRefs`。 + +**待確認**:交易列表上顯示「待確認」chip,列表上方可切到「只看待確認」(`?review=1`)。在 web 編輯並指定 +分類、或 MCP `update_transaction` 指定分類 / 傳 `needsReview: false`,就會清掉。 + +### 入口 + +- Web:帳戶頁(`/dashboard/bank-accounts`)的「從 Wise 同步」(owner / admin、整合可用且有對應時才出現): + 先跑 dry run,Sheet 顯示每個帳戶的期間、讀到 / 已存在 / 切換日前 / 將新增筆數與前 50 筆樣本, + 按「寫入 N 筆」才寫。對應到 Wise 的帳戶名稱旁有「Wise」標記。 +- MCP:`wise_list_balances`、`wise_get_statement`、`wise_sync_transactions`(`dryRun` 預設 true, + 工具說明要求模型先給使用者看試算、取得同意才以 `dryRun: false` 寫入)。見 [mcp.md](mcp.md)。 + ## Security rules - Credentials are encrypted with `FIELD_ENCRYPTION_KEY` (see diff --git a/docs/mcp.md b/docs/mcp.md index 13f4624..12c3ddb 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -102,9 +102,10 @@ the `tools-*.ts` modules): `create_invoice` → "Record an invoice"); - MCP `annotations`: `readOnlyHint` / `destructiveHint` / `idempotentHint` derived from the verb, plus `openWorldHint`, which is `false` for everything - except `sync_billing_calendar` (the only tool that writes to a third-party - system). Four overrides correct the verb heuristic: `sync_billing_calendar` - gets `openWorldHint: true`; `pay_employee_salary` gets `destructiveHint: true` + except the tools that reach a third-party system: `sync_billing_calendar` + (writes to Google Calendar) and the three `wise_*` tools (read-only GETs to + Wise; they declare their own read/write annotations). Four overrides correct + the verb heuristic: `sync_billing_calendar` gets `openWorldHint: true`; `pay_employee_salary` gets `destructiveHint: true` — it writes the payslip plus the salary-expense ledger entry, the month can't be booked twice and no tool reverses it; and `set_subscription_period` (an upsert) and `unmark_accountant_notified` (clears a flag to null) get @@ -117,8 +118,8 @@ the `tools-*.ts` modules): - `_meta["openai/toolInvocation/invoking" | "invoked"]`, the status line ChatGPT shows while a call is in flight. -**Output schemas.** Every tool declares an `outputSchema` — all 71 of them, as of -server version 1.4.0. When a tool declares one the handler additionally returns +**Output schemas.** Every tool declares an `outputSchema` — all 74 of them, as of +server version 1.5.0. When a tool declares one the handler additionally returns the result as MCP `structuredContent` (the JSON text block stays, per MCP's back-compat recommendation), which is what ChatGPT and Codex prefer over parsing JSON out of text. `list_organizations` remains the reference implementation. @@ -180,7 +181,11 @@ board automatically. `sync_billing_calendar` pushes the board to Google Calendar **Ledger (內外帳)** — `list_transactions`, `get_transaction`, `list_outstanding_advances`, `create_transaction` (expense/income/advance/transfer), `update_transaction` (date/amount/category/project/…), `delete_transaction`, -`create_reimbursement` (book an advance as repaid). +`create_reimbursement` (book an advance as repaid). Rows imported by an +integration (the Wise sync) carry `externalSource` / `externalRef` and +`needsReview` (待確認); `list_transactions` takes `needsReview: true` to list only +those, and `update_transaction` clears the flag when it sets a category (or pass +`needsReview: false` explicitly). **Accounting master data** — parties: `list_parties`/`get_party`/`create_party`/ `update_party`/`delete_party`; categories: `list_categories`/`create_category`/ @@ -230,6 +235,18 @@ telling an owner/admin to fix it in 設定 › 整合. Every call to the externa service is logged with `auditIntegrationCall()`. See [`integrations.md`](integrations.md). +**Wise (read-only)** — `wise_list_balances` (profiles, balances with live amount, +and the ledger account each is mapped to + its cutover date), +`wise_get_statement` (`accountId` **or** `profileId` + `balanceId`, `startDate`, +optional `endDate` / `limit` → compact statement rows), and +`wise_sync_transactions` (`accountId?`, `startDate?`, `dryRun` — **defaults to +true**). The description tells the model to show the dry-run preview and get the +user's explicit approval before calling it with `dryRun: false`. All three only +send GET requests to Wise; the sync writes only this organization's ledger +(internal book, uncategorized, `needsReview`), deduped by Wise reference. Mapping +balances to ledger accounts and setting the cutover date is done in the web app +(設定 › 整合 › Wise). + **Not exposed (do in the app):** creating an organization, uploading invoice/receipt **files** (R2), multi-currency FX entry, and *connecting* Google Calendar (the OAuth consent needs a browser — do it once in 設定 › 整合, after which From 76fdf9987fc90debc47e61a992be9d896bb60f1b Mon Sep 17 00:00:00 2001 From: YJack0000 Date: Thu, 24 Sep 2026 17:41:36 +0800 Subject: [PATCH 20/27] =?UTF-8?q?[feature]=20Simpany=20=E6=95=B4=E5=90=88?= =?UTF-8?q?=EF=BC=9A=E7=99=BC=E7=A5=A8=E9=A0=81=E5=90=8C=E6=AD=A5=20Sheet?= =?UTF-8?q?=E3=80=81=E8=AA=B2=E7=A8=85=E5=88=A5=20/=20=E4=BD=9C=E5=BB=A2?= =?UTF-8?q?=E6=A8=99=E7=A4=BA=EF=BC=8C=E7=9C=8B=E6=9D=BF=E3=80=8C=E5=9C=A8?= =?UTF-8?q?=20Simpany=20=E9=96=8B=E7=AB=8B=E3=80=8D=E9=A0=90=E8=A6=BD?= =?UTF-8?q?=E7=A2=BA=E8=AA=8D=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../billing/issue-invoice-dialog.tsx | 10 +- src/app/dashboard/billing/page.tsx | 75 ++- src/app/dashboard/invoices/page.tsx | 110 +++- src/app/dashboard/invoices/simpany-actions.ts | 183 +++++++ .../invoices/simpany-issue-sheet.tsx | 502 ++++++++++++++++++ .../dashboard/invoices/simpany-sync-sheet.tsx | 198 +++++++ src/db/queries.ts | 5 + src/i18n/messages/invoices.ts | 118 ++++ 8 files changed, 1168 insertions(+), 33 deletions(-) create mode 100644 src/app/dashboard/invoices/simpany-actions.ts create mode 100644 src/app/dashboard/invoices/simpany-issue-sheet.tsx create mode 100644 src/app/dashboard/invoices/simpany-sync-sheet.tsx diff --git a/src/app/dashboard/billing/issue-invoice-dialog.tsx b/src/app/dashboard/billing/issue-invoice-dialog.tsx index f1fbdd2..269d084 100644 --- a/src/app/dashboard/billing/issue-invoice-dialog.tsx +++ b/src/app/dashboard/billing/issue-invoice-dialog.tsx @@ -1,6 +1,7 @@ "use client"; import * as React from "react"; +import Link from "next/link"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { toast } from "sonner"; @@ -40,7 +41,8 @@ function todayISO() { /** * 看板上的「開發票」:把這一期的資料預填成一張發票草稿,存檔後回填開發票日。 * - * 只做本系統這一半 —— Simpany 那邊還是要人去開。所以外部狀態預設 pending, + * Simpany 整合沒開(或不是 owner / admin)時的手動流程:只做本系統這一半 —— + * Simpany 那邊還是要人去開。所以外部狀態預設 pending, * 開好之後把 Simpany 的號碼填進來(或在發票頁補),對帳表才對得起來。 * * 請款金額是未稅還是含稅,各家合約寫法不同,這裡讓人選,選了就即時換算。 @@ -63,6 +65,7 @@ export function IssueInvoiceDialog({ currency: string; }>) { const t = useTranslations("billing.issueInvoice"); + const tInv = useTranslations("invoices.simpany"); const tCommon = useTranslations("billing.common"); const [open, setOpen] = React.useState(false); const [basis, setBasis] = React.useState<"gross" | "net">("gross"); @@ -104,6 +107,11 @@ export function IssueInvoiceDialog({ {t("dialog.title")} {t("dialog.description")} +

    + + {tInv("manualHint")} + +

    {/* 綁定關係與品名沿用這一期的資料,不讓人重打。 */} diff --git a/src/app/dashboard/billing/page.tsx b/src/app/dashboard/billing/page.tsx index c96cd05..edb5683 100644 --- a/src/app/dashboard/billing/page.tsx +++ b/src/app/dashboard/billing/page.tsx @@ -26,7 +26,8 @@ import { } from "@/db/queries"; import { deleteBillingItem } from "@/db/mutations"; import { formatCurrency, formatDate } from "@/lib/format"; -import { requireOrg } from "@/lib/session"; +import { canManageOrg, requireOrgWithRole } from "@/lib/session"; +import { getIntegration } from "@/lib/integrations/store"; import { getCalendarSettings } from "@/lib/google-calendar"; import { cn } from "@/lib/utils"; import { BillingStatusBadge } from "./billing-status"; @@ -37,6 +38,7 @@ import { QuickMark } from "./quick-actions"; import { IssueInvoiceDialog } from "./issue-invoice-dialog"; import { RecordPaymentDialog } from "./record-payment-dialog"; import { SyncCalendarButton } from "./sync-calendar-button"; +import { SimpanyIssueSheet } from "../invoices/simpany-issue-sheet"; export const dynamic = "force-dynamic"; @@ -107,8 +109,9 @@ function InvoiceCell({ row, t }: Readonly<{ row: BillingRow; t: T }>) { * 還沒請款 → 標記已請款 * 已請款 → 登記收款(有缺口時)+ 開發票(該開未開時) * - * 開發票走預填草稿,只有 billing_items 有實體列可綁;訂閱期別沒有 id 可綁, - * 退回單純的日期標記,發票本身到發票頁建立。 + * 開發票:Simpany 整合開啟時(owner / admin),請款項目與訂閱期別都走「在 Simpany 開立」 + * (預覽 → 確認 → 開立,並回填開發票日);否則請款項目走預填草稿、訂閱期別退回單純的 + * 日期標記,發票本身到 Simpany 手開。 */ /** * 項目底下那行來源說明:訂閱期別連回訂閱、掛了合約的一次性項目連回合約, @@ -140,8 +143,46 @@ function SourceLine({ row, t }: Readonly<{ row: BillingRow; t: T }>) { return <>{row.projectName ?? t("table.oneTimeSource")}; } -function RowActions({ row }: Readonly<{ row: BillingRow }>) { +/** 這一列「開發票」要用哪個流程:Simpany 直接開立,或本系統記錄 + 人去 Simpany 開。 */ +function InvoiceAction({ row, simpany }: Readonly<{ row: BillingRow; simpany: boolean }>) { const itemId = row.source === "billing_item" ? row.billingItemId : null; + if (simpany) { + const source = + itemId != null + ? ({ kind: "billing_item", billingItemId: itemId } as const) + : row.subscriptionId != null && row.periodStart + ? ({ kind: "subscription", subscriptionId: row.subscriptionId, periodStart: row.periodStart } as const) + : null; + if (source) { + return ( + + ); + } + } + if (itemId == null) { + return ; + } + return ( + + ); +} + +function RowActions({ row, simpany }: Readonly<{ row: BillingRow; simpany: boolean }>) { const outstanding = row.expected - row.paid; if (!row.billedOn) { @@ -160,20 +201,7 @@ function RowActions({ row }: Readonly<{ row: BillingRow }>) { customerName={row.customerName} /> )} - {row.needsInvoice && - (itemId == null ? ( - - ) : ( - - ))} + {row.needsInvoice && } ); @@ -182,19 +210,24 @@ function RowActions({ row }: Readonly<{ row: BillingRow }>) { export default async function BillingPage({ searchParams, }: Readonly<{ searchParams: Promise<{ filter?: string }> }>) { - const { orgId } = await requireOrg(); + const { orgId, role } = await requireOrgWithRole(); const t = await getTranslations("billing"); const { filter: rawFilter } = await searchParams; const filter = rawFilter && FILTERS.has(rawFilter as BoardFilter) ? (rawFilter as BoardFilter) : null; - const [rows, parties, projects, contracts, calendar] = await Promise.all([ + const [rows, parties, projects, contracts, calendar, simpanyIntegration] = await Promise.all([ listBillingBoard(orgId), listParties(orgId), listProjects(orgId), listContracts(orgId), getCalendarSettings(orgId), + getIntegration(orgId, "simpany"), ]); + // 「在 Simpany 開立」只給 owner / admin,且整合要已開啟;其餘沿用手動流程。 + const simpany = + canManageOrg(role) && + Boolean(simpanyIntegration?.enabled && simpanyIntegration.status === "connected"); // 摘要一律用全部資料算,篩選只影響下方表格 —— 否則點了卡片其他數字會跟著歸零。 const summary = summarizeBilling(rows); @@ -296,7 +329,7 @@ export default async function BillingPage({
    - +
    diff --git a/src/app/dashboard/invoices/page.tsx b/src/app/dashboard/invoices/page.tsx index 9155fa6..406beae 100644 --- a/src/app/dashboard/invoices/page.tsx +++ b/src/app/dashboard/invoices/page.tsx @@ -23,10 +23,14 @@ import { listParties, } from "@/db/queries"; import { deleteInvoice } from "@/db/mutations"; -import { formatCurrency, formatDate } from "@/lib/format"; -import { requireOrg } from "@/lib/session"; +import { formatCurrency, formatDate, formatDateTime } from "@/lib/format"; +import { canManageOrg, requireOrgWithRole } from "@/lib/session"; +import { getIntegration } from "@/lib/integrations/store"; +import type { IntegrationSummary } from "@/lib/integrations/types"; +import { defaultSyncRange } from "@/lib/simpany-sync"; import { NewInvoiceDialog } from "./new-invoice-dialog"; import { EditInvoiceForm } from "./edit-invoice-form"; +import { SimpanySyncSheet } from "./simpany-sync-sheet"; export const dynamic = "force-dynamic"; @@ -54,29 +58,106 @@ async function ExternalStatusBadge({ status }: Readonly<{ status: string }>) { return {externalLabel[status] ?? status}; } -export default async function InvoicesPage({ - searchParams, -}: Readonly<{ searchParams: Promise<{ direction?: string }> }>) { +const taxChipClass: Record = { + taxable: "text-muted-foreground", + zero_rated: "border-sky-500/40 text-sky-700 dark:text-sky-400", + exempt: "border-amber-500/40 text-amber-700 dark:text-amber-400", +}; + +/** 發票列的狀態格:課稅別 + 有效 / 作廢(作廢附原因)。 */ +async function InvoiceStatusCell({ + status, + taxTreatment, + voidReason, +}: Readonly<{ status: string; taxTreatment: string; voidReason: string | null }>) { const t = await getTranslations("invoices"); + const taxLabel: Record = { + taxable: t("taxTreatment.taxable"), + zero_rated: t("taxTreatment.zero_rated"), + exempt: t("taxTreatment.exempt"), + }; const statusLabel: Record = { valid: t("status.valid"), - void: t("status.void"), + void: t("voidedChip"), allowance: t("status.allowance"), }; + return ( +
    +
    + + {taxLabel[taxTreatment] ?? taxTreatment} + + {statusLabel[status] ?? status} +
    + {status === "void" && voidReason ? ( + + {t("voidReason", { reason: voidReason })} + + ) : null} +
    + ); +} + +/** 標題下方那行 Simpany 狀態:連接了才顯示。 */ +async function SimpanyStatusLine({ + integration, + canManage, +}: Readonly<{ integration: IntegrationSummary | null; canManage: boolean }>) { + if (!integration) return null; + const t = await getTranslations("invoices.simpany.status"); + const settings = ( + + {t("settingsLink")} + + ); + let text: React.ReactNode; + if (integration.status !== "connected") { + text = ( + + {t("needsReauth", { error: integration.lastError ?? "" })} · {settings} + + ); + } else if (!integration.enabled) { + text = ( + <> + {t("off")} {settings} + + ); + } else { + text = ( + <> + {integration.lastSyncedAt + ? t("lastSynced", { date: formatDateTime(integration.lastSyncedAt) }) + : t("neverSynced")} + {canManage ? null : ` · ${t("readOnly")}`} + + ); + } + return

    {text}

    ; +} + +export default async function InvoicesPage({ + searchParams, +}: Readonly<{ searchParams: Promise<{ direction?: string }> }>) { + const t = await getTranslations("invoices"); const directions = [ { key: "issued" as const, label: t("direction.issued") }, { key: "received" as const, label: t("direction.received") }, ]; - const { orgId } = await requireOrg(); + const { orgId, role } = await requireOrgWithRole(); + const canManage = canManageOrg(role); const { direction: raw } = await searchParams; const direction = raw === "received" ? "received" : "issued"; - const [rows, parties, contracts, billingItems] = await Promise.all([ + const [rows, parties, contracts, billingItems, simpany] = await Promise.all([ listInvoicesDetailed(orgId, direction), listParties(orgId), listContracts(orgId), listInvoiceableBillingItems(orgId), + getIntegration(orgId, "simpany"), ]); + const simpanyUsable = Boolean(simpany?.enabled && simpany.status === "connected"); + const syncRange = defaultSyncRange(); const partyOptions = parties.map((p) => ({ id: p.id, name: p.name })); const contractOptions = contracts.map((c) => ({ id: c.id, name: c.title })); @@ -88,6 +169,9 @@ export default async function InvoicesPage({ return ( <> + {simpanyUsable && canManage ? ( + + ) : null} + + + + {t("title")} + {t("description")} + + + {step.name === "form" ? ( +
    +
    + + setName(e.target.value)} required /> + + + setVat(e.target.value)} + /> + + + + + + setAddress(e.target.value)} /> + + + setEmails(e.target.value)} + /> + + + + + {taxTreatment === "zero_rated" ? ( + + + + ) : ( +
    + )} + + + setItemName(e.target.value)} required /> + + + {isForeign ? ( + <> + + setAmount(e.target.value)} + required + /> + + + setExchangeRate(e.target.value)} + required + /> + +

    + {t("fields.exchangeRateHint", { twd: twd == null ? "—" : formatCurrency(twd, "TWD") })} +

    + + ) : ( + <> + + setAmount(e.target.value)} + required + /> + + + + + + )} + + + setRemark(e.target.value)} + /> + + + {error ? : null} +
    + + + + + ) : null} + + {step.name === "preview" ? ( +
    +
    + + {error ? : null} + +
    + + + + +
    + ) : null} + + {step.name === "done" ? ( +
    +
    +

    + + {t("done", { number: step.result.invoiceNumber ?? step.result.externalId })} +

    +

    + {t("doneDetail", { total: step.result.total.toLocaleString("zh-TW") })} +

    +
    + + + +
    + ) : null} + + + ); +} + +function ErrorBox({ message }: Readonly<{ message: string }>) { + return ( +

    + {message} +

    + ); +} + +function Row({ label, children }: Readonly<{ label: string; children: React.ReactNode }>) { + return ( +
    + {label} + {children} +
    + ); +} + +function PreviewCard({ preview: p }: Readonly<{ preview: InvoicePreview }>) { + const t = useTranslations("invoices.simpany.issue"); + const tTax = useTranslations("invoices.taxTreatment"); + return ( +
    +
    + + {p.type} · {p.buyer.name} + {p.buyer.vat ? `(${p.buyer.vat})` : ""} + + + {p.buyer.emails.length ? p.buyer.emails.join(", ") : t("summary.noEmails")} + + + {tTax(p.taxTreatment)} + {p.zeroRateReason ? ` · ${p.zeroRateReason.code} ${p.zeroRateReason.name}` : ""} + + {p.foreign ? ( + + {p.foreign.currency} {p.foreign.amount} × {p.foreign.exchangeRate} ={" "} + {formatCurrency(p.foreign.twdAmount, "TWD")} + + ) : null} + {p.remark ? {p.remark} : null} +
    + +
    + +
      + {p.items.map((it, i) => ( +
    • + + {it.name} + {it.quantity === 1 ? "" : ` × ${it.quantity}`} + + {formatCurrency(it.subTotal, "TWD")} +
    • + ))} +
    +
    + +
    +
    +
    {t("summary.untaxed")}
    +
    {formatCurrency(p.amounts.untaxed, "TWD")}
    +
    +
    +
    {t("summary.taxAmount")}
    +
    {formatCurrency(p.amounts.tax, "TWD")}
    +
    +
    +
    {t("summary.total")}
    +
    {formatCurrency(p.amounts.total, "TWD")}
    +
    +
    + + {p.warnings.length > 0 ? ( +
    +

    + {t("warningsTitle")} +

    +
      + {p.warnings.map((w) => ( +
    • {w}
    • + ))} +
    +
    + ) : null} + +

    + {t("expires", { time: formatDateTime(p.expiresAt) })} +

    +
    + ); +} diff --git a/src/app/dashboard/invoices/simpany-sync-sheet.tsx b/src/app/dashboard/invoices/simpany-sync-sheet.tsx new file mode 100644 index 0000000..dfc0a9c --- /dev/null +++ b/src/app/dashboard/invoices/simpany-sync-sheet.tsx @@ -0,0 +1,198 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { RefreshCw } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Field } from "@/components/form-field"; +import { DatePicker } from "@/components/date-picker"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@/components/ui/sheet"; +import { formatCurrency } from "@/lib/format"; +import type { SyncResult } from "@/lib/simpany-sync"; +import { syncSimpanyAction } from "./simpany-actions"; + +/** + * 「從 Simpany 同步」:選日期區間 → 同步 → 就地顯示結果(數量、自動綁定、作廢清理、 + * 需要人工確認)。結果本身就是回饋,不另外跳 toast。 + */ +export function SimpanySyncSheet({ + defaultStart, + defaultEnd, +}: Readonly<{ defaultStart: string; defaultEnd: string }>) { + const t = useTranslations("invoices.simpany.sync"); + const router = useRouter(); + const [open, setOpen] = useState(false); + const [start, setStart] = useState(defaultStart); + const [end, setEnd] = useState(defaultEnd); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const [pending, run] = useTransition(); + + function submit() { + setError(null); + run(async () => { + const res = await syncSimpanyAction({ startDate: start, endDate: end }); + if (!res.ok) { + setError(res.error); + return; + } + setResult(res.data); + router.refresh(); + }); + } + + return ( + { + if (pending) return; + setOpen(next); + if (!next) { + setResult(null); + setError(null); + } + }} + > + + + + + + {t("title")} + {t("description")} + +
    +
    + + + + + + +
    + {error ? ( +

    + {error} +

    + ) : null} + {result ? : null} +
    + + + + +
    +
    + ); +} + +function SyncResultView({ result }: Readonly<{ result: SyncResult }>) { + const t = useTranslations("invoices.simpany.sync"); + return ( +
    +

    {t("resultTitle")}

    +

    + {t("counts", { + seen: result.seen, + created: result.created, + updated: result.updated, + unchanged: result.unchanged, + voided: result.voided, + linked: result.autoLinked.length, + })} +

    + {result.incomplete ? ( +

    + {t("incomplete")} +

    + ) : null} + + {result.autoLinked.length > 0 ? ( +
    +

    {t("linkedTitle")}

    +
      + {result.autoLinked.map((l) => ( +
    • + {l.invoiceNumber ?? t("noNumber")} → {l.linkedTo.join("、")} +
    • + ))} +
    +
    + ) : null} + + {result.voidCleanups.length > 0 ? ( +
    +

    {t("voidTitle")}

    +
      + {result.voidCleanups.map((v) => { + const parts: string[] = []; + if (v.clearedBillingItemId != null) parts.push(t("voidBilling", { id: v.clearedBillingItemId })); + if (v.clearedSubscription) { + parts.push( + t("voidSubscription", { + id: v.clearedSubscription.subscriptionId, + period: v.clearedSubscription.periodStart, + }), + ); + } + if (v.unlinkedTransactionIds.length) { + parts.push(t("voidTxns", { count: v.unlinkedTransactionIds.length })); + } + return ( +
    • + {t("voidLine", { number: v.invoiceNumber ?? t("noNumber"), what: parts.join("、") || "—" })} +
    • + ); + })} +
    +
    + ) : null} + +
    +

    {t("reviewTitle")}

    + {result.needsReview.length === 0 ? ( +

    {t("nothingToReview")}

    + ) : ( +
      + {result.needsReview.map((r, i) => ( +
    • +
      + + {r.invoiceNumber ?? t("noNumber")} · {r.buyer ?? "—"} + + + {formatCurrency(r.amount, "TWD")} + +
      +

      {r.reason}

      + {r.candidates.length > 0 ? ( +
        + {r.candidates.map((c) => ( +
      • {c.label}
      • + ))} +
      + ) : null} +
    • + ))} +
    + )} +
    +
    + ); +} diff --git a/src/db/queries.ts b/src/db/queries.ts index 463e018..4b63b7f 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -447,6 +447,11 @@ export async function listInvoicesDetailed( billingItemTitle: billingItems.title, externalStatus: invoices.externalStatus, externalRef: invoices.externalRef, + taxTreatment: invoices.taxTreatment, + zeroRateReason: invoices.zeroRateReason, + invoiceType: invoices.invoiceType, + voidedAt: invoices.voidedAt, + voidReason: invoices.voidReason, }) .from(invoices) .leftJoin(parties, eq(parties.id, invoices.partyId)) diff --git a/src/i18n/messages/invoices.ts b/src/i18n/messages/invoices.ts index f011c0e..9b5c237 100644 --- a/src/i18n/messages/invoices.ts +++ b/src/i18n/messages/invoices.ts @@ -74,6 +74,124 @@ const invoices = { note: { "zh-TW": "備註", en: "Note" }, notePlaceholder: { "zh-TW": "選填", en: "Optional" }, }, + taxTreatment: { + taxable: { "zh-TW": "應稅", en: "Taxable" }, + zero_rated: { "zh-TW": "零稅率", en: "Zero-rated" }, + exempt: { "zh-TW": "免稅", en: "Exempt" }, + }, + voidedChip: { "zh-TW": "已作廢", en: "Voided" }, + voidReason: { "zh-TW": "作廢原因:{reason}", en: "Void reason: {reason}" }, + simpany: { + status: { + lastSynced: { "zh-TW": "Simpany 已連接 · 上次同步 {date}", en: "Simpany connected · last synced {date}" }, + neverSynced: { "zh-TW": "Simpany 已連接 · 尚未同步", en: "Simpany connected · not synced yet" }, + off: { "zh-TW": "Simpany 整合已連接但未開啟。", en: "The Simpany integration is connected but switched off." }, + needsReauth: { "zh-TW": "Simpany 需要重新連接:{error}", en: "Simpany needs reconnecting: {error}" }, + settingsLink: { "zh-TW": "設定 › 整合", en: "Settings › Integrations" }, + readOnly: { "zh-TW": "只有擁有者或管理員可以同步、開立或作廢。", en: "Only owners or admins can sync, issue or void." }, + }, + sync: { + trigger: { "zh-TW": "從 Simpany 同步", en: "Sync from Simpany" }, + title: { "zh-TW": "從 Simpany 同步發票", en: "Sync invoices from Simpany" }, + description: { + "zh-TW": "把這段期間在 Simpany 開出與作廢的發票拉回來。同客戶、同金額、±45 天內只有一個候選的收款或請款會自動綁定;有疑義的列在下方讓你處理。重跑不會重複建立。", + en: "Pulls invoices issued and voided in Simpany during this period. Payments or billing items of the same client, same amount, within ±45 days with exactly one candidate are linked automatically; anything ambiguous is listed below. Safe to re-run.", + }, + startDate: { "zh-TW": "起日", en: "From" }, + endDate: { "zh-TW": "迄日", en: "To" }, + submit: { "zh-TW": "開始同步", en: "Sync" }, + submitting: { "zh-TW": "同步中…", en: "Syncing…" }, + close: { "zh-TW": "關閉", en: "Close" }, + resultTitle: { "zh-TW": "同步結果", en: "Result" }, + counts: { + "zh-TW": "Simpany {seen} 張:新增 {created}、更新 {updated}、未變動 {unchanged}、作廢 {voided}、自動綁定 {linked}", + en: "{seen} in Simpany: {created} added, {updated} updated, {unchanged} unchanged, {voided} voided, {linked} auto-linked", + }, + incomplete: { + "zh-TW": "這次沒做完(發票太多),再按一次同步會接著處理。", + en: "Not finished (too many invoices) — sync again to continue.", + }, + linkedTitle: { "zh-TW": "已自動綁定", en: "Linked automatically" }, + voidTitle: { "zh-TW": "作廢後已重新列為待開發票", en: "Voided — back to needing an invoice" }, + voidLine: { + "zh-TW": "{number}:{what}", + en: "{number}: {what}", + }, + voidBilling: { "zh-TW": "請款項目 #{id}", en: "billing item #{id}" }, + voidSubscription: { "zh-TW": "訂閱 #{id} {period} 期", en: "subscription #{id}, period {period}" }, + voidTxns: { "zh-TW": "解除 {count} 筆收款綁定", en: "{count} payments unlinked" }, + reviewTitle: { "zh-TW": "需要人工確認", en: "Needs review" }, + nothingToReview: { "zh-TW": "沒有需要人工確認的項目。", en: "Nothing needs review." }, + noNumber: { "zh-TW": "(無號碼)", en: "(no number)" }, + }, + issue: { + trigger: { "zh-TW": "在 Simpany 開立", en: "Issue in Simpany" }, + title: { "zh-TW": "在 Simpany 開立發票", en: "Issue an invoice in Simpany" }, + description: { + "zh-TW": "先預覽,確認無誤再開立。開立會產生正式電子發票、上傳財政部並寄通知給買受人。", + en: "Preview first, then issue. Issuing creates a legal e-invoice, uploads it to the Ministry of Finance and notifies the buyer.", + }, + fields: { + type: { "zh-TW": "發票類型", en: "Invoice type" }, + typeAuto: { "zh-TW": "自動(有統編 B2B,否則 B2C)", en: "Auto (B2B with a tax ID, else B2C)" }, + vat: { "zh-TW": "買受人統編", en: "Buyer tax ID" }, + vatPlaceholder: { "zh-TW": "8 碼;海外買方留空", en: "8 digits; blank for foreign buyers" }, + name: { "zh-TW": "買受人名稱", en: "Buyer name" }, + address: { "zh-TW": "地址", en: "Address" }, + emails: { "zh-TW": "通知 Email", en: "Notification emails" }, + emailsPlaceholder: { "zh-TW": "多個用逗號分隔;留空則用客戶聯絡資料裡的 email", en: "Comma-separated; blank uses emails from the client's contact" }, + taxTreatment: { "zh-TW": "課稅別", en: "Tax treatment" }, + taxAuto: { "zh-TW": "自動(外幣且無統編 → 零稅率)", en: "Auto (foreign currency, no tax ID → zero-rated)" }, + zeroRateReason: { "zh-TW": "零稅率原因", en: "Zero-rate reason" }, + itemName: { "zh-TW": "品名", en: "Item" }, + amount: { "zh-TW": "金額(台幣)", en: "Amount (TWD)" }, + basis: { "zh-TW": "金額基準", en: "Amount basis" }, + basisGross: { "zh-TW": "含稅", en: "Tax-inclusive" }, + basisNet: { "zh-TW": "未稅", en: "Tax-exclusive" }, + foreignAmount: { "zh-TW": "外幣金額({currency})", en: "Foreign amount ({currency})" }, + exchangeRate: { "zh-TW": "匯率(水單)", en: "Exchange rate (remittance slip)" }, + exchangeRateHint: { + "zh-TW": "必須取自銀行的匯入匯款水單,不可自行估算。台幣銷售額 = round(外幣金額 × 匯率) = {twd}", + en: "Must come from the bank's remittance slip, not an estimate. TWD sales = round(foreign × rate) = {twd}", + }, + remark: { "zh-TW": "備註", en: "Remark" }, + remarkPlaceholder: { "zh-TW": "選填,例如報價單號", en: "Optional, e.g. a quote number" }, + }, + preview: { "zh-TW": "預覽", en: "Preview" }, + previewing: { "zh-TW": "產生預覽…", en: "Preparing preview…" }, + back: { "zh-TW": "返回修改", en: "Back to edit" }, + confirmCheck: { + "zh-TW": "我已確認以上內容正確。開立後會上傳財政部並寄通知給買受人,只能以作廢撤銷。", + en: "I've checked the above. Issuing uploads it to the Ministry of Finance and notifies the buyer; it can only be undone by voiding.", + }, + confirm: { "zh-TW": "確認開立", en: "Issue invoice" }, + issuing: { "zh-TW": "開立中…", en: "Issuing…" }, + done: { "zh-TW": "已開立 {number}", en: "Issued {number}" }, + doneDetail: { + "zh-TW": "總計 NT${total}。發票已存進本系統並回填這一期的開發票日。", + en: "Total NT${total}. Saved to the books and this period's invoice date filled in.", + }, + close: { "zh-TW": "關閉", en: "Close" }, + expires: { "zh-TW": "預覽有效至 {time}", en: "Preview valid until {time}" }, + warningsTitle: { "zh-TW": "請注意", en: "Please check" }, + summary: { + buyer: { "zh-TW": "買受人", en: "Buyer" }, + emails: { "zh-TW": "通知", en: "Notify" }, + noEmails: { "zh-TW": "(不寄通知)", en: "(no notification)" }, + tax: { "zh-TW": "課稅別", en: "Tax" }, + items: { "zh-TW": "品項", en: "Items" }, + untaxed: { "zh-TW": "未稅", en: "Excl. tax" }, + taxAmount: { "zh-TW": "稅額", en: "Tax" }, + total: { "zh-TW": "總計", en: "Total" }, + foreign: { "zh-TW": "外幣換算", en: "FX conversion" }, + remark: { "zh-TW": "備註", en: "Remark" }, + }, + }, + manualHint: { + "zh-TW": "想直接在這裡開立電子發票?請擁有者或管理員到 設定 › 整合 連接並開啟 Simpany。", + en: "Want to issue e-invoices from here? An owner or admin can connect and enable Simpany under Settings › Integrations.", + }, + }, reconcile: { title: { "zh-TW": "Simpany 對帳", en: "Simpany reconciliation" }, description: { "zh-TW": "本系統該開的發票,與 Simpany 實際開的對得起來嗎", en: "Do the invoices this system expects match what Simpany actually issued?" }, From fe20f6019aec2e3518c6130304dbb00c5b1a33a7 Mon Sep 17 00:00:00 2001 From: YJack0000 Date: Thu, 24 Sep 2026 17:42:21 +0800 Subject: [PATCH 21/27] =?UTF-8?q?[docs]=20Simpany=20=E6=95=B4=E5=90=88?= =?UTF-8?q?=EF=BC=9A=E7=AB=AF=E9=BB=9E=E3=80=81=E9=9D=9E=E5=AE=98=E6=96=B9?= =?UTF-8?q?=20API=20=E9=A2=A8=E9=9A=AA=E3=80=81=E9=87=8D=E6=96=B0=E7=99=BB?= =?UTF-8?q?=E5=85=A5=E8=88=87=20MCP=20=E5=B7=A5=E5=85=B7=E6=B8=85=E5=96=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/integrations.md | 56 +++++++++++++++++++++++++++++++++++++++++++- docs/mcp.md | 28 ++++++++++++++++++---- 2 files changed, 78 insertions(+), 6 deletions(-) diff --git a/docs/integrations.md b/docs/integrations.md index 31b291e..8ce5046 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -16,7 +16,7 @@ settings page just lists it alongside the others. | `migrations/0023_org_integrations.sql` / `orgIntegrations` in `src/db/schema.ts` | One row per (organization, provider). `credentials_enc` / `token_cache_enc` are ciphertext from `src/lib/crypto.ts` (`FIELD_ENCRYPTION_KEY`). `config` is non-secret jsonb. | | `src/lib/integrations/types.ts` | Provider ids, field/catalog/provider types, `IntegrationSummary` (the secret-free view). Client-safe. | | `src/lib/integrations/catalog.ts` | Static catalog: logo + credential/config fields per provider. Drives the settings UI. Client-safe. | -| `src/lib/integrations/registry.ts` | Map of **implementations** (`testConnection`). Empty until a provider lands. Server only. | +| `src/lib/integrations/registry.ts` | Map of **implementations** (`testConnection`). Simpany is registered. Server only. | | `src/lib/integrations/store.ts` | The only code that reads/writes `org_integrations`. Server only. | | `src/app/dashboard/settings/integrations/` | Settings page, server actions (owner/admin only), connect Sheet. | | `src/lib/mcp/tools-integrations.ts` | `list_integrations`, plus `requireIntegrationForTool` and `auditIntegrationCall` for provider tools. | @@ -137,6 +137,60 @@ Also: a new migration extending `chk_org_integration_provider` (and the matching `check(...)` in `schema.ts`), the id in `INTEGRATION_PROVIDER_IDS`, a catalog entry + `INTEGRATION_ORDER`, and `integrations.providers..name/description` in i18n. +## Simpany(電子發票) + +Simpany(simpany.co)是公司的電子發票加值中心兼記帳士。**它沒有公開 API**:這裡用的是 +它會員網頁背後的私有 REST API(讀前端 bundle、用真實 session 做唯讀呼叫確認過形狀)。 +Simpany 改版就可能壞,所以所有回應都防禦式解析,認不得就把 Simpany 的原始錯誤訊息 +(截短)丟給使用者,不猜。使用者明確選擇了這條路,並同意把 Simpany 帳密加密存放。 + +| Where | What | +| --- | --- | +| `src/lib/integrations/simpany.ts` | `simpanyProvider`(連接測試)與 `SimpanyClient` / `getSimpanyClient(orgId)` | +| `src/lib/simpany-sync.ts` | Simpany → `invoices` 同步、自動綁定、作廢清理 | +| `src/lib/simpany-issue.ts` | 預覽(`invoice_drafts`)→ 開立、作廢;MCP 與 web 共用 | +| `src/lib/mcp/tools-simpany.ts` | MCP 工具(見 [mcp.md](mcp.md)) | +| `src/app/dashboard/invoices/simpany-*.ts(x)` | 發票頁「從 Simpany 同步」、看板「在 Simpany 開立」、server actions | +| `migrations/0025_invoice_simpany_sync.sql` | invoices 的課稅別 / 零稅率原因 / 外幣匯率 / B2B-B2C / `external_id` / 作廢欄位,`invoice_drafts` 表 | + +**Config**:`companyId` + `companyName`(非機密)。帳號底下只有一家公司時連接時自動選; +多家就要在連接 Sheet 填「公司 ID」(失敗訊息會列出可選的 ID)。 + +**用到的端點**(其他一概不碰): + +| Host | Endpoint | 用途 | +| --- | --- | --- | +| `api.simpany.co/v1` | `POST login` `{account, password}` → `data.token`(JWT,`exp` ≈ 30 天) | 登入 | +| | `GET me` → `data.companies[]` | 選公司 | +| `member2.simpany.co/api/v1/c/{companyId}/` | `GET receipts?status=ALL\|INVALID&startDate&endDate&page&limit[&query]` | 列表(`status` 必填) | +| | `GET receipts/{R-id}` | 明細(id 是 R…,不是發票號碼) | +| | `POST receipts/b2b` / `receipts/b2c` | **開立**(照會員網頁組的 body) | +| | `DELETE receipts/{R-id}` `{reason, emails: []}` | **作廢** | +| | `GET receipts/zero-tax-rate-reasons` | 零稅率原因清單 | +| | `GET track-numbers?year=<民國年>` | 字軌剩餘(形狀未驗證,只用來提示) | + +所有請求帶 `Accept: application/json`、`X-Requested-With: XMLHttpRequest`、 +`Authorization: Bearer `。 + +**重新登入**:`getSimpanyClient` 先用 `loadTokenCache` 的 JWT(到期時間取自 JWT 的 +`exp`);沒有就用解密後的帳密登入並 `saveTokenCache`。請求回 401 → `clearTokenCache`、 +重新登入、重試一次;重新登入本身被拒(密碼改了)→ `markNeedsReauth`,整合轉成「需要 +重新連接」並丟出中文錯誤。網路錯 / 5xx → `recordSyncFailure`(狀態不變);成功 → +`recordSyncSuccess`。帳密與 JWT 不進 log、錯誤訊息或任何回傳值。 + +**開立一定兩段式**:preview 把要送出的 body 原樣存成 `invoice_drafts`(2 小時過期); +開立只收 `draftId`,先以 `pending → issued` 的條件式 update 搶下草稿(按兩次也只會開一張), +再送出。Simpany 明確拒絕(4xx)→ 草稿退回 `pending`;網路中斷 / 5xx(不知道開了沒)→ +草稿改 `cancelled`,請使用者先同步確認再重新預覽,避免重複開立。 + +**稅務規則**(預覽時檢查):B2B 要 8 碼統編;海外買方沒有台灣統編 → B2C、零稅率、 +原因 72 外銷勞務、`NOT_VIA_CUSTOMS`;外幣收款一定要提供取自銀行水單的匯率, +台幣銷售額 = round(外幣 × 匯率);稅額算法同 Simpany(含稅 round(sum − sum/1.05)、 +未稅 round(sum × 0.05))。外銷勞務**不是**免稅(FW10873800 就是開成 B2C 免稅而作廢)。 + +**xlsx 對帳**(`src/lib/simpany-export.ts`、發票 › Simpany 對帳)保留,給沒開整合的組織用; +API 同步取代它。 + ## Security rules - Credentials are encrypted with `FIELD_ENCRYPTION_KEY` (see diff --git a/docs/mcp.md b/docs/mcp.md index 13f4624..0c84742 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -102,9 +102,13 @@ the `tools-*.ts` modules): `create_invoice` → "Record an invoice"); - MCP `annotations`: `readOnlyHint` / `destructiveHint` / `idempotentHint` derived from the verb, plus `openWorldHint`, which is `false` for everything - except `sync_billing_calendar` (the only tool that writes to a third-party - system). Four overrides correct the verb heuristic: `sync_billing_calendar` - gets `openWorldHint: true`; `pay_employee_salary` gets `destructiveHint: true` + except `sync_billing_calendar` and the `simpany_*` tools (the ones that reach a + third-party system). The overrides that correct the verb heuristic: + `sync_billing_calendar` and every `simpany_*` tool get `openWorldHint: true`; + `simpany_void_invoice` gets `destructiveHint: true` (voiding a legal e-invoice + cannot be undone); `simpany_list_*` / `simpany_get_invoice` declare + `readOnlyHint: true` themselves (their names don't start with `list_`/`get_`); + `pay_employee_salary` gets `destructiveHint: true` — it writes the payslip plus the salary-expense ledger entry, the month can't be booked twice and no tool reverses it; and `set_subscription_period` (an upsert) and `unmark_accountant_notified` (clears a flag to null) get @@ -117,8 +121,9 @@ the `tools-*.ts` modules): - `_meta["openai/toolInvocation/invoking" | "invoked"]`, the status line ChatGPT shows while a call is in flight. -**Output schemas.** Every tool declares an `outputSchema` — all 71 of them, as of -server version 1.4.0. When a tool declares one the handler additionally returns +**Output schemas.** Every tool declares an `outputSchema` — all 78 of them, as of +server version 1.5.0 (the Simpany tools whose result shape comes from Simpany's +unofficial API declare an open object schema). When a tool declares one the handler additionally returns the result as MCP `structuredContent` (the JSON text block stays, per MCP's back-compat recommendation), which is what ChatGPT and Codex prefer over parsing JSON out of text. `list_organizations` remains the reference implementation. @@ -230,6 +235,19 @@ telling an owner/admin to fix it in 設定 › 整合. Every call to the externa service is logged with `auditIntegrationCall()`. See [`integrations.md`](integrations.md). +**Simpany e-invoice** (`src/lib/mcp/tools-simpany.ts`, unofficial API — see +integrations.md): + +| Tool | Inputs | Notes | +| --- | --- | --- | +| `simpany_list_invoices` | `startDate?`, `endDate?` (default last 90 days), `status?` (`all`/`void`), `query?` | Read straight from Simpany; compact rows incl. invoice number, R-id, type, buyer, total, status, void info. | +| `simpany_get_invoice` | `invoice` (number like `FW10873802` or R-id) | Full detail: items, tax type, zero-rate reason, emails, MOF upload status. | +| `simpany_sync_invoices` | `startDate?`, `endDate?` | Owner/admin. Upserts into `invoices` by `external_id`, auto-links unique same-party / same-amount / ±45-day income transactions and billing items / subscription periods, returns `needsReview` for ambiguous ones, clears 開發票日 of voided invoices. Writes only to these books. | +| `simpany_preview_invoice` | `transactionId?` / `billingItemId?` / `subscriptionId?`+`subscriptionPeriod?`, `type?`, `buyer?{vat,name,address,emails}`, `taxTreatment?`, `zeroRateReason?`, `customsClearance?`, `items?[{name,quantity,price}]`, `isTaxIncluded?`, `remark?`, `foreignCurrency?`, `foreignAmount?`, `exchangeRate?` | Validates and computes amounts exactly like Simpany, warns about duplicates, stores an `invoice_drafts` row (2 h). Does **not** issue. | +| `simpany_issue_invoice` | `draftId`, `notifyEmails?` | Owner/admin. Issues the previewed draft verbatim — a legal e-invoice uploaded to the MOF and emailed to the buyer. Only after the user approved the preview. Saves + links the invoice. | +| `simpany_void_invoice` | `invoice`, `reason` (≤ 20 chars) | Owner/admin, destructive. Voids in Simpany, re-syncs, clears 開發票日 / transaction links. | +| `simpany_list_zero_rate_reasons` | — | Simpany's reason codes (71 外銷貨物, 72 外銷勞務, …). | + **Not exposed (do in the app):** creating an organization, uploading invoice/receipt **files** (R2), multi-currency FX entry, and *connecting* Google Calendar (the OAuth consent needs a browser — do it once in 設定 › 整合, after which From a91a6caf02d90a20d0a6e71d47b0aa50113cb1ab Mon Sep 17 00:00:00 2001 From: YJack0000 Date: Thu, 24 Sep 2026 17:42:55 +0800 Subject: [PATCH 22/27] =?UTF-8?q?[fix]=20Wise=20=E6=8F=9B=E5=8C=AF?= =?UTF-8?q?=E5=96=AE=E9=82=8A=E8=BD=89=E5=B8=B3=E5=8F=AF=E7=B7=A8=E8=BC=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/integrations.md | 5 +- .../transactions/edit-transaction-form.tsx | 31 +++++++++++- src/app/dashboard/transactions/page.tsx | 15 +++++- src/db/mutations.ts | 47 ++++++++++++++++++- src/db/queries.ts | 1 + src/i18n/messages/transactions.ts | 6 +++ src/lib/external-transfer.ts | 41 ++++++++++++++++ src/lib/mcp/tools-transactions.ts | 17 +++++-- 8 files changed, 154 insertions(+), 9 deletions(-) create mode 100644 src/lib/external-transfer.ts diff --git a/docs/integrations.md b/docs/integrations.md index cbd3844..98f29cb 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -202,7 +202,10 @@ Wise 是**唯讀**整合:只把 Wise 對帳單的交易匯入本組織的帳 不進損益、兩邊帳戶餘額都正確,`needs_review = false`。 - 另一腳**沒有對應**時,退回 income / expense(對象「Wise 換匯」)並標 `needs_review`。 - 兩腳共用同一個 referenceNumber,所以 `external_ref` 加幣別後綴(`BALANCE-123:USD`)。 -- 單腳轉帳在 web 的編輯表單存檔時會被「轉帳需要兩個帳戶」擋下;要改請先刪掉再手動記。 +- 單腳轉帳可以照常編輯(web 表單只顯示原本那一腳的帳戶;MCP `update_transaction` 不動帳戶): + `src/lib/external-transfer.ts` 的 `externalSingleLegSide()` 只對「有 external_source、type = transfer、 + 只有一邊帳戶」的列放寬,一般手動轉帳仍需兩個帳戶。編輯時保留原本的 book(不套「轉帳固定 both」)。 + 交易列表的類型標籤依 `external_meta` 顯示成「換匯 USD → THB」。 **去重**:`(organization_id, external_source, external_ref)` 有部分唯一索引(migration 0026),寫入用 `ON CONFLICT DO NOTHING`。已存在的列(包括已軟刪除的)永遠不改、不重寫 —— 刪掉一筆同步進來的交易, diff --git a/src/app/dashboard/transactions/edit-transaction-form.tsx b/src/app/dashboard/transactions/edit-transaction-form.tsx index 6bc5366..72aaa17 100644 --- a/src/app/dashboard/transactions/edit-transaction-form.tsx +++ b/src/app/dashboard/transactions/edit-transaction-form.tsx @@ -4,6 +4,7 @@ import { useActionState, useState } from "react"; import { toast } from "sonner"; import { Paperclip } from "lucide-react"; import { useTranslations } from "next-intl"; +import { externalSingleLegSide } from "@/lib/external-transfer"; import { updateTransaction, deleteTransactionDocument, type ActionState } from "@/db/mutations"; import type { TxnDocument, AuditMeta as AuditMetaData } from "@/db/queries"; import { AuditMeta } from "@/components/audit-meta"; @@ -66,6 +67,10 @@ type Txn = { settleName: string | null; fromAccountId: number | null; toAccountId: number | null; + /** 外部同步來源(wise…);手動輸入為 null。 */ + externalSource?: string | null; + /** 外部同步的單腳換匯的顯示字串(「換匯 USD → THB」);其他交易為 null。 */ + conversionText?: string | null; projectId: number | null; contractId: number | null; }; @@ -121,12 +126,20 @@ export function EditTransactionForm({ ); const isTransfer = txn.type === "transfer"; + // 外部同步的單腳轉帳(Wise 換匯):只有原本那一腳的帳戶可選,另一腳固定空白。 + const singleLeg = externalSingleLegSide({ + type: txn.type, + externalSource: txn.externalSource ?? null, + fromAccountId: txn.fromAccountId, + toAccountId: txn.toAccountId, + }); const isIncome = txn.type === "income"; const isAdvance = txn.type === "advance"; const fromCurrency = accountCurrency(accounts, fromAccountId); const toCurrency = accountCurrency(accounts, toAccountId); // 代墊沒有帳戶(是員工先墊的),所以只有它還能自由選幣別。 - const lockedCurrency = isTransfer ? fromCurrency : accountCurrency(accounts, accountId); + let lockedCurrency = isTransfer ? fromCurrency : accountCurrency(accounts, accountId); + if (singleLeg === "to") lockedCurrency = toCurrency; const defaultCategoryName = categories.find((c) => c.id === txn.categoryId)?.name ?? ""; const typeLabel: Record = Object.fromEntries( TYPE_KEYS.map((k) => [k, t(`type.${k}`)]), @@ -195,7 +208,21 @@ export function EditTransactionForm({ )} - {isTransfer ? ( + {singleLeg ? ( + <> + +

    + {txn.conversionText ? `${txn.conversionText} · ` : null} + {t("table.conversionLegHint")} +

    + + ) : isTransfer ? ( <> string; + /** 外部同步的單腳轉帳(Wise 換匯)的類型標籤,例如「換匯 USD → THB」。 */ + conversionLabel: (c: { from: string; to: string } | null) => string; categories: Opt[]; parties: Opt[]; employees: Opt[]; @@ -100,6 +104,10 @@ function TransactionRow({ }>) { // 表格上「最後更新」的操作人:改過就顯示最後修改人,沒改過就顯示建立人 const updater = audit?.updatedBy ?? audit?.createdBy ?? null; + const singleLeg = externalSingleLegSide(t) !== null; + const typeText = singleLeg + ? conversionLabel(conversionCurrencies(t.externalMeta, t.currency)) + : (typeLabel[t.type] ?? t.type); return ( {t.partyName ?? t.settleName ?? "—"}
    - {typeLabel[t.type] ?? t.type} + {typeText} {t.needsReview ? ( tr("table.needsReviewHint", { source })} + conversionLabel={(c) => + c ? tr("type.conversion", c) : tr("type.conversionPlain") + } categories={categories} parties={partyOpts} employees={employeeOpts} diff --git a/src/db/mutations.ts b/src/db/mutations.ts index 4ee7105..4eae255 100644 --- a/src/db/mutations.ts +++ b/src/db/mutations.ts @@ -39,6 +39,7 @@ import { type ScheduleInput, } from "@/lib/billing-schedule"; import { findAccountCurrencyMismatches } from "@/lib/account-currency"; +import { externalSingleLegSide, type SingleLegSide } from "@/lib/external-transfer"; export type ActionState = { ok: boolean; error?: string }; @@ -427,6 +428,32 @@ async function resolveTransfer( return { fields: { ...blankFields, fromAccountId, toAccountId } }; } +/** + * 外部同步的單腳轉帳(Wise 換匯的一腳):只有原本那一腳的帳戶,另一腳固定留空。 + * 只在編輯既有的外部同步列時使用 —— 一般轉帳照 resolveTransfer 要求兩個帳戶。 + */ +async function resolveSingleLegTransfer( + db: ReturnType, + orgId: string, + side: SingleLegSide, + formData: FormData, +): Promise { + const accountId = num(formData.get(side === "from" ? "fromAccountId" : "toAccountId")); + if (!accountId) { + const t = await getTranslations("errors"); + return { error: t("required.transferAccounts") }; + } + const refError = await unownedRefError(db, orgId, [[bankAccounts, [accountId]]]); + if (refError) return { error: refError }; + return { + fields: { + ...blankFields, + fromAccountId: side === "from" ? accountId : null, + toAccountId: side === "to" ? accountId : null, + }, + }; +} + // 依情境(type)解析交易要寫的欄位,順便驗證;回傳欄位或錯誤訊息。 async function resolveTxnFields( db: ReturnType, @@ -1132,12 +1159,28 @@ export async function updateTransaction( // join(queries.ts listAccountantNotices)是純用 id 對的,會把對方的金額、 // 對象名稱與分類一起顯示出來。 const [owned] = await db - .select({ id: transactions.id }) + .select({ + id: transactions.id, + type: transactions.type, + book: transactions.book, + externalSource: transactions.externalSource, + fromAccountId: transactions.fromAccountId, + toAccountId: transactions.toAccountId, + }) .from(transactions) .where(and(eq(transactions.organizationId, orgId), eq(transactions.id, id))) .limit(1); if (!owned) return { ok: false, error: t("notFound.transaction") }; - const resolved = await resolveTxnFields(db, orgId, header.type, formData); + // 外部同步的單腳轉帳(Wise 換匯):只驗原本那一腳,且保留原本的 book(同步進來是 internal, + // 一般轉帳「固定 both」的規則不套用)。type 以 DB 為準,不聽表單。 + const singleLeg = externalSingleLegSide(owned); + if (singleLeg) { + header.type = owned.type; + header.book = owned.book === "internal" ? "internal" : "both"; + } + const resolved = singleLeg + ? await resolveSingleLegTransfer(db, orgId, singleLeg, formData) + : await resolveTxnFields(db, orgId, header.type, formData); if ("error" in resolved) return { ok: false, error: resolved.error }; const f = resolved.fields; const linkError = await transactionLinkError(db, orgId, formData); diff --git a/src/db/queries.ts b/src/db/queries.ts index 8e88668..50d6663 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -143,6 +143,7 @@ export async function listTransactions( needsReview: transactions.needsReview, externalSource: transactions.externalSource, externalRef: transactions.externalRef, + externalMeta: transactions.externalMeta, }) .from(transactions) .leftJoin(categories, eq(categories.id, transactions.categoryId)) diff --git a/src/i18n/messages/transactions.ts b/src/i18n/messages/transactions.ts index d527ea7..9e1073b 100644 --- a/src/i18n/messages/transactions.ts +++ b/src/i18n/messages/transactions.ts @@ -20,6 +20,10 @@ const transactions = { rowsCount: { "zh-TW": "{count} 筆", en: "{count} entries" }, uncategorized: { "zh-TW": "未分類", en: "Uncategorized" }, needsReview: { "zh-TW": "待確認", en: "To review" }, + conversionLegHint: { + "zh-TW": "外部同步的換匯(單邊):另一腳在另一個幣別的帳戶,各記一列。", + en: "Synced currency conversion (one leg): the other leg is booked on the other currency's account.", + }, needsReviewHint: { "zh-TW": "自動匯入({source}),還沒有人確認。指定分類後會清掉。", en: "Imported automatically ({source}) and not yet reviewed. Choosing a category clears it.", @@ -44,6 +48,8 @@ const transactions = { advance: { "zh-TW": "員工代墊", en: "Employee advance" }, reimbursement: { "zh-TW": "撥款", en: "Reimbursement" }, transfer: { "zh-TW": "轉帳", en: "Transfer" }, + conversion: { "zh-TW": "換匯 {from} → {to}", en: "FX {from} → {to}" }, + conversionPlain: { "zh-TW": "換匯", en: "FX conversion" }, }, filters: { book: { diff --git a/src/lib/external-transfer.ts b/src/lib/external-transfer.ts new file mode 100644 index 0000000..2eded61 --- /dev/null +++ b/src/lib/external-transfer.ts @@ -0,0 +1,41 @@ +/** + * 外部同步進來的「單腳轉帳」(Wise 換匯的其中一腳,見 src/lib/wise-sync.ts)。 + * + * 帳本一列只有一個幣別,所以跨幣換匯的兩腳各記一列 type = transfer、只填自己這邊的帳戶。 + * 一般手動轉帳仍然必須兩個帳戶都有;只有「外部同步來的、本來就只有一腳」的列才放寬。 + * 純函式,client / server 都能用。 + */ + +export type SingleLegSide = "from" | "to"; + +type Row = { + type: string; + externalSource: string | null; + fromAccountId: number | null; + toAccountId: number | null; +}; + +/** 外部同步的單腳轉帳 → 回傳帳戶在哪一腳;其餘(含一般轉帳)回 null。 */ +export function externalSingleLegSide(row: Row): SingleLegSide | null { + if (row.type !== "transfer" || !row.externalSource) return null; + const hasFrom = row.fromAccountId !== null; + const hasTo = row.toAccountId !== null; + if (hasFrom === hasTo) return null; + return hasFrom ? "from" : "to"; +} + +/** + * 從 external_meta 讀出換匯方向(「USD → THB」的兩個幣別);讀不到回 null。 + * DEBIT 腳:本列幣別 → 對方幣別;CREDIT 腳:對方幣別 → 本列幣別。 + */ +export function conversionCurrencies( + meta: unknown, + currency: string, +): { from: string; to: string } | null { + if (!meta || typeof meta !== "object") return null; + const m = meta as { wiseType?: unknown; conversion?: { counterCurrency?: unknown } | null }; + const counter = m.conversion?.counterCurrency; + if (typeof counter !== "string" || !counter) return null; + const own = currency.trim().toUpperCase(); + return m.wiseType === "CREDIT" ? { from: counter, to: own } : { from: own, to: counter }; +} diff --git a/src/lib/mcp/tools-transactions.ts b/src/lib/mcp/tools-transactions.ts index c6e1c5c..7cb975a 100644 --- a/src/lib/mcp/tools-transactions.ts +++ b/src/lib/mcp/tools-transactions.ts @@ -37,6 +37,7 @@ import { rowSchema, type ToolDef, } from "./shared"; +import { externalSingleLegSide } from "@/lib/external-transfer"; import { assertAccountCurrency, findMismatchInMap, @@ -163,6 +164,10 @@ const TXN_LIST_ROW = rowSchema({ }, externalSource: { type: ["string", "null"], description: "e.g. 'wise'; null when entered by hand." }, externalRef: { type: ["string", "null"], description: "Source reference, e.g. Wise referenceNumber." }, + externalMeta: { + type: ["object", "null"], + description: "Raw details from the source (merchant, original amount, rate, fees; for a Wise conversion leg: conversion.counterCurrency).", + }, }); // getOverview 已經把聚合結果轉成 number。 @@ -576,6 +581,7 @@ function applyTxnAmountPatch( patch: Record, args: Record, existing: { type: string; amount: string; currency: string }, + singleLeg = false, ) { const amountProvided = optNumber(args, "amount") !== undefined; const currencyProvided = optString(args, "currency") !== undefined; @@ -587,8 +593,11 @@ function applyTxnAmountPatch( patch.amountTwd = currency === "TWD" ? amount : null; } if (optBoolean(args, "reported") !== undefined) { + // 外部同步的單腳轉帳(Wise 換匯)不套「轉帳固定 both」,照 reported 決定。 patch.book = - existing.type === "transfer" || optBoolean(args, "reported") ? "both" : "internal"; + (existing.type === "transfer" && !singleLeg) || optBoolean(args, "reported") + ? "both" + : "internal"; } } @@ -930,7 +939,7 @@ export const transactionTools: Record = { update_transaction: { description: - "Edit a transaction's date, amount, currency, description, category (categoryId 0 clears it → 未分類; setting a category also clears needsReview on imported rows), needsReview (待確認 flag on rows imported by e.g. the Wise sync; pass false to confirm a row without choosing a category), project, contract, subscription, subscription period, reported flag, or billedToCompanyTaxId (有報公司統編) — only provided fields change. To change the account or counterparty, delete and recreate, or use the app. If the edit touches a contract-linked transaction, the result carries `contractProgress` for the contracts involved — when an entry is `fullyCollected` while the contract is still draft/active, tell the user and ask whether to set that contract to completed (已完成) via update_contract; never flip the status without asking.", + "Edit a transaction's date, amount, currency, description, category (categoryId 0 clears it → 未分類; setting a category also clears needsReview on imported rows), needsReview (待確認 flag on rows imported by e.g. the Wise sync; pass false to confirm a row without choosing a category; a synced Wise currency conversion is a transfer with only one account set — that is expected and it can be edited like any other row), project, contract, subscription, subscription period, reported flag, or billedToCompanyTaxId (有報公司統編) — only provided fields change. To change the account or counterparty, delete and recreate, or use the app. If the edit touches a contract-linked transaction, the result carries `contractProgress` for the contracts involved — when an entry is `fullyCollected` while the contract is still draft/active, tell the user and ask whether to set that contract to completed (已完成) via update_contract; never flip the status without asking.", inputSchema: { type: "object", properties: { @@ -984,11 +993,13 @@ export const transactionTools: Record = { contractId: transactions.contractId, fromAccountId: transactions.fromAccountId, toAccountId: transactions.toAccountId, + externalSource: transactions.externalSource, }) .from(transactions) .where(and(eq(transactions.organizationId, orgId), eq(transactions.id, id))) .limit(1); if (!existing) throw new Error(`Transaction ${id} not found in your organization.`); + const singleLeg = externalSingleLegSide(existing) !== null; const patch: Record = { updatedAt: new Date().toISOString() }; if (optDate(args, "txnDate") !== undefined) patch.txnDate = optDate(args, "txnDate"); @@ -1003,7 +1014,7 @@ export const transactionTools: Record = { if (optBoolean(args, "needsReview") !== undefined) { patch.needsReview = optBoolean(args, "needsReview"); } - applyTxnAmountPatch(patch, args, existing); + applyTxnAmountPatch(patch, args, existing, singleLeg); // 這支工具改不了帳戶,但改得了幣別 —— 改完仍要跟原本綁的帳戶對得起來。 if (typeof patch.currency === "string") { await assertAccountCurrency(db, orgId, patch.currency, [ From 05f15d7226321a57d8683f0488859710c08a6e89 Mon Sep 17 00:00:00 2001 From: YJack0000 Date: Thu, 24 Sep 2026 17:50:12 +0800 Subject: [PATCH 23/27] =?UTF-8?q?[docs]=20MCP=20=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E6=95=B8=E6=9B=B4=E6=96=B0=E7=82=BA=2085?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/mcp.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/mcp.md b/docs/mcp.md index aaac0c4..7230437 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -342,7 +342,7 @@ things that don't live in this repo: Both directories ask for the same thing in different words — OpenAI wants "test credentials for a fully populated account", Anthropic wants a "fully featured demo account with sample data". An empty workspace fails review: most -of the 74 tools would answer with an empty array and the reviewer has no way to +of the 85 tools would answer with an empty array and the reviewer has no way to tell what the connector does. Two commands produce that account. Run them against the environment you are From f0a81d70fb49d7041cc3dc332929abe945828a47 Mon Sep 17 00:00:00 2001 From: YJack0000 Date: Thu, 24 Sep 2026 18:07:04 +0800 Subject: [PATCH 24/27] =?UTF-8?q?[refactor]=20=E6=B8=85=20Sonar=EF=BC=9A?= =?UTF-8?q?=E6=8B=86=E9=AB=98=E8=AA=8D=E7=9F=A5=E8=A4=87=E9=9B=9C=E5=BA=A6?= =?UTF-8?q?=E5=87=BD=E5=BC=8F=E3=80=81=E5=B7=A2=E7=8B=80=E4=B8=89=E5=85=83?= =?UTF-8?q?=E3=80=81=E5=90=A6=E5=AE=9A=E6=A2=9D=E4=BB=B6=E7=AD=89=2080=20?= =?UTF-8?q?=E6=A2=9D=EF=BC=88=E8=A1=8C=E7=82=BA=E4=B8=8D=E8=AE=8A=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/migrate-employee-pii.ts | 167 ++-- .../bank-accounts/wise-sync-sheet.tsx | 14 +- src/app/dashboard/billing/page.tsx | 18 +- .../employees/employee-accounts-section.tsx | 6 +- src/app/dashboard/invoices/page.tsx | 22 +- .../invoices/simpany-issue-sheet.tsx | 428 ++++++---- .../settings/integrations/actions.ts | 63 +- .../integrations/integrations-client.tsx | 75 +- .../settings/integrations/wise-actions.ts | 4 +- .../integrations/wise-mapping-client.tsx | 8 +- .../transactions/edit-transaction-form.tsx | 410 +++++---- src/db/employee-accounts.ts | 62 +- src/lib/crypto.ts | 5 +- src/lib/employee-accounts.ts | 4 +- src/lib/integrations/simpany.ts | 56 +- src/lib/integrations/wise.ts | 9 +- src/lib/mcp/tools-employee-accounts.ts | 3 +- src/lib/mcp/tools-integrations.ts | 2 +- src/lib/simpany-issue.ts | 674 +++++++++------ src/lib/simpany-sync.ts | 364 ++++---- src/lib/wise-sync.ts | 787 +++++++++++------- 21 files changed, 1982 insertions(+), 1199 deletions(-) diff --git a/scripts/migrate-employee-pii.ts b/scripts/migrate-employee-pii.ts index b70148c..535b8b1 100644 --- a/scripts/migrate-employee-pii.ts +++ b/scripts/migrate-employee-pii.ts @@ -45,6 +45,71 @@ function bump(c: Counts, key: string) { c[key] = (c[key] ?? 0) + 1; } +type Db = ReturnType; +type LegacyAccount = NonNullable>; +type EmployeeRow = typeof employees.$inferSelect; +type SalaryRow = Pick; +type NationalIdRow = Pick; + +/** 既有帳戶裡是否已經有這串帳號(解密後正規化比對)。 */ +async function anyAccountMatches(existing: { enc: string }[], accountNumber: string): Promise { + const target = normalizeAccountNumber(accountNumber); + for (const a of existing) { + if (normalizeAccountNumber(await decryptField(a.enc)) === target) return true; + } + return false; +} + +/** 建帳戶 + 清空舊欄位,同一個交易。 */ +async function createAccountFromLegacy(db: Db, e: SalaryRow, parsed: LegacyAccount) { + const insert = db.insert(employeeBankAccounts).values({ + organizationId: e.organizationId, + employeeId: e.id, + kind: parsed.kind, + bankCode: parsed.bankCode, + branchCode: parsed.branchCode, + bankName: parsed.bankName, + accountHolder: e.name, + accountNumberEnc: await encryptField(parsed.accountNumber), + accountLast5: accountLast5(parsed.accountNumber), + currency: "TWD", + defaultForSalary: true, + defaultForReimbursement: false, + isActive: true, + note: LEGACY_ACCOUNT_NOTE, + }); + await db.batch([insert, clearSalaryAccount(db, e.id)]); +} + +function clearSalaryAccount(db: Db, id: number) { + return db.update(employees).set({ salaryAccount: null }).where(eq(employees.id, id)); +} + +/** 處理一位員工的 salary_account,回傳計數用的分類。 */ +async function migrateSalaryAccount(db: Db, e: SalaryRow): Promise { + const parsed = parseLegacySalaryAccount(e.salaryAccount ?? ""); + if (!parsed?.accountNumber) { + // 只有空白:直接清空 + if (apply) await clearSalaryAccount(db, e.id); + return "blankCleared"; + } + + const existing = await db + .select({ enc: employeeBankAccounts.accountNumberEnc }) + .from(employeeBankAccounts) + .where(and(eq(employeeBankAccounts.employeeId, e.id), isNull(employeeBankAccounts.deletedAt))); + + if (existing.length === 0) { + if (apply) await createAccountFromLegacy(db, e, parsed); + return parsed.kind === "bank" ? "createdBank" : "createdOther"; + } + + // 已經有帳戶:舊值若已經在其中之一,就只是還沒清掉的明文 + if (!(await anyAccountMatches(existing, parsed.accountNumber))) return "needsReview"; + if (apply) await clearSalaryAccount(db, e.id); + return "alreadyMigratedCleared"; +} + async function migrateSalaryAccounts(): Promise { const db = getDb(); const counts: Counts = {}; @@ -58,64 +123,33 @@ async function migrateSalaryAccounts(): Promise { .from(employees) .where(isNotNull(employees.salaryAccount)); - for (const e of rows) { - const clearLegacy = db - .update(employees) - .set({ salaryAccount: null }) - .where(eq(employees.id, e.id)); - const parsed = parseLegacySalaryAccount(e.salaryAccount ?? ""); - if (!parsed?.accountNumber) { - // 只有空白:直接清空 - bump(counts, "blankCleared"); - if (apply) await clearLegacy; - continue; - } + for (const e of rows) bump(counts, await migrateSalaryAccount(db, e)); + return counts; +} - const existing = await db - .select({ enc: employeeBankAccounts.accountNumberEnc }) - .from(employeeBankAccounts) - .where(and(eq(employeeBankAccounts.employeeId, e.id), isNull(employeeBankAccounts.deletedAt))); - - if (existing.length === 0) { - bump(counts, parsed.kind === "bank" ? "createdBank" : "createdOther"); - if (!apply) continue; - const insert = db.insert(employeeBankAccounts).values({ - organizationId: e.organizationId, - employeeId: e.id, - kind: parsed.kind, - bankCode: parsed.bankCode, - branchCode: parsed.branchCode, - bankName: parsed.bankName, - accountHolder: e.name, - accountNumberEnc: await encryptField(parsed.accountNumber), - accountLast5: accountLast5(parsed.accountNumber), - currency: "TWD", - defaultForSalary: true, - defaultForReimbursement: false, - isActive: true, - note: LEGACY_ACCOUNT_NOTE, - }); - await db.batch([insert, clearLegacy]); - continue; - } +function clearNationalId(db: Db, id: number) { + return db.update(employees).set({ nationalId: null }).where(eq(employees.id, id)); +} - // 已經有帳戶:舊值若已經在其中之一,就只是還沒清掉的明文 - const target = normalizeAccountNumber(parsed.accountNumber); - let matched = false; - for (const a of existing) { - if (normalizeAccountNumber(await decryptField(a.enc)) === target) { - matched = true; - break; - } - } - if (matched) { - bump(counts, "alreadyMigratedCleared"); - if (apply) await clearLegacy; - } else { - bump(counts, "needsReview"); +/** 處理一位員工的 national_id,回傳計數用的分類。 */ +async function migrateNationalId(db: Db, e: NationalIdRow): Promise { + const plain = e.nationalId?.trim() ?? ""; + if (!plain) { + if (apply) await clearNationalId(db, e.id); + return "blankCleared"; + } + if (!e.nationalIdEnc) { + if (apply) { + await db + .update(employees) + .set({ nationalIdEnc: await encryptField(plain), nationalId: null }) + .where(eq(employees.id, e.id)); } + return "encrypted"; } - return counts; + if ((await decryptField(e.nationalIdEnc)) !== plain) return "needsReview"; + if (apply) await clearNationalId(db, e.id); + return "alreadyEncryptedCleared"; } async function migrateNationalIds(): Promise { @@ -130,30 +164,7 @@ async function migrateNationalIds(): Promise { .from(employees) .where(isNotNull(employees.nationalId)); - for (const e of rows) { - const plain = e.nationalId?.trim() ?? ""; - if (!plain) { - bump(counts, "blankCleared"); - if (apply) await db.update(employees).set({ nationalId: null }).where(eq(employees.id, e.id)); - continue; - } - if (!e.nationalIdEnc) { - bump(counts, "encrypted"); - if (apply) { - await db - .update(employees) - .set({ nationalIdEnc: await encryptField(plain), nationalId: null }) - .where(eq(employees.id, e.id)); - } - continue; - } - if ((await decryptField(e.nationalIdEnc)) === plain) { - bump(counts, "alreadyEncryptedCleared"); - if (apply) await db.update(employees).set({ nationalId: null }).where(eq(employees.id, e.id)); - } else { - bump(counts, "needsReview"); - } - } + for (const e of rows) bump(counts, await migrateNationalId(db, e)); return counts; } diff --git a/src/app/dashboard/bank-accounts/wise-sync-sheet.tsx b/src/app/dashboard/bank-accounts/wise-sync-sheet.tsx index 7c42b2c..0e62f08 100644 --- a/src/app/dashboard/bank-accounts/wise-sync-sheet.tsx +++ b/src/app/dashboard/bank-accounts/wise-sync-sheet.tsx @@ -19,6 +19,13 @@ import { formatCurrency } from "@/lib/currency"; import type { SyncResult } from "@/lib/wise-sync"; import { applyWiseSync, previewWiseSync } from "./wise-sync-actions"; +/** 樣本金額前的正負號:收入 +、支出 −、轉帳不加。 */ +function amountSign(type: string): string { + if (type === "income") return "+"; + if (type === "expense") return "−"; + return ""; +} + /** * 帳戶頁「從 Wise 同步」:打開就先跑一次試算(dry run,不寫入),列出每個帳戶會新增 * 幾筆與前 50 筆樣本;使用者按「寫入 N 筆」才真的寫。只給 owner / admin 看到。 @@ -133,7 +140,10 @@ export function WiseSyncButton() {

    {t("sync.skipped", { list: skipped - .map((s) => `${s.profileName} ${s.currency}(${t(`sync.reason.${s.reason}`)})`) + .map((s) => { + const reason = t(`sync.reason.${s.reason}`); + return `${s.profileName} ${s.currency}(${reason})`; + }) .join("、"), })}

    @@ -172,7 +182,7 @@ export function WiseSyncButton() { {r.description} - {r.type === "income" ? "+" : r.type === "expense" ? "−" : ""} + {amountSign(r.type)} {formatCurrency(r.amount, r.currency)} diff --git a/src/app/dashboard/billing/page.tsx b/src/app/dashboard/billing/page.tsx index edb5683..c05d19f 100644 --- a/src/app/dashboard/billing/page.tsx +++ b/src/app/dashboard/billing/page.tsx @@ -38,7 +38,7 @@ import { QuickMark } from "./quick-actions"; import { IssueInvoiceDialog } from "./issue-invoice-dialog"; import { RecordPaymentDialog } from "./record-payment-dialog"; import { SyncCalendarButton } from "./sync-calendar-button"; -import { SimpanyIssueSheet } from "../invoices/simpany-issue-sheet"; +import { SimpanyIssueSheet, type SimpanyIssueSource } from "../invoices/simpany-issue-sheet"; export const dynamic = "force-dynamic"; @@ -143,16 +143,20 @@ function SourceLine({ row, t }: Readonly<{ row: BillingRow; t: T }>) { return <>{row.projectName ?? t("table.oneTimeSource")}; } +/** 這一列在 Simpany 開票時對應的來源:單次款項或訂閱的某一期;都對不上就回 null。 */ +function simpanySourceFor(row: BillingRow, itemId: number | null): SimpanyIssueSource | null { + if (itemId != null) return { kind: "billing_item", billingItemId: itemId }; + if (row.subscriptionId != null && row.periodStart) { + return { kind: "subscription", subscriptionId: row.subscriptionId, periodStart: row.periodStart }; + } + return null; +} + /** 這一列「開發票」要用哪個流程:Simpany 直接開立,或本系統記錄 + 人去 Simpany 開。 */ function InvoiceAction({ row, simpany }: Readonly<{ row: BillingRow; simpany: boolean }>) { const itemId = row.source === "billing_item" ? row.billingItemId : null; if (simpany) { - const source = - itemId != null - ? ({ kind: "billing_item", billingItemId: itemId } as const) - : row.subscriptionId != null && row.periodStart - ? ({ kind: "subscription", subscriptionId: row.subscriptionId, periodStart: row.periodStart } as const) - : null; + const source = simpanySourceFor(row, itemId); if (source) { return ( + // 攔 Enter:這塊在員工表單裡面,按 Enter 會把整張員工表單送出去。 + // 這個 div 本身不可互動,只是接住內層輸入框冒泡上來的 keydown(事件委派), + // 所以標 role="presentation"(jsx-a11y 對「接冒泡事件的容器」建議的做法)。 +
    {draft.id ? t("form.editTitle") : t("form.addTitle")}
    diff --git a/src/app/dashboard/invoices/page.tsx b/src/app/dashboard/invoices/page.tsx index 406beae..a0d54fd 100644 --- a/src/app/dashboard/invoices/page.tsx +++ b/src/app/dashboard/invoices/page.tsx @@ -111,13 +111,16 @@ async function SimpanyStatusLine({ ); let text: React.ReactNode; - if (integration.status !== "connected") { + if (integration.status === "connected" && integration.enabled) { text = ( - - {t("needsReauth", { error: integration.lastError ?? "" })} · {settings} - + <> + {integration.lastSyncedAt + ? t("lastSynced", { date: formatDateTime(integration.lastSyncedAt) }) + : t("neverSynced")} + {canManage ? null : ` · ${t("readOnly")}`} + ); - } else if (!integration.enabled) { + } else if (integration.status === "connected") { text = ( <> {t("off")} {settings} @@ -125,12 +128,9 @@ async function SimpanyStatusLine({ ); } else { text = ( - <> - {integration.lastSyncedAt - ? t("lastSynced", { date: formatDateTime(integration.lastSyncedAt) }) - : t("neverSynced")} - {canManage ? null : ` · ${t("readOnly")}`} - + + {t("needsReauth", { error: integration.lastError ?? "" })} · {settings} + ); } return

    {text}

    ; diff --git a/src/app/dashboard/invoices/simpany-issue-sheet.tsx b/src/app/dashboard/invoices/simpany-issue-sheet.tsx index 5fbc5d6..137acd4 100644 --- a/src/app/dashboard/invoices/simpany-issue-sheet.tsx +++ b/src/app/dashboard/invoices/simpany-issue-sheet.tsx @@ -47,6 +47,77 @@ type Step = const AUTO = "auto"; +/** 預設的零稅率原因(Simpany 清單還沒載入或載入失敗時用)。 */ +const FALLBACK_ZERO_TAX_REASONS: SimpanyZeroTaxReason[] = [ + { code: "71", name: "外銷貨物" }, + { code: "72", name: "外銷勞務" }, +]; + +/** 表單目前的值(字串原樣)加上已算好的外幣換算。 */ +type IssueFormValues = { + type: string; + vat: string; + name: string; + address: string; + emails: string; + taxTreatment: string; + zeroRateReason: string; + itemName: string; + amount: string; + basis: "gross" | "net"; + remark: string; + currency: string; + foreignAmount: number; + rate: number; + twd: number | null; +}; + +function parseEmailList(emails: string): string[] { + return emails + .split(/[,,;\s]+/) + .map((e) => e.trim()) + .filter(Boolean); +} + +/** 品項:台幣直接用輸入金額;外幣要有匯率算出台幣才送,否則交給 server 端處理。 */ +function buildItems(f: IssueFormValues, isForeign: boolean): SimpanyPreviewFormInput["items"] { + if (!isForeign) return [{ name: f.itemName, quantity: 1, price: Number(f.amount) || 0 }]; + if (f.twd == null) return undefined; + return [{ name: f.itemName, quantity: 1, price: f.twd }]; +} + +function buildPreviewInput(source: SimpanyIssueSource, f: IssueFormValues): SimpanyPreviewFormInput { + const isForeign = f.currency.toUpperCase() !== "TWD"; + const emailList = parseEmailList(f.emails); + const base: SimpanyPreviewFormInput = + source.kind === "billing_item" + ? { billingItemId: source.billingItemId } + : { subscriptionId: source.subscriptionId, subscriptionPeriod: source.periodStart }; + const tt = f.taxTreatment === AUTO ? undefined : (f.taxTreatment as InvoicePreview["taxTreatment"]); + return { + ...base, + type: f.type === AUTO ? undefined : (f.type as "B2B" | "B2C"), + buyer: { + vat: f.vat.trim() || null, + name: f.name.trim() || undefined, + address: f.address.trim() || undefined, + emails: emailList.length ? emailList : undefined, + }, + taxTreatment: tt, + zeroRateReason: tt === "zero_rated" ? f.zeroRateReason : undefined, + items: buildItems(f, isForeign), + isTaxIncluded: isForeign ? undefined : f.basis === "gross", + remark: f.remark.trim() || undefined, + ...(isForeign + ? { + foreignCurrency: f.currency.toUpperCase(), + foreignAmount: f.foreignAmount, + exchangeRate: f.rate > 0 ? f.rate : undefined, + } + : {}), + }; +} + /** * 看板上的「在 Simpany 開立」:表單 → 預覽(server 端算好金額、檢查重複、存成草稿)→ * 勾選確認 + 按「確認開立」才真的開。開立與 MCP 共用同一套 preview / issue 程式碼。 @@ -101,49 +172,29 @@ export function SimpanyIssueSheet({ loadZeroTaxReasonsAction().then(setReasons, () => setReasons([])); } - function buildInput(): SimpanyPreviewFormInput { - const emailList = emails - .split(/[,,;\s]+/) - .map((e) => e.trim()) - .filter(Boolean); - const base: SimpanyPreviewFormInput = - source.kind === "billing_item" - ? { billingItemId: source.billingItemId } - : { subscriptionId: source.subscriptionId, subscriptionPeriod: source.periodStart }; - const tt = taxTreatment === AUTO ? undefined : (taxTreatment as InvoicePreview["taxTreatment"]); - return { - ...base, - type: type === AUTO ? undefined : (type as "B2B" | "B2C"), - buyer: { - vat: vat.trim() || null, - name: name.trim() || undefined, - address: address.trim() || undefined, - emails: emailList.length ? emailList : undefined, - }, - taxTreatment: tt, - zeroRateReason: tt === "zero_rated" ? zeroRateReason : undefined, - items: isForeign - ? twd != null - ? [{ name: itemName, quantity: 1, price: twd }] - : undefined - : [{ name: itemName, quantity: 1, price: Number(amount) || 0 }], - isTaxIncluded: isForeign ? undefined : basis === "gross", - remark: remark.trim() || undefined, - ...(isForeign - ? { - foreignCurrency: currency.toUpperCase(), - foreignAmount, - exchangeRate: rate > 0 ? rate : undefined, - } - : {}), - }; - } - function preview(e: React.FormEvent) { e.preventDefault(); setError(null); run(async () => { - const res = await previewSimpanyAction(buildInput()); + const res = await previewSimpanyAction( + buildPreviewInput(source, { + type, + vat, + name, + address, + emails, + taxTreatment, + zeroRateReason, + itemName, + amount, + basis, + remark, + currency, + foreignAmount, + rate, + twd, + }), + ); if (!res.ok) { setError(res.error); return; @@ -260,13 +311,11 @@ export function SimpanyIssueSheet({ - {(reasons && reasons.length ? reasons : [{ code: "71", name: "外銷貨物" }, { code: "72", name: "外銷勞務" }]).map( - (r) => ( - - {r.code} {r.name} - - ), - )} + {(reasons?.length ? reasons : FALLBACK_ZERO_TAX_REASONS).map((r) => ( + + {r.code} {r.name} + + ))} @@ -278,57 +327,17 @@ export function SimpanyIssueSheet({ setItemName(e.target.value)} required /> - {isForeign ? ( - <> - - setAmount(e.target.value)} - required - /> - - - setExchangeRate(e.target.value)} - required - /> - -

    - {t("fields.exchangeRateHint", { twd: twd == null ? "—" : formatCurrency(twd, "TWD") })} -

    - - ) : ( - <> - - setAmount(e.target.value)} - required - /> - - - - - - )} + -
    - - {error ? : null} - -
    - - - - -
    + backToForm(step.preview.draftId)} + onIssue={() => issue(step.preview.draftId)} + /> ) : null} - {step.name === "done" ? ( -
    -
    -

    - - {t("done", { number: step.result.invoiceNumber ?? step.result.externalId })} -

    -

    - {t("doneDetail", { total: step.result.total.toLocaleString("zh-TW") })} -

    -
    - - - -
    - ) : null} + {step.name === "done" ? onOpenChange(false)} /> : null} ); } +/** 金額欄位:外幣要填原幣金額 + 匯率(顯示換算台幣),台幣填金額 + 含稅/未稅。 */ +function AmountFields({ + isForeign, + currency, + amount, + onAmountChange, + exchangeRate, + onExchangeRateChange, + twd, + basis, + onBasisChange, +}: Readonly<{ + isForeign: boolean; + currency: string; + amount: string; + onAmountChange: (v: string) => void; + exchangeRate: string; + onExchangeRateChange: (v: string) => void; + twd: number | null; + basis: "gross" | "net"; + onBasisChange: (v: "gross" | "net") => void; +}>) { + const t = useTranslations("invoices.simpany.issue"); + if (isForeign) { + return ( + <> + + onAmountChange(e.target.value)} + required + /> + + + onExchangeRateChange(e.target.value)} + required + /> + +

    + {t("fields.exchangeRateHint", { twd: twd == null ? "—" : formatCurrency(twd, "TWD") })} +

    + + ); + } + return ( + <> + + onAmountChange(e.target.value)} + required + /> + + + + + + ); +} + +function PreviewStep({ + preview, + error, + pending, + confirmed, + onConfirmedChange, + onBack, + onIssue, +}: Readonly<{ + preview: InvoicePreview; + error: string | null; + pending: boolean; + confirmed: boolean; + onConfirmedChange: (v: boolean) => void; + onBack: () => void; + onIssue: () => void; +}>) { + const t = useTranslations("invoices.simpany.issue"); + return ( +
    +
    + + {error ? : null} + +
    + + + + +
    + ); +} + +function DoneStep({ result, onClose }: Readonly<{ result: IssueResult; onClose: () => void }>) { + const t = useTranslations("invoices.simpany.issue"); + return ( +
    +
    +

    + + {t("done", { number: result.invoiceNumber ?? result.externalId })} +

    +

    + {t("doneDetail", { total: result.total.toLocaleString("zh-TW") })} +

    +
    + + + +
    + ); +} + function ErrorBox({ message }: Readonly<{ message: string }>) { return (

    @@ -425,6 +540,19 @@ function Row({ label, children }: Readonly<{ label: string; children: React.Reac ); } +/** 預覽品項沒有 id:用內容當 key,完全相同的品項再加上第幾次出現,確保唯一且穩定。 */ +function withItemKeys( + items: T[], +): { key: string; item: T }[] { + const seen = new Map(); + return items.map((item) => { + const base = `${item.name}|${item.quantity}|${item.price}`; + const n = seen.get(base) ?? 0; + seen.set(base, n + 1); + return { key: `${base}|${n}`, item }; + }); +} + function PreviewCard({ preview: p }: Readonly<{ preview: InvoicePreview }>) { const t = useTranslations("invoices.simpany.issue"); const tTax = useTranslations("invoices.taxTreatment"); @@ -454,8 +582,8 @@ function PreviewCard({ preview: p }: Readonly<{ preview: InvoicePreview }>) {

      - {p.items.map((it, i) => ( -
    • + {withItemKeys(p.items).map(({ key, item: it }) => ( +
    • {it.name} {it.quantity === 1 ? "" : ` × ${it.quantity}`} diff --git a/src/app/dashboard/settings/integrations/actions.ts b/src/app/dashboard/settings/integrations/actions.ts index b50ddcf..ebcdb7b 100644 --- a/src/app/dashboard/settings/integrations/actions.ts +++ b/src/app/dashboard/settings/integrations/actions.ts @@ -15,10 +15,13 @@ import { } from "@/lib/integrations/store"; import { isIntegrationProviderId, + type IntegrationCatalogEntry, type IntegrationConfig, type IntegrationCredentials, type IntegrationField, + type IntegrationProvider, type IntegrationProviderId, + type TestConnectionResult, } from "@/lib/integrations/types"; /** @@ -61,6 +64,40 @@ function pickFields( return out; } +/** 回傳第一個沒填的必填欄位;都有填就回 null。 */ +function findMissingRequiredField( + entry: IntegrationCatalogEntry, + credentials: IntegrationCredentials, + configInput: IntegrationConfig, +): IntegrationField | null { + for (const f of [...entry.credentialFields, ...(entry.configFields ?? [])]) { + if (f.required && !(f.key in credentials) && !(f.key in configInput)) return f; + } + return null; +} + +type TestOutcome = + | { ok: true; result: Extract } + | { ok: false; error: string }; + +/** 跑 provider.testConnection,把丟出的例外與 ok:false 都收斂成錯誤字串。 */ +async function runConnectionTest( + impl: IntegrationProvider, + credentials: IntegrationCredentials, + config: IntegrationConfig, +): Promise { + let result: TestConnectionResult; + try { + result = await impl.testConnection(credentials, config); + } catch (e) { + // provider 自己沒處理好的例外(網路錯之類)。訊息照樣給人看,provider 有責任 + // 不把憑證放進錯誤訊息裡。 + return { ok: false, error: e instanceof Error ? e.message : String(e) }; + } + if (!result.ok) return { ok: false, error: result.error }; + return { ok: true, result }; +} + async function connectOrReconnect( providerArg: string, values: Record, @@ -82,33 +119,25 @@ async function connectOrReconnect( const entry = getCatalogEntry(provider); const credentials: IntegrationCredentials = pickFields(entry.credentialFields, values); const configInput: IntegrationConfig = pickFields(entry.configFields, values); - for (const f of [...entry.credentialFields, ...(entry.configFields ?? [])]) { - if (f.required && !(f.key in credentials) && !(f.key in configInput)) { - return { ok: false, error: t("errors.requiredField", { field: t(`fields.${f.labelKey}`) }) }; - } + const missing = findMissingRequiredField(entry, credentials, configInput); + if (missing) { + return { ok: false, error: t("errors.requiredField", { field: t(`fields.${missing.labelKey}`) }) }; } // 重新連接時把既有 config 一起給 provider:有些 provider 需要之前發現的 id 才能測。 const existing = await getIntegration(me.orgId, provider); - const config: IntegrationConfig = { ...(existing?.config ?? {}), ...configInput }; - - let result; - try { - result = await impl.testConnection(credentials, config); - } catch (e) { - // provider 自己沒處理好的例外(網路錯之類)。訊息照樣給人看,provider 有責任 - // 不把憑證放進錯誤訊息裡。 - const msg = e instanceof Error ? e.message : String(e); - return { ok: false, error: t("errors.testFailed", { error: msg }) }; - } - if (!result.ok) return { ok: false, error: t("errors.testFailed", { error: result.error }) }; + const config: IntegrationConfig = { ...existing?.config, ...configInput }; + + const outcome = await runConnectionTest(impl, credentials, config); + if (!outcome.ok) return { ok: false, error: t("errors.testFailed", { error: outcome.error }) }; + const { result } = outcome; await saveConnection({ orgId: me.orgId, provider, userId: me.userId, credentials, - config: { ...configInput, ...(result.config ?? {}) }, + config: { ...configInput, ...result.config }, tokenCache: result.tokenCache, }); diff --git a/src/app/dashboard/settings/integrations/integrations-client.tsx b/src/app/dashboard/settings/integrations/integrations-client.tsx index b8c3de4..963c5a5 100644 --- a/src/app/dashboard/settings/integrations/integrations-client.tsx +++ b/src/app/dashboard/settings/integrations/integrations-client.tsx @@ -126,6 +126,45 @@ function LogoTile({ letter, className }: Readonly<{ letter: string; className: s ); } +type ConnectionData = IntegrationRowData["connection"]; +type IntegrationsT = ReturnType>; + +function StatusLine({ + connection: c, + enabled, +}: Readonly<{ connection: ConnectionData; enabled: boolean }>) { + const t = useTranslations("integrations"); + if (!c) return {t("status.notConnected")}; + if (c.status === "needs_reauth") { + return ( + + {t("status.needsReauth", { error: c.lastError ?? t("status.unknownError") })} + + ); + } + if (c.status === "error") { + return ( + + {t("status.error", { error: c.lastError ?? t("status.unknownError") })} + + ); + } + return {enabled ? t("status.connected") : t("status.connectedOff")}; +} + +/** 「由誰連接 · 上次同步」這行;沒連接時回空陣列(畫面改顯示說明文字)。 */ +function connectionMeta(c: ConnectionData, t: IntegrationsT): string[] { + if (!c) return []; + const meta = [ + t("status.connectedBy", { + name: c.connectedByName ?? t("status.unknownMember"), + date: c.connectedAt, + }), + ]; + if (c.lastSyncedAt) meta.push(t("status.lastSynced", { date: c.lastSyncedAt })); + return meta; +} + function IntegrationRow({ row, canManage, @@ -161,35 +200,7 @@ function IntegrationRow({ }); } - let statusLine: React.ReactNode; - if (!c) { - statusLine = {t("status.notConnected")}; - } else if (c.status === "needs_reauth") { - statusLine = ( - - {t("status.needsReauth", { error: c.lastError ?? t("status.unknownError") })} - - ); - } else if (c.status === "error") { - statusLine = ( - - {t("status.error", { error: c.lastError ?? t("status.unknownError") })} - - ); - } else { - statusLine = {optimisticEnabled ? t("status.connected") : t("status.connectedOff")}; - } - - const meta: string[] = []; - if (c) { - meta.push( - t("status.connectedBy", { - name: c.connectedByName ?? t("status.unknownMember"), - date: c.connectedAt, - }), - ); - if (c.lastSyncedAt) meta.push(t("status.lastSynced", { date: c.lastSyncedAt })); - } + const meta = connectionMeta(c, t); return (
    • @@ -197,7 +208,9 @@ function IntegrationRow({

      {name}

      -

      {statusLine}

      +

      + +

      {meta.length > 0 ? (

      {meta.join(" · ")}

      ) : ( @@ -210,7 +223,7 @@ function IntegrationRow({ diff --git a/src/app/dashboard/settings/integrations/wise-actions.ts b/src/app/dashboard/settings/integrations/wise-actions.ts index ab3df41..5510aa4 100644 --- a/src/app/dashboard/settings/integrations/wise-actions.ts +++ b/src/app/dashboard/settings/integrations/wise-actions.ts @@ -23,7 +23,9 @@ async function requireManager(): Promise<{ orgId: string } | { error: string }> } function toInt(v: unknown): number | null { - const n = typeof v === "number" ? v : typeof v === "string" && v.trim() !== "" ? Number(v) : NaN; + let n = Number.NaN; + if (typeof v === "number") n = v; + else if (typeof v === "string" && v.trim() !== "") n = Number(v); return Number.isInteger(n) ? n : null; } diff --git a/src/app/dashboard/settings/integrations/wise-mapping-client.tsx b/src/app/dashboard/settings/integrations/wise-mapping-client.tsx index 4296786..da3931b 100644 --- a/src/app/dashboard/settings/integrations/wise-mapping-client.tsx +++ b/src/app/dashboard/settings/integrations/wise-mapping-client.tsx @@ -78,16 +78,16 @@ export function WiseMappingSection({ syncFrom: rows[i].syncFrom || null, })), ); - if (!res.ok) toast.error(res.error ?? t("errors.failed")); - else toast.success(t("mapping.saved")); + if (res.ok) toast.success(t("mapping.saved")); + else toast.error(res.error ?? t("errors.failed")); }); } function refresh() { start(async () => { const res = await refreshWiseBalancesAction(); - if (!res.ok) toast.error(res.error ?? t("errors.failed")); - else toast.success(t("mapping.refreshed")); + if (res.ok) toast.success(t("mapping.refreshed")); + else toast.error(res.error ?? t("errors.failed")); }); } diff --git a/src/app/dashboard/transactions/edit-transaction-form.tsx b/src/app/dashboard/transactions/edit-transaction-form.tsx index 72aaa17..3a682bd 100644 --- a/src/app/dashboard/transactions/edit-transaction-form.tsx +++ b/src/app/dashboard/transactions/edit-transaction-form.tsx @@ -4,7 +4,7 @@ import { useActionState, useState } from "react"; import { toast } from "sonner"; import { Paperclip } from "lucide-react"; import { useTranslations } from "next-intl"; -import { externalSingleLegSide } from "@/lib/external-transfer"; +import { externalSingleLegSide, type SingleLegSide } from "@/lib/external-transfer"; import { updateTransaction, deleteTransactionDocument, type ActionState } from "@/db/mutations"; import type { TxnDocument, AuditMeta as AuditMetaData } from "@/db/queries"; import { AuditMeta } from "@/components/audit-meta"; @@ -145,39 +145,52 @@ export function EditTransactionForm({ TYPE_KEYS.map((k) => [k, t(`type.${k}`)]), ); + // 帳戶區塊三種版型:單腳換匯 / 一般轉帳 / 收入支出代墊。 + let accountFields: React.ReactNode; + if (singleLeg) { + accountFields = ( + + ); + } else if (isTransfer) { + accountFields = ( + + ); + } else { + accountFields = ( + + ); + } + return ( <> - {docs.length > 0 && ( -
      -
      {t("form.attachedDocs")}
      -
        - {docs.map((d) => ( -
      • - - - {t(`form.docLabel.${docLabelKey(d.docType, d.invoiceKind)}`)} - - {d.invoiceKind === "paper" ? ( - - {t("form.needsNotifyAccountant")} - - ) : null} - - {d.fileName ?? t("form.viewFile")} - - - - -
      • - ))} -
      -
      - )} +
      @@ -208,92 +221,7 @@ export function EditTransactionForm({ )} - {singleLeg ? ( - <> - -

      - {txn.conversionText ? `${txn.conversionText} · ` : null} - {t("table.conversionLegHint")} -

      - - ) : isTransfer ? ( - <> - - - - - ) : ( - <> - - - - {isAdvance && ( - - - - )} - - - - {!isAdvance && ( - - )} - - {projects.map((p) => ( - - {p.name} - - ))} - - - - - - )} + {accountFields} - {!isTransfer && ( -
      - - - -
      + {isTransfer ? null : ( + )}
      @@ -349,6 +256,227 @@ export function EditTransactionForm({ ); } +function AttachedDocs({ docs }: Readonly<{ docs: TxnDocument[] }>) { + const t = useTranslations("transactions"); + if (docs.length === 0) return null; + return ( +
      +
      {t("form.attachedDocs")}
      +
        + {docs.map((d) => ( +
      • + + + {t(`form.docLabel.${docLabelKey(d.docType, d.invoiceKind)}`)} + + {d.invoiceKind === "paper" ? ( + + {t("form.needsNotifyAccountant")} + + ) : null} + + {d.fileName ?? t("form.viewFile")} + + + + +
      • + ))} +
      +
      + ); +} + +/** 外部同步的單腳轉帳(Wise 換匯):只有原本那一腳的帳戶可選,另一腳固定空白。 */ +function SingleLegAccountField({ + side, + accounts, + value, + onChange, + conversionText, +}: Readonly<{ + side: SingleLegSide; + accounts: Account[]; + value: string; + onChange: (v: string) => void; + conversionText: string | null; +}>) { + const t = useTranslations("transactions"); + return ( + <> + +

      + {conversionText ? `${conversionText} · ` : null} + {t("table.conversionLegHint")} +

      + + ); +} + +function TransferAccountFields({ + accounts, + fromAccountId, + toAccountId, + onFromChange, + onToChange, + fromCurrency, + toCurrency, +}: Readonly<{ + accounts: Account[]; + fromAccountId: string; + toAccountId: string; + onFromChange: (v: string) => void; + onToChange: (v: string) => void; + fromCurrency: ReturnType; + toCurrency: ReturnType; +}>) { + const t = useTranslations("transactions"); + return ( + <> + + + + + ); +} + +/** 收入 / 支出 / 代墊:對象、(代墊的)墊款人、分類、帳戶、專案、合約。 */ +function PartyAccountFields({ + txn, + isIncome, + isAdvance, + parties, + employees, + categories, + defaultCategoryName, + accounts, + accountId, + onAccountChange, + projects, + contracts, +}: Readonly<{ + txn: Txn; + isIncome: boolean; + isAdvance: boolean; + parties: { id: number; name: string }[]; + employees: { id: number; name: string }[]; + categories: { id: number; name: string }[]; + defaultCategoryName: string; + accounts: Account[]; + accountId: string; + onAccountChange: (v: string) => void; + projects: { id: number; name: string }[]; + contracts: ContractOption[]; +}>) { + const t = useTranslations("transactions"); + return ( + <> + + + + {isAdvance && ( + + + + )} + + + + {isAdvance ? null : ( + + )} + + {projects.map((p) => ( + + {p.name} + + ))} + + + + + + ); +} + +/** 報稅 / 統編勾選與憑證欄位(轉帳沒有這些)。 */ +function ReportingFields({ + book, + billed, + onBilledChange, +}: Readonly<{ book: string; billed: boolean; onBilledChange: (v: boolean) => void }>) { + const t = useTranslations("transactions"); + return ( +
      + + + +
      + ); +} + function VoucherFields({ billed }: Readonly<{ billed: boolean }>) { const t = useTranslations("transactions"); const kindKeys = billed ? (["e_invoice", "paper_invoice"] as const) : DOC_KIND_KEYS; diff --git a/src/db/employee-accounts.ts b/src/db/employee-accounts.ts index ee3d308..c65421d 100644 --- a/src/db/employee-accounts.ts +++ b/src/db/employee-accounts.ts @@ -122,8 +122,7 @@ export type EmployeeAccountInput = { }; function textOrNull(v: string | null | undefined): string | null { - const s = v?.trim(); - return s ? s : null; + return v?.trim() || null; } function resolveKind(v: string | null | undefined, fallback: EmployeeAccountKind): EmployeeAccountKind { @@ -214,6 +213,40 @@ export async function createEmployeeAccount( return row; } +type AccountPatch = Partial; + +/** 更新時沒帶的欄位(undefined)沿用既有值;null 表示明確清空,照用。 */ +function orExisting(next: T | undefined, existing: T): T { + return next === undefined ? existing : next; +} + +/** 新帳號 → 加密 + 末五碼;沒帶新帳號就不動(但改成銀行帳戶時必須重填)。 */ +async function accountNumberPatch( + kind: EmployeeAccountKind, + existingKind: string, + rawNumber: string | null | undefined, +): Promise { + const newNumber = textOrNull(rawNumber); + if (!newNumber) { + // 改成銀行帳戶時,舊帳號可能不是純數字(例如舊資料的 other)→ 要求重新輸入。 + if (kind === "bank" && existingKind !== "bank") throw new EmployeeAccountError("numberDigits"); + return {}; + } + const n = checkAccountNumber(kind, newNumber); + return { accountNumberEnc: await encryptField(n), accountLast5: accountLast5(n) }; +} + +/** 銀行名稱:有帶就用(空白則依代碼帶預設);只改代碼時跟著代碼換;都沒動回 undefined。 */ +function bankNamePatch( + input: EmployeeAccountInput, + bankCode: string | null, + existingBankCode: string | null, +): string | null | undefined { + if (input.bankName !== undefined) return textOrNull(input.bankName) ?? bankNameForCode(bankCode); + if (input.bankCode !== undefined && bankCode !== existingBankCode) return bankNameForCode(bankCode); + return undefined; +} + /** 更新帳戶(只改有帶的欄位)。accountNumber 省略 / 空白 = 不改帳號。 */ export async function updateEmployeeAccount( orgId: string, @@ -227,30 +260,19 @@ export async function updateEmployeeAccount( const kind = input.kind === undefined ? resolveKind(existing.kind, "other") : resolveKind(input.kind, "bank"); const codes = checkAccountCodes({ kind, - bankCode: input.bankCode === undefined ? existing.bankCode : input.bankCode, - branchCode: input.branchCode === undefined ? existing.branchCode : input.branchCode, - currency: input.currency === undefined ? existing.currency : input.currency, + bankCode: orExisting(input.bankCode, existing.bankCode), + branchCode: orExisting(input.branchCode, existing.branchCode), + currency: orExisting(input.currency, existing.currency), }); - const patch: Partial = { + const patch: AccountPatch = { kind, ...codes, + ...(await accountNumberPatch(kind, existing.kind, input.accountNumber)), updatedAt: new Date().toISOString(), }; - const newNumber = textOrNull(input.accountNumber); - if (newNumber) { - const n = checkAccountNumber(kind, newNumber); - patch.accountNumberEnc = await encryptField(n); - patch.accountLast5 = accountLast5(n); - } else if (kind === "bank" && existing.kind !== "bank") { - // 改成銀行帳戶時,舊帳號可能不是純數字(例如舊資料的 other)→ 要求重新輸入。 - throw new EmployeeAccountError("numberDigits"); - } - if (input.bankName !== undefined) { - patch.bankName = textOrNull(input.bankName) ?? bankNameForCode(codes.bankCode); - } else if (input.bankCode !== undefined && codes.bankCode !== existing.bankCode) { - patch.bankName = bankNameForCode(codes.bankCode); - } + const bankName = bankNamePatch(input, codes.bankCode, existing.bankCode); + if (bankName !== undefined) patch.bankName = bankName; if (input.accountHolder !== undefined) patch.accountHolder = textOrNull(input.accountHolder); if (input.label !== undefined) patch.label = textOrNull(input.label); if (input.note !== undefined) patch.note = textOrNull(input.note); diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts index a805228..ed4b41b 100644 --- a/src/lib/crypto.ts +++ b/src/lib/crypto.ts @@ -19,14 +19,15 @@ export class EncryptionKeyMissingError extends Error { function toBase64(bytes: Uint8Array): string { let s = ""; - for (const b of bytes) s += String.fromCharCode(b); + for (const b of bytes) s += String.fromCodePoint(b); return btoa(s); } function fromBase64(b64: string): Uint8Array { + // atob 的輸出每個字元都是 0–255 的單一 code unit,codePointAt 不會碰到代理對。 const s = atob(b64); const out = new Uint8Array(s.length); - for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i); + for (let i = 0; i < s.length; i++) out[i] = s.codePointAt(i) ?? 0; return out; } diff --git a/src/lib/employee-accounts.ts b/src/lib/employee-accounts.ts index 1393827..b93280d 100644 --- a/src/lib/employee-accounts.ts +++ b/src/lib/employee-accounts.ts @@ -79,7 +79,7 @@ export function bankNameForCode(code: string | null | undefined): string | null /** 去掉空白與連字號(使用者常照存摺格式輸入 807-0180-1234…)。 */ export function normalizeAccountNumber(raw: string): string { - return raw.replace(/[\s\-‐-―]/g, ""); + return raw.replaceAll(/[\s\-‐-―]/g, ""); } /** 末 5 碼(正規化之後)。 */ @@ -182,7 +182,7 @@ export function parseLegacySalaryAccount(raw: string): ParsedLegacyAccount | nul const rest = m[2].replace(/^\D+/, ""); const branchMatch = /^(\d{4})[\s\-/]+(.+)$/.exec(rest); const branchCode = branchMatch ? branchMatch[1] : null; - const digits = (branchMatch ? branchMatch[2] : rest).replace(/\D/g, ""); + const digits = (branchMatch ? branchMatch[2] : rest).replaceAll(/\D/g, ""); if (/^\d{6,20}$/.test(digits)) { return { kind: "bank", bankCode, branchCode, bankName: bankNameForCode(bankCode), accountNumber: digits }; } diff --git a/src/lib/integrations/simpany.ts b/src/lib/integrations/simpany.ts index 14385df..c429200 100644 --- a/src/lib/integrations/simpany.ts +++ b/src/lib/integrations/simpany.ts @@ -181,31 +181,41 @@ function snippet(body: unknown): string { } catch { s = String(body); } - s = s.replace(/\s+/g, " ").trim(); + s = s.replaceAll(/\s+/g, " ").trim(); return s.length > 400 ? `${s.slice(0, 399)}…` : s; } +/** 驗證錯誤:{ errors: { field: [msg] } } → 「field: msg、msg;field: msg」;沒有內容回 null。 */ +function validationErrorsMessage(errors: Record): string | null { + const parts: string[] = []; + for (const [field, msgs] of Object.entries(errors)) { + const list = Array.isArray(msgs) ? msgs.map((m) => str(m) ?? snippet(m)) : [snippet(msgs)]; + parts.push(`${field}: ${list.join("、")}`); + } + return parts.length ? parts.join(";") : null; +} + +/** 業務錯誤:{ status: "error", error: { title, details } } / { error: { code } };沒有內容回 null。 */ +function businessErrorMessage(e: Record): string | null { + const title = str(e.title) ?? str(e.message); + const details = str(e.details) ?? (e.details === undefined ? null : snippet(e.details)); + const code = str(e.code); + const text = [title, details].filter(Boolean).join(":"); + if (text) return code ? `${text}(${code})` : text; + if (code) return `錯誤代碼 ${code}`; + return null; +} + /** 從 Simpany 的各種錯誤形狀裡抽出人看得懂的訊息。 */ export function simpanyErrorMessage(body: unknown): string | null { if (!isObj(body)) return typeof body === "string" && body.trim() ? snippet(body) : null; - // 驗證錯誤:{ errors: { field: [msg] } } if (isObj(body.errors)) { - const parts: string[] = []; - for (const [field, msgs] of Object.entries(body.errors)) { - const list = Array.isArray(msgs) ? msgs.map((m) => str(m) ?? snippet(m)) : [snippet(msgs)]; - parts.push(`${field}: ${list.join("、")}`); - } - if (parts.length) return parts.join(";"); + const validation = validationErrorsMessage(body.errors); + if (validation) return validation; } - // 業務錯誤:{ status: "error", error: { title, details } } / { error: { code } } if (isObj(body.error)) { - const e = body.error; - const title = str(e.title) ?? str(e.message); - const details = str(e.details) ?? (e.details === undefined ? null : snippet(e.details)); - const code = str(e.code); - const text = [title, details].filter(Boolean).join(":"); - if (text) return code ? `${text}(${code})` : text; - if (code) return `錯誤代碼 ${code}`; + const business = businessErrorMessage(body.error); + if (business) return business; } const message = str(body.message); if (message) return message; @@ -351,11 +361,8 @@ async function login(creds: IntegrationCredentials): Promise { if (res.status >= 500) { throw new SimpanyError("http", `Simpany 登入失敗(HTTP ${res.status})`, res.status); } - throw new SimpanyError( - "http", - `Simpany 登入失敗:${simpanyErrorMessage(body) ?? `HTTP ${res.status}`}`, - res.status, - ); + const reason = simpanyErrorMessage(body) ?? `HTTP ${res.status}`; + throw new SimpanyError("http", `Simpany 登入失敗:${reason}`, res.status); } async function fetchCompanies(token: string): Promise { @@ -365,11 +372,8 @@ async function fetchCompanies(token: string): Promise { const body = await readBody(res); if (res.status === 401) throw new SimpanyError("auth", "Simpany 登入已失效", 401); if (!res.ok) { - throw new SimpanyError( - "http", - `讀取 Simpany 公司清單失敗:${simpanyErrorMessage(body) ?? `HTTP ${res.status}`}`, - res.status, - ); + const reason = simpanyErrorMessage(body) ?? `HTTP ${res.status}`; + throw new SimpanyError("http", `讀取 Simpany 公司清單失敗:${reason}`, res.status); } const data = unwrapData(body); const companies = isObj(data) && Array.isArray(data.companies) ? data.companies : []; diff --git a/src/lib/integrations/wise.ts b/src/lib/integrations/wise.ts index b7fa3bc..6765ebb 100644 --- a/src/lib/integrations/wise.ts +++ b/src/lib/integrations/wise.ts @@ -72,7 +72,8 @@ export type WiseMoney = { value: number; currency: string }; export type WiseProfile = { id: number; - type: "PERSONAL" | "BUSINESS" | string; + /** 已知值:"PERSONAL"、"BUSINESS";Wise 可能新增其他值,所以保留 string。 */ + type: string; fullName?: string; businessName?: string; details?: { name?: string; firstName?: string; lastName?: string }; @@ -87,7 +88,8 @@ export type WiseBalance = { }; export type WiseStatementTransaction = { - type: "DEBIT" | "CREDIT" | string; + /** 已知值:"DEBIT"、"CREDIT";保留 string 以容納未知值。 */ + type: string; date: string; amount: WiseMoney; totalFees?: WiseMoney | null; @@ -184,7 +186,8 @@ async function wiseGet( if (!res.ok) { // 回應本文可能很長;只取開頭,且不含我們送出的任何東西(token 在 header,不會回顯)。 const body = (await res.text().catch(() => "")).slice(0, 200); - throw new WiseApiError(res.status, `Wise 回應 ${res.status}${body ? `:${body}` : ""}`); + const detail = body ? `:${body}` : ""; + throw new WiseApiError(res.status, `Wise 回應 ${res.status}${detail}`); } return (await res.json()) as T; } diff --git a/src/lib/mcp/tools-employee-accounts.ts b/src/lib/mcp/tools-employee-accounts.ts index 1b64d8d..da37e75 100644 --- a/src/lib/mcp/tools-employee-accounts.ts +++ b/src/lib/mcp/tools-employee-accounts.ts @@ -64,7 +64,8 @@ export const EMPLOYEE_ACCOUNT_ROW = rowSchema({ /** 輸出用的遮罩摘要:「永豐銀行 807 ••••90123」。 */ export function maskedAccountSummary(a: MaskedEmployeeAccount): string { const head = [a.bankName, a.bankCode].filter(Boolean).join(" "); - return `${head ? `${head} ` : ""}••••${a.accountLast5}`; + const prefix = head ? `${head} ` : ""; + return `${prefix}••••${a.accountLast5}`; } /** 發薪 / 撥款結果裡的「匯入帳戶」欄位(可為 null)。 */ diff --git a/src/lib/mcp/tools-integrations.ts b/src/lib/mcp/tools-integrations.ts index 966de45..b9f7177 100644 --- a/src/lib/mcp/tools-integrations.ts +++ b/src/lib/mcp/tools-integrations.ts @@ -125,7 +125,7 @@ export const integrationTools: Record = { available: getProvider(provider) !== null, connected: Boolean(s), enabled: s?.enabled ?? false, - usable: Boolean(s && s.enabled && s.status === "connected"), + usable: Boolean(s?.enabled && s.status === "connected"), status: s?.status ?? null, config: s?.config ?? {}, connectedAt: iso(s?.connectedAt ?? null), diff --git a/src/lib/simpany-issue.ts b/src/lib/simpany-issue.ts index d1e4951..093b5a5 100644 --- a/src/lib/simpany-issue.ts +++ b/src/lib/simpany-issue.ts @@ -170,7 +170,137 @@ function extractEmails(text: string | null | undefined): string[] { /** Simpany 的品名不能有半形冒號(他們的 UI 會換成全形)。 */ export function sanitizeItemName(name: string): string { - return name.replaceAll(":", ":").replace(/\s+/g, " ").trim(); + return name.replaceAll(":", ":").replaceAll(/\s+/g, " ").trim(); +} + +/** 從交易預填:金額、幣別、品名、客戶,並把交易的綁定帶進 links。 */ +async function applyTransactionSource( + orgId: string, + transactionId: number, + src: Source, + links: DraftLinks, + warnings: string[], +): Promise { + const [txn] = await getDb() + .select({ + id: transactions.id, + type: transactions.type, + amount: transactions.amount, + currency: transactions.currency, + description: transactions.description, + partyId: transactions.partyId, + invoiceId: transactions.invoiceId, + billingItemId: transactions.billingItemId, + subscriptionId: transactions.subscriptionId, + subscriptionPeriod: transactions.subscriptionPeriod, + contractId: transactions.contractId, + }) + .from(transactions) + .where( + and( + eq(transactions.organizationId, orgId), + eq(transactions.id, transactionId), + isNull(transactions.deletedAt), + ), + ) + .limit(1); + if (!txn) throw new SimpanyPreviewError(`找不到交易 #${transactionId}`); + if (txn.type !== "income") throw new SimpanyPreviewError(`交易 #${txn.id} 不是收入,不能拿來開發票`); + if (txn.invoiceId != null) warnings.push(`交易 #${txn.id} 已經綁定發票 #${txn.invoiceId},可能重複開立`); + links.transactionIds.push(txn.id); + links.billingItemId ??= txn.billingItemId; + if (txn.subscriptionId != null && txn.subscriptionPeriod) { + links.subscriptionId ??= txn.subscriptionId; + links.subscriptionPeriod ??= txn.subscriptionPeriod; + } + links.contractId ??= txn.contractId; + src.partyId = txn.partyId; + src.amount = Number(txn.amount); + src.currency = txn.currency; + src.itemName = txn.description; +} + +/** 從請款項目預填(金額優先於交易)。 */ +async function applyBillingItemSource( + orgId: string, + billingItemId: number, + src: Source, + links: DraftLinks, + warnings: string[], +): Promise { + const [it] = await getDb() + .select({ + id: billingItems.id, + customerPartyId: billingItems.customerPartyId, + contractId: billingItems.contractId, + contractTitle: contracts.title, + title: billingItems.title, + amount: billingItems.amount, + currency: billingItems.currency, + invoicedOn: billingItems.invoicedOn, + }) + .from(billingItems) + .leftJoin(contracts, eq(contracts.id, billingItems.contractId)) + .where( + and( + eq(billingItems.organizationId, orgId), + eq(billingItems.id, billingItemId), + isNull(billingItems.deletedAt), + ), + ) + .limit(1); + if (!it) throw new SimpanyPreviewError(`找不到請款項目 #${billingItemId}`); + if (it.invoicedOn) warnings.push(`請款項目「${it.title}」已標記開發票日 ${it.invoicedOn},可能重複開立`); + links.billingItemId = it.id; + links.contractId ??= it.contractId; + src.partyId ??= it.customerPartyId; + // 請款項目的金額優先於交易(交易可能扣了手續費)。 + src.amount = Number(it.amount); + src.currency = it.currency; + src.itemName = it.contractTitle ? `${it.contractTitle} ${it.title}` : it.title; +} + +/** 從訂閱的某一期預填(期別起日要對得上排程)。 */ +async function applySubscriptionSource( + orgId: string, + subscriptionId: number, + subscriptionPeriod: string | undefined, + src: Source, + links: DraftLinks, + warnings: string[], +): Promise { + if (!subscriptionPeriod || !/^\d{4}-\d{2}-\d{2}$/.test(subscriptionPeriod)) { + throw new SimpanyPreviewError("指定訂閱時要一併給 subscriptionPeriod(該期起日 YYYY-MM-DD)"); + } + const [sub] = await getDb() + .select({ customerPartyId: subscriptions.customerPartyId, contractId: subscriptions.contractId }) + .from(subscriptions) + .where( + and( + eq(subscriptions.organizationId, orgId), + eq(subscriptions.id, subscriptionId), + isNull(subscriptions.deletedAt), + ), + ) + .limit(1); + if (!sub) throw new SimpanyPreviewError(`找不到訂閱 #${subscriptionId}`); + const schedule = await getSubscriptionSchedule(orgId, subscriptionId); + const period = schedule?.periods.find((p) => p.periodStart === subscriptionPeriod); + if (!schedule || !period) { + throw new SimpanyPreviewError( + `訂閱 #${subscriptionId} 沒有 ${subscriptionPeriod} 這一期(期別起日要對得上 get_subscription_schedule)`, + ); + } + if (period.invoicedOn) { + warnings.push(`訂閱這一期已標記開發票日 ${period.invoicedOn},可能重複開立`); + } + links.subscriptionId = subscriptionId; + links.subscriptionPeriod = subscriptionPeriod; + links.contractId ??= sub.contractId; + src.partyId ??= sub.customerPartyId; + src.amount = period.expected; + src.currency = schedule.currency; + src.itemName = `${schedule.name}(${period.periodLabel})`; } async function loadSource( @@ -179,128 +309,33 @@ async function loadSource( links: DraftLinks, warnings: string[], ): Promise { - const db = getDb(); const src: Source = { partyId: null, amount: null, currency: "TWD", itemName: null }; - + // 順序有意義:後面的來源會覆蓋前面的金額 / 品名(請款項目、訂閱優先於交易)。 if (input.transactionId != null) { - const [txn] = await db - .select({ - id: transactions.id, - type: transactions.type, - amount: transactions.amount, - currency: transactions.currency, - description: transactions.description, - partyId: transactions.partyId, - invoiceId: transactions.invoiceId, - billingItemId: transactions.billingItemId, - subscriptionId: transactions.subscriptionId, - subscriptionPeriod: transactions.subscriptionPeriod, - contractId: transactions.contractId, - }) - .from(transactions) - .where( - and( - eq(transactions.organizationId, orgId), - eq(transactions.id, input.transactionId), - isNull(transactions.deletedAt), - ), - ) - .limit(1); - if (!txn) throw new SimpanyPreviewError(`找不到交易 #${input.transactionId}`); - if (txn.type !== "income") throw new SimpanyPreviewError(`交易 #${txn.id} 不是收入,不能拿來開發票`); - if (txn.invoiceId != null) warnings.push(`交易 #${txn.id} 已經綁定發票 #${txn.invoiceId},可能重複開立`); - links.transactionIds.push(txn.id); - links.billingItemId ??= txn.billingItemId; - if (txn.subscriptionId != null && txn.subscriptionPeriod) { - links.subscriptionId ??= txn.subscriptionId; - links.subscriptionPeriod ??= txn.subscriptionPeriod; - } - links.contractId ??= txn.contractId; - src.partyId = txn.partyId; - src.amount = Number(txn.amount); - src.currency = txn.currency; - src.itemName = txn.description; + await applyTransactionSource(orgId, input.transactionId, src, links, warnings); } - const billingItemId = input.billingItemId ?? null; if (billingItemId != null) { - const [it] = await db - .select({ - id: billingItems.id, - customerPartyId: billingItems.customerPartyId, - contractId: billingItems.contractId, - contractTitle: contracts.title, - title: billingItems.title, - amount: billingItems.amount, - currency: billingItems.currency, - invoicedOn: billingItems.invoicedOn, - }) - .from(billingItems) - .leftJoin(contracts, eq(contracts.id, billingItems.contractId)) - .where( - and( - eq(billingItems.organizationId, orgId), - eq(billingItems.id, billingItemId), - isNull(billingItems.deletedAt), - ), - ) - .limit(1); - if (!it) throw new SimpanyPreviewError(`找不到請款項目 #${billingItemId}`); - if (it.invoicedOn) warnings.push(`請款項目「${it.title}」已標記開發票日 ${it.invoicedOn},可能重複開立`); - links.billingItemId = it.id; - links.contractId ??= it.contractId; - src.partyId ??= it.customerPartyId; - // 請款項目的金額優先於交易(交易可能扣了手續費)。 - src.amount = Number(it.amount); - src.currency = it.currency; - src.itemName = it.contractTitle ? `${it.contractTitle} ${it.title}` : it.title; + await applyBillingItemSource(orgId, billingItemId, src, links, warnings); } - if (input.subscriptionId != null) { - if (!input.subscriptionPeriod || !/^\d{4}-\d{2}-\d{2}$/.test(input.subscriptionPeriod)) { - throw new SimpanyPreviewError("指定訂閱時要一併給 subscriptionPeriod(該期起日 YYYY-MM-DD)"); - } - const [sub] = await db - .select({ customerPartyId: subscriptions.customerPartyId, contractId: subscriptions.contractId }) - .from(subscriptions) - .where( - and( - eq(subscriptions.organizationId, orgId), - eq(subscriptions.id, input.subscriptionId), - isNull(subscriptions.deletedAt), - ), - ) - .limit(1); - if (!sub) throw new SimpanyPreviewError(`找不到訂閱 #${input.subscriptionId}`); - const schedule = await getSubscriptionSchedule(orgId, input.subscriptionId); - const period = schedule?.periods.find((p) => p.periodStart === input.subscriptionPeriod); - if (!schedule || !period) { - throw new SimpanyPreviewError( - `訂閱 #${input.subscriptionId} 沒有 ${input.subscriptionPeriod} 這一期(期別起日要對得上 get_subscription_schedule)`, - ); - } - if (period.invoicedOn) { - warnings.push(`訂閱這一期已標記開發票日 ${period.invoicedOn},可能重複開立`); - } - links.subscriptionId = input.subscriptionId; - links.subscriptionPeriod = input.subscriptionPeriod; - links.contractId ??= sub.contractId; - src.partyId ??= sub.customerPartyId; - src.amount = period.expected; - src.currency = schedule.currency; - src.itemName = `${schedule.name}(${period.periodLabel})`; + await applySubscriptionSource(orgId, input.subscriptionId, input.subscriptionPeriod, src, links, warnings); } - links.partyId = src.partyId; return src; } -async function duplicateWarnings( +/** 本系統的發票顯示用:有號碼用號碼,沒有就用 #id。 */ +function invoiceLabel(r: { id: number; number: string | null }): string { + return r.number ?? `#${r.id}`; +} + +/** 本系統內的重複檢查:同請款項目、同訂閱期別、近期同客戶同金額。 */ +async function internalDuplicateWarnings( orgId: string, - client: SimpanyClient, links: DraftLinks, - buyer: { vat: string | null; name: string }, total: number, + since: string, ): Promise { const db = getDb(); const out: string[] = []; @@ -314,7 +349,7 @@ async function duplicateWarnings( .select({ id: invoices.id, number: invoices.invoiceNumber }) .from(invoices) .where(and(notVoid, eq(invoices.billingItemId, links.billingItemId))); - for (const r of rows) out.push(`這個請款項目已經有發票 ${r.number ?? `#${r.id}`}`); + for (const r of rows) out.push(`這個請款項目已經有發票 ${invoiceLabel(r)}`); } if (links.subscriptionId != null && links.subscriptionPeriod) { const rows = await db @@ -327,11 +362,8 @@ async function duplicateWarnings( eq(invoices.subscriptionPeriod, links.subscriptionPeriod), ), ); - for (const r of rows) out.push(`這個訂閱期別已經有發票 ${r.number ?? `#${r.id}`}`); + for (const r of rows) out.push(`這個訂閱期別已經有發票 ${invoiceLabel(r)}`); } - - const today = taipeiDate(); - const since = format(addDays(parseISO(today), -DUPLICATE_LOOKBACK_DAYS), "yyyy-MM-dd"); if (links.partyId != null) { const rows = await db .select({ id: invoices.id, number: invoices.invoiceNumber, date: invoices.invoiceDate }) @@ -346,9 +378,24 @@ async function duplicateWarnings( ), ); for (const r of rows) { - out.push(`本系統 ${DUPLICATE_LOOKBACK_DAYS} 天內已有同客戶、同金額的發票 ${r.number ?? `#${r.id}`}(${r.date ?? "無日期"}),請確認不是重複開立`); + out.push(`本系統 ${DUPLICATE_LOOKBACK_DAYS} 天內已有同客戶、同金額的發票 ${invoiceLabel(r)}(${r.date ?? "無日期"}),請確認不是重複開立`); } } + return out; +} + +/** + * Simpany 端的重複檢查:近期同買受人、同金額、未作廢的發票。直接推進 out, + * 已經在 out 裡提過的號碼不重複提。查詢失敗只警示,不擋預覽。 + */ +async function pushSimpanyDuplicateWarnings( + out: string[], + client: SimpanyClient, + buyer: { vat: string | null; name: string }, + total: number, + since: string, + today: string, +): Promise { try { const res = await client.listReceipts({ status: "ALL", @@ -368,32 +415,42 @@ async function duplicateWarnings( } catch (e) { out.push(`無法向 Simpany 檢查是否重複開立:${e instanceof Error ? e.message : String(e)}`); } +} + +async function duplicateWarnings( + orgId: string, + client: SimpanyClient, + links: DraftLinks, + buyer: { vat: string | null; name: string }, + total: number, +): Promise { + const today = taipeiDate(); + const since = format(addDays(parseISO(today), -DUPLICATE_LOOKBACK_DAYS), "yyyy-MM-dd"); + const out = await internalDuplicateWarnings(orgId, links, total, since); + await pushSimpanyDuplicateWarnings(out, client, buyer, total, since, today); return out; } -/** - * 產生開立預覽並存成草稿。會讀 Simpany(原因清單、字軌、重複檢查),**不會開立**。 - * 驗證失敗丟 SimpanyPreviewError(訊息給人看)。 - */ -export async function previewSimpanyInvoice( +/** 大於 0(NaN 視為否)。刻意不寫成 `x <= 0`:那樣 NaN 會被當成合法。 */ +function isPositive(n: number | null | undefined): n is number { + return n != null && n > 0; +} + +type ResolvedBuyer = { vat: string | null; name: string; address: string; emails: string[] }; + +/** 買受人:輸入優先,沒給就用客戶資料;驗證統編與 email。 */ +async function resolveBuyer( orgId: string, - userId: string, + partyId: number | null, input: PreviewInput, - client?: SimpanyClient, -): Promise { - const simpany = client ?? (await getSimpanyClient(orgId)); - const db = getDb(); - const warnings: string[] = []; - const links: DraftLinks = { transactionIds: [] }; - const src = await loadSource(orgId, input, links, warnings); - - // ---- buyer ---- + warnings: string[], +): Promise { let party: { name: string; taxId: string | null; contact: string | null } | null = null; - if (src.partyId != null) { - [party] = await db + if (partyId != null) { + [party] = await getDb() .select({ name: parties.name, taxId: parties.taxId, contact: parties.contact }) .from(parties) - .where(and(eq(parties.organizationId, orgId), eq(parties.id, src.partyId))) + .where(and(eq(parties.organizationId, orgId), eq(parties.id, partyId))) .limit(1); } const vatInput = input.buyer?.vat; @@ -403,8 +460,8 @@ export async function previewSimpanyInvoice( if (vatTrimmed && !vat) { throw new SimpanyPreviewError(`統一編號「${vatTrimmed}」不是 8 碼數字`); } - const buyerName = (input.buyer?.name ?? party?.name ?? "").trim(); - if (!buyerName) throw new SimpanyPreviewError("缺少買受人名稱(buyer.name)"); + const name = (input.buyer?.name ?? party?.name ?? "").trim(); + if (!name) throw new SimpanyPreviewError("缺少買受人名稱(buyer.name)"); const address = (input.buyer?.address ?? "").trim(); const emails = (input.buyer?.emails ?? extractEmails(party?.contact)).map((e) => e.trim()).filter(Boolean); for (const e of emails) { @@ -413,36 +470,44 @@ export async function previewSimpanyInvoice( if (emails.length === 0) { warnings.push("沒有買受人 email:Simpany 不會寄開立通知,請確認這樣可以"); } + return { vat, name, address, emails }; +} - // ---- foreign currency ---- - const srcForeign = src.currency.toUpperCase() !== "TWD" ? src.currency.toUpperCase() : null; +/** 外幣收款:幣別、外幣金額、水單匯率 → 台幣銷售額;台幣收款回 null。 */ +function resolveForeign(input: PreviewInput, src: Source): ForeignInfo | null { + const srcCurrency = src.currency.toUpperCase(); + const srcForeign = srcCurrency === "TWD" ? null : srcCurrency; const foreignCurrency = (input.foreignCurrency?.trim().toUpperCase() || srcForeign) ?? null; - let foreign: ForeignInfo | null = null; - if (foreignCurrency && foreignCurrency !== "TWD") { - if (!/^[A-Z]{3}$/.test(foreignCurrency)) { - throw new SimpanyPreviewError(`幣別「${foreignCurrency}」不是 3 碼代號`); - } - const foreignAmount = - input.foreignAmount ?? (srcForeign === foreignCurrency ? src.amount ?? undefined : undefined); - if (foreignAmount == null || !(foreignAmount > 0)) { - throw new SimpanyPreviewError("外幣收款要提供外幣金額(foreignAmount)"); - } - const rate = input.exchangeRate; - if (rate == null || !(rate > 0)) { - throw new SimpanyPreviewError( - `這是 ${foreignCurrency} 收款:要提供匯率(exchangeRate),而且必須取自銀行的匯入匯款水單,不可自行估算。台幣銷售額 = round(外幣金額 × 匯率)。`, - ); - } - foreign = { - currency: foreignCurrency, - amount: foreignAmount, - exchangeRate: rate, - twdAmount: Math.round(foreignAmount * rate), - }; + if (!foreignCurrency || foreignCurrency === "TWD") return null; + if (!/^[A-Z]{3}$/.test(foreignCurrency)) { + throw new SimpanyPreviewError(`幣別「${foreignCurrency}」不是 3 碼代號`); } - const baseTwd = foreign ? foreign.twdAmount : src.amount; + const foreignAmount = + input.foreignAmount ?? (srcForeign === foreignCurrency ? src.amount ?? undefined : undefined); + if (!isPositive(foreignAmount)) { + throw new SimpanyPreviewError("外幣收款要提供外幣金額(foreignAmount)"); + } + const rate = input.exchangeRate; + if (!isPositive(rate)) { + throw new SimpanyPreviewError( + `這是 ${foreignCurrency} 收款:要提供匯率(exchangeRate),而且必須取自銀行的匯入匯款水單,不可自行估算。台幣銷售額 = round(外幣金額 × 匯率)。`, + ); + } + return { + currency: foreignCurrency, + amount: foreignAmount, + exchangeRate: rate, + twdAmount: Math.round(foreignAmount * rate), + }; +} - // ---- type / tax treatment ---- +/** 發票類型(B2B / B2C)與課稅別,並檢查兩者和統編、外幣是否相符。 */ +function resolveTypeAndTreatment( + input: PreviewInput, + vat: string | null, + foreign: ForeignInfo | null, + warnings: string[], +): { type: SimpanyReceiptType; taxTreatment: TaxTreatment } { const type: SimpanyReceiptType = input.type ?? (vat ? "B2B" : "B2C"); if (type === "B2B" && !vat) { throw new SimpanyPreviewError("B2B 發票需要 8 碼統一編號;海外買方沒有台灣統編時請開 B2C(零稅率)"); @@ -461,59 +526,148 @@ export async function previewSimpanyInvoice( if (taxTreatment !== "zero_rated" && input.zeroRateReason) { throw new SimpanyPreviewError("只有零稅率發票才能指定零稅率原因"); } + return { type, taxTreatment }; +} - let zeroRateReason: SimpanyZeroTaxReason | null = null; - let customsClearanceType: InvoicePreview["customsClearanceType"] = null; - if (taxTreatment === "zero_rated") { - if (foreign == null && !input.items && src.currency.toUpperCase() !== "TWD") { - throw new SimpanyPreviewError("零稅率外幣收款需要匯率(exchangeRate,取自水單)"); - } - const code = (input.zeroRateReason ?? "72").trim(); - let reasons: SimpanyZeroTaxReason[] = []; - try { - reasons = await simpany.getZeroTaxReasons(); - } catch { - reasons = []; - } - if (reasons.length) { - const hit = reasons.find((r) => r.code === code); - if (!hit) { - throw new SimpanyPreviewError( - `零稅率原因「${code}」不在 Simpany 的清單內:${reasons.map((r) => `${r.code} ${r.name}`).join("、")}`, - ); - } - zeroRateReason = hit; - } else { - if (!/^7\d$/.test(code)) throw new SimpanyPreviewError(`零稅率原因「${code}」格式不正確(應為 71–79)`); - zeroRateReason = KNOWN_ZERO_TAX_REASONS.find((r) => r.code === code) ?? { code, name: "" }; - warnings.push("無法從 Simpany 取得零稅率原因清單,原因代碼未經驗證"); - } - customsClearanceType = input.customsClearance ?? "NOT_VIA_CUSTOMS"; - if (code === "72" && customsClearanceType !== "NOT_VIA_CUSTOMS") { - warnings.push("外銷勞務(72)通常是「非經海關出口」(NOT_VIA_CUSTOMS)"); - } +/** 零稅率原因:優先對 Simpany 的清單驗證;拿不到清單時只檢查格式並警示。 */ +async function resolveZeroRateReason( + simpany: SimpanyClient, + code: string, + warnings: string[], +): Promise { + let reasons: SimpanyZeroTaxReason[] = []; + try { + reasons = await simpany.getZeroTaxReasons(); + } catch { + reasons = []; + } + if (reasons.length === 0) { + if (!/^7\d$/.test(code)) throw new SimpanyPreviewError(`零稅率原因「${code}」格式不正確(應為 71–79)`); + warnings.push("無法從 Simpany 取得零稅率原因清單,原因代碼未經驗證"); + return KNOWN_ZERO_TAX_REASONS.find((r) => r.code === code) ?? { code, name: "" }; } + const hit = reasons.find((r) => r.code === code); + if (!hit) { + const known = reasons.map((r) => `${r.code} ${r.name}`).join("、"); + throw new SimpanyPreviewError(`零稅率原因「${code}」不在 Simpany 的清單內:${known}`); + } + return hit; +} - // ---- items ---- - const rawItems: PreviewItemInput[] = - input.items && input.items.length > 0 - ? input.items - : baseTwd != null - ? [{ name: src.itemName ?? "服務費", quantity: 1, price: baseTwd }] - : []; - if (rawItems.length === 0) { - throw new SimpanyPreviewError("沒有品項也沒有可預填的金額:請給 items,或指定 transactionId / billingItemId / subscriptionId"); +/** 零稅率的原因與通關方式;非零稅率兩者都是 null。 */ +async function resolveZeroRate( + simpany: SimpanyClient, + input: PreviewInput, + src: Source, + foreign: ForeignInfo | null, + taxTreatment: TaxTreatment, + warnings: string[], +): Promise<{ + zeroRateReason: SimpanyZeroTaxReason | null; + customsClearanceType: InvoicePreview["customsClearanceType"]; +}> { + if (taxTreatment !== "zero_rated") return { zeroRateReason: null, customsClearanceType: null }; + if (foreign == null && !input.items && src.currency.toUpperCase() !== "TWD") { + throw new SimpanyPreviewError("零稅率外幣收款需要匯率(exchangeRate,取自水單)"); } - const items = rawItems.map((it, i) => { + const code = (input.zeroRateReason ?? "72").trim(); + const zeroRateReason = await resolveZeroRateReason(simpany, code, warnings); + const customsClearanceType = input.customsClearance ?? "NOT_VIA_CUSTOMS"; + if (code === "72" && customsClearanceType !== "NOT_VIA_CUSTOMS") { + warnings.push("外銷勞務(72)通常是「非經海關出口」(NOT_VIA_CUSTOMS)"); + } + return { zeroRateReason, customsClearanceType }; +} + +/** 品項來源:有給 items 就用;否則用預填的台幣金額組一個品項;都沒有回空陣列。 */ +function rawItemsFor(input: PreviewInput, baseTwd: number | null, itemName: string | null): PreviewItemInput[] { + if (input.items && input.items.length > 0) return input.items; + if (baseTwd == null) return []; + return [{ name: itemName ?? "服務費", quantity: 1, price: baseTwd }]; +} + +/** 驗證並正規化品項(品名、數量、單價),算出每項小計。 */ +function normalizeItems(rawItems: PreviewItemInput[]): InvoicePreview["items"] { + return rawItems.map((it, i) => { const name = sanitizeItemName(String(it.name ?? "")); if (!name) throw new SimpanyPreviewError(`第 ${i + 1} 個品項沒有品名`); if (name.length > 256) throw new SimpanyPreviewError(`第 ${i + 1} 個品項品名過長`); const quantity = Number(it.quantity); const price = Number(it.price); - if (!(quantity > 0)) throw new SimpanyPreviewError(`第 ${i + 1} 個品項數量要大於 0`); - if (!(price > 0)) throw new SimpanyPreviewError(`第 ${i + 1} 個品項單價要大於 0`); + if (!isPositive(quantity)) throw new SimpanyPreviewError(`第 ${i + 1} 個品項數量要大於 0`); + if (!isPositive(price)) throw new SimpanyPreviewError(`第 ${i + 1} 個品項單價要大於 0`); return { name, quantity, price, subTotal: round2(quantity * price) }; }); +} + +/** 預覽的一行摘要(MCP 與 UI 用)。 */ +function buildSummaryLine(p: { + type: SimpanyReceiptType; + buyer: ResolvedBuyer; + taxTreatment: TaxTreatment; + zeroRateReason: SimpanyZeroTaxReason | null; + amounts: InvoiceAmounts; + foreign: ForeignInfo | null; + itemCount: number; +}): string { + const vatPart = p.buyer.vat ? `(${p.buyer.vat})` : ""; + let reasonPart = ""; + if (p.zeroRateReason) { + const reasonName = p.zeroRateReason.name ? ` ${p.zeroRateReason.name}` : ""; + reasonPart = ` ${p.zeroRateReason.code}${reasonName}`; + } + return [ + `${p.type} ${p.buyer.name}${vatPart}`, + `${TAX_TREATMENT_LABEL[p.taxTreatment]}${reasonPart}`, + `未稅 ${p.amounts.untaxed} + 稅 ${p.amounts.tax} = 總計 NT$${p.amounts.total}`, + p.foreign ? `${p.foreign.currency} ${p.foreign.amount} × ${p.foreign.exchangeRate}` : null, + `${p.itemCount} 個品項`, + ] + .filter(Boolean) + .join("|"); +} + +/** + * 產生開立預覽並存成草稿。會讀 Simpany(原因清單、字軌、重複檢查),**不會開立**。 + * 驗證失敗丟 SimpanyPreviewError(訊息給人看)。 + */ +export async function previewSimpanyInvoice( + orgId: string, + userId: string, + input: PreviewInput, + client?: SimpanyClient, +): Promise { + const simpany = client ?? (await getSimpanyClient(orgId)); + const db = getDb(); + const warnings: string[] = []; + const links: DraftLinks = { transactionIds: [] }; + const src = await loadSource(orgId, input, links, warnings); + + // ---- buyer ---- + const buyer = await resolveBuyer(orgId, src.partyId, input, warnings); + const { vat, name: buyerName, address, emails } = buyer; + + // ---- foreign currency ---- + const foreign = resolveForeign(input, src); + const baseTwd = foreign ? foreign.twdAmount : src.amount; + + // ---- type / tax treatment ---- + const { type, taxTreatment } = resolveTypeAndTreatment(input, vat, foreign, warnings); + const { zeroRateReason, customsClearanceType } = await resolveZeroRate( + simpany, + input, + src, + foreign, + taxTreatment, + warnings, + ); + + // ---- items ---- + const rawItems = rawItemsFor(input, baseTwd, src.itemName); + if (rawItems.length === 0) { + throw new SimpanyPreviewError("沒有品項也沒有可預填的金額:請給 items,或指定 transactionId / billingItemId / subscriptionId"); + } + const items = normalizeItems(rawItems); const sum = round2(items.reduce((s, it) => s + it.subTotal, 0)); if (!Number.isInteger(sum)) { throw new SimpanyPreviewError(`品項合計 ${sum} 不是整數台幣;發票金額必須是整數`); @@ -523,7 +677,7 @@ export async function previewSimpanyInvoice( } const isTaxIncluded = input.isTaxIncluded ?? true; const amounts = computeAmounts(sum, taxTreatment, isTaxIncluded); - if (!(amounts.total > 0)) throw new SimpanyPreviewError("發票總額必須大於 0"); + if (!isPositive(amounts.total)) throw new SimpanyPreviewError("發票總額必須大於 0"); // ---- checks against Simpany / internal ---- warnings.push(...(await duplicateWarnings(orgId, simpany, links, { vat, name: buyerName }, amounts.total))); @@ -551,15 +705,15 @@ export async function previewSimpanyInvoice( zeroTaxRateReasonCode: zeroRateReason?.code ?? null, }; - const summaryLine = [ - `${type} ${buyerName}${vat ? `(${vat})` : ""}`, - `${TAX_TREATMENT_LABEL[taxTreatment]}${zeroRateReason ? ` ${zeroRateReason.code}${zeroRateReason.name ? ` ${zeroRateReason.name}` : ""}` : ""}`, - `未稅 ${amounts.untaxed} + 稅 ${amounts.tax} = 總計 NT$${amounts.total}`, - foreign ? `${foreign.currency} ${foreign.amount} × ${foreign.exchangeRate}` : null, - `${items.length} 個品項`, - ] - .filter(Boolean) - .join("|"); + const summaryLine = buildSummaryLine({ + type, + buyer, + taxTreatment, + zeroRateReason, + amounts, + foreign, + itemCount: items.length, + }); const expiresAt = new Date(Date.now() + DRAFT_TTL_MS).toISOString(); const preview: Omit = { @@ -627,9 +781,8 @@ async function explainUnavailableDraft(orgId: string, draftId: number): Promise< .limit(1); if (!d) throw new SimpanyPreviewError(`找不到草稿 #${draftId}`); if (d.status === "issued") { - throw new SimpanyPreviewError( - `草稿 #${draftId} 已經開立過了${d.issuedInvoiceId ? `(發票 #${d.issuedInvoiceId})` : ""},不會重複開立`, - ); + const issuedRef = d.issuedInvoiceId ? `(發票 #${d.issuedInvoiceId})` : ""; + throw new SimpanyPreviewError(`草稿 #${draftId} 已經開立過了${issuedRef},不會重複開立`); } if (d.status === "cancelled") { throw new SimpanyPreviewError(`草稿 #${draftId} 已取消(先前送出失敗或結果不明),請重新預覽`); @@ -679,6 +832,58 @@ async function findJustIssued( return hits[0] ? client.getReceipt(hits[0].id) : null; } +/** Simpany 明確拒絕(驗證 / 業務 / 認證 / 設定錯誤):確定沒開出來。 */ +function isDefiniteRejection(e: unknown): e is SimpanyError { + return ( + e instanceof SimpanyError && + (e.kind === "validation" || e.kind === "business" || e.kind === "auth" || e.kind === "config") + ); +} + +/** + * 送出開立。失敗時依錯誤種類處理草稿:明確拒絕 → 退回 pending 可再送; + * 網路中斷 / 5xx → 不知道開了沒,草稿作廢並丟出請使用者先同步確認的錯誤。 + */ +async function submitDraft( + client: SimpanyClient, + draftId: number, + type: SimpanyReceiptType, + body: SimpanyCreateBody, +): Promise { + try { + return await client.createReceipt(type, body); + } catch (e) { + if (isDefiniteRejection(e)) { + // Simpany 明確拒絕 → 沒開出來,草稿退回可再送(修正後通常要重新預覽)。 + await markDraft(draftId, "pending", e.message); + throw e; + } + // 網路中斷 / 5xx:不知道到底開了沒有。草稿作廢,避免盲目重送造成重複開立。 + const msg = e instanceof Error ? e.message : String(e); + await markDraft(draftId, "cancelled", msg); + throw new SimpanyError( + "http", + `送出後沒有收到明確結果(${msg})。發票可能已開出也可能沒有:請先執行「從 Simpany 同步」確認,確定沒開出再重新預覽開立。`, + ); + } +} + +/** 取完整明細:回應形狀未經實測,一律再 GET 一次;拿不到 id 就用買受人 + 金額找今天最新的一張。 */ +async function fetchIssuedDetail( + client: SimpanyClient, + created: unknown, + body: SimpanyCreateBody, + total: number, +): Promise { + const createdId = isObj(created) && typeof created.id === "string" ? created.id : null; + try { + if (createdId) return await client.getReceipt(createdId); + return parseDetail(created) ?? (await findJustIssued(client, body, total)); + } catch { + return parseDetail(created); + } +} + /** * 開立一筆預覽過的草稿。**會在 Simpany 產生正式電子發票、上傳財政部、寄信給買受人。** * 只能在使用者明確確認預覽之後呼叫。 @@ -689,10 +894,8 @@ export async function issueSimpanyDraft( opts: { notifyEmails?: string[] } = {}, ): Promise { const db = getDb(); - if (opts.notifyEmails) { - for (const e of opts.notifyEmails) { - if (!isValidEmail(e.trim())) throw new SimpanyPreviewError(`Email 格式不正確:${e}`); - } + for (const e of opts.notifyEmails ?? []) { + if (!isValidEmail(e.trim())) throw new SimpanyPreviewError(`Email 格式不正確:${e}`); } // 先搶下草稿(pending → issued):同一份草稿被按兩次,第二次會搶不到,不會開兩張。 const [draft] = await db @@ -729,35 +932,8 @@ export async function issueSimpanyDraft( throw e; } - let created: unknown; - try { - created = await client.createReceipt(type, body); - } catch (e) { - const definite = - e instanceof SimpanyError && (e.kind === "validation" || e.kind === "business" || e.kind === "auth" || e.kind === "config"); - if (definite) { - // Simpany 明確拒絕 → 沒開出來,草稿退回可再送(修正後通常要重新預覽)。 - await markDraft(draftId, "pending", e.message); - throw e; - } - // 網路中斷 / 5xx:不知道到底開了沒有。草稿作廢,避免盲目重送造成重複開立。 - const msg = e instanceof Error ? e.message : String(e); - await markDraft(draftId, "cancelled", msg); - throw new SimpanyError( - "http", - `送出後沒有收到明確結果(${msg})。發票可能已開出也可能沒有:請先執行「從 Simpany 同步」確認,確定沒開出再重新預覽開立。`, - ); - } - - // 取完整明細:回應形狀未經實測,一律再 GET 一次;拿不到 id 就用買受人 + 金額找今天最新的一張。 - let detail: SimpanyReceiptDetail | null = null; - const createdId = isObj(created) && typeof created.id === "string" ? created.id : null; - try { - if (createdId) detail = await client.getReceipt(createdId); - else detail = parseDetail(created) ?? (await findJustIssued(client, body, total)); - } catch { - detail = parseDetail(created); - } + const created = await submitDraft(client, draftId, type, body); + const detail = await fetchIssuedDetail(client, created, body, total); if (!detail) { throw new SimpanyError( "business", @@ -807,7 +983,7 @@ export async function resolveSimpanyReceiptId( ): Promise { const r = ref.trim(); if (/^R\d+$/i.test(r)) return r.toUpperCase(); - const number = r.toUpperCase().replace(/[\s-]/g, ""); + const number = r.toUpperCase().replaceAll(/[\s-]/g, ""); if (!/^[A-Z]{2}\d{8}$/.test(number)) { throw new SimpanyPreviewError(`「${ref}」不是發票號碼(兩個英文字母 + 8 碼數字)也不是 Simpany id(R 開頭)`); } diff --git a/src/lib/simpany-sync.ts b/src/lib/simpany-sync.ts index 5a83734..f4b7443 100644 --- a/src/lib/simpany-sync.ts +++ b/src/lib/simpany-sync.ts @@ -179,27 +179,16 @@ export type UpsertOutcome = { hasBillingLink: boolean; }; -/** - * 把一張 Simpany 發票寫進 invoices。找既有列的順序: - * 1. 同 external_id - * 2. 同發票號碼(external_ref / invoice_number)、還沒有 external_id 的銷項發票(手動或 xlsx 對帳建的) - * 3. 「待開立」(pending、沒號碼)的銷項發票,同客戶、同金額、±45 天且唯一 —— 舊流程先在本系統建草稿、 - * 再到 Simpany 手開的那種 - * 都沒有才新增。既有列的綁定(客戶、合約、請款項目、訂閱期別、外幣資訊)不覆寫。 - */ -export async function upsertSimpanyReceipt( +/** upsertSimpanyReceipt 的既有列查找(順序見下方說明);找不到回 existing = null。 */ +async function findExistingForReceipt( orgId: string, d: SimpanyReceiptDetail, - ctx: { parties?: PartyLite[]; existing?: ExistingInvoice | null } = {}, -): Promise { + partyId: number | null, + invoiceDate: string | null, + known: ExistingInvoice | null | undefined, +): Promise<{ existing: ExistingInvoice | null; action: UpsertOutcome["action"] }> { const db = getDb(); - const allParties = ctx.parties ?? (await loadParties(orgId)); - const voided = isVoid(d.status); - const invoiceDate = dateOfIssued(d.issuedAt); - const partyId = matchPartyId(allParties, d.buyerVat, d.buyerName); - - let existing: ExistingInvoice | null | undefined = ctx.existing; - let action: UpsertOutcome["action"] = "updated"; + let existing: ExistingInvoice | null | undefined = known; if (existing === undefined) { [existing] = await db .select(existingColumns) @@ -207,7 +196,8 @@ export async function upsertSimpanyReceipt( .where(and(eq(invoices.organizationId, orgId), eq(invoices.externalId, d.id))) .limit(1); } - if (!existing && d.invoiceNumber) { + if (existing) return { existing, action: "updated" }; + if (d.invoiceNumber) { const [byNumber] = await db .select(existingColumns) .from(invoices) @@ -221,12 +211,9 @@ export async function upsertSimpanyReceipt( ), ) .limit(1); - if (byNumber) { - existing = byNumber; - action = "adopted"; - } + if (byNumber) return { existing: byNumber, action: "adopted" }; } - if (!existing && partyId != null && invoiceDate) { + if (partyId != null && invoiceDate) { const pending = await db .select(existingColumns) .from(invoices) @@ -243,11 +230,31 @@ export async function upsertSimpanyReceipt( const hits = pending.filter( (p) => sameMoney(p.amountGross, d.totalAmount) && withinWindow(p.invoiceDate, invoiceDate), ); - if (hits.length === 1) { - existing = hits[0]; - action = "adopted"; - } + if (hits.length === 1) return { existing: hits[0], action: "adopted" }; } + return { existing: null, action: "updated" }; +} + +/** + * 把一張 Simpany 發票寫進 invoices。找既有列的順序: + * 1. 同 external_id + * 2. 同發票號碼(external_ref / invoice_number)、還沒有 external_id 的銷項發票(手動或 xlsx 對帳建的) + * 3. 「待開立」(pending、沒號碼)的銷項發票,同客戶、同金額、±45 天且唯一 —— 舊流程先在本系統建草稿、 + * 再到 Simpany 手開的那種 + * 都沒有才新增。既有列的綁定(客戶、合約、請款項目、訂閱期別、外幣資訊)不覆寫。 + */ +export async function upsertSimpanyReceipt( + orgId: string, + d: SimpanyReceiptDetail, + ctx: { parties?: PartyLite[]; existing?: ExistingInvoice | null } = {}, +): Promise { + const db = getDb(); + const allParties = ctx.parties ?? (await loadParties(orgId)); + const voided = isVoid(d.status); + const invoiceDate = dateOfIssued(d.issuedAt); + const partyId = matchPartyId(allParties, d.buyerVat, d.buyerName); + + const { existing, action } = await findExistingForReceipt(orgId, d, partyId, invoiceDate, ctx.existing); const facts = { direction: "issued", @@ -750,6 +757,58 @@ export type SyncResult = { incomplete: boolean; }; +type TouchedInvoice = UpsertOutcome & { invoiceNumber: string | null; buyer: string | null }; + +/** 已同步過、狀態 / 號碼 / 金額都沒變 → 不用重抓明細。 */ +function isUnchangedReceipt(existing: ExistingInvoice, item: SimpanyReceiptListItem): boolean { + const statusNow = isVoid(item.status) ? "void" : "valid"; + return ( + Boolean(existing.externalSyncedAt) && + existing.status === statusNow && + existing.invoiceNumber === item.invoiceNumber && + sameMoney(existing.amountGross, item.totalAmount) + ); +} + +/** 沒變的有效發票仍要參加自動綁定(可能之前沒綁上);作廢或沒日期的不參加。 */ +function touchedFromUnchanged(existing: ExistingInvoice, item: SimpanyReceiptListItem): TouchedInvoice | null { + if (isVoid(item.status) || !existing.invoiceDate) return null; + return { + invoiceId: existing.id, + action: "updated", + becameVoid: false, + partyId: existing.partyId, + invoiceDate: existing.invoiceDate, + amountGross: item.totalAmount, + hasBillingLink: existing.billingItemId != null || existing.subscriptionId != null, + invoiceNumber: existing.invoiceNumber, + buyer: item.buyerName, + }; +} + +/** 抓一張的明細並寫入,更新計數;新作廢的清掉綁定。回傳要參加自動綁定的發票(作廢的不參加)。 */ +async function syncReceiptDetail( + orgId: string, + simpany: SimpanyClient, + receiptId: string, + existing: ExistingInvoice | null, + allParties: PartyLite[], + result: SyncResult, +): Promise { + const detail = await simpany.getReceipt(receiptId); + const outcome = await upsertSimpanyReceipt(orgId, detail, { parties: allParties, existing }); + if (outcome.action === "created") result.created++; + else result.updated++; + if (outcome.becameVoid) { + result.voided++; + if (outcome.action !== "created") { + result.voidCleanups.push(await clearLinksForVoidedInvoice(orgId, outcome.invoiceId)); + } + } + if (isVoid(detail.status)) return null; + return { ...outcome, invoiceNumber: detail.invoiceNumber, buyer: detail.buyerName }; +} + /** * 同步一段日期區間的 Simpany 發票進 invoices,並嘗試自動綁定。冪等:同一張發票重跑只會更新。 * 已同步過、狀態沒變的發票不會重抓明細。 @@ -803,31 +862,14 @@ export async function syncSimpanyInvoices( ]); const existingById = new Map(existingRows.map((r) => [r.externalId as string, r])); - const touched: (UpsertOutcome & { invoiceNumber: string | null; buyer: string | null })[] = []; + const touched: TouchedInvoice[] = []; let fetched = 0; for (const item of list) { const existing = existingById.get(item.id) ?? null; - const statusNow = isVoid(item.status) ? "void" : "valid"; - if ( - existing?.externalSyncedAt && - existing.status === statusNow && - existing.invoiceNumber === item.invoiceNumber && - sameMoney(existing.amountGross, item.totalAmount) - ) { + if (existing && isUnchangedReceipt(existing, item)) { result.unchanged++; - if (statusNow === "valid" && existing.invoiceDate) { - touched.push({ - invoiceId: existing.id, - action: "updated", - becameVoid: false, - partyId: existing.partyId, - invoiceDate: existing.invoiceDate, - amountGross: item.totalAmount, - hasBillingLink: existing.billingItemId != null || existing.subscriptionId != null, - invoiceNumber: existing.invoiceNumber, - buyer: item.buyerName, - }); - } + const touch = touchedFromUnchanged(existing, item); + if (touch) touched.push(touch); continue; } if (fetched >= MAX_DETAIL_FETCHES) { @@ -835,58 +877,39 @@ export async function syncSimpanyInvoices( continue; } fetched++; - const detail = await simpany.getReceipt(item.id); - const outcome = await upsertSimpanyReceipt(orgId, detail, { parties: allParties, existing }); - if (outcome.action === "created") result.created++; - else result.updated++; - if (outcome.becameVoid) { - result.voided++; - if (outcome.action !== "created") { - result.voidCleanups.push(await clearLinksForVoidedInvoice(orgId, outcome.invoiceId)); - } - } - if (!isVoid(detail.status)) { - touched.push({ ...outcome, invoiceNumber: detail.invoiceNumber, buyer: detail.buyerName }); - } + const touch = await syncReceiptDetail(orgId, simpany, item.id, existing, allParties, result); + if (touch) touched.push(touch); } await autoLink(orgId, range, touched, result); return result; } -async function autoLink( - orgId: string, - range: { startDate: string; endDate: string }, - touched: (UpsertOutcome & { invoiceNumber: string | null; buyer: string | null })[], - result: SyncResult, -): Promise { - if (touched.length === 0) return; - const db = getDb(); - const linkedTxn = new Set( - ( - await db - .select({ invoiceId: transactions.invoiceId }) - .from(transactions) - .where( - and( - eq(transactions.organizationId, orgId), - isNull(transactions.deletedAt), - inArray( - transactions.invoiceId, - touched.map((t) => t.invoiceId), - ), - ), - ) - ).map((r) => r.invoiceId), - ); - const pools = await loadCandidatePools(orgId, range.startDate, range.endDate); +type LinkPlan = { t: TouchedInvoice; txn: Candidate[]; billing: Candidate[] }; - // 第一輪:每張發票各自的候選。 - const plans: { - t: (typeof touched)[number]; - txn: Candidate[]; - billing: Candidate[]; - }[] = []; +/** 已經有交易綁著的發票 id(這些不用再找收款交易)。 */ +async function invoiceIdsWithLinkedTxn(orgId: string, invoiceIds: number[]): Promise> { + const rows = await getDb() + .select({ invoiceId: transactions.invoiceId }) + .from(transactions) + .where( + and( + eq(transactions.organizationId, orgId), + isNull(transactions.deletedAt), + inArray(transactions.invoiceId, invoiceIds), + ), + ); + return new Set(rows.map((r) => r.invoiceId)); +} + +/** 第一輪:每張發票各自的候選。對不上客戶的新發票直接列入待確認。 */ +function planAutoLinks( + touched: TouchedInvoice[], + linkedTxn: Set, + pools: CandidatePools, + result: SyncResult, +): LinkPlan[] { + const plans: LinkPlan[] = []; for (const t of touched) { const wantTxn = !linkedTxn.has(t.invoiceId); const wantBilling = !t.hasBillingLink; @@ -911,8 +934,11 @@ async function autoLink( ); plans.push({ t, ...c }); } + return plans; +} - // 第二輪:同一個候選若是多張發票的唯一候選,也算模稜兩可。 +/** 第二輪:每個候選是幾張發票的「唯一候選」。大於 1 就是模稜兩可。 */ +function countSoleClaims(plans: LinkPlan[]): Map { const soleClaims = new Map(); for (const p of plans) { for (const list of [p.txn, p.billing]) { @@ -922,62 +948,104 @@ async function autoLink( } } } + return soleClaims; +} - for (const { t, txn, billing } of plans) { - const linkedTo: string[] = []; - const links: InvoiceLinks = {}; - const review = (reason: string, cands: Candidate[]) => - result.needsReview.push({ - invoiceId: t.invoiceId, - invoiceNumber: t.invoiceNumber, - buyer: t.buyer, - amount: t.amountGross, - reason, - candidates: cands.map((c) => ({ - kind: c.kind, - id: c.id, - ...(c.kind === "subscription_period" ? { periodStart: c.periodStart } : {}), - label: c.label, - })), - }); - - if (txn.length === 1 && (soleClaims.get(candidateKey(txn[0])) ?? 0) === 1) { - links.transactionIds = [txn[0].id]; - linkedTo.push(`交易 #${txn[0].id}`); - } else if (txn.length > 0) { - review( - txn.length > 1 - ? "有多筆可能對應的收入交易,未自動綁定" - : "對應的收入交易同時也可能屬於另一張發票,未自動綁定", - txn, - ); - } +/** 只有一個候選、而且它沒有被別張發票當成唯一候選,才能自動綁。 */ +function uniqueUnclaimed(cands: Candidate[], soleClaims: Map): Candidate | null { + const only = cands[0]; + if (cands.length !== 1 || !only) return null; + return (soleClaims.get(candidateKey(only)) ?? 0) === 1 ? only : null; +} - const b = billing[0]; - if (billing.length === 1 && b && (soleClaims.get(candidateKey(b)) ?? 0) === 1) { - if (b.kind === "billing_item") { - links.billingItemId = b.id; - links.contractId = b.contractId; - linkedTo.push(`請款項目 #${b.id}`); - } else if (b.kind === "subscription_period") { - links.subscriptionId = b.id; - links.subscriptionPeriod = b.periodStart; - links.contractId = b.contractId; - linkedTo.push(`訂閱 #${b.id} ${b.periodStart} 期`); - } - } else if (billing.length > 0) { - review( - billing.length > 1 - ? "有多個可能對應的請款項目 / 訂閱期別,未自動綁定" - : "對應的請款項目 / 訂閱期別同時也可能屬於另一張發票,未自動綁定", - billing, - ); - } +/** 把請款項目 / 訂閱期別候選寫進 links;回傳給人看的描述(交易候選不會走到這裡)。 */ +function applyBillingCandidate(b: Candidate, links: InvoiceLinks): string | null { + if (b.kind === "billing_item") { + links.billingItemId = b.id; + links.contractId = b.contractId; + return `請款項目 #${b.id}`; + } + if (b.kind === "subscription_period") { + links.subscriptionId = b.id; + links.subscriptionPeriod = b.periodStart; + links.contractId = b.contractId; + return `訂閱 #${b.id} ${b.periodStart} 期`; + } + return null; +} - if (linkedTo.length) { - await applyInvoiceLinks(orgId, t.invoiceId, t.invoiceDate, links); - result.autoLinked.push({ invoiceNumber: t.invoiceNumber, invoiceId: t.invoiceId, linkedTo }); - } +function reviewItem(t: TouchedInvoice, reason: string, cands: Candidate[]): NeedsReviewItem { + return { + invoiceId: t.invoiceId, + invoiceNumber: t.invoiceNumber, + buyer: t.buyer, + amount: t.amountGross, + reason, + candidates: cands.map((c) => ({ + kind: c.kind, + id: c.id, + ...(c.kind === "subscription_period" ? { periodStart: c.periodStart } : {}), + label: c.label, + })), + }; +} + +/** 依一張發票的候選決定綁定:唯一且無爭議就綁,否則列入待確認。 */ +async function applyLinkPlan( + orgId: string, + { t, txn, billing }: LinkPlan, + soleClaims: Map, + result: SyncResult, +): Promise { + const linkedTo: string[] = []; + const links: InvoiceLinks = {}; + + const txnHit = uniqueUnclaimed(txn, soleClaims); + if (txnHit) { + links.transactionIds = [txnHit.id]; + linkedTo.push(`交易 #${txnHit.id}`); + } else if (txn.length > 0) { + const reason = + txn.length > 1 + ? "有多筆可能對應的收入交易,未自動綁定" + : "對應的收入交易同時也可能屬於另一張發票,未自動綁定"; + result.needsReview.push(reviewItem(t, reason, txn)); + } + + const billingHit = uniqueUnclaimed(billing, soleClaims); + if (billingHit) { + const label = applyBillingCandidate(billingHit, links); + if (label) linkedTo.push(label); + } else if (billing.length > 0) { + const reason = + billing.length > 1 + ? "有多個可能對應的請款項目 / 訂閱期別,未自動綁定" + : "對應的請款項目 / 訂閱期別同時也可能屬於另一張發票,未自動綁定"; + result.needsReview.push(reviewItem(t, reason, billing)); + } + + if (linkedTo.length) { + await applyInvoiceLinks(orgId, t.invoiceId, t.invoiceDate, links); + result.autoLinked.push({ invoiceNumber: t.invoiceNumber, invoiceId: t.invoiceId, linkedTo }); + } +} + +async function autoLink( + orgId: string, + range: { startDate: string; endDate: string }, + touched: TouchedInvoice[], + result: SyncResult, +): Promise { + if (touched.length === 0) return; + const linkedTxn = await invoiceIdsWithLinkedTxn( + orgId, + touched.map((t) => t.invoiceId), + ); + const pools = await loadCandidatePools(orgId, range.startDate, range.endDate); + const plans = planAutoLinks(touched, linkedTxn, pools, result); + const soleClaims = countSoleClaims(plans); + for (const plan of plans) { + await applyLinkPlan(orgId, plan, soleClaims, result); } } diff --git a/src/lib/wise-sync.ts b/src/lib/wise-sync.ts index dd2ad34..5c5e343 100644 --- a/src/lib/wise-sync.ts +++ b/src/lib/wise-sync.ts @@ -75,6 +75,13 @@ function num(v: unknown): number | null { return typeof v === "number" && Number.isFinite(v) ? v : null; } +/** 字串 / 數字 / 布林轉成文字;其他(物件、null…)用 fallback,避免變成 "[object Object]"。 */ +function text(v: unknown, fallback: string): string { + if (typeof v === "string") return v; + if (typeof v === "number" || typeof v === "boolean") return String(v); + return fallback; +} + /** 從 org_integrations.config 讀出 Wise 設定;形狀不對的項目直接略過。 */ export function parseWiseConfig(config: IntegrationConfig | null | undefined): WiseConfig { const c = config ?? {}; @@ -84,7 +91,7 @@ export function parseWiseConfig(config: IntegrationConfig | null | undefined): W const id = num(o?.id); return id === null ? [] - : [{ id, type: String(o.type ?? ""), name: String(o.name ?? id) }]; + : [{ id, type: text(o.type, ""), name: text(o.name, String(id)) }]; }) : []; const balances = Array.isArray(c.balances) @@ -252,62 +259,95 @@ export async function saveWiseMappings( const integ = await getIntegration(orgId, "wise"); if (!integ) return { error: "Wise 尚未連接" }; const cfg = parseWiseConfig(integ.config); - const db = getDb(); const accountIds = input.map((m) => m.bankAccountId).filter((x): x is number => x !== null); - const accounts = accountIds.length - ? await db - .select({ id: bankAccounts.id, name: bankAccounts.name, currency: bankAccounts.currency }) - .from(bankAccounts) - .where( - and( - eq(bankAccounts.organizationId, orgId), - inArray(bankAccounts.id, accountIds), - isNull(bankAccounts.deletedAt), - ), - ) - : []; + const accounts = await loadOrgAccounts(getDb(), orgId, accountIds); const byId = new Map(accounts.map((a) => [a.id, a])); const seenAccounts = new Set(); - const needSuggestion: number[] = []; const out: WiseAccountMapping[] = []; for (const m of input) { - const bal = cfg.balances.find((b) => b.balanceId === m.balanceId && b.profileId === m.profileId); - if (!bal) return { error: `找不到 Wise 餘額 #${m.balanceId},請先重新整理餘額` }; - if (m.syncFrom !== null && !isIsoDate(m.syncFrom)) { - return { error: `切換日格式錯誤:${m.syncFrom}(應為 YYYY-MM-DD)` }; - } - if (m.bankAccountId !== null) { - const acct = byId.get(m.bankAccountId); - if (!acct) return { error: `找不到帳本帳戶 #${m.bankAccountId}` }; - if (acct.currency.trim().toUpperCase() !== bal.currency) { - return { - error: `「${acct.name}」是 ${acct.currency.trim()} 帳戶,不能對應 Wise 的 ${bal.currency} 餘額`, - }; - } - if (seenAccounts.has(acct.id)) { - return { error: `「${acct.name}」被對應到兩個 Wise 餘額,一個帳本帳戶只能對應一個` }; - } - seenAccounts.add(acct.id); - if (m.syncFrom === null) needSuggestion.push(acct.id); - } - out.push({ + const checked = validateMapping(m, cfg, byId, seenAccounts); + if ("error" in checked) return checked; + out.push(checked.mapping); + } + await fillSuggestedSyncFrom(orgId, out); + await updateConfig(orgId, "wise", { accountMappings: out }); + return { mappings: out }; +} + +type AccountRow = { id: number; name: string; currency: string }; + +/** 讀本組織、未刪除的帳本帳戶(只取 id / 名稱 / 幣別)。 */ +async function loadOrgAccounts(db: Db, orgId: string, ids: number[]): Promise { + if (ids.length === 0) return []; + return db + .select({ id: bankAccounts.id, name: bankAccounts.name, currency: bankAccounts.currency }) + .from(bankAccounts) + .where( + and( + eq(bankAccounts.organizationId, orgId), + inArray(bankAccounts.id, ids), + isNull(bankAccounts.deletedAt), + ), + ); +} + +/** 驗證單一對應;通過就回傳要存的對應(syncFrom 可能仍是 null,稍後補建議值)。 */ +function validateMapping( + m: MappingInput, + cfg: WiseConfig, + byId: Map, + seenAccounts: Set, +): { error: string } | { mapping: WiseAccountMapping } { + const bal = cfg.balances.find((b) => b.balanceId === m.balanceId && b.profileId === m.profileId); + if (!bal) return { error: `找不到 Wise 餘額 #${m.balanceId},請先重新整理餘額` }; + if (m.syncFrom !== null && !isIsoDate(m.syncFrom)) { + return { error: `切換日格式錯誤:${m.syncFrom}(應為 YYYY-MM-DD)` }; + } + if (m.bankAccountId !== null) { + const error = checkMappedAccount(byId.get(m.bankAccountId), m.bankAccountId, bal.currency, seenAccounts); + if (error) return { error }; + } + return { + mapping: { profileId: bal.profileId, balanceId: bal.balanceId, currency: bal.currency, bankAccountId: m.bankAccountId, syncFrom: m.syncFrom, - }); + }, + }; +} + +/** 帳本帳戶存在、幣別相符、且沒被重複對應;通過時記進 seenAccounts。 */ +function checkMappedAccount( + acct: AccountRow | undefined, + bankAccountId: number, + currency: string, + seenAccounts: Set, +): string | null { + if (!acct) return `找不到帳本帳戶 #${bankAccountId}`; + if (acct.currency.trim().toUpperCase() !== currency) { + return `「${acct.name}」是 ${acct.currency.trim()} 帳戶,不能對應 Wise 的 ${currency} 餘額`; } - if (needSuggestion.length) { - const suggested = await suggestSyncFrom(orgId, needSuggestion); - for (const m of out) { - if (m.bankAccountId !== null && m.syncFrom === null) { - m.syncFrom = suggested.get(m.bankAccountId) ?? null; - } + if (seenAccounts.has(acct.id)) { + return `「${acct.name}」被對應到兩個 Wise 餘額,一個帳本帳戶只能對應一個`; + } + seenAccounts.add(acct.id); + return null; +} + +/** 有對應帳戶但沒填 syncFrom 的,補上建議切換日(就地修改)。 */ +async function fillSuggestedSyncFrom(orgId: string, mappings: WiseAccountMapping[]): Promise { + const needSuggestion = mappings + .filter((m) => m.bankAccountId !== null && m.syncFrom === null) + .map((m) => m.bankAccountId as number); + if (needSuggestion.length === 0) return; + const suggested = await suggestSyncFrom(orgId, needSuggestion); + for (const m of mappings) { + if (m.bankAccountId !== null && m.syncFrom === null) { + m.syncFrom = suggested.get(m.bankAccountId) ?? null; } } - await updateConfig(orgId, "wise", { accountMappings: out }); - return { mappings: out }; } /** 重新向 Wise 抓 profile 與餘額(唯讀),寫回 config。整合必須已開啟。 */ @@ -338,10 +378,17 @@ export type PlannedRow = { type ResolvedMapping = WiseAccountMapping & { bankAccountId: number }; +type WiseDetails = NonNullable; +type WiseMoney = WiseStatementTransaction["amount"]; + function fmtAmount(v: number): string { return Math.abs(v).toFixed(2); } +function feeLabel(fee: WiseMoney): string { + return `fee ${fmtAmount(fee.value)} ${fee.currency}`; +} + function recipientName(r: unknown): string | null { if (!r) return null; if (typeof r === "string") return r; @@ -364,21 +411,34 @@ function counterCurrencyOf(tx: WiseStatementTransaction, ownCurrency: string): s return null; } -/** 一筆 Wise 對帳單交易 → 一列帳本交易(尚未寫入)。 */ -export function mapWiseTransaction( +/** CREDIT 進帳記在 to,DEBIT 出帳記在 from。 */ +function accountSides( + isCredit: boolean, + bankAccountId: number, +): { fromAccountId: number | null; toAccountId: number | null } { + return isCredit + ? { fromAccountId: null, toAccountId: bankAccountId } + : { fromAccountId: bankAccountId, toAccountId: null }; +} + +function merchantMeta(merchant: NonNullable): Record { + return { + name: merchant.name ?? null, + city: merchant.city ?? null, + country: merchant.country ?? null, + category: merchant.category ?? null, + }; +} + +/** 寫進 external_meta 的原始資訊。 */ +function buildMeta( tx: WiseStatementTransaction, + d: WiseDetails, + dtype: string, m: ResolvedMapping, - all: readonly WiseAccountMapping[], -): PlannedRow { - const d = tx.details ?? {}; - const dtype = (d.type ?? "UNKNOWN").toUpperCase(); - const isCredit = tx.type === "CREDIT"; - const currency = m.currency; - const amount = fmtAmount(tx.amount.value); - const fee = tx.totalFees && tx.totalFees.value ? tx.totalFees : null; - const orig = d.amount && d.amount.currency && d.amount.currency.toUpperCase() !== currency ? d.amount : null; - const rate = tx.exchangeDetails?.rate ?? d.rate ?? null; - + rate: number | null, + fee: WiseMoney | null, +): Record { const meta: Record = { referenceNumber: tx.referenceNumber, wiseType: tx.type, @@ -388,93 +448,120 @@ export function mapWiseTransaction( balanceId: m.balanceId, }; if (d.category) meta.category = d.category; - if (d.merchant) { - meta.merchant = { - name: d.merchant.name ?? null, - city: d.merchant.city ?? null, - country: d.merchant.country ?? null, - category: d.merchant.category ?? null, - }; - } + if (d.merchant) meta.merchant = merchantMeta(d.merchant); if (d.amount) meta.originalAmount = { value: d.amount.value, currency: d.amount.currency }; if (rate !== null) meta.exchangeRate = rate; if (fee) meta.fees = { value: fee.value, currency: fee.currency }; if (d.cardLastFourDigits) meta.cardLastFour = d.cardLastFourDigits; if (d.cardHolderFullName) meta.cardHolder = d.cardHolderFullName; if (tx.runningBalance) meta.runningBalance = tx.runningBalance.value; + return meta; +} - const suffix: string[] = []; - if (orig) suffix.push(`${orig.currency} ${Math.abs(orig.value)}`); - if (fee) suffix.push(`fee ${fmtAmount(fee.value)} ${fee.currency}`); - - if (dtype === "CONVERSION") { - const counter = counterCurrencyOf(tx, currency); - const counterMapping = counter - ? all.find((x) => x.profileId === m.profileId && x.currency === counter && x.bankAccountId !== null) - : undefined; - const from = tx.exchangeDetails?.fromAmount; - const to = tx.exchangeDetails?.toAmount; - const desc = [ - from && to - ? `Wise 換匯 ${from.currency} ${Math.abs(from.value)} → ${to.currency} ${Math.abs(to.value)}${rate ? ` @ ${rate}` : ""}` - : `Wise 換匯 ${d.description ?? ""}`.trim(), - fee ? `fee ${fmtAmount(fee.value)} ${fee.currency}` : null, - ] - .filter(Boolean) - .join(" · "); - meta.conversion = { - counterCurrency: counter, - counterBankAccountId: counterMapping?.bankAccountId ?? null, - }; - const base = { - bankAccountId: m.bankAccountId, - txnDate: taipeiDate(tx.date), - amount, - currency, - description: desc, - externalRef: `${tx.referenceNumber}:${currency}`, - externalMeta: meta, - }; - if (counterMapping) { - return { - ...base, - type: "transfer", - partyName: null, - needsReview: false, - fromAccountId: isCredit ? null : m.bankAccountId, - toAccountId: isCredit ? m.bankAccountId : null, - }; - } - return { - ...base, - type: isCredit ? "income" : "expense", - partyName: CONVERSION_PARTY, - needsReview: true, - fromAccountId: isCredit ? null : m.bankAccountId, - toAccountId: isCredit ? m.bankAccountId : null, - }; +function conversionDescription( + tx: WiseStatementTransaction, + d: WiseDetails, + rate: number | null, + fee: WiseMoney | null, +): string { + const from = tx.exchangeDetails?.fromAmount; + const to = tx.exchangeDetails?.toAmount; + let main: string; + if (from && to) { + const rateText = rate ? ` @ ${rate}` : ""; + main = `Wise 換匯 ${from.currency} ${Math.abs(from.value)} → ${to.currency} ${Math.abs(to.value)}${rateText}`; + } else { + main = `Wise 換匯 ${d.description ?? ""}`.trim(); } + return [main, fee ? feeLabel(fee) : null].filter(Boolean).join(" · "); +} - const partyName = +/** + * 換匯的其中一腳:另一腳的餘額有對應 → 單腳轉帳;沒有 → 退回 income / expense 並標 needs_review。 + */ +function mapConversion( + tx: WiseStatementTransaction, + d: WiseDetails, + m: ResolvedMapping, + all: readonly WiseAccountMapping[], + meta: Record, + rate: number | null, + fee: WiseMoney | null, +): PlannedRow { + const isCredit = tx.type === "CREDIT"; + const currency = m.currency; + const counter = counterCurrencyOf(tx, currency); + const counterMapping = counter + ? all.find((x) => x.profileId === m.profileId && x.currency === counter && x.bankAccountId !== null) + : undefined; + meta.conversion = { + counterCurrency: counter, + counterBankAccountId: counterMapping?.bankAccountId ?? null, + }; + const base = { + bankAccountId: m.bankAccountId, + txnDate: taipeiDate(tx.date), + amount: fmtAmount(tx.amount.value), + currency, + description: conversionDescription(tx, d, rate, fee), + externalRef: `${tx.referenceNumber}:${currency}`, + externalMeta: meta, + ...accountSides(isCredit, m.bankAccountId), + }; + if (counterMapping) { + return { ...base, type: "transfer", partyName: null, needsReview: false }; + } + return { + ...base, + type: isCredit ? "income" : "expense", + partyName: CONVERSION_PARTY, + needsReview: true, + }; +} + +function partyNameOf(d: WiseDetails): string { + return ( d.merchant?.name?.trim() || d.senderName?.trim() || recipientName(d.recipient) || d.description?.trim() || - "Wise"; + "Wise" + ); +} + +/** 一筆 Wise 對帳單交易 → 一列帳本交易(尚未寫入)。 */ +export function mapWiseTransaction( + tx: WiseStatementTransaction, + m: ResolvedMapping, + all: readonly WiseAccountMapping[], +): PlannedRow { + const d: WiseDetails = tx.details ?? {}; + const dtype = (d.type ?? "UNKNOWN").toUpperCase(); + const isCredit = tx.type === "CREDIT"; + const currency = m.currency; + const fee = tx.totalFees?.value ? tx.totalFees : null; + const orig = d.amount?.currency && d.amount.currency.toUpperCase() !== currency ? d.amount : null; + const rate = tx.exchangeDetails?.rate ?? d.rate ?? null; + const meta = buildMeta(tx, d, dtype, m, rate, fee); + + if (dtype === "CONVERSION") return mapConversion(tx, d, m, all, meta, rate, fee); + + const suffix: string[] = []; + if (orig) suffix.push(`${orig.currency} ${Math.abs(orig.value)}`); + if (fee) suffix.push(feeLabel(fee)); const description = [d.description?.trim() || dtype, ...suffix].join(" · "); return { bankAccountId: m.bankAccountId, type: isCredit ? "income" : "expense", txnDate: taipeiDate(tx.date), - amount, + amount: fmtAmount(tx.amount.value), currency, - partyName: partyName.slice(0, 200), + partyName: partyNameOf(d).slice(0, 200), description, externalRef: tx.referenceNumber, externalMeta: meta, needsReview: true, - fromAccountId: isCredit ? null : m.bankAccountId, - toAccountId: isCredit ? m.bankAccountId : null, + ...accountSides(isCredit, m.bankAccountId), }; } @@ -607,6 +694,252 @@ async function fetchStatementRange( return out; } +function compareStr(a: string, b: string): number { + if (a === b) return 0; + return a < b ? -1 : 1; +} + +type ProfileNameFn = (id: number) => string; + +function skippedEntry( + b: { profileId: number; balanceId: number; currency: string }, + profileName: ProfileNameFn, + reason: SkippedBalance["reason"], +): SkippedBalance { + return { + profileId: b.profileId, + profileName: profileName(b.profileId), + balanceId: b.balanceId, + currency: b.currency, + reason, + }; +} + +/** config 裡發現過、但沒有對應到帳本帳戶的 Wise 餘額。 */ +function unmappedBalances(cfg: WiseConfig, profileName: ProfileNameFn): SkippedBalance[] { + return cfg.balances + .filter((b) => { + const m = cfg.accountMappings.find((x) => x.balanceId === b.balanceId); + return (m?.bankAccountId ?? null) === null; + }) + .map((b) => skippedEntry(b, profileName, "unmapped")); +} + +/** 要同步的對應(有帳本帳戶的;指定 accountId 時只取那一個)。沒有就丟錯。 */ +function selectMappings(cfg: WiseConfig, accountId: number | undefined): ResolvedMapping[] { + let mapped = cfg.accountMappings.filter((m): m is ResolvedMapping => m.bankAccountId !== null); + if (accountId !== undefined) { + mapped = mapped.filter((m) => m.bankAccountId === accountId); + if (mapped.length === 0) { + throw new Error( + `帳本帳戶 #${accountId} 沒有對應到任何 Wise 餘額,請先到 設定 › 整合 › Wise 設定帳戶對應`, + ); + } + } + if (mapped.length === 0) { + throw new Error("還沒有任何 Wise 餘額對應到帳本帳戶,請先到 設定 › 整合 › Wise 設定帳戶對應"); + } + return mapped; +} + +type Eligibility = + | { ok: true; acct: AccountRow; syncFrom: string } + | { ok: false; reason: SkippedBalance["reason"] }; + +/** 帳本帳戶存在、幣別相符、有切換日,才能同步。 */ +function checkEligible( + acct: AccountRow | undefined, + m: ResolvedMapping, + globalSyncFrom: string | null, +): Eligibility { + if (!acct) return { ok: false, reason: "account_missing" }; + if (acct.currency.trim().toUpperCase() !== m.currency) { + return { ok: false, reason: "currency_mismatch" }; + } + const syncFrom = m.syncFrom ?? globalSyncFrom; + if (!syncFrom) return { ok: false, reason: "no_sync_from" }; + return { ok: true, acct, syncFrom }; +} + +type PlanContext = { + db: Db; + orgId: string; + client: WiseClient; + cfg: WiseConfig; + profileName: ProfileNameFn; + now: Date; + today: string; + startDate: string | undefined; + /** 跨帳戶共用:同一次抓回來的資料裡看過的鍵。 */ + seenRefs: Set; + duplicateRefs: string[]; +}; + +/** + * 這個帳戶從哪天開始抓:有指定 startDate 就用它(不得早於切換日); + * 否則 max(切換日, 最後一筆 Wise 交易日 − OVERLAP_DAYS)。 + */ +async function resolveStart( + ctx: PlanContext, + m: ResolvedMapping, + acctName: string, + syncFrom: string, +): Promise { + if (ctx.startDate === undefined) { + const last = await lastWiseDate(ctx.db, ctx.orgId, m.bankAccountId); + const overlap = last ? addDaysStr(last, -OVERLAP_DAYS) : null; + return overlap && overlap > syncFrom ? overlap : syncFrom; + } + if (ctx.startDate < syncFrom) { + throw new Error( + `起始日 ${ctx.startDate} 早於「${acctName}」的切換日 ${syncFrom}。切換日之前的 Wise 交易已以手動彙總入帳,不能再同步;真的要回補請先到 設定 › 整合 › Wise 調整切換日`, + ); + } + return ctx.startDate; +} + +/** 把抓回來的交易轉成帳本列,濾掉切換日 / 起始日之前的、以及同批重複的鍵。 */ +function planRows( + ctx: PlanContext, + txns: WiseStatementTransaction[], + m: ResolvedMapping, + syncFrom: string, + start: string, +): { rows: PlannedRow[]; beforeCutover: number } { + let beforeCutover = 0; + const rows: PlannedRow[] = []; + for (const tx of txns) { + if (!tx?.referenceNumber || !tx.amount) continue; + const r = mapWiseTransaction(tx, m, ctx.cfg.accountMappings); + if (r.txnDate < syncFrom || r.txnDate < start) { + beforeCutover++; + continue; + } + if (ctx.seenRefs.has(r.externalRef)) { + ctx.duplicateRefs.push(r.externalRef); + continue; + } + ctx.seenRefs.add(r.externalRef); + rows.push(r); + } + return { rows, beforeCutover }; +} + +/** 抓一個帳戶的對帳單並規劃要寫的列;created / alreadySynced 之後再填。 */ +async function planAccount( + ctx: PlanContext, + m: ResolvedMapping, + acct: AccountRow, + syncFrom: string, +): Promise<{ result: SyncAccountResult; rows: PlannedRow[] }> { + const start = await resolveStart(ctx, m, acct.name, syncFrom); + const txns = start <= ctx.today ? await fetchStatementRange(ctx.client, m, start, ctx.now) : []; + const { rows, beforeCutover } = planRows(ctx, txns, m, syncFrom, start); + return { + rows, + result: { + bankAccountId: m.bankAccountId, + bankAccountName: acct.name, + profileId: m.profileId, + profileName: ctx.profileName(m.profileId), + balanceId: m.balanceId, + currency: m.currency, + syncFrom, + rangeStart: start, + rangeEnd: ctx.today, + fetched: txns.length, + beforeCutover, + alreadySynced: 0, + created: 0, + needsReview: 0, + }, + }; +} + +function toInsertRow( + orgId: string, + p: PlannedRow, + partyIds: Map, +): typeof transactions.$inferInsert { + return { + organizationId: orgId, + type: p.type, + txnDate: p.txnDate, + description: p.description, + categoryId: null, + partyId: p.partyName ? (partyIds.get(p.partyName) ?? null) : null, + amount: p.amount, + currency: p.currency, + // 與 create_transaction 相同:只有 TWD 才填 amount_twd,外幣不換算。 + amountTwd: p.currency === "TWD" ? p.amount : null, + fromAccountId: p.fromAccountId, + toAccountId: p.toAccountId, + book: "internal", + billedToCompanyTaxId: false, + externalSource: SOURCE, + externalRef: p.externalRef, + externalMeta: p.externalMeta, + needsReview: p.needsReview, + }; +} + +/** 寫入帳本(ON CONFLICT DO NOTHING),回傳實際寫進去的 external_ref。 */ +async function insertPlanned(db: Db, orgId: string, toCreate: PlannedRow[]): Promise> { + const labelByName = new Map(); + for (const p of toCreate) { + if (p.partyName && !labelByName.has(p.partyName)) { + labelByName.set(p.partyName, p.type === "income" ? "customer" : "vendor"); + } + } + const partyIds = await resolveParties(db, orgId, labelByName); + const createdRefs = new Set(); + for (let i = 0; i < toCreate.length; i += INSERT_CHUNK) { + const chunk = toCreate.slice(i, i + INSERT_CHUNK); + const inserted = await db + .insert(transactions) + .values(chunk.map((p) => toInsertRow(orgId, p, partyIds))) + .onConflictDoNothing() + .returning({ ref: transactions.externalRef }); + for (const r of inserted) if (r.ref) createdRefs.add(r.ref); + } + return createdRefs; +} + +/** 把已同步 / 新增 / 待審的數字填回每個帳戶的結果。 */ +function tallyResults( + results: SyncAccountResult[], + planned: PlannedRow[], + existing: Set, + toCreate: PlannedRow[], + createdRefs: Set, + dryRun: boolean, +): void { + for (const r of results) { + const mine = planned.filter((p) => p.bankAccountId === r.bankAccountId); + r.alreadySynced = mine.filter((p) => existing.has(p.externalRef)).length; + const toCreateMine = toCreate.filter((p) => p.bankAccountId === r.bankAccountId); + const created = toCreateMine.filter((p) => createdRefs.has(p.externalRef)); + r.created = created.length; + r.needsReview = created.filter((p) => p.needsReview).length; + // 寫入時被 ON CONFLICT 擋下的(同時有另一個同步在跑)算已同步。 + if (!dryRun) r.alreadySynced += toCreateMine.length - created.length; + } +} + +function toSample(p: PlannedRow): SyncResult["sample"][number] { + return { + bankAccountId: p.bankAccountId, + txnDate: p.txnDate, + type: p.type, + amount: p.amount, + currency: p.currency, + partyName: p.partyName, + description: p.description, + externalRef: p.externalRef, + needsReview: p.needsReview, + }; +} + /** * 同步(或試算)Wise 交易到帳本。整合必須已連接且開啟。 * 錯誤(未對應、起始日早於切換日…)以 Error 丟出,訊息給人看。 @@ -620,191 +953,49 @@ export async function syncWiseTransactions(orgId: string, opts: SyncOptions): Pr const cfg = parseWiseConfig(row.config); const profileName = (id: number) => cfg.profiles.find((p) => p.id === id)?.name ?? String(id); - const skippedBalances: SkippedBalance[] = []; - for (const b of cfg.balances) { - const m = cfg.accountMappings.find((x) => x.balanceId === b.balanceId); - if (!m || m.bankAccountId === null) { - skippedBalances.push({ - profileId: b.profileId, - profileName: profileName(b.profileId), - balanceId: b.balanceId, - currency: b.currency, - reason: "unmapped", - }); - } - } - - let mapped = cfg.accountMappings.filter( - (m): m is ResolvedMapping => m.bankAccountId !== null, - ); - if (opts.accountId !== undefined) { - mapped = mapped.filter((m) => m.bankAccountId === opts.accountId); - if (mapped.length === 0) { - throw new Error( - `帳本帳戶 #${opts.accountId} 沒有對應到任何 Wise 餘額,請先到 設定 › 整合 › Wise 設定帳戶對應`, - ); - } - } - if (mapped.length === 0) { - throw new Error("還沒有任何 Wise 餘額對應到帳本帳戶,請先到 設定 › 整合 › Wise 設定帳戶對應"); - } - - const accountIds = mapped.map((m) => m.bankAccountId); - const accounts = await db - .select({ id: bankAccounts.id, name: bankAccounts.name, currency: bankAccounts.currency }) - .from(bankAccounts) - .where( - and( - eq(bankAccounts.organizationId, orgId), - inArray(bankAccounts.id, accountIds), - isNull(bankAccounts.deletedAt), - ), - ); + const skippedBalances = unmappedBalances(cfg, profileName); + const mapped = selectMappings(cfg, opts.accountId); + const accounts = await loadOrgAccounts(db, orgId, mapped.map((m) => m.bankAccountId)); const acctById = new Map(accounts.map((a) => [a.id, a])); const now = new Date(); - const today = taipeiDate(now); + const ctx: PlanContext = { + db, + orgId, + client, + cfg, + profileName, + now, + today: taipeiDate(now), + startDate: opts.startDate, + seenRefs: new Set(), + duplicateRefs: [], + }; const results: SyncAccountResult[] = []; const planned: PlannedRow[] = []; - const duplicateRefs: string[] = []; - const seenRefs = new Set(); for (const m of mapped) { - const acct = acctById.get(m.bankAccountId); - const skip = (reason: SkippedBalance["reason"]) => - skippedBalances.push({ - profileId: m.profileId, - profileName: profileName(m.profileId), - balanceId: m.balanceId, - currency: m.currency, - reason, - }); - if (!acct) { - skip("account_missing"); - continue; - } - if (acct.currency.trim().toUpperCase() !== m.currency) { - skip("currency_mismatch"); + const check = checkEligible(acctById.get(m.bankAccountId), m, cfg.syncFrom); + if (!check.ok) { + skippedBalances.push(skippedEntry(m, profileName, check.reason)); continue; } - const syncFrom = m.syncFrom ?? cfg.syncFrom; - if (!syncFrom) { - skip("no_sync_from"); - continue; - } - let start: string; - if (opts.startDate !== undefined) { - if (opts.startDate < syncFrom) { - throw new Error( - `起始日 ${opts.startDate} 早於「${acct.name}」的切換日 ${syncFrom}。切換日之前的 Wise 交易已以手動彙總入帳,不能再同步;真的要回補請先到 設定 › 整合 › Wise 調整切換日`, - ); - } - start = opts.startDate; - } else { - const last = await lastWiseDate(db, orgId, m.bankAccountId); - const overlap = last ? addDaysStr(last, -OVERLAP_DAYS) : null; - start = overlap && overlap > syncFrom ? overlap : syncFrom; - } - - const txns = start <= today ? await fetchStatementRange(client, m, start, now) : []; - let beforeCutover = 0; - const rowsForAccount: PlannedRow[] = []; - for (const tx of txns) { - if (!tx?.referenceNumber || !tx.amount) continue; - const r = mapWiseTransaction(tx, m, cfg.accountMappings); - if (r.txnDate < syncFrom || r.txnDate < start) { - beforeCutover++; - continue; - } - if (seenRefs.has(r.externalRef)) { - duplicateRefs.push(r.externalRef); - continue; - } - seenRefs.add(r.externalRef); - rowsForAccount.push(r); - } - planned.push(...rowsForAccount); - results.push({ - bankAccountId: m.bankAccountId, - bankAccountName: acct.name, - profileId: m.profileId, - profileName: profileName(m.profileId), - balanceId: m.balanceId, - currency: m.currency, - syncFrom, - rangeStart: start, - rangeEnd: today, - fetched: txns.length, - beforeCutover, - alreadySynced: 0, - created: 0, - needsReview: 0, - }); + const { result, rows } = await planAccount(ctx, m, check.acct, check.syncFrom); + planned.push(...rows); + results.push(result); } const existing = await existingRefs(db, orgId, planned.map((p) => p.externalRef)); const toCreate = planned .filter((p) => !existing.has(p.externalRef)) - .sort((a, b) => (a.txnDate === b.txnDate ? 0 : a.txnDate < b.txnDate ? -1 : 1)); - for (const r of results) { - const mine = planned.filter((p) => p.bankAccountId === r.bankAccountId); - r.alreadySynced = mine.filter((p) => existing.has(p.externalRef)).length; - } + .sort((a, b) => compareStr(a.txnDate, b.txnDate)); - let createdRefs = new Set(toCreate.map((p) => p.externalRef)); - if (!opts.dryRun && toCreate.length > 0) { - const labelByName = new Map(); - for (const p of toCreate) { - if (p.partyName && !labelByName.has(p.partyName)) { - labelByName.set(p.partyName, p.type === "income" ? "customer" : "vendor"); - } - } - const partyIds = await resolveParties(db, orgId, labelByName); - createdRefs = new Set(); - for (let i = 0; i < toCreate.length; i += INSERT_CHUNK) { - const chunk = toCreate.slice(i, i + INSERT_CHUNK); - const inserted = await db - .insert(transactions) - .values( - chunk.map((p) => ({ - organizationId: orgId, - type: p.type, - txnDate: p.txnDate, - description: p.description, - categoryId: null, - partyId: p.partyName ? (partyIds.get(p.partyName) ?? null) : null, - amount: p.amount, - currency: p.currency, - // 與 create_transaction 相同:只有 TWD 才填 amount_twd,外幣不換算。 - amountTwd: p.currency === "TWD" ? p.amount : null, - fromAccountId: p.fromAccountId, - toAccountId: p.toAccountId, - book: "internal", - billedToCompanyTaxId: false, - externalSource: SOURCE, - externalRef: p.externalRef, - externalMeta: p.externalMeta, - needsReview: p.needsReview, - })), - ) - .onConflictDoNothing() - .returning({ ref: transactions.externalRef }); - for (const r of inserted) if (r.ref) createdRefs.add(r.ref); - } - } + const createdRefs = + opts.dryRun || toCreate.length === 0 + ? new Set(toCreate.map((p) => p.externalRef)) + : await insertPlanned(db, orgId, toCreate); - for (const r of results) { - const mine = toCreate.filter( - (p) => p.bankAccountId === r.bankAccountId && createdRefs.has(p.externalRef), - ); - r.created = mine.length; - r.needsReview = mine.filter((p) => p.needsReview).length; - // 寫入時被 ON CONFLICT 擋下的(同時有另一個同步在跑)算已同步。 - if (!opts.dryRun) { - r.alreadySynced += - toCreate.filter((p) => p.bankAccountId === r.bankAccountId).length - mine.length; - } - } + tallyResults(results, planned, existing, toCreate, createdRefs, opts.dryRun); return { dryRun: opts.dryRun, @@ -815,21 +1006,11 @@ export async function syncWiseTransactions(orgId: string, opts: SyncOptions): Pr alreadySynced: results.reduce((s, r) => s + r.alreadySynced, 0), beforeCutover: results.reduce((s, r) => s + r.beforeCutover, 0), }, - duplicateRefs, + duplicateRefs: ctx.duplicateRefs, sample: toCreate .filter((p) => createdRefs.has(p.externalRef)) .slice(0, SAMPLE_LIMIT) - .map((p) => ({ - bankAccountId: p.bankAccountId, - txnDate: p.txnDate, - type: p.type, - amount: p.amount, - currency: p.currency, - partyName: p.partyName, - description: p.description, - externalRef: p.externalRef, - needsReview: p.needsReview, - })), + .map(toSample), }; }); } @@ -891,6 +1072,6 @@ export async function getWiseStatement( .filter((t) => t?.referenceNumber && t.amount) .map(compactWiseTxn) .filter((t) => t.date >= startDate && t.date <= endDate) - .sort((a, b) => (a.dateTime < b.dateTime ? -1 : a.dateTime > b.dateTime ? 1 : 0)); + .sort((a, b) => compareStr(a.dateTime, b.dateTime)); }); } From 7480c7d69dc24b7833ae601ebc8a4bab2f3a73fa Mon Sep 17 00:00:00 2001 From: YJack0000 Date: Thu, 24 Sep 2026 18:10:24 +0800 Subject: [PATCH 25/27] =?UTF-8?q?[fix]=20Sonar=20=E6=94=B6=E5=B0=BE?= =?UTF-8?q?=EF=BC=9A=E5=B8=B3=E6=88=B6=E8=A1=A8=E5=96=AE=E6=94=B9=20fields?= =?UTF-8?q?et=E3=80=81orExisting=20=E6=98=8E=E5=AF=AB=20undefined=20?= =?UTF-8?q?=E5=88=A4=E6=96=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/dashboard/employees/employee-accounts-section.tsx | 7 +++---- src/db/employee-accounts.ts | 4 +++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/app/dashboard/employees/employee-accounts-section.tsx b/src/app/dashboard/employees/employee-accounts-section.tsx index 5e40d26..af52fcf 100644 --- a/src/app/dashboard/employees/employee-accounts-section.tsx +++ b/src/app/dashboard/employees/employee-accounts-section.tsx @@ -359,9 +359,8 @@ function AccountForm({ return ( // 攔 Enter:這塊在員工表單裡面,按 Enter 會把整張員工表單送出去。 - // 這個 div 本身不可互動,只是接住內層輸入框冒泡上來的 keydown(事件委派), - // 所以標 role="presentation"(jsx-a11y 對「接冒泡事件的容器」建議的做法)。 -
      + // 用 fieldset 當群組容器(語意上就是「表單裡的一組欄位」),接住內層輸入框冒泡上來的 keydown。 +
      {draft.id ? t("form.editTitle") : t("form.addTitle")}
      @@ -470,7 +469,7 @@ function AccountForm({ {pending ? t("form.saving") : t("form.save")}
      -
      + ); } diff --git a/src/db/employee-accounts.ts b/src/db/employee-accounts.ts index c65421d..4f315f9 100644 --- a/src/db/employee-accounts.ts +++ b/src/db/employee-accounts.ts @@ -217,7 +217,9 @@ type AccountPatch = Partial; /** 更新時沒帶的欄位(undefined)沿用既有值;null 表示明確清空,照用。 */ function orExisting(next: T | undefined, existing: T): T { - return next === undefined ? existing : next; + // 刻意不用 ??:null(明確清空)必須保留,只有 undefined 才沿用既有值。 + if (next === undefined) return existing; + return next; } /** 新帳號 → 加密 + 末五碼;沒帶新帳號就不動(但改成銀行帳戶時必須重填)。 */ From 23be48163c7ae42c44e1d82bd4a77240b8f795bd Mon Sep 17 00:00:00 2001 From: YJack0000 Date: Thu, 24 Sep 2026 18:36:44 +0800 Subject: [PATCH 26/27] =?UTF-8?q?[fix]=20=E5=B8=B3=E6=88=B6=E8=A1=A8?= =?UTF-8?q?=E5=96=AE=E6=94=94=20Enter=20=E6=94=B9=E7=94=A8=E5=8E=9F?= =?UTF-8?q?=E7=94=9F=E4=BA=8B=E4=BB=B6=E5=A7=94=E6=B4=BE=EF=BC=8C=E9=81=BF?= =?UTF-8?q?=E5=85=8D=E5=9C=A8=E9=9D=9E=E4=BA=92=E5=8B=95=E5=85=83=E7=B4=A0?= =?UTF-8?q?=E4=B8=8A=E6=8E=9B=20keydown=EF=BC=88Sonar=20S6847=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../employees/employee-accounts-section.tsx | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/app/dashboard/employees/employee-accounts-section.tsx b/src/app/dashboard/employees/employee-accounts-section.tsx index af52fcf..a25a9f3 100644 --- a/src/app/dashboard/employees/employee-accounts-section.tsx +++ b/src/app/dashboard/employees/employee-accounts-section.tsx @@ -1,6 +1,6 @@ "use client"; -import { useMemo, useState, useTransition } from "react"; +import { useEffect, useMemo, useRef, useState, useTransition } from "react"; import { toast } from "sonner"; import { useTranslations } from "next-intl"; import { Eye, EyeOff, Pencil, Plus, Trash2 } from "lucide-react"; @@ -106,8 +106,8 @@ function parseBankInput(v: string): { bankCode: string; bankName: string } { } /** 在帳戶輸入框按 Enter 不要送出外層的員工表單。 */ -function swallowEnter(e: React.KeyboardEvent) { - if (e.key === "Enter" && (e.target as HTMLElement).tagName === "INPUT") e.preventDefault(); +function swallowEnter(e: KeyboardEvent) { + if (e.key === "Enter" && (e.target as HTMLElement | null)?.tagName === "INPUT") e.preventDefault(); } export function EmployeeAccountsSection({ @@ -357,10 +357,19 @@ function AccountForm({ return codes.includes(draft.currency) ? codes : [draft.currency, ...codes]; }, [draft.currency]); + const groupRef = useRef(null); + useEffect(() => { + const el = groupRef.current; + if (!el) return; + el.addEventListener("keydown", swallowEnter); + return () => el.removeEventListener("keydown", swallowEnter); + }, []); + return ( // 攔 Enter:這塊在員工表單裡面,按 Enter 會把整張員工表單送出去。 - // 用 fieldset 當群組容器(語意上就是「表單裡的一組欄位」),接住內層輸入框冒泡上來的 keydown。 -
      + // fieldset 當群組容器(語意上就是「表單裡的一組欄位」);keydown 以原生 listener 做事件委派 + // (見上方 useEffect),接住內層輸入框(含 Combobox 內部 input)冒泡上來的 Enter。 +
      {draft.id ? t("form.editTitle") : t("form.addTitle")}
      From 37b7cf04e57b24d7dcd5c4e0ac3868191c6cbd20 Mon Sep 17 00:00:00 2001 From: YJack0000 Date: Mon, 28 Sep 2026 14:41:30 +0800 Subject: [PATCH 27/27] =?UTF-8?q?[fix]=20=E4=B8=89=E8=99=95=20regex=20?= =?UTF-8?q?=E6=94=B9=E6=88=90=E7=B7=9A=E6=80=A7=E5=AF=AB=E6=B3=95=EF=BC=8C?= =?UTF-8?q?=E6=B6=88=E9=99=A4=E5=9B=9E=E6=BA=AF=E9=A2=A8=E9=9A=AA=EF=BC=88?= =?UTF-8?q?Sonar=20S5852=20hotspots=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dashboard/employees/employee-accounts-section.tsx | 8 +++++--- src/lib/employee-accounts.ts | 10 +++++++--- src/lib/simpany-issue.ts | 8 ++++++-- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/app/dashboard/employees/employee-accounts-section.tsx b/src/app/dashboard/employees/employee-accounts-section.tsx index a25a9f3..7752af5 100644 --- a/src/app/dashboard/employees/employee-accounts-section.tsx +++ b/src/app/dashboard/employees/employee-accounts-section.tsx @@ -100,9 +100,11 @@ function draftFrom(a: MaskedEmployeeAccount): Draft { /** 銀行欄位的自由輸入 → 代碼與名稱。開頭 3 碼數字就是代碼,其餘文字當名稱。 */ function parseBankInput(v: string): { bankCode: string; bankName: string } { - const m = /^\s*(\d{3})\s*(.*)$/.exec(v); - if (!m) return { bankCode: "", bankName: v.trim() }; - return { bankCode: m[1], bankName: m[2].trim() || (bankNameForCode(m[1]) ?? "") }; + // 先 trim 再只比對開頭 3 碼,剩下用 slice 取:不讓兩個可重疊的量詞夾住同一段空白(ReDoS,S5852)。 + const t = v.trim(); + if (!/^\d{3}/.test(t)) return { bankCode: "", bankName: t }; + const bankCode = t.slice(0, 3); + return { bankCode, bankName: t.slice(3).trim() || (bankNameForCode(bankCode) ?? "") }; } /** 在帳戶輸入框按 Enter 不要送出外層的員工表單。 */ diff --git a/src/lib/employee-accounts.ts b/src/lib/employee-accounts.ts index b93280d..905823f 100644 --- a/src/lib/employee-accounts.ts +++ b/src/lib/employee-accounts.ts @@ -180,9 +180,13 @@ export function parseLegacySalaryAccount(raw: string): ParsedLegacyAccount | nul const bankCode = m[1]; // 代碼後面可能夾著銀行名稱或括號(「807 永豐 0180-1234…」),先跳到第一個數字 const rest = m[2].replace(/^\D+/, ""); - const branchMatch = /^(\d{4})[\s\-/]+(.+)$/.exec(rest); - const branchCode = branchMatch ? branchMatch[1] : null; - const digits = (branchMatch ? branchMatch[2] : rest).replaceAll(/\D/g, ""); + // 分行代碼 = 開頭 4 碼後面緊接分隔符;分隔符後面的部分用 slice 取,不用 `[\s\-/]+(.+)` + // 這種兩個量詞可重疊的寫法(失敗時會退化成 O(n²),S5852)。 + const branchMatch = /^(\d{4})[\s\-/]+/.exec(rest); + const afterBranch = branchMatch ? rest.slice(branchMatch[0].length) : ""; + const hasBranch = branchMatch !== null && afterBranch.length > 0; + const branchCode = hasBranch ? branchMatch[1] : null; + const digits = (hasBranch ? afterBranch : rest).replaceAll(/\D/g, ""); if (/^\d{6,20}$/.test(digits)) { return { kind: "bank", bankCode, branchCode, bankName: bankNameForCode(bankCode), accountNumber: digits }; } diff --git a/src/lib/simpany-issue.ts b/src/lib/simpany-issue.ts index 093b5a5..7e48ccc 100644 --- a/src/lib/simpany-issue.ts +++ b/src/lib/simpany-issue.ts @@ -164,8 +164,12 @@ type Source = { function extractEmails(text: string | null | undefined): string[] { if (!text) return []; - const found = text.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi) ?? []; - return [...new Set(found.map((e) => e.toLowerCase()))].filter(isValidEmail); + // 先依分隔符切成片段,再逐一用 isValidEmail(線性、不回溯)檢查, + // 不在整段自由文字上跑 `[A-Z0-9.-]+\.[A-Z]{2,}` 這種會回溯的樣式(S5852)。 + const tokens = text + .split(/[\s,;<>()"',;、]+/) + .filter((t) => t.includes("@")); + return [...new Set(tokens.map((e) => e.toLowerCase()))].filter(isValidEmail); } /** Simpany 的品名不能有半形冒號(他們的 UI 會換成全形)。 */