Date: Thu, 24 Sep 2026 17:27:34 +0800
Subject: [PATCH 09/27] =?UTF-8?q?[docs]=20=E5=93=A1=E5=B7=A5=E5=A4=9A?=
=?UTF-8?q?=E5=B8=B3=E6=88=B6=EF=BC=9A=E9=9A=B1=E7=A7=81=E6=AC=8A=E6=94=BF?=
=?UTF-8?q?=E7=AD=96=E6=94=B9=E5=AF=AB=E7=82=BA=E6=AC=84=E4=BD=8D=E5=8A=A0?=
=?UTF-8?q?=E5=AF=86=E3=80=81=E8=99=95=E8=99=95=E9=81=AE=E7=BD=A9=E3=80=81?=
=?UTF-8?q?owner/admin=20=E9=A1=AF=E7=A4=BA=E9=9C=80=E7=95=99=E7=A8=BD?=
=?UTF-8?q?=E6=A0=B8=E7=B4=80=E9=8C=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/app/privacy/content-en.tsx | 49 +++++++++++++++++++------------
src/app/privacy/content-zh-tw.tsx | 29 ++++++++++--------
src/lib/pii.ts | 5 ++--
3 files changed, 50 insertions(+), 33 deletions(-)
diff --git a/src/app/privacy/content-en.tsx b/src/app/privacy/content-en.tsx
index ec5f445..4833d8e 100644
--- a/src/app/privacy/content-en.tsx
+++ b/src/app/privacy/content-en.tsx
@@ -137,26 +137,34 @@ export function PrivacyEn() {
-
- National ID numbers and payroll bank accounts are{" "}
- stored as plain text in database columns; we do not apply
- field-level encryption or hashing. What protects them is transport encryption
- (HTTPS), the encryption at rest provided by our database and object storage
- providers, and login, organization isolation and role-based permissions.
+ Employees’ bank account numbers and national ID numbers are{" "}
+ encrypted at the field level before they are stored (AES-GCM). The
+ encryption key is a secret of the deployment environment and is not kept in the
+ database, so the database contents alone do not reveal these values. On top of that
+ come transport encryption (HTTPS), the encryption at rest provided by our database
+ and object storage providers, and login, organization isolation and role-based
+ permissions.
-
- Masking happens only at the output layer, and only for MCP: when
- employee data is read through MCP, only the first 3 characters of the national ID
- number and the last 5 characters of the payroll bank account are kept, and the rest
- are replaced with
*.
+ Masked everywhere: lists, forms, payroll and reimbursement records,
+ and MCP only ever show masked values — the last 5 characters of an
+ account number and the first 3 characters of a national ID number, with the rest
+ replaced by *.
-
- In the web interface, members of the same organization who have the permission{" "}
- see the full values (filing labor and health insurance and running
- payroll transfers require them).
+ The one exception is an organization owner or admin explicitly
+ clicking “show full number” in the web app (payroll transfers need it), or
+ editing an employee’s national ID number. Regular members cannot see the full
+ values and cannot change employee records.
-
- In other words: masking is not encryption, and the values stored in the database are
- not rewritten by it.
+ Every “show full number” is written to the audit records (who, when, which
+ account); the record itself does not contain the number. MCP has no way to reveal a
+ full account number.
+
+ -
+ Older records entered as plain text before this feature are converted to the encrypted
+ fields, and the plain text removed, by a one-time migration.
@@ -263,13 +271,15 @@ export function PrivacyEn() {
Masking still applies
- Employees’ national ID numbers and payroll bank accounts are always masked
- before being sent out over MCP (see the section on sensitive fields above); AI
- clients do not receive the full values.
+ Employees’ national ID numbers and bank account numbers are always masked
+ before being sent out over MCP (see the section on sensitive fields above), and MCP
+ has no tool that reveals a full account number; AI clients do not receive the full
+ values.
Every access is recorded
- Every write through MCP, and every read of employee data, is written to the audit
+ Every write through MCP, and every read of employee data or employee bank accounts,
+ is written to the audit
records with the channel marked as mcp, viewable inside the system.
Where tokens are stored
@@ -398,7 +408,8 @@ export function PrivacyEn() {
The measures we take include: login with a Google account (the Service holds no
passwords), data isolation along organization boundaries, role-based permissions,
always re-verifying permissions on the server rather than trusting the front end,
- complete audit records, masking of sensitive fields in MCP output, and origin
+ complete audit records, field-level encryption and masking of employee account
+ numbers and national ID numbers, and origin
validation on the MCP endpoint. We do not claim the system is absolutely secure; if an
incident affects your data, we will notify the affected organizations as soon as
possible after establishing the facts.
diff --git a/src/app/privacy/content-zh-tw.tsx b/src/app/privacy/content-zh-tw.tsx
index 49cbe44..7961013 100644
--- a/src/app/privacy/content-zh-tw.tsx
+++ b/src/app/privacy/content-zh-tw.tsx
@@ -3,7 +3,9 @@
* (章節編號是 LEGAL_CSS 的 counter 產生的,順序一動編號就對不上)。
*
* 內容是照 code 實際行為寫的,不是樣板。改動系統行為時請一起改這裡**與英文版**,特別是:
- * 遮罩只發生在 MCP 輸出層(src/lib/pii.ts + tools-hr.ts 的 redactEmployee)、
+ * 帳號與身分證字號以 AES-GCM 欄位加密(src/lib/crypto.ts、src/db/employee-accounts.ts),
+ * 除了 owner/admin 明確「顯示完整帳號」之外處處遮罩,且顯示會寫稽核紀錄
+ * (src/app/dashboard/employees/account-actions.ts)、
* 稽核紀錄沒有清理機制(src/db/activity.ts)、多數刪除是 soft delete 且 R2 檔案
* 不會移除(src/db/mutations.ts)。
*/
@@ -103,17 +105,21 @@ export function PrivacyZhTw() {
這一節寫得比一般政策細,因為含糊其詞會讓人誤會保護程度:
-
- 身分證字號與薪轉帳號以明文存在資料庫欄位裡,我們沒有做欄位層級的加密或雜湊。
- 保護它們的是傳輸加密(HTTPS)、資料庫與物件儲存供應商提供的靜態加密,以及登入、組織隔離與角色權限。
+ 員工的銀行帳號與身分證字號以欄位層級加密後才存進資料庫(AES-GCM)。加密金鑰是部署環境的
+ secret,不存在資料庫裡,所以單獨拿到資料庫內容看不到這些值。此外還有傳輸加密(HTTPS)、資料庫與物件儲存供應商提供的靜態加密,以及登入、組織隔離與角色權限。
-
- 遮罩只發生在輸出的那一層,而且只針對 MCP:透過 MCP
- 讀取員工資料時,身分證字號只保留前 3 碼、薪轉帳號只保留末 5 碼,其餘以
* 取代。
+ 處處遮罩:列表、表單、發薪與撥款紀錄與 MCP 一律只顯示遮罩後的值 ——
+ 帳號只保留末 5 碼,身分證字號只保留前 3 碼,其餘以* 取代。
-
- 在網頁介面上,同組織中有權限的成員看得到完整值(勞健保申報與薪轉作業需要)。
+ 唯一的例外是組織的擁有者或管理員在網頁上明確按下「顯示完整帳號」(薪轉作業需要),或編輯員工的身分證字號。
+ 一般成員看不到完整值,也不能修改員工資料。
- - 換句話說:遮罩不等於加密,資料庫裡儲存的值並沒有因此被改寫。
+ -
+ 每一次「顯示完整帳號」都會寫進稽核紀錄(誰、何時、哪個帳戶),紀錄本身不含帳號。MCP 沒有顯示完整帳號的功能。
+
+ - 本功能上線前以明文輸入的舊資料,會由一次性的搬移程序轉成加密欄位並清除明文。
@@ -202,12 +208,12 @@ export function PrivacyZhTw() {
遮罩仍然有效
- 員工的身分證字號與薪轉帳號在送出 MCP 之前一律遮罩(見上方「敏感欄位」一節),AI
- 客戶端拿不到完整值。
+ 員工的身分證字號與銀行帳號在送出 MCP 之前一律遮罩(見上方「敏感欄位」一節),MCP
+ 也沒有顯示完整帳號的工具,AI 客戶端拿不到完整值。
每一次存取都留紀錄
- 所有經由 MCP 的寫入,以及員工資料的讀取,都會寫進稽核紀錄,通道標記為mcp,可在系統內查看。
+ 所有經由 MCP 的寫入,以及員工資料與員工帳戶的讀取,都會寫進稽核紀錄,通道標記為mcp,可在系統內查看。
token 存在哪裡
@@ -293,8 +299,7 @@ export function PrivacyZhTw() {
安全措施
- 我們採取的措施包含:以 Google 帳號登入(本服務不保管密碼)、以組織為界的資料隔離、依角色區分的權限、伺服器端一律重新驗證權限而非信任前端、完整的稽核紀錄、MCP
- 輸出的敏感欄位遮罩,以及對 MCP 端點的來源驗證。
+ 我們採取的措施包含:以 Google 帳號登入(本服務不保管密碼)、以組織為界的資料隔離、依角色區分的權限、伺服器端一律重新驗證權限而非信任前端、完整的稽核紀錄、員工帳號與身分證字號的欄位加密與遮罩,以及對 MCP 端點的來源驗證。
我們不宣稱系統絕對安全;若發生影響你資料的事故,我們會在查明後盡快通知受影響的組織。
diff --git a/src/lib/pii.ts b/src/lib/pii.ts
index 8405c3c..f8c43dd 100644
--- a/src/lib/pii.ts
+++ b/src/lib/pii.ts
@@ -1,6 +1,7 @@
// 員工個資的共用處理(data minimization):
-// - 高敏感識別欄位(身分證字號、薪轉帳戶)經 MCP 回傳前一律遮罩,
-// 完整值只在網頁端使用(勞健保申報、薪轉作業才需要)。
+// - 高敏感識別欄位(身分證字號、銀行帳號)以密文存放(src/db/employee-accounts.ts),
+// 網頁與 MCP 一律顯示遮罩值;完整值只給 owner / admin(編輯身分證字號、
+// 「顯示完整帳號」並寫稽核紀錄),勞健保申報、薪轉作業才需要。
// - Email 寫入前做格式檢查,網頁 action 與 MCP tool 共用同一套規則。
/** 身分證字號等識別碼:保留前 3 碼,其餘遮罩(A12*******)。 */
From 529bf495345062fa410d69136e6d053d52911893 Mon Sep 17 00:00:00 2001
From: YJack0000
Date: Thu, 24 Sep 2026 17:28:45 +0800
Subject: [PATCH 10/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=E4=B8=80=E6=AC=A1=E6=80=A7=E6=90=AC?=
=?UTF-8?q?=E7=A7=BB=E8=85=B3=E6=9C=AC=20migrate-employee-pii=EF=BC=88?=
=?UTF-8?q?=E9=A0=90=E8=A8=AD=20dry=20run=EF=BC=8C--apply=20=E6=89=8D?=
=?UTF-8?q?=E5=AF=AB=E5=85=A5=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
scripts/migrate-employee-pii.ts | 191 ++++++++++++++++++++++++++++++++
1 file changed, 191 insertions(+)
create mode 100644 scripts/migrate-employee-pii.ts
diff --git a/scripts/migrate-employee-pii.ts b/scripts/migrate-employee-pii.ts
new file mode 100644
index 0000000..b70148c
--- /dev/null
+++ b/scripts/migrate-employee-pii.ts
@@ -0,0 +1,191 @@
+/**
+ * 一次性搬移:把員工的明文個資搬進 migrations/0024 的加密欄位。
+ *
+ * (1) employees.salary_account(舊的自由文字薪轉帳戶)
+ * → 這位員工還沒有任何帳戶:建一個 employee_bank_accounts(帳號加密、設為
+ * 薪資預設、戶名帶員工姓名),然後清空 salary_account。
+ * → 已經有帳戶:解密比對,若某個帳戶的帳號就是這串舊值,只清空 salary_account;
+ * 對不上的列入「需人工確認」,不動。
+ * (2) employees.national_id(明文身分證字號)
+ * → national_id_enc 還是空的:加密寫入 national_id_enc,清空 national_id。
+ * → 兩欄都有值:解密比對,一致就清空明文;不一致列入「需人工確認」,不動。
+ *
+ * 用法(先確認 0024 已經套用):
+ * bun run scripts/migrate-employee-pii.ts # dry run,只印筆數
+ * bun run scripts/migrate-employee-pii.ts --apply # 真的寫入
+ *
+ * 需要 DATABASE_URL 與 FIELD_ENCRYPTION_KEY(bun 會自動讀 .env.local;要指定別的
+ * 環境檔就用 `bun --env-file=.dev.vars run scripts/migrate-employee-pii.ts`)。
+ * FIELD_ENCRYPTION_KEY 必須跟正式環境的 Worker secret 是同一把,否則寫進去的密文
+ * 網頁端解不開。
+ *
+ * 安全性質(刻意的設計,不要拿掉):
+ * - 冪等:已經搬過的列不會再被選到,重跑只會處理剩下的。
+ * - 每位員工的「建帳戶 + 清空舊欄位」用 db.batch 在同一個交易裡完成,中途失敗
+ * 不會留下「帳戶建了但明文還在」或反過來的狀態。
+ * - 只印筆數,絕不印出帳號、身分證字號或員工姓名。
+ * - 軟刪除的員工一樣處理:明文不該因為人離職被刪除就留在資料庫裡。
+ */
+import { and, eq, isNotNull, isNull } from "drizzle-orm";
+import { getDb } from "@/db";
+import { employeeBankAccounts, employees } from "@/db/schema";
+import { decryptField, encryptField } from "@/lib/crypto";
+import {
+ LEGACY_ACCOUNT_NOTE,
+ accountLast5,
+ normalizeAccountNumber,
+ parseLegacySalaryAccount,
+} from "@/lib/employee-accounts";
+
+const apply = process.argv.includes("--apply");
+
+type Counts = Record;
+
+function bump(c: Counts, key: string) {
+ c[key] = (c[key] ?? 0) + 1;
+}
+
+async function migrateSalaryAccounts(): Promise {
+ const db = getDb();
+ const counts: Counts = {};
+ const rows = await db
+ .select({
+ id: employees.id,
+ organizationId: employees.organizationId,
+ name: employees.name,
+ salaryAccount: employees.salaryAccount,
+ })
+ .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;
+ }
+
+ 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;
+ }
+
+ // 已經有帳戶:舊值若已經在其中之一,就只是還沒清掉的明文
+ 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");
+ }
+ }
+ return counts;
+}
+
+async function migrateNationalIds(): Promise {
+ const db = getDb();
+ const counts: Counts = {};
+ const rows = await db
+ .select({
+ id: employees.id,
+ nationalId: employees.nationalId,
+ nationalIdEnc: employees.nationalIdEnc,
+ })
+ .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");
+ }
+ }
+ return counts;
+}
+
+function print(title: string, counts: Counts) {
+ const entries = Object.entries(counts);
+ console.log(`\n${title}`);
+ if (entries.length === 0) {
+ console.log(" (nothing to do)");
+ return;
+ }
+ for (const [k, v] of entries) console.log(` ${k}: ${v}`);
+}
+
+async function main() {
+ if (!process.env.DATABASE_URL) throw new Error("DATABASE_URL is not set");
+ if (!process.env.FIELD_ENCRYPTION_KEY) throw new Error("FIELD_ENCRYPTION_KEY is not set");
+ // 先試一次加解密,金鑰格式不對就在動任何資料之前失敗
+ const probe = await encryptField("probe");
+ if ((await decryptField(probe)) !== "probe") throw new Error("FIELD_ENCRYPTION_KEY self-test failed");
+
+ console.log(apply ? "Mode: APPLY (writing changes)" : "Mode: dry run (no writes; pass --apply to write)");
+ print("salary_account → employee_bank_accounts", await migrateSalaryAccounts());
+ print("national_id → national_id_enc", await migrateNationalIds());
+ console.log(
+ "\nneedsReview rows were left untouched: fix them in the web app (employee → 帳戶 / 身分證), then re-run.",
+ );
+}
+
+main().catch((e) => {
+ // 只印錯誤訊息,不印可能含資料的物件。drizzle 的查詢錯誤會把參數(姓名、密文)
+ // 串在訊息後面,一律截掉。
+ const msg = e instanceof Error ? e.message : "migration failed";
+ console.error(msg.split(/\bparams:/)[0].trim());
+ process.exit(1);
+});
From 2cf5000638b70a4ee65bc9aeaa2eb8f506a03c14 Mon Sep 17 00:00:00 2001
From: YJack0000
Date: Thu, 24 Sep 2026 17:32:14 +0800
Subject: [PATCH 11/27] =?UTF-8?q?[feature]=20Wise=20=E6=95=B4=E5=90=88?=
=?UTF-8?q?=EF=BC=9Atransactions=20=E5=A4=96=E9=83=A8=E4=BE=86=E6=BA=90?=
=?UTF-8?q?=E6=AC=84=E4=BD=8D=E8=88=87=E5=8E=BB=E9=87=8D=E7=B4=A2=E5=BC=95?=
=?UTF-8?q?=EF=BC=88migration=200026=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
migrations/0026_transaction_external_ref.sql | 41 ++++++++++++++++++++
src/db/schema.ts | 9 +++++
2 files changed, 50 insertions(+)
create mode 100644 migrations/0026_transaction_external_ref.sql
diff --git a/migrations/0026_transaction_external_ref.sql b/migrations/0026_transaction_external_ref.sql
new file mode 100644
index 0000000..3074e75
--- /dev/null
+++ b/migrations/0026_transaction_external_ref.sql
@@ -0,0 +1,41 @@
+-- 0026: 交易的外部來源標記(Wise 交易同步,之後其他銀行 / 卡片 feed 共用)。
+--
+-- 目的:從外部服務自動匯入的交易,要能 (1) 永遠不重複寫入、(2) 留下原始細節供對帳、
+-- (3) 標出「還沒有人看過」的列。手動輸入的交易這四欄都是 NULL / false,行為不變。
+--
+-- - external_source 來源代號,例如 'wise'。手動輸入 = NULL。
+-- - external_ref 來源端的唯一鍵(Wise 的 referenceNumber,例如 'CARD-4370840324';
+-- 換匯兩腳各一列,鍵加上幣別後綴,見 src/lib/wise-sync.ts)。
+-- - external_meta 非機密的原始細節:商家、原幣金額、匯率、手續費、卡號末四碼、持卡人、
+-- Wise 分類。只給人對帳看,程式不依賴它的形狀做判斷。
+-- - needs_review 自動匯入的列預設 true(分類留空、待人確認);在 web 編輯並指定分類、
+-- 或 MCP update_transaction 指定分類時清掉。
+--
+-- 去重:(organization_id, external_source, external_ref) 的部分唯一索引。同步程式用
+-- ON CONFLICT DO NOTHING 寫入,所以就算兩次同步重疊也不會寫兩次;既有的列不會被改動。
+--
+-- Forward-only,全部 additive(新欄位皆可為 NULL 或有預設值)。
+-- Run AFTER 0023_org_integrations.sql(0024 / 0025 保留給同期開發的 Simpany 整合)。
+
+ALTER TABLE transactions ADD COLUMN external_source text;
+ALTER TABLE transactions ADD COLUMN external_ref text;
+ALTER TABLE transactions ADD COLUMN external_meta jsonb;
+ALTER TABLE transactions ADD COLUMN needs_review boolean NOT NULL DEFAULT false;
+
+ALTER TABLE transactions
+ ADD CONSTRAINT chk_txn_external_ref_source
+ CHECK (external_ref IS NULL OR external_source IS NOT NULL);
+
+CREATE UNIQUE INDEX uq_txn_external_ref
+ ON transactions (organization_id, external_source, external_ref)
+ WHERE external_ref IS NOT NULL;
+
+-- 「待確認」清單用:只索引少數 needs_review = true 的列。
+CREATE INDEX idx_txn_needs_review
+ ON transactions (organization_id)
+ WHERE needs_review AND deleted_at IS NULL;
+
+COMMENT ON COLUMN transactions.external_source IS '外部來源代號(wise…);手動輸入為 NULL';
+COMMENT ON COLUMN transactions.external_ref IS '外部來源的唯一鍵(Wise referenceNumber),與 org + source 組成去重鍵';
+COMMENT ON COLUMN transactions.external_meta IS '外部來源的原始細節(商家、原幣、匯率、手續費…),僅供對帳顯示';
+COMMENT ON COLUMN transactions.needs_review IS '自動匯入待人確認;指定分類後清掉';
diff --git a/src/db/schema.ts b/src/db/schema.ts
index ecad4d7..90dbd8d 100644
--- a/src/db/schema.ts
+++ b/src/db/schema.ts
@@ -251,7 +251,15 @@ export const transactions = pgTable("transactions", {
// 請款項目綁定(選填):把 income 交易掛到某一筆 billing_items,讓該期「已收多少」
// 自動算出來。FK 在 DB 端(migrations/0017)建立,這裡只放欄位避免宣告順序衝突。
billingItemId: bigint("billing_item_id", { mode: "number" }),
+ // 外部來源(migrations/0026):自動匯入的交易(Wise 同步)用 (org, source, ref) 去重,
+ // 原始細節放 externalMeta,needsReview = 還沒有人確認過(指定分類後清掉)。
+ externalSource: text("external_source"),
+ externalRef: text("external_ref"),
+ externalMeta: jsonb("external_meta").$type>(),
+ needsReview: boolean("needs_review").default(false).notNull(),
}, (table) => [
+ uniqueIndex("uq_txn_external_ref").on(table.organizationId, table.externalSource, table.externalRef).where(sql`external_ref IS NOT NULL`),
+ index("idx_txn_needs_review").on(table.organizationId).where(sql`needs_review AND deleted_at IS NULL`),
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")),
@@ -299,6 +307,7 @@ export const transactions = pgTable("transactions", {
}),
check("chk_txn_book", sql`book = ANY (ARRAY['both'::text, 'internal'::text, 'external'::text])`),
check("chk_txn_type", sql`type = ANY (ARRAY['expense'::text, 'income'::text, 'advance'::text, 'reimbursement'::text, 'transfer'::text])`),
+ check("chk_txn_external_ref_source", sql`external_ref IS NULL OR external_source IS NOT NULL`),
]);
export const accountReconciliations = pgTable("account_reconciliations", {
From 9d54032cba349a05adad6ef317a47ad5b5381cd2 Mon Sep 17 00:00:00 2001
From: YJack0000
Date: Thu, 24 Sep 2026 17:32:14 +0800
Subject: [PATCH 12/27] =?UTF-8?q?[feature]=20Wise=20=E6=95=B4=E5=90=88?=
=?UTF-8?q?=EF=BC=9A=E5=94=AF=E8=AE=80=20client=E3=80=81=E5=B8=B3=E6=88=B6?=
=?UTF-8?q?=E5=B0=8D=E6=87=89=E8=88=87=E4=BA=A4=E6=98=93=E5=90=8C=E6=AD=A5?=
=?UTF-8?q?=EF=BC=88dry=20run=E3=80=81=E5=88=87=E6=8F=9B=E6=97=A5=E3=80=81?=
=?UTF-8?q?=E5=8E=BB=E9=87=8D=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/lib/integrations/registry.ts | 5 +-
src/lib/integrations/wise.ts | 310 +++++++++++
src/lib/wise-sync.ts | 896 +++++++++++++++++++++++++++++++
3 files changed, 1210 insertions(+), 1 deletion(-)
create mode 100644 src/lib/integrations/wise.ts
create mode 100644 src/lib/wise-sync.ts
diff --git a/src/lib/integrations/registry.ts b/src/lib/integrations/registry.ts
index 8b2a898..0e4497d 100644
--- a/src/lib/integrations/registry.ts
+++ b/src/lib/integrations/registry.ts
@@ -1,4 +1,5 @@
import type { IntegrationProvider, IntegrationProviderId } from "./types";
+import { wiseProvider } from "./wise";
/**
* 已實作的整合。目前是空的:框架先上,Simpany / Wise 的實作各自接上來。
@@ -28,7 +29,9 @@ import type { IntegrationProvider, IntegrationProviderId } from "./types";
* 顯示用的資料(名稱、欄位、logo)不在這裡,在 catalog.ts 與 i18n。
* ─────────────────────────────────────────────────────────────────────
*/
-const PROVIDERS: Partial> = {};
+const PROVIDERS: Partial> = {
+ wise: wiseProvider,
+};
/** 取實作;還沒實作的回 null(設定頁據此停用「連接」)。 */
export function getProvider(id: IntegrationProviderId): IntegrationProvider | null {
diff --git a/src/lib/integrations/wise.ts b/src/lib/integrations/wise.ts
new file mode 100644
index 0000000..b7fa3bc
--- /dev/null
+++ b/src/lib/integrations/wise.ts
@@ -0,0 +1,310 @@
+import {
+ markNeedsReauth,
+ recordSyncFailure,
+ recordSyncSuccess,
+ requireEnabledIntegration,
+} from "./store";
+import type { IntegrationConfig, IntegrationProvider, IntegrationSummary } from "./types";
+
+/**
+ * Wise(TransferWise)整合 —— **唯讀**。
+ *
+ * 這支只會對 Wise 發 GET:列 profile、列餘額、讀對帳單(balance statement)。
+ * 不建立 quote、transfer、conversion,也不碰任何會動到錢的端點。為了讓這件事在程式上
+ * 成立而不是只靠自律,所有請求都經過 `wiseGet()`:
+ * 1. method 寫死 GET,且 `assertReadOnly()` 會拒絕任何非 GET;
+ * 2. path 必須符合 READ_ONLY_PATHS 白名單之一,否則直接丟錯、不發請求。
+ * 要新增端點就只能加進白名單,而且必須是 GET 的讀取端點。
+ *
+ * Token 只存在 server 記憶體:不進 log、不進錯誤訊息、不進回傳值。
+ */
+
+const WISE_BASE = "https://api.wise.com";
+
+/** 允許呼叫的端點(全部是 GET 讀取)。 */
+const READ_ONLY_PATHS: readonly RegExp[] = [
+ /^\/v2\/profiles$/,
+ /^\/v4\/profiles\/\d+\/balances$/,
+ /^\/v1\/profiles\/\d+\/balance-statements\/\d+\/statement\.json$/,
+];
+
+/** 對帳單單次查詢上限是 469 天;我們一律按月切,遠低於上限。 */
+const REQUEST_TIMEOUT_MS = 30_000;
+
+// ---------------------------------------------------------------------------
+// 錯誤
+// ---------------------------------------------------------------------------
+
+export class WiseApiError extends Error {
+ constructor(
+ readonly status: number,
+ message: string,
+ ) {
+ super(message);
+ this.name = "WiseApiError";
+ }
+}
+
+/** Token 被 Wise 拒絕(401)。 */
+export class WiseAuthError extends WiseApiError {
+ constructor() {
+ super(401, "Wise API token 無效或已撤銷,請 owner / admin 到 設定 › 整合 重新連接 Wise");
+ this.name = "WiseAuthError";
+ }
+}
+
+/** Wise 要求強驗證(SCA)才能讀這份資料。我們不實作 SCA 簽章。 */
+export class WiseScaRequiredError extends WiseApiError {
+ constructor() {
+ super(
+ 403,
+ "Wise 要求強驗證(SCA)才能讀取這份對帳單。本系統不支援 SCA 簽章:請改用不需 SCA 的 token(Wise 的個人 API token 通常不需要),或縮短查詢區間後再試",
+ );
+ this.name = "WiseScaRequiredError";
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Wise 回應型別(只列我們用到的欄位)
+// ---------------------------------------------------------------------------
+
+export type WiseMoney = { value: number; currency: string };
+
+export type WiseProfile = {
+ id: number;
+ type: "PERSONAL" | "BUSINESS" | string;
+ fullName?: string;
+ businessName?: string;
+ details?: { name?: string; firstName?: string; lastName?: string };
+};
+
+export type WiseBalance = {
+ id: number;
+ currency: string;
+ amount: WiseMoney;
+ type?: string;
+ name?: string | null;
+};
+
+export type WiseStatementTransaction = {
+ type: "DEBIT" | "CREDIT" | string;
+ date: string;
+ amount: WiseMoney;
+ totalFees?: WiseMoney | null;
+ details?: {
+ type?: string;
+ description?: string | null;
+ amount?: WiseMoney | null;
+ category?: string | null;
+ merchant?: {
+ name?: string | null;
+ city?: string | null;
+ country?: string | null;
+ category?: string | null;
+ } | null;
+ cardLastFourDigits?: string | null;
+ cardHolderFullName?: string | null;
+ senderName?: string | null;
+ senderAccount?: string | null;
+ recipient?: { name?: string | null } | string | null;
+ paymentReference?: string | null;
+ sourceAmount?: WiseMoney | null;
+ targetAmount?: WiseMoney | null;
+ rate?: number | null;
+ } | null;
+ exchangeDetails?: {
+ toAmount?: WiseMoney | null;
+ fromAmount?: WiseMoney | null;
+ rate?: number | null;
+ } | null;
+ runningBalance?: WiseMoney | null;
+ referenceNumber: string;
+};
+
+export type WiseStatement = {
+ transactions: WiseStatementTransaction[];
+ startOfStatementBalance?: WiseMoney | null;
+ endOfStatementBalance?: WiseMoney | null;
+};
+
+/** 寫進 config 的精簡 profile / balance(非機密)。 */
+export type WiseProfileSummary = { id: number; type: string; name: string };
+export type WiseBalanceSummary = {
+ profileId: number;
+ balanceId: number;
+ currency: string;
+ /** 發現時的餘額(顯示用,可能過時;以 fetchedAt 為準)。 */
+ amount: number | null;
+ fetchedAt: string;
+};
+
+export function profileName(p: WiseProfile): string {
+ if (p.type === "BUSINESS") return p.businessName ?? p.details?.name ?? `Business ${p.id}`;
+ return (
+ p.fullName ??
+ ([p.details?.firstName, p.details?.lastName].filter(Boolean).join(" ") ||
+ `Personal ${p.id}`)
+ );
+}
+
+// ---------------------------------------------------------------------------
+// 唯讀 HTTP
+// ---------------------------------------------------------------------------
+
+/** 任何非 GET 的請求一律拒絕 —— 這個整合永遠不得動到錢。 */
+export function assertReadOnly(method: string, path: string): void {
+ if (method.toUpperCase() !== "GET") {
+ throw new Error(`Wise 整合是唯讀的,拒絕送出 ${method} ${path}`);
+ }
+ if (!READ_ONLY_PATHS.some((re) => re.test(path))) {
+ throw new Error(`Wise 整合不允許呼叫 ${path}(不在唯讀端點白名單內)`);
+ }
+}
+
+async function wiseGet(
+ token: string,
+ path: string,
+ query?: Record,
+): Promise {
+ assertReadOnly("GET", path);
+ const url = new URL(path, WISE_BASE);
+ for (const [k, v] of Object.entries(query ?? {})) url.searchParams.set(k, v);
+ let res: Response;
+ try {
+ res = await fetch(url, {
+ method: "GET",
+ headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
+ });
+ } catch (e) {
+ throw new WiseApiError(0, `無法連線到 Wise:${e instanceof Error ? e.message : String(e)}`);
+ }
+ if (res.status === 401) throw new WiseAuthError();
+ if (res.status === 403 && res.headers.get("x-2fa-approval")) throw new WiseScaRequiredError();
+ if (!res.ok) {
+ // 回應本文可能很長;只取開頭,且不含我們送出的任何東西(token 在 header,不會回顯)。
+ const body = (await res.text().catch(() => "")).slice(0, 200);
+ throw new WiseApiError(res.status, `Wise 回應 ${res.status}${body ? `:${body}` : ""}`);
+ }
+ return (await res.json()) as T;
+}
+
+// ---------------------------------------------------------------------------
+// Client
+// ---------------------------------------------------------------------------
+
+export type WiseClient = {
+ listProfiles(): Promise;
+ listBalances(profileId: number): Promise;
+ /** start / end 是 ISO 8601 時間點(UTC)。 */
+ getStatement(
+ profileId: number,
+ balanceId: number,
+ currency: string,
+ start: string,
+ end: string,
+ ): Promise;
+};
+
+function makeClient(token: string): WiseClient {
+ return {
+ listProfiles: () => wiseGet(token, "/v2/profiles"),
+ listBalances: (profileId) =>
+ wiseGet(token, `/v4/profiles/${Math.trunc(profileId)}/balances`, {
+ types: "STANDARD",
+ }),
+ getStatement: (profileId, balanceId, currency, start, end) =>
+ wiseGet(
+ token,
+ `/v1/profiles/${Math.trunc(profileId)}/balance-statements/${Math.trunc(balanceId)}/statement.json`,
+ { currency, intervalStart: start, intervalEnd: end, type: "COMPACT" },
+ ),
+ };
+}
+
+/** 探索這支 token 看得到的 profile 與 STANDARD 餘額。 */
+export async function discoverAccounts(client: WiseClient): Promise<{
+ profiles: WiseProfileSummary[];
+ balances: WiseBalanceSummary[];
+}> {
+ const profiles = await client.listProfiles();
+ const fetchedAt = new Date().toISOString();
+ const balances: WiseBalanceSummary[] = [];
+ for (const p of profiles) {
+ const list = await client.listBalances(p.id);
+ for (const b of list) {
+ balances.push({
+ profileId: p.id,
+ balanceId: b.id,
+ currency: b.currency,
+ amount: typeof b.amount?.value === "number" ? b.amount.value : null,
+ fetchedAt,
+ });
+ }
+ }
+ return {
+ profiles: profiles.map((p) => ({ id: p.id, type: p.type, name: profileName(p) })),
+ balances,
+ };
+}
+
+/**
+ * 拿這個組織的 Wise client 並執行 fn。整合必須已連接、已開啟(否則丟
+ * IntegrationUnavailableError)。成功記 recordSyncSuccess;401 → markNeedsReauth;
+ * 其他錯誤 → recordSyncFailure。錯誤會原樣往外丟。
+ */
+export async function withWiseClient(
+ orgId: string,
+ fn: (client: WiseClient, row: IntegrationSummary) => Promise,
+): Promise {
+ const { row, credentials } = await requireEnabledIntegration(orgId, "wise");
+ const token = credentials.apiToken;
+ if (!token) throw new WiseAuthError();
+ try {
+ const out = await fn(makeClient(token), row);
+ await recordSyncSuccess(orgId, "wise");
+ return out;
+ } catch (e) {
+ if (e instanceof WiseAuthError) {
+ await markNeedsReauth(orgId, "wise", "Wise API token 無效或已撤銷");
+ } else if (e instanceof WiseApiError) {
+ await recordSyncFailure(orgId, "wise", e.message);
+ }
+ throw e;
+ }
+}
+
+/** getWiseClient 的「拿了就用」版本;不自動記錄成功 / 失敗,給需要自己控管的呼叫端。 */
+export async function getWiseClient(orgId: string): Promise<{
+ client: WiseClient;
+ row: IntegrationSummary;
+}> {
+ const { row, credentials } = await requireEnabledIntegration(orgId, "wise");
+ if (!credentials.apiToken) throw new WiseAuthError();
+ return { client: makeClient(credentials.apiToken), row };
+}
+
+// ---------------------------------------------------------------------------
+// Provider(testConnection)
+// ---------------------------------------------------------------------------
+
+export const wiseProvider: IntegrationProvider = {
+ id: "wise",
+ async testConnection(creds): Promise<
+ { ok: true; config: IntegrationConfig } | { ok: false; error: string }
+ > {
+ const token = creds.apiToken?.trim();
+ if (!token) return { ok: false, error: "請輸入 Wise API token" };
+ try {
+ const { profiles, balances } = await discoverAccounts(makeClient(token));
+ if (profiles.length === 0) {
+ return { ok: false, error: "這支 token 看不到任何 Wise profile" };
+ }
+ return { ok: true, config: { profiles, balances } };
+ } catch (e) {
+ if (e instanceof WiseAuthError) return { ok: false, error: "API token 無效或已撤銷" };
+ if (e instanceof WiseApiError) return { ok: false, error: e.message };
+ throw e;
+ }
+ },
+};
diff --git a/src/lib/wise-sync.ts b/src/lib/wise-sync.ts
new file mode 100644
index 0000000..dd2ad34
--- /dev/null
+++ b/src/lib/wise-sync.ts
@@ -0,0 +1,896 @@
+import { and, eq, inArray, isNull, max, or, sql } from "drizzle-orm";
+import { getDb } from "@/db";
+import { bankAccounts, parties, transactions } from "@/db/schema";
+import { getIntegration, updateConfig } from "@/lib/integrations/store";
+import type { IntegrationConfig } from "@/lib/integrations/types";
+import {
+ discoverAccounts,
+ withWiseClient,
+ type WiseBalanceSummary,
+ type WiseClient,
+ type WiseProfileSummary,
+ type WiseStatementTransaction,
+} from "@/lib/integrations/wise";
+
+/**
+ * Wise 對帳單 → 本組織帳本(transactions)的同步。
+ *
+ * 只「讀」Wise(見 src/lib/integrations/wise.ts 的唯讀白名單),只「寫」自己的帳本。
+ *
+ * 規則(docs/integrations.md 的 Wise 一節有同樣的說明):
+ * - 只同步「有對應到帳本帳戶」的 Wise 餘額;沒對應的列在結果的 skippedBalances。
+ * - 每個對應有自己的 syncFrom(切換日,YYYY-MM-DD,台北日期):這天以前的 Wise 交易
+ * 一律不寫,避免跟過去手動輸入的月彙總重複。沒設 syncFrom 的對應不會同步。
+ * - 每次從 max(syncFrom, 這個帳戶上最後一筆 Wise 交易日 − 3 天) 抓到現在,按月切塊。
+ * - 去重鍵是 (org, 'wise', referenceNumber),DB 有部分唯一索引;寫入用
+ * ON CONFLICT DO NOTHING。已存在(含已軟刪除)的列永遠不改、不重寫。
+ * - CREDIT → income、DEBIT → expense;分類留空(未分類)、needs_review = true、
+ * book = 'internal'(與過去手動輸入的 Wise 列一致)。
+ * - 換匯(CONVERSION):本帳本一列只有一個幣別,不支援跨幣轉帳。所以換匯的兩腳各記一列
+ * 「單腳轉帳」(type = transfer,只填自己這邊的帳戶)—— 不影響損益、帳戶餘額正確。
+ * 兩腳共用同一個 referenceNumber,所以 external_ref 加上幣別後綴(`BALANCE-123:USD`)。
+ * 若另一腳的餘額沒有對應,就退回 income / expense 並標 needs_review。
+ * - dryRun 只算不寫:回傳每個帳戶會新增幾筆與前 50 筆樣本。
+ */
+
+const SOURCE = "wise";
+const TZ = "Asia/Taipei";
+const OVERLAP_DAYS = 3;
+const SAMPLE_LIMIT = 50;
+const INSERT_CHUNK = 500;
+const CONVERSION_PARTY = "Wise 換匯";
+
+type Db = ReturnType;
+
+// ---------------------------------------------------------------------------
+// Config
+// ---------------------------------------------------------------------------
+
+export type WiseAccountMapping = {
+ profileId: number;
+ balanceId: number;
+ currency: string;
+ bankAccountId: number | null;
+ /** 切換日(台北日期 YYYY-MM-DD);null = 尚未設定,不同步。 */
+ syncFrom: string | null;
+};
+
+export type WiseConfig = {
+ profiles: WiseProfileSummary[];
+ balances: WiseBalanceSummary[];
+ accountMappings: WiseAccountMapping[];
+ /** 全域切換日;對應本身沒設 syncFrom 時用這個。 */
+ syncFrom: string | null;
+};
+
+const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
+
+export function isIsoDate(v: unknown): v is string {
+ if (typeof v !== "string" || !DATE_RE.test(v)) return false;
+ const d = new Date(`${v}T00:00:00Z`);
+ return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === v;
+}
+
+function num(v: unknown): number | null {
+ return typeof v === "number" && Number.isFinite(v) ? v : null;
+}
+
+/** 從 org_integrations.config 讀出 Wise 設定;形狀不對的項目直接略過。 */
+export function parseWiseConfig(config: IntegrationConfig | null | undefined): WiseConfig {
+ const c = config ?? {};
+ const profiles = Array.isArray(c.profiles)
+ ? (c.profiles as unknown[]).flatMap((p) => {
+ const o = p as Record;
+ const id = num(o?.id);
+ return id === null
+ ? []
+ : [{ id, type: String(o.type ?? ""), name: String(o.name ?? id) }];
+ })
+ : [];
+ const balances = Array.isArray(c.balances)
+ ? (c.balances as unknown[]).flatMap((b) => {
+ const o = b as Record;
+ const profileId = num(o?.profileId);
+ const balanceId = num(o?.balanceId);
+ if (profileId === null || balanceId === null || typeof o.currency !== "string") return [];
+ return [
+ {
+ profileId,
+ balanceId,
+ currency: o.currency.toUpperCase(),
+ amount: num(o.amount),
+ fetchedAt: typeof o.fetchedAt === "string" ? o.fetchedAt : "",
+ },
+ ];
+ })
+ : [];
+ const accountMappings = Array.isArray(c.accountMappings)
+ ? (c.accountMappings as unknown[]).flatMap((m) => {
+ const o = m as Record;
+ const profileId = num(o?.profileId);
+ const balanceId = num(o?.balanceId);
+ if (profileId === null || balanceId === null || typeof o.currency !== "string") return [];
+ return [
+ {
+ profileId,
+ balanceId,
+ currency: o.currency.toUpperCase(),
+ bankAccountId: num(o.bankAccountId),
+ syncFrom: isIsoDate(o.syncFrom) ? o.syncFrom : null,
+ },
+ ];
+ })
+ : [];
+ return {
+ profiles,
+ balances,
+ accountMappings,
+ syncFrom: isIsoDate(c.syncFrom) ? c.syncFrom : null,
+ };
+}
+
+// ---------------------------------------------------------------------------
+// 日期
+// ---------------------------------------------------------------------------
+
+const taipeiFmt = new Intl.DateTimeFormat("en-CA", {
+ timeZone: TZ,
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+});
+
+/** ISO 時間點 → 台北日期 YYYY-MM-DD。 */
+export function taipeiDate(iso: string | Date): string {
+ return taipeiFmt.format(typeof iso === "string" ? new Date(iso) : iso);
+}
+
+/** 台北日期當天 00:00 對應的 UTC ISO 時間點。 */
+export function taipeiStartUtc(date: string): string {
+ return new Date(`${date}T00:00:00+08:00`).toISOString();
+}
+
+function addDaysStr(date: string, days: number): string {
+ const d = new Date(`${date}T00:00:00Z`);
+ d.setUTCDate(d.getUTCDate() + days);
+ return d.toISOString().slice(0, 10);
+}
+
+function firstOfNextMonth(date: string): string {
+ const [y, m] = date.split("-").map(Number);
+ return m === 12 ? `${y + 1}-01-01` : `${y}-${String(m + 1).padStart(2, "0")}-01`;
+}
+
+/** [start, now) 切成按月(台北日曆月)的區間,回 UTC ISO。 */
+export function monthlyChunks(startDate: string, now = new Date()): { start: string; end: string }[] {
+ const out: { start: string; end: string }[] = [];
+ const endMs = now.getTime();
+ let cur = startDate;
+ while (new Date(taipeiStartUtc(cur)).getTime() < endMs) {
+ const next = firstOfNextMonth(cur);
+ const nextMs = new Date(taipeiStartUtc(next)).getTime();
+ out.push({
+ start: taipeiStartUtc(cur),
+ end: new Date(Math.min(nextMs, endMs)).toISOString(),
+ });
+ cur = next;
+ }
+ return out;
+}
+
+// ---------------------------------------------------------------------------
+// 切換日建議
+// ---------------------------------------------------------------------------
+
+/**
+ * 每個帳本帳戶的建議切換日:這個帳戶上最後一筆「非 Wise 同步」交易的下個月 1 號。
+ * 帳戶上沒有任何交易時,建議本月 1 號(台北)。
+ */
+export async function suggestSyncFrom(
+ orgId: string,
+ bankAccountIds: number[],
+): Promise