@@ -304,6 +370,11 @@ 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 })}
+ conversionLabel={(c) =>
+ c ? tr("type.conversion", c) : tr("type.conversionPlain")
+ }
categories={categories}
parties={partyOpts}
employees={employeeOpts}
@@ -326,7 +397,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/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/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/edit-form.tsx b/src/components/edit-form.tsx
index e9e5e4a..6dd4417 100644
--- a/src/components/edit-form.tsx
+++ b/src/components/edit-form.tsx
@@ -29,6 +29,7 @@ export function EditForm({
submittingLabel,
footer,
className,
+ readOnly,
children,
}: Readonly<{
action: (prev: ActionState, formData: FormData) => Promise;
@@ -40,6 +41,11 @@ export function EditForm({
footer?: ReactNode;
/** 覆寫表單格線;預設兩欄。 */
className?: string;
+ /**
+ * 唯讀:欄位全部停用、不顯示「儲存」。給沒有寫入權限的角色看資料用;
+ * 真正的權限檢查仍在 server action 裡。
+ */
+ readOnly?: boolean;
children: ReactNode;
}>) {
const close = useRowDialogClose();
@@ -62,15 +68,24 @@ export function EditForm({
onSubmit={submitAction(dispatch)}
className={cn("grid gap-4 sm:grid-cols-2", className)}
>
- {children}
+ {readOnly ? (
+ // fieldset 不參與格線(display: contents),欄位照原本的兩欄排
+
+ ) : (
+ children
+ )}
{footer ? {footer}
: null}
-
+ {readOnly ? null : (
+
+ )}
);
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/db/employee-accounts.ts b/src/db/employee-accounts.ts
new file mode 100644
index 0000000..4f315f9
--- /dev/null
+++ b/src/db/employee-accounts.ts
@@ -0,0 +1,439 @@
+// 員工收款帳戶與身分證字號的資料層(server only)。網頁 action、MCP tool 共用。
+//
+// 安全性質(刻意的設計,不要拿掉):
+// - 帳號只以密文寫入(encryptField),讀取一律投影成 MaskedEmployeeAccount,
+// select 清單裡根本不含 account_number_enc —— 列表路徑沒有機會把密文或明文帶出去。
+// - 解密只有兩個入口:revealEmployeeAccountNumber(呼叫端必須先確認 owner/admin
+// 並寫 activity_log)與 readNationalId(server 端用來遮罩或給 owner/admin 編輯)。
+// - 「預設帳戶」的切換用 db.batch 送出:neon-http 的 batch 在同一個交易裡執行,
+// 先清掉舊預設再設新預設,partial unique index 不會在中間狀態撞到。
+import { and, asc, desc, eq, inArray, isNull, ne, or } from "drizzle-orm";
+import { getDb } from "./index";
+import { employeeBankAccounts, employees } from "./schema";
+import { decryptField, encryptField } from "@/lib/crypto";
+import { maskNationalId } from "@/lib/pii";
+import {
+ EmployeeAccountError,
+ LEGACY_ACCOUNT_NOTE,
+ accountLast5,
+ bankNameForCode,
+ checkAccountCodes,
+ checkAccountNumber,
+ isAccountKind,
+ parseLegacySalaryAccount,
+ type EmployeeAccountKind,
+ type MaskedEmployeeAccount,
+} from "@/lib/employee-accounts";
+
+type Db = ReturnType;
+
+/** 遮罩後的欄位投影。不含 account_number_enc。 */
+const maskedColumns = {
+ id: employeeBankAccounts.id,
+ employeeId: employeeBankAccounts.employeeId,
+ kind: employeeBankAccounts.kind,
+ bankCode: employeeBankAccounts.bankCode,
+ branchCode: employeeBankAccounts.branchCode,
+ bankName: employeeBankAccounts.bankName,
+ accountHolder: employeeBankAccounts.accountHolder,
+ accountLast5: employeeBankAccounts.accountLast5,
+ currency: employeeBankAccounts.currency,
+ label: employeeBankAccounts.label,
+ defaultForSalary: employeeBankAccounts.defaultForSalary,
+ defaultForReimbursement: employeeBankAccounts.defaultForReimbursement,
+ isActive: employeeBankAccounts.isActive,
+ note: employeeBankAccounts.note,
+};
+
+function notDeleted(orgId: string) {
+ return and(eq(employeeBankAccounts.organizationId, orgId), isNull(employeeBankAccounts.deletedAt));
+}
+
+/**
+ * 列出帳戶(遮罩)。employeeIds 省略 = 整個組織;排序:啟用中在前、預設在前、建立順序。
+ */
+export async function listEmployeeAccounts(
+ orgId: string,
+ employeeIds?: number | number[],
+): Promise {
+ const ids = employeeIds === undefined ? undefined : [employeeIds].flat();
+ if (ids?.length === 0) return [];
+ return getDb()
+ .select(maskedColumns)
+ .from(employeeBankAccounts)
+ .where(and(notDeleted(orgId), ids ? inArray(employeeBankAccounts.employeeId, ids) : undefined))
+ .orderBy(
+ asc(employeeBankAccounts.employeeId),
+ desc(employeeBankAccounts.isActive),
+ desc(employeeBankAccounts.defaultForSalary),
+ desc(employeeBankAccounts.defaultForReimbursement),
+ asc(employeeBankAccounts.id),
+ );
+}
+
+/** 依員工分組,給一次撈整個組織的頁面用。 */
+export function groupAccountsByEmployee(
+ rows: MaskedEmployeeAccount[],
+): Map {
+ const map = new Map();
+ for (const r of rows) {
+ const list = map.get(r.employeeId);
+ if (list) list.push(r);
+ else map.set(r.employeeId, [r]);
+ }
+ return map;
+}
+
+export async function getEmployeeAccount(
+ orgId: string,
+ id: number,
+): Promise {
+ const [row] = await getDb()
+ .select(maskedColumns)
+ .from(employeeBankAccounts)
+ .where(and(notDeleted(orgId), eq(employeeBankAccounts.id, id)))
+ .limit(1);
+ return row ?? null;
+}
+
+async function assertEmployeeInOrg(db: Db, orgId: string, employeeId: number) {
+ const [row] = await db
+ .select({ id: employees.id })
+ .from(employees)
+ .where(and(eq(employees.organizationId, orgId), eq(employees.id, employeeId), isNull(employees.deletedAt)))
+ .limit(1);
+ if (!row) throw new EmployeeAccountError("wrongEmployee");
+}
+
+export type EmployeeAccountInput = {
+ kind?: string | null;
+ bankCode?: string | null;
+ branchCode?: string | null;
+ bankName?: string | null;
+ accountHolder?: string | null;
+ /** 完整帳號(明文)。更新時省略 = 不改帳號。 */
+ accountNumber?: string | null;
+ currency?: string | null;
+ label?: string | null;
+ defaultForSalary?: boolean;
+ defaultForReimbursement?: boolean;
+ isActive?: boolean;
+ note?: string | null;
+};
+
+function textOrNull(v: string | null | undefined): string | null {
+ return v?.trim() || null;
+}
+
+function resolveKind(v: string | null | undefined, fallback: EmployeeAccountKind): EmployeeAccountKind {
+ const k = v?.trim() || fallback;
+ if (!isAccountKind(k)) throw new EmployeeAccountError("kindInvalid");
+ return k;
+}
+
+/** 設成預設之前,把同一位員工其他帳戶的同類預設清掉(batch 的第一句)。 */
+function clearDefaultsQuery(
+ db: Db,
+ orgId: string,
+ employeeId: number,
+ flags: { salary: boolean; reimbursement: boolean },
+ exceptId?: number,
+) {
+ const patch: { defaultForSalary?: boolean; defaultForReimbursement?: boolean; updatedAt: string } = {
+ updatedAt: new Date().toISOString(),
+ };
+ const which = [];
+ if (flags.salary) {
+ patch.defaultForSalary = false;
+ which.push(eq(employeeBankAccounts.defaultForSalary, true));
+ }
+ if (flags.reimbursement) {
+ patch.defaultForReimbursement = false;
+ which.push(eq(employeeBankAccounts.defaultForReimbursement, true));
+ }
+ return db
+ .update(employeeBankAccounts)
+ .set(patch)
+ .where(
+ and(
+ notDeleted(orgId),
+ eq(employeeBankAccounts.employeeId, employeeId),
+ exceptId === undefined ? undefined : ne(employeeBankAccounts.id, exceptId),
+ or(...which),
+ ),
+ );
+}
+
+/** 新增帳戶;有勾預設就在同一個交易裡清掉舊預設。回傳遮罩後的列。 */
+export async function createEmployeeAccount(
+ orgId: string,
+ employeeId: number,
+ input: EmployeeAccountInput,
+): Promise {
+ const db = getDb();
+ await assertEmployeeInOrg(db, orgId, employeeId);
+ const kind = resolveKind(input.kind, "bank");
+ const codes = checkAccountCodes({ ...input, kind });
+ const number = checkAccountNumber(kind, input.accountNumber);
+ const isActive = input.isActive ?? true;
+ // 停用的帳戶不能當預設:發薪 / 撥款不該預選一個已經不用的帳戶。
+ const defaultForSalary = isActive && !!input.defaultForSalary;
+ const defaultForReimbursement = isActive && !!input.defaultForReimbursement;
+
+ const insert = db
+ .insert(employeeBankAccounts)
+ .values({
+ organizationId: orgId,
+ employeeId,
+ kind,
+ ...codes,
+ bankName: textOrNull(input.bankName) ?? bankNameForCode(codes.bankCode),
+ accountHolder: textOrNull(input.accountHolder),
+ accountNumberEnc: await encryptField(number),
+ accountLast5: accountLast5(number),
+ label: textOrNull(input.label),
+ defaultForSalary,
+ defaultForReimbursement,
+ isActive,
+ note: textOrNull(input.note),
+ })
+ .returning(maskedColumns);
+
+ if (defaultForSalary || defaultForReimbursement) {
+ const [, inserted] = await db.batch([
+ clearDefaultsQuery(db, orgId, employeeId, {
+ salary: defaultForSalary,
+ reimbursement: defaultForReimbursement,
+ }),
+ insert,
+ ]);
+ return inserted[0];
+ }
+ const [row] = await insert;
+ return row;
+}
+
+type AccountPatch = Partial;
+
+/** 更新時沒帶的欄位(undefined)沿用既有值;null 表示明確清空,照用。 */
+function orExisting(next: T | undefined, existing: T): T {
+ // 刻意不用 ??:null(明確清空)必須保留,只有 undefined 才沿用既有值。
+ if (next === undefined) return existing;
+ return 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,
+ id: number,
+ input: EmployeeAccountInput,
+): Promise {
+ const db = getDb();
+ const existing = await getEmployeeAccount(orgId, id);
+ if (!existing) throw new EmployeeAccountError("notFound");
+
+ const kind = input.kind === undefined ? resolveKind(existing.kind, "other") : resolveKind(input.kind, "bank");
+ const codes = checkAccountCodes({
+ kind,
+ bankCode: orExisting(input.bankCode, existing.bankCode),
+ branchCode: orExisting(input.branchCode, existing.branchCode),
+ currency: orExisting(input.currency, existing.currency),
+ });
+
+ const patch: AccountPatch = {
+ kind,
+ ...codes,
+ ...(await accountNumberPatch(kind, existing.kind, input.accountNumber)),
+ updatedAt: new Date().toISOString(),
+ };
+ 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);
+
+ const isActive = input.isActive ?? existing.isActive;
+ patch.isActive = isActive;
+ const wantSalary = isActive && (input.defaultForSalary ?? existing.defaultForSalary);
+ const wantReimb = isActive && (input.defaultForReimbursement ?? existing.defaultForReimbursement);
+ patch.defaultForSalary = wantSalary;
+ patch.defaultForReimbursement = wantReimb;
+
+ const update = db
+ .update(employeeBankAccounts)
+ .set(patch)
+ .where(and(notDeleted(orgId), eq(employeeBankAccounts.id, id)))
+ .returning(maskedColumns);
+
+ const newlySalary = wantSalary && !existing.defaultForSalary;
+ const newlyReimb = wantReimb && !existing.defaultForReimbursement;
+ if (newlySalary || newlyReimb) {
+ const [, updated] = await db.batch([
+ clearDefaultsQuery(db, orgId, existing.employeeId, { salary: newlySalary, reimbursement: newlyReimb }, id),
+ update,
+ ]);
+ return updated[0];
+ }
+ const [row] = await update;
+ return row;
+}
+
+/** 軟刪除。已被薪資單 / 交易引用的帳戶一樣可以刪(FK 仍指得到那一列,紀錄不會壞)。 */
+export async function softDeleteEmployeeAccount(orgId: string, id: number): Promise {
+ const existing = await getEmployeeAccount(orgId, id);
+ if (!existing) throw new EmployeeAccountError("notFound");
+ await getDb()
+ .update(employeeBankAccounts)
+ .set({
+ deletedAt: new Date().toISOString(),
+ defaultForSalary: false,
+ defaultForReimbursement: false,
+ })
+ .where(and(notDeleted(orgId), eq(employeeBankAccounts.id, id)));
+ return existing;
+}
+
+/**
+ * 解密完整帳號。**呼叫端負責**:先確認 owner/admin、事後寫 activity_log。
+ * MCP 不得呼叫這支(MCP 沒有顯示完整帳號的能力)。
+ */
+export async function revealEmployeeAccountNumber(orgId: string, id: number): Promise {
+ const [row] = await getDb()
+ .select({ enc: employeeBankAccounts.accountNumberEnc })
+ .from(employeeBankAccounts)
+ .where(and(notDeleted(orgId), eq(employeeBankAccounts.id, id)))
+ .limit(1);
+ if (!row) throw new EmployeeAccountError("notFound");
+ return decryptField(row.enc);
+}
+
+export type PayoutPurpose = "salary" | "reimbursement";
+
+/**
+ * 發薪 / 撥款要記錄的「匯入帳戶」。
+ * - 有指定 id:必須屬於這個組織、這位員工、未刪除且啟用中,否則丟錯。
+ * - 沒指定:用這位員工該用途的預設帳戶;沒有預設就回 null(欄位本來就是選填)。
+ */
+export async function resolvePayoutAccount(
+ orgId: string,
+ employeeId: number | null,
+ purpose: PayoutPurpose,
+ explicitId?: number | null,
+): Promise {
+ if (explicitId != null) {
+ const acct = await getEmployeeAccount(orgId, explicitId);
+ if (!acct) throw new EmployeeAccountError("notFound");
+ if (acct.employeeId !== employeeId) throw new EmployeeAccountError("wrongEmployee");
+ if (!acct.isActive) throw new EmployeeAccountError("inactive");
+ return acct;
+ }
+ if (employeeId == null) return null;
+ const flag =
+ purpose === "salary"
+ ? employeeBankAccounts.defaultForSalary
+ : employeeBankAccounts.defaultForReimbursement;
+ const [row] = await getDb()
+ .select(maskedColumns)
+ .from(employeeBankAccounts)
+ .where(
+ and(
+ notDeleted(orgId),
+ eq(employeeBankAccounts.employeeId, employeeId),
+ eq(employeeBankAccounts.isActive, true),
+ eq(flag, true),
+ ),
+ )
+ .limit(1);
+ return row ?? null;
+}
+
+/**
+ * 舊 salary_account → 一個「薪資預設」帳戶,成功後清空舊欄位(明文不再留在 DB)。
+ * 已經有任何帳戶的員工不轉(避免蓋掉人手動建的預設),回傳 null。
+ */
+export async function convertLegacySalaryAccount(
+ orgId: string,
+ employeeId: number,
+): Promise {
+ const db = getDb();
+ const [emp] = await db
+ .select({ salaryAccount: employees.salaryAccount, name: employees.name })
+ .from(employees)
+ .where(and(eq(employees.organizationId, orgId), eq(employees.id, employeeId), isNull(employees.deletedAt)))
+ .limit(1);
+ if (!emp) throw new EmployeeAccountError("wrongEmployee");
+ const parsed = emp.salaryAccount ? parseLegacySalaryAccount(emp.salaryAccount) : null;
+ if (!parsed) return null;
+ const existing = await listEmployeeAccounts(orgId, employeeId);
+ if (existing.length > 0) return null;
+ const created = await createEmployeeAccount(orgId, employeeId, {
+ ...parsed,
+ accountHolder: emp.name,
+ currency: "TWD",
+ defaultForSalary: true,
+ note: LEGACY_ACCOUNT_NOTE,
+ });
+ await db
+ .update(employees)
+ .set({ salaryAccount: null })
+ .where(and(eq(employees.organizationId, orgId), eq(employees.id, employeeId)));
+ return created;
+}
+
+// ---- 身分證字號 ----
+
+/** 身分證字號明文:優先解密 national_id_enc,沒有才退回舊的明文欄位。 */
+export async function readNationalId(row: {
+ nationalId: string | null;
+ nationalIdEnc: string | null;
+}): Promise {
+ if (row.nationalIdEnc) return decryptField(row.nationalIdEnc);
+ return row.nationalId;
+}
+
+/** 遮罩後的身分證字號;解不開(金鑰缺失 / 密文損壞)時回固定遮罩,不讓頁面整個掛掉。 */
+export async function readMaskedNationalId(row: {
+ nationalId: string | null;
+ nationalIdEnc: string | null;
+}): Promise {
+ try {
+ return maskNationalId(await readNationalId(row));
+ } catch {
+ return "***";
+ }
+}
+
+/** 寫入用:明文 → { nationalIdEnc, nationalId: null };空值兩欄都清空。 */
+export async function nationalIdColumns(
+ value: string | null | undefined,
+): Promise<{ nationalId: null; nationalIdEnc: string | null }> {
+ const v = value?.trim();
+ return { nationalId: null, nationalIdEnc: v ? await encryptField(v) : null };
+}
diff --git a/src/db/mutations.ts b/src/db/mutations.ts
index 73d99bb..4c5e27e 100644
--- a/src/db/mutations.ts
+++ b/src/db/mutations.ts
@@ -28,7 +28,7 @@ import {
} from "./schema";
import { oauthAccessToken, oauthConsent, member } from "./auth-schema";
import { uploadDocument } from "@/lib/storage";
-import { requireOrg } from "@/lib/session";
+import { canManageOrg, requireOrg, requireOrgWithRole } from "@/lib/session";
import { auth } from "@/lib/auth";
import { logWeb } from "@/db/activity";
import { isValidEmail } from "@/lib/pii";
@@ -39,6 +39,9 @@ import {
type ScheduleInput,
} from "@/lib/billing-schedule";
import { findAccountCurrencyMismatches } from "@/lib/account-currency";
+import { nationalIdColumns, resolvePayoutAccount, type PayoutPurpose } from "./employee-accounts";
+import { EmployeeAccountError } from "@/lib/employee-accounts";
+import { externalSingleLegSide, type SingleLegSide } from "@/lib/external-transfer";
export type ActionState = { ok: boolean; error?: string };
@@ -427,6 +430,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,
@@ -673,6 +702,14 @@ export async function createReimbursement(
if (!adv) return { ok: false, error: t("notFound.advanceRecord") };
const refError = await unownedRefError(db, orgId, [[bankAccounts, [fromAccountId]]]);
if (refError) return { ok: false, error: refError };
+ // 匯入代墊人的哪個帳戶(選填):沒選就用他的報銷預設帳戶
+ const payout = await payoutAccountFromForm(
+ orgId,
+ adv.settleEmployeeId,
+ "reimbursement",
+ formData,
+ );
+ if ("error" in payout) return { ok: false, error: payout.error };
const advCurrency = adv.currency ?? "TWD";
// 撥款的幣別跟著原代墊走,所以要驗的是「付款帳戶是不是同一種幣別」。
const currencyError = await accountCurrencyError(db, orgId, advCurrency, [fromAccountId]);
@@ -685,6 +722,7 @@ export async function createReimbursement(
type: "reimbursement",
txnDate: payDate,
settleEmployeeId: adv.settleEmployeeId, // 付給代墊人(員工)
+ settleToAccountId: payout.accountId,
amount,
currency: advCurrency,
amountTwd: advCurrency === "TWD" ? amount : null,
@@ -849,8 +887,49 @@ export async function deleteBankAccount(id: number): Promise {
}
}
+// ---- 員工收款帳戶(發薪 / 撥款的匯入帳戶)----
+
+/**
+ * 表單的 toEmployeeAccountId → 要記錄的員工帳戶 id。
+ * - 欄位不存在:用該用途的預設帳戶(沒有預設就是 null)
+ * - "none":使用者明確選了「不記錄」→ null
+ * - 其他值:必須是這位員工、這個組織、啟用中的帳戶
+ */
+async function payoutAccountFromForm(
+ orgId: string,
+ employeeId: number | null,
+ purpose: PayoutPurpose,
+ formData: FormData,
+): Promise<{ accountId: number | null } | { error: string }> {
+ const raw = str(formData.get("toEmployeeAccountId"));
+ if (raw === "none") return { accountId: null };
+ try {
+ const acct = await resolvePayoutAccount(orgId, employeeId, purpose, raw === null ? null : Number(raw));
+ return { accountId: acct?.id ?? null };
+ } catch (e) {
+ if (e instanceof EmployeeAccountError) {
+ const t = await getTranslations("errors");
+ return { error: t(`employeeAccount.${e.code}`) };
+ }
+ throw e;
+ }
+}
+
// ---- 員工 ----
+/**
+ * 員工資料的寫入只給 owner / admin(成員仍可讀非敏感欄位)。
+ * 隱藏按鈕只擋得住誤按,擋不住直接呼叫 action,所以在 server 端重驗角色。
+ */
+async function requireEmployeeManager(): Promise<{ orgId: string } | { error: string }> {
+ const { orgId, role } = await requireOrgWithRole();
+ if (!canManageOrg(role)) {
+ const t = await getTranslations("errors");
+ return { error: t("forbidden.manageEmployees") };
+ }
+ return { orgId };
+}
+
/**
* 員工表單的 user 綁定值 → user_id。空值/未選 = 不綁定(NULL)。
* 綁定前檢查:該 user 是本組織成員、且尚未綁到其他員工(不強制 1:1,但不能一人綁多筆)。
@@ -892,6 +971,8 @@ async function employeeEmailError(formData: FormData): Promise {
/**
* 新增與編輯員工共用的欄位。差別只有 isActive:新增一律 true,編輯看勾選。
+ * 身分證字號另外走 nationalIdColumns(加密),薪轉帳戶改由 employee_bank_accounts 管理,
+ * 這裡都不碰 —— 舊的 salary_account 值會留著,直到轉成帳戶或跑搬移腳本。
*/
function employeeColumns(
formData: FormData,
@@ -902,7 +983,6 @@ function employeeColumns(
const healthInsured = str(formData.get("healthInsuredSalary"));
return {
name,
- nationalId: str(formData.get("nationalId")),
employmentType: str(formData.get("employmentType")) ?? "full_time",
hasLaborInsurance: laborInsured !== null,
hasHealthInsurance: healthInsured !== null,
@@ -910,7 +990,6 @@ function employeeColumns(
baseSalary: str(formData.get("baseSalary")),
laborInsuredSalary: laborInsured,
healthInsuredSalary: healthInsured,
- salaryAccount: str(formData.get("salaryAccount")),
startDate: str(formData.get("startDate")),
endDate: str(formData.get("endDate")),
workEmail: str(formData.get("workEmail")),
@@ -931,13 +1010,16 @@ export async function createEmployee(
const emailErr = await employeeEmailError(formData);
if (emailErr) return { ok: false, error: emailErr };
try {
- const { orgId } = await requireOrg();
+ const auth = await requireEmployeeManager();
+ if ("error" in auth) return { ok: false, error: auth.error };
+ const { orgId } = auth;
const userId = await resolveEmployeeUserId(orgId, str(formData.get("userId")));
const [inserted] = await getDb()
.insert(employees)
.values({
organizationId: orgId,
...employeeColumns(formData, name, userId),
+ ...(await nationalIdColumns(str(formData.get("nationalId")))),
isActive: true,
})
.returning({ id: employees.id });
@@ -961,12 +1043,19 @@ export async function updateEmployee(
const emailErr = await employeeEmailError(formData);
if (emailErr) return { ok: false, error: emailErr };
try {
- const { orgId } = await requireOrg();
+ const auth = await requireEmployeeManager();
+ if ("error" in auth) return { ok: false, error: auth.error };
+ const { orgId } = auth;
const userId = await resolveEmployeeUserId(orgId, str(formData.get("userId")), id);
+ // 身分證字號欄位有送上來才改(寫密文、清空明文);沒送就維持原值。
+ const nationalId = formData.has("nationalId")
+ ? await nationalIdColumns(str(formData.get("nationalId")))
+ : {};
await getDb()
.update(employees)
.set({
...employeeColumns(formData, name, userId),
+ ...nationalId,
isActive: formData.get("isActive") === "on",
})
.where(and(eq(employees.organizationId, orgId), eq(employees.id, id)));
@@ -982,7 +1071,9 @@ export async function updateEmployee(
export async function deleteEmployee(id: number): Promise {
const t = await getTranslations("errors");
try {
- const { orgId } = await requireOrg();
+ const auth = await requireEmployeeManager();
+ if ("error" in auth) return { ok: false, error: auth.error };
+ const { orgId } = auth;
await getDb()
.update(employees)
.set({ deletedAt: new Date().toISOString() })
@@ -1132,12 +1223,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);
@@ -1152,6 +1259,8 @@ export async function updateTransaction(
.update(transactions)
.set({
...transactionColumns(formData, header, f),
+ // 自動匯入(Wise 同步)的列:有人在編輯時指定了分類,就算確認過了。
+ ...(f.categoryId === null ? {} : { needsReview: false }),
updatedAt: new Date().toISOString(),
})
.where(and(eq(transactions.organizationId, orgId), eq(transactions.id, id)));
@@ -1592,6 +1701,9 @@ export async function payEmployeeSalary(
[payrollItemTypes, items.map((r) => r.itemTypeId)],
]);
if (refError) return { ok: false, error: refError };
+ // 薪資匯入員工的哪個帳戶(選填):沒選就用他的薪資預設帳戶
+ const payout = await payoutAccountFromForm(orgId, employeeId, "salary", formData);
+ if ("error" in payout) return { ok: false, error: payout.error };
const runId = await getOrCreatePayrollRunId(db, orgId, year, month, payDate);
// 一個員工一個月一張:已發放就擋
@@ -1627,6 +1739,7 @@ export async function payEmployeeSalary(
description: tRec("records.salary", { period, name: emp?.name ?? "" }).trim(),
categoryId: cat?.id ?? null,
settleEmployeeId: employeeId,
+ settleToAccountId: payout.accountId,
amount: String(net),
currency: "TWD",
amountTwd: String(net),
@@ -1642,6 +1755,7 @@ export async function payEmployeeSalary(
deductionTotal: String(deductionTotal),
netPay: String(net),
paidTransactionId: txn.id,
+ paidToAccountId: payout.accountId,
};
let payslipId: number;
diff --git a/src/db/queries.ts b/src/db/queries.ts
index 463e018..bbcd033 100644
--- a/src/db/queries.ts
+++ b/src/db/queries.ts
@@ -20,6 +20,7 @@ import {
contracts,
billingItems,
activityLog,
+ employeeBankAccounts,
} from "./schema";
import {
oauthApplication,
@@ -66,11 +67,13 @@ export type TxnFilters = {
accountId?: number;
projectId?: number;
period?: string; // YYYY-MM
+ /** 只看自動匯入、還沒人確認的列(Wise 同步)。 */
+ needsReview?: boolean;
};
// 內外帳列表與筆數共用的 where(篩選條件的 single source of truth)
function txnWhere(orgId: string, filters: TxnFilters) {
- const { book, categoryId, accountId, projectId, period } = filters;
+ const { book, categoryId, accountId, projectId, period, needsReview } = filters;
return and(
eq(transactions.organizationId, orgId),
book ? eq(transactions.book, book) : undefined,
@@ -83,6 +86,7 @@ function txnWhere(orgId: string, filters: TxnFilters) {
: undefined,
projectId ? eq(transactions.projectId, projectId) : undefined,
period ? sql`to_char(${transactions.txnDate}, 'YYYY-MM') = ${period}` : undefined,
+ needsReview === undefined ? undefined : eq(transactions.needsReview, needsReview),
isNull(transactions.deletedAt),
);
}
@@ -109,6 +113,8 @@ export async function listTransactions(
const db = getDb();
const fromAcct = aliasedTable(bankAccounts, "from_acct");
const toAcct = aliasedTable(bankAccounts, "to_acct");
+ // 撥款 / 薪資匯入的員工帳戶:只取遮罩後能顯示的欄位(沒有帳號密文)
+ const settleTo = aliasedTable(employeeBankAccounts, "settle_to_acct");
const rows = await db
.select({
@@ -137,6 +143,13 @@ export async function listTransactions(
toAccount: toAcct.name,
partyName: parties.name,
settleName: employees.name,
+ settleToAccountId: transactions.settleToAccountId,
+ settleToBankName: sql`coalesce(${settleTo.bankName}, ${settleTo.label}, ${settleTo.bankCode})`,
+ settleToAccountLast5: settleTo.accountLast5,
+ needsReview: transactions.needsReview,
+ externalSource: transactions.externalSource,
+ externalRef: transactions.externalRef,
+ externalMeta: transactions.externalMeta,
})
.from(transactions)
.leftJoin(categories, eq(categories.id, transactions.categoryId))
@@ -144,6 +157,7 @@ export async function listTransactions(
.leftJoin(toAcct, eq(toAcct.id, transactions.toAccountId))
.leftJoin(parties, eq(parties.id, transactions.partyId))
.leftJoin(employees, eq(employees.id, transactions.settleEmployeeId))
+ .leftJoin(settleTo, eq(settleTo.id, transactions.settleToAccountId))
.leftJoin(projects, eq(projects.id, transactions.projectId))
.where(txnWhere(orgId, filters))
.orderBy(desc(transactions.txnDate), desc(transactions.id))
@@ -387,6 +401,7 @@ export async function listOutstandingAdvances(orgId: string) {
currency: transactions.currency,
description: transactions.description,
vendorName: vendor.name,
+ settleEmployeeId: transactions.settleEmployeeId,
settleName: employees.name,
categoryName: categories.name,
})
@@ -447,6 +462,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))
@@ -864,10 +884,14 @@ export async function listPayslipRecords(orgId: string, limit = 200) {
deductionTotal: payslips.deductionTotal,
netPay: payslips.netPay,
paidTransactionId: payslips.paidTransactionId,
+ paidToAccountId: payslips.paidToAccountId,
+ paidToBankName: sql`coalesce(${employeeBankAccounts.bankName}, ${employeeBankAccounts.label}, ${employeeBankAccounts.bankCode})`,
+ paidToAccountLast5: employeeBankAccounts.accountLast5,
})
.from(payslips)
.innerJoin(payrollRuns, eq(payrollRuns.id, payslips.payrollRunId))
.leftJoin(employees, eq(employees.id, payslips.employeeId))
+ .leftJoin(employeeBankAccounts, eq(employeeBankAccounts.id, payslips.paidToAccountId))
.where(and(eq(payrollRuns.organizationId, orgId), isNull(payslips.deletedAt)))
.orderBy(desc(payrollRuns.periodYear), desc(payrollRuns.periodMonth), employees.name)
.limit(limit);
diff --git a/src/db/schema.ts b/src/db/schema.ts
index 2fcb818..b314538 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"
@@ -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", {
@@ -70,7 +92,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 +104,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 +122,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 +193,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 +325,23 @@ 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" }),
+ // 外部來源(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_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")),
@@ -299,6 +389,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", {
@@ -592,3 +683,53 @@ 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])`),
+]);
+
+// ---- 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])`),
+]);
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/advances.ts b/src/i18n/messages/advances.ts
index 4cee906..053e68a 100644
--- a/src/i18n/messages/advances.ts
+++ b/src/i18n/messages/advances.ts
@@ -34,6 +34,8 @@ const advances = {
"zh-TW": "目前沒有 {currency} 帳戶,請先到「銀行帳戶」建立一個。",
en: "There is no {currency} account yet — create one on the Bank accounts page first.",
},
+ toAccount: { "zh-TW": "匯入帳戶", en: "Paid into" },
+ toAccountNone: { "zh-TW": "不記錄", en: "Don't record" },
amountLabel: { "zh-TW": "金額(依代墊,不可改)", en: "Amount (fixed by the advance, can't change)" },
confirm: { "zh-TW": "確認撥款", en: "Confirm reimbursement" },
saving: { "zh-TW": "儲存中…", en: "Saving…" },
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/employees.ts b/src/i18n/messages/employees.ts
index e4e9219..2e15d14 100644
--- a/src/i18n/messages/employees.ts
+++ b/src/i18n/messages/employees.ts
@@ -42,8 +42,6 @@ const employees = {
optional: { "zh-TW": "選填", en: "Optional" },
employmentTypeLabel: { "zh-TW": "雇用類型", en: "Employment type" },
baseSalaryLabel: { "zh-TW": "底薪", en: "Base salary" },
- salaryAccountLabel: { "zh-TW": "薪資戶", en: "Salary account" },
- salaryAccountPlaceholder: { "zh-TW": "例:永豐銀行 帳號末四碼 1234", en: "e.g. SinoPac Bank, account ending 1234" },
contactSection: { "zh-TW": "聯絡資訊", en: "Contact info" },
workEmailLabel: { "zh-TW": "工作 Email", en: "Work email" },
personalEmailLabel: { "zh-TW": "聯絡 Email", en: "Personal email" },
@@ -64,6 +62,66 @@ const employees = {
saveChanges: { "zh-TW": "儲存變更", en: "Save changes" },
saving: { "zh-TW": "儲存中…", en: "Saving…" },
},
+ accounts: {
+ title: { "zh-TW": "帳戶", en: "Bank accounts" },
+ add: { "zh-TW": "新增帳戶", en: "Add account" },
+ empty: { "zh-TW": "尚未設定收款帳戶", en: "No bank accounts yet" },
+ branchSuffix: { "zh-TW": "{code} 分行", en: "branch {code}" },
+ reveal: { "zh-TW": "顯示完整帳號", en: "Show full number" },
+ hide: { "zh-TW": "隱藏", en: "Hide" },
+ edit: { "zh-TW": "編輯", en: "Edit" },
+ delete: { "zh-TW": "刪除", en: "Delete" },
+ cancel: { "zh-TW": "取消", en: "Cancel" },
+ kind: {
+ bank: { "zh-TW": "銀行 / 郵局", en: "Bank / post office" },
+ wise: { "zh-TW": "Wise", en: "Wise" },
+ other: { "zh-TW": "其他", en: "Other" },
+ },
+ chips: {
+ salary: { "zh-TW": "薪資", en: "Salary" },
+ reimbursement: { "zh-TW": "報銷", en: "Reimbursement" },
+ inactive: { "zh-TW": "停用", en: "Inactive" },
+ },
+ legacy: {
+ notice: { "zh-TW": "舊的薪資帳戶欄位:{value}", en: "Legacy salary account field: {value}" },
+ convert: { "zh-TW": "轉成帳戶", en: "Convert to account" },
+ },
+ deleteConfirm: {
+ title: { "zh-TW": "刪除這個帳戶?", en: "Delete this account?" },
+ description: { "zh-TW": "已記錄在薪資單或撥款上的帳戶紀錄會保留,但之後不能再選這個帳戶。", en: "Payslips and reimbursements already recorded against it keep the record, but it can no longer be selected." },
+ },
+ form: {
+ addTitle: { "zh-TW": "新增帳戶", en: "New account" },
+ editTitle: { "zh-TW": "編輯帳戶", en: "Edit account" },
+ kind: { "zh-TW": "類型", en: "Type" },
+ bank: { "zh-TW": "銀行", en: "Bank" },
+ bankPlaceholder: { "zh-TW": "輸入代碼或名稱,例:807", en: "Code or name, e.g. 807" },
+ bankFreeEntry: { "zh-TW": "不在清單上:直接輸入「3 碼代碼 + 名稱」", en: "Not listed — type the 3-digit code and the bank name" },
+ providerName: { "zh-TW": "機構名稱", en: "Provider" },
+ branchCode: { "zh-TW": "分行代碼(4 碼)", en: "Branch code (4 digits)" },
+ branchPlaceholder: { "zh-TW": "選填,例:0180", en: "Optional, e.g. 0180" },
+ holder: { "zh-TW": "戶名", en: "Account holder" },
+ number: { "zh-TW": "帳號", en: "Account number" },
+ numberPlaceholder: { "zh-TW": "空白與連字號會自動去除", en: "Spaces and dashes are removed" },
+ numberKeep: { "zh-TW": "留空表示不變更", en: "Leave blank to keep the current number" },
+ currency: { "zh-TW": "幣別", en: "Currency" },
+ label: { "zh-TW": "標籤", en: "Label" },
+ labelPlaceholder: { "zh-TW": "選填,例:薪轉戶", en: "Optional, e.g. payroll account" },
+ note: { "zh-TW": "備註", en: "Note" },
+ optional: { "zh-TW": "選填", en: "Optional" },
+ defaultForSalary: { "zh-TW": "薪資預設帳戶", en: "Default for salary" },
+ defaultForReimbursement: { "zh-TW": "報銷預設帳戶", en: "Default for reimbursements" },
+ isActive: { "zh-TW": "啟用", en: "Active" },
+ save: { "zh-TW": "儲存帳戶", en: "Save account" },
+ saving: { "zh-TW": "儲存中…", en: "Saving…" },
+ },
+ toast: {
+ created: { "zh-TW": "已新增帳戶", en: "Account added" },
+ updated: { "zh-TW": "已更新帳戶", en: "Account updated" },
+ deleted: { "zh-TW": "已刪除帳戶", en: "Account deleted" },
+ converted: { "zh-TW": "已轉成帳戶,並設為薪資預設", en: "Converted to an account and set as the salary default" },
+ },
+ },
new: {
trigger: { "zh-TW": "新增員工", en: "Add employee" },
title: { "zh-TW": "新增員工", en: "Add employee" },
@@ -84,6 +142,8 @@ const employees = {
selectAccountPlaceholder: { "zh-TW": "— 選擇帳戶 —", en: "— Select account —" },
accountOption: { "zh-TW": "{name}({currency})", en: "{name} ({currency})" },
bookLabel: { "zh-TW": "帳別", en: "Book" },
+ toAccountLabel: { "zh-TW": "匯入帳戶", en: "Paid into" },
+ toAccountNone: { "zh-TW": "不記錄", en: "Don't record" },
bookOptions: {
both: { "zh-TW": "內外帳(both)", en: "Both (internal & external)" },
internal: { "zh-TW": "僅內帳(不報稅)", en: "Internal only (not for tax filing)" },
diff --git a/src/i18n/messages/errors.ts b/src/i18n/messages/errors.ts
index aee0930..9740e73 100644
--- a/src/i18n/messages/errors.ts
+++ b/src/i18n/messages/errors.ts
@@ -76,6 +76,24 @@ const errors = {
// 說「那筆屬於別的組織」等於幫人確認該 id 存在,本身就是一種洩漏。
referencedRecord: { "zh-TW": "找不到選取的資料,請重新選擇", en: "A selected record was not found. Please choose again." },
},
+ forbidden: {
+ manageEmployees: { "zh-TW": "只有組織的擁有者或管理員可以修改員工資料", en: "Only organization owners and admins can change employee records" },
+ },
+ employeeAccount: {
+ kindInvalid: { "zh-TW": "帳戶類型不正確", en: "Invalid account type" },
+ numberRequired: { "zh-TW": "請輸入帳號", en: "Enter an account number" },
+ numberDigits: { "zh-TW": "銀行帳號須為 6–20 位數字(空白與連字號會自動去除)", en: "A bank account number must be 6–20 digits (spaces and dashes are ignored)" },
+ numberLength: { "zh-TW": "帳號最多 64 個字元", en: "Account number can be at most 64 characters" },
+ bankCodeRequired: { "zh-TW": "銀行帳戶需要 3 碼銀行代碼(例:807)", en: "A bank account needs a 3-digit bank code (e.g. 807)" },
+ bankCodeFormat: { "zh-TW": "銀行代碼須為 3 位數字", en: "Bank code must be 3 digits" },
+ branchCodeFormat: { "zh-TW": "分行代碼須為 4 位數字", en: "Branch code must be 4 digits" },
+ currencyFormat: { "zh-TW": "幣別須為 3 碼英文代碼(例:TWD)", en: "Currency must be a 3-letter code (e.g. TWD)" },
+ notFound: { "zh-TW": "找不到這個員工帳戶", en: "Employee bank account not found" },
+ inactive: { "zh-TW": "這個員工帳戶已停用", en: "That employee bank account is deactivated" },
+ wrongEmployee: { "zh-TW": "這個帳戶不屬於該員工", en: "That account belongs to a different employee" },
+ revealFailed: { "zh-TW": "無法顯示完整帳號(加密金鑰未設定或密文損壞)", en: "Could not reveal the account number (encryption key missing or data corrupted)" },
+ nothingToConvert: { "zh-TW": "沒有可轉換的舊薪資帳戶,或這位員工已經有帳戶了", en: "There is no legacy salary account to convert, or this employee already has accounts" },
+ },
unsupported: {
transactionType: { "zh-TW": "不支援的交易類型", en: "Unsupported transaction type" },
},
diff --git a/src/i18n/messages/index.ts b/src/i18n/messages/index.ts
index 8b5d10a..7d805d8 100644
--- a/src/i18n/messages/index.ts
+++ b/src/i18n/messages/index.ts
@@ -20,6 +20,8 @@ import payroll from "./payroll";
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";
@@ -48,6 +50,8 @@ const catalogue = {
members,
activity,
settings,
+ integrations,
+ wise,
auth,
errors,
lib,
diff --git a/src/i18n/messages/integrations.ts b/src/i18n/messages/integrations.ts
new file mode 100644
index 0000000..0809514
--- /dev/null
+++ b/src/i18n/messages/integrations.ts
@@ -0,0 +1,153 @@
+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" },
+ companyId: { "zh-TW": "公司 ID(選填,帳號有多家公司時才需要)", en: "Company ID (optional; only if the account has several companies)" },
+ },
+ 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/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?" },
diff --git a/src/i18n/messages/lib.ts b/src/i18n/messages/lib.ts
index dd1e910..e9cce72 100644
--- a/src/i18n/messages/lib.ts
+++ b/src/i18n/messages/lib.ts
@@ -12,6 +12,7 @@ const lib = {
calendarDisconnected: { "zh-TW": "中斷 Google 日曆連結", en: "Google Calendar disconnected" },
advanceReimbursed: { "zh-TW": "員工代墊撥款", en: "Employee advance reimbursed" },
salaryPaid: { "zh-TW": "發放薪資", en: "Salary paid" },
+ accountRevealed: { "zh-TW": "顯示完整帳號:{account}", en: "Revealed full account number: {account}" },
paymentMatched: { "zh-TW": "收款配對 #{id}", en: "Payment matched #{id}" },
paymentMatchedPeriod: { "zh-TW": "{period} 收款配對 #{id}", en: "{period} payment matched #{id}" },
},
@@ -25,7 +26,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" },
diff --git a/src/i18n/messages/transactions.ts b/src/i18n/messages/transactions.ts
index e732c01..9e1073b 100644
--- a/src/i18n/messages/transactions.ts
+++ b/src/i18n/messages/transactions.ts
@@ -19,6 +19,20 @@ 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" },
+ 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.",
+ },
+ },
+ 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" },
@@ -34,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/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;
diff --git a/src/lib/crypto.ts b/src/lib/crypto.ts
new file mode 100644
index 0000000..ed4b41b
--- /dev/null
+++ b/src/lib/crypto.ts
@@ -0,0 +1,83 @@
+// 欄位層級加密(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.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.codePointAt(i) ?? 0;
+ 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;
+}
diff --git a/src/lib/employee-accounts.ts b/src/lib/employee-accounts.ts
new file mode 100644
index 0000000..905823f
--- /dev/null
+++ b/src/lib/employee-accounts.ts
@@ -0,0 +1,216 @@
+// 員工收款帳戶的純函式(不碰 DB、不碰加密),server 與 client 共用:
+// - 台灣常見銀行代碼表(表單的銀行下拉用;不在表上的代碼仍可自由輸入)
+// - 帳號正規化與驗證(網頁 action、MCP tool、搬移腳本共用同一套規則)
+// - 舊 salary_account 自由文字的解析
+// - 遮罩後的顯示字串
+//
+// 這裡的型別只描述「遮罩過」的帳戶;完整帳號從來不會出現在這個檔案處理的物件裡。
+
+export const EMPLOYEE_ACCOUNT_KINDS = ["bank", "wise", "other"] as const;
+export type EmployeeAccountKind = (typeof EMPLOYEE_ACCOUNT_KINDS)[number];
+
+/** 列表 / MCP / client 看得到的帳戶形狀:沒有完整帳號,只有末 5 碼。 */
+export type MaskedEmployeeAccount = {
+ id: number;
+ employeeId: number;
+ kind: string;
+ bankCode: string | null;
+ branchCode: string | null;
+ bankName: string | null;
+ accountHolder: string | null;
+ accountLast5: string;
+ currency: string;
+ label: string | null;
+ defaultForSalary: boolean;
+ defaultForReimbursement: boolean;
+ isActive: boolean;
+ note: string | null;
+};
+
+/**
+ * 台灣常見的金融機構代碼(財金資訊公司的 3 碼總機構代號)。只收常見的幾十家,
+ * 給下拉選單用;不在表上的代碼一樣可以手動輸入。
+ * 815 日盛已於 2023 年併入台北富邦、809 凱基前身為萬泰,舊帳戶仍可能出現故保留。
+ */
+export const TW_BANKS: ReadonlyArray<{ code: string; name: string }> = [
+ { code: "004", name: "臺灣銀行" },
+ { code: "005", name: "土地銀行" },
+ { code: "006", name: "合作金庫" },
+ { code: "007", name: "第一銀行" },
+ { code: "008", name: "華南銀行" },
+ { code: "009", name: "彰化銀行" },
+ { code: "011", name: "上海商銀" },
+ { code: "012", name: "台北富邦" },
+ { code: "013", name: "國泰世華" },
+ { code: "016", name: "高雄銀行" },
+ { code: "017", name: "兆豐銀行" },
+ { code: "048", name: "王道銀行" },
+ { code: "050", name: "臺灣企銀" },
+ { code: "052", name: "渣打銀行" },
+ { code: "053", name: "台中銀行" },
+ { code: "054", name: "京城銀行" },
+ { code: "081", name: "滙豐銀行" },
+ { code: "103", name: "新光銀行" },
+ { code: "108", name: "陽信銀行" },
+ { code: "118", name: "板信銀行" },
+ { code: "147", name: "三信銀行" },
+ { code: "700", name: "中華郵政" },
+ { code: "803", name: "聯邦銀行" },
+ { code: "805", name: "遠東商銀" },
+ { code: "806", name: "元大銀行" },
+ { code: "807", name: "永豐銀行" },
+ { code: "808", name: "玉山銀行" },
+ { code: "809", name: "凱基銀行" },
+ { code: "810", name: "星展銀行" },
+ { code: "812", name: "台新銀行" },
+ { code: "815", name: "日盛銀行" },
+ { code: "816", name: "安泰銀行" },
+ { code: "822", name: "中國信託" },
+ { code: "823", name: "將來銀行" },
+ { code: "824", name: "連線銀行(LINE Bank)" },
+ { code: "826", name: "樂天銀行" },
+];
+
+const BANK_BY_CODE = new Map(TW_BANKS.map((b) => [b.code, b.name]));
+
+export function bankNameForCode(code: string | null | undefined): string | null {
+ return code ? (BANK_BY_CODE.get(code) ?? null) : null;
+}
+
+/** 去掉空白與連字號(使用者常照存摺格式輸入 807-0180-1234…)。 */
+export function normalizeAccountNumber(raw: string): string {
+ return raw.replaceAll(/[\s\-‐-―]/g, "");
+}
+
+/** 末 5 碼(正規化之後)。 */
+export function accountLast5(normalized: string): string {
+ return normalized.slice(-5);
+}
+
+export type EmployeeAccountErrorCode =
+ | "kindInvalid"
+ | "numberRequired"
+ | "numberDigits"
+ | "numberLength"
+ | "bankCodeRequired"
+ | "bankCodeFormat"
+ | "branchCodeFormat"
+ | "currencyFormat"
+ | "notFound"
+ | "inactive"
+ | "wrongEmployee";
+
+// 英文訊息給 MCP 直接回傳;網頁端用 code 查 i18n(errors.employeeAccount.*)。
+const ERROR_MESSAGES: Record = {
+ kindInvalid: `"kind" must be one of: ${EMPLOYEE_ACCOUNT_KINDS.join(", ")}.`,
+ numberRequired: "Account number is required.",
+ numberDigits: "A bank account number must be 6–20 digits (spaces and dashes are ignored).",
+ numberLength: "Account number must be 1–64 characters.",
+ bankCodeRequired: "A bank account needs a 3-digit bank code (e.g. 807).",
+ bankCodeFormat: "Bank code must be exactly 3 digits.",
+ branchCodeFormat: "Branch code must be exactly 4 digits.",
+ currencyFormat: "Currency must be a 3-letter code (e.g. TWD, USD).",
+ notFound: "Employee bank account not found in your organization.",
+ inactive: "That employee bank account is deactivated.",
+ wrongEmployee: "That bank account belongs to a different employee.",
+};
+
+export class EmployeeAccountError extends Error {
+ constructor(public readonly code: EmployeeAccountErrorCode) {
+ super(ERROR_MESSAGES[code]);
+ this.name = "EmployeeAccountError";
+ }
+}
+
+export function isAccountKind(v: string): v is EmployeeAccountKind {
+ return (EMPLOYEE_ACCOUNT_KINDS as readonly string[]).includes(v);
+}
+
+/** 檢查帳號本體;回傳正規化後的值。 */
+export function checkAccountNumber(kind: EmployeeAccountKind, raw: string | null | undefined): string {
+ const n = normalizeAccountNumber(raw ?? "");
+ if (!n) throw new EmployeeAccountError("numberRequired");
+ if (kind === "bank") {
+ if (!/^\d{6,20}$/.test(n)) throw new EmployeeAccountError("numberDigits");
+ } else if (n.length > 64) {
+ throw new EmployeeAccountError("numberLength");
+ }
+ return n;
+}
+
+/** 檢查銀行 / 分行代碼與幣別;回傳正規化後的值(空字串 → null、幣別轉大寫)。 */
+export function checkAccountCodes(input: {
+ kind: EmployeeAccountKind;
+ bankCode?: string | null;
+ branchCode?: string | null;
+ currency?: string | null;
+}): { bankCode: string | null; branchCode: string | null; currency: string } {
+ const bankCode = input.bankCode?.trim() || null;
+ const branchCode = input.branchCode?.trim() || null;
+ const currency = (input.currency?.trim() || "TWD").toUpperCase();
+ if (input.kind === "bank" && !bankCode) throw new EmployeeAccountError("bankCodeRequired");
+ if (bankCode && !/^\d{3}$/.test(bankCode)) throw new EmployeeAccountError("bankCodeFormat");
+ if (branchCode && !/^\d{4}$/.test(branchCode)) throw new EmployeeAccountError("branchCodeFormat");
+ if (!/^[A-Z]{3}$/.test(currency)) throw new EmployeeAccountError("currencyFormat");
+ return { bankCode, branchCode, currency };
+}
+
+/** 舊 salary_account 自由文字轉成帳戶時寫進 note 的固定字樣(搬移腳本也用)。 */
+export const LEGACY_ACCOUNT_NOTE = "由舊的薪資帳戶欄位轉入";
+
+export type ParsedLegacyAccount = {
+ kind: EmployeeAccountKind;
+ bankCode: string | null;
+ branchCode: string | null;
+ bankName: string | null;
+ accountNumber: string;
+};
+
+/**
+ * 舊 salary_account(自由文字)→ 帳戶欄位。
+ * - 開頭是 3 碼數字 → 當成銀行代碼;後面若是「4 碼 + 分隔 + 數字」就拆出分行,
+ * 其餘數字當帳號。帳號湊不到 6 碼就放棄解析,改走 other。
+ * - 其他情況 → kind = other,整串(去掉空白 / 連字號)當帳號,交給人事後修正。
+ */
+export function parseLegacySalaryAccount(raw: string): ParsedLegacyAccount | null {
+ const s = raw.trim();
+ if (!s) return null;
+ const m = /^(\d{3})([\s\S]*)$/.exec(s);
+ if (m) {
+ const bankCode = m[1];
+ // 代碼後面可能夾著銀行名稱或括號(「807 永豐 0180-1234…」),先跳到第一個數字
+ const rest = m[2].replace(/^\D+/, "");
+ // 分行代碼 = 開頭 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 };
+ }
+ }
+ const whole = normalizeAccountNumber(s).slice(0, 64);
+ return { kind: "other", bankCode: null, branchCode: null, bankName: null, accountNumber: whole };
+}
+
+/** 帳戶的簡短稱呼:銀行名 → 標籤 → 銀行代碼,都沒有就回 null 讓呼叫端決定。 */
+export function accountDisplayName(a: {
+ bankName: string | null;
+ label: string | null;
+ bankCode: string | null;
+}): string | null {
+ return a.bankName ?? a.label ?? a.bankCode ?? null;
+}
+
+/** 「永豐銀行 ••••90123」— 發薪紀錄、撥款紀錄上的一行摘要。 */
+export function formatAccountShort(a: {
+ bankName: string | null;
+ label: string | null;
+ bankCode: string | null;
+ accountLast5: string;
+}): string {
+ const name = accountDisplayName(a);
+ return name ? `${name} ••••${a.accountLast5}` : `••••${a.accountLast5}`;
+}
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/integrations/catalog.ts b/src/lib/integrations/catalog.ts
new file mode 100644
index 0000000..33fde06
--- /dev/null
+++ b/src/lib/integrations/catalog.ts
@@ -0,0 +1,41 @@
+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" },
+ ],
+ // 帳號底下只有一家公司時自動選;多家才需要填(連接失敗訊息會列出可選的 ID)。
+ configFields: [
+ { key: "companyId", labelKey: "companyId", type: "text", required: false, autoComplete: "off" },
+ ],
+ website: "https://simpany.co",
+ },
+ 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..5e97184
--- /dev/null
+++ b/src/lib/integrations/registry.ts
@@ -0,0 +1,46 @@
+import type { IntegrationProvider, IntegrationProviderId } from "./types";
+import { wiseProvider } from "./wise";
+import { simpanyProvider } from "./simpany";
+
+/**
+ * 已實作的整合。Simpany 已接上(src/lib/integrations/simpany.ts)。
+ *
+ * ── 如何新增一個整合的實作 ──────────────────────────────────────────────
+ * 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> = {
+ wise: wiseProvider,
+ simpany: simpanyProvider,
+};
+
+/** 取實作;還沒實作的回 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/simpany.ts b/src/lib/integrations/simpany.ts
new file mode 100644
index 0000000..c429200
--- /dev/null
+++ b/src/lib/integrations/simpany.ts
@@ -0,0 +1,753 @@
+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.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;
+ if (isObj(body.errors)) {
+ const validation = validationErrorsMessage(body.errors);
+ if (validation) return validation;
+ }
+ if (isObj(body.error)) {
+ const business = businessErrorMessage(body.error);
+ if (business) return business;
+ }
+ 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);
+ }
+ const reason = simpanyErrorMessage(body) ?? `HTTP ${res.status}`;
+ throw new SimpanyError("http", `Simpany 登入失敗:${reason}`, 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) {
+ 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 : [];
+ 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")),
+ );
+}
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;
+};
diff --git a/src/lib/integrations/wise.ts b/src/lib/integrations/wise.ts
new file mode 100644
index 0000000..6765ebb
--- /dev/null
+++ b/src/lib/integrations/wise.ts
@@ -0,0 +1,313 @@
+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;
+ /** 已知值:"PERSONAL"、"BUSINESS";Wise 可能新增其他值,所以保留 string。 */
+ type: 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 = {
+ /** 已知值:"DEBIT"、"CREDIT";保留 string 以容納未知值。 */
+ type: 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);
+ const detail = body ? `:${body}` : "";
+ throw new WiseApiError(res.status, `Wise 回應 ${res.status}${detail}`);
+ }
+ 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/mcp/handler.ts b/src/lib/mcp/handler.ts
index d2ec17b..984cfaf 100644
--- a/src/lib/mcp/handler.ts
+++ b/src/lib/mcp/handler.ts
@@ -21,7 +21,9 @@ const AUDIT_VERB_PREFIXES: ReadonlyArray<[prefix: string, action: ActivityAction
// Tools whose name doesn't follow the verb_entity convention. Read tools are
// normally not logged — except employee reads, which carry PII (email/phone +
-// masked national id/account) and are logged as "read".
+// masked national id/account) and are logged as "read". Employee bank-account
+// writes (create_/update_/delete_employee_bank_account) are covered by the verb
+// prefixes above with entityType "employee_bank_account", same as the web side.
const AUDIT_BY_NAME: Record> = {
bulk_create_transactions: { action: "create", entityType: "transaction" },
pay_employee_salary: { action: "create", entityType: "payslip" },
@@ -29,6 +31,7 @@ const AUDIT_BY_NAME: Record> = {
unmark_accountant_notified: { action: "update", entityType: "transaction" },
list_employees: { action: "read", entityType: "employee" },
get_employee: { action: "read", entityType: "employee" },
+ list_employee_bank_accounts: { action: "read", entityType: "employee_bank_account" },
accept_invitation: { action: "create", entityType: "member" },
};
@@ -72,7 +75,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.6.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. */
@@ -108,7 +111,7 @@ const LATEST_PROTOCOL_VERSION = SUPPORTED_PROTOCOL_VERSIONS[0];
// belong to multiple organizations and we never guess.
const INSTRUCTIONS = [
"This server is the operational surface for one organization's bookkeeping: ledger transactions (內外帳), parties, categories, bank accounts, invoices, projects, subscriptions, contracts, employees, payroll records, and reconciliations.",
- "It is a bookkeeping system and nothing else. Every write creates or edits a record in this organization's own books. No tool moves money: none of them initiates, authorizes or executes a payment, transfer, payout or trade, and the server is not connected to any bank, card or payment provider. Words like pay, payment, transfer, salary, reimbursement and advance always describe an entry being recorded, never money being sent.",
+ "It is a bookkeeping system and nothing else. Every write creates or edits a record in this organization's own books. No tool moves money: none of them initiates, authorizes or executes a payment, transfer, payout or trade, and the server cannot instruct any bank, card or payment provider — the only bank link is an optional, read-only Wise integration (wise_* tools) that reads balances and statements and can import them into the ledger. Words like pay, payment, transfer, salary, reimbursement and advance always describe an entry being recorded, never money being sent.",
"The signed-in account may belong to multiple organizations.",
"At the start of each session, before calling any org-scoped tool, call list_organizations and ask the user which organization to work in.",
"If list_organizations is empty, or the user says they were invited to an organization, call list_my_invitations and offer to accept the right one with accept_invitation after the user confirms — invitations are sent from the web app, and this is the only way to join an organization over MCP.",
@@ -260,11 +263,26 @@ 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 },
+ // Read-only calls to the Wise API (GET only); sync writes only our own ledger.
+ wise_list_balances: { openWorldHint: true },
+ wise_get_statement: { openWorldHint: true },
+ wise_sync_transactions: { 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 +325,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-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-employee-accounts.ts b/src/lib/mcp/tools-employee-accounts.ts
new file mode 100644
index 0000000..da37e75
--- /dev/null
+++ b/src/lib/mcp/tools-employee-accounts.ts
@@ -0,0 +1,218 @@
+// 員工收款帳戶的 MCP tools。
+//
+// 刻意的限制(不要放寬):
+// - 任何回傳都只有遮罩後的欄位(末 5 碼),MCP 沒有「顯示完整帳號」的能力。
+// 完整帳號只能在網頁端由 owner / admin 明確點擊顯示,並寫入操作紀錄。
+// - 寫入時接受完整帳號(跟 create_employee 收身分證字號一樣),但從不回顯。
+// - 寫入只給 owner / admin,跟網頁端的員工資料寫入權限一致。
+import { getMemberRole } from "@/db/queries";
+import {
+ createEmployeeAccount,
+ listEmployeeAccounts,
+ softDeleteEmployeeAccount,
+ updateEmployeeAccount,
+ type EmployeeAccountInput,
+} from "@/db/employee-accounts";
+import { employees } from "@/db/schema";
+import { getDb } from "@/db";
+import { EMPLOYEE_ACCOUNT_KINDS, type MaskedEmployeeAccount } from "@/lib/employee-accounts";
+import {
+ assertInOrg,
+ listResult,
+ listSchema,
+ optBoolean,
+ optNumber,
+ optString,
+ ORG_ARG,
+ requireNumber,
+ resolveOrg,
+ rowSchema,
+ type ToolDef,
+} from "./shared";
+
+/** 員工資料(含收款帳戶)的寫入只給 owner / admin。 */
+export async function assertCanManageEmployees(orgId: string, userId: string): Promise {
+ const role = await getMemberRole(orgId, userId);
+ if (role !== "owner" && role !== "admin") {
+ throw new Error(
+ "Only organization owners and admins can change employee records or employee bank accounts.",
+ );
+ }
+}
+
+/** 一個遮罩後的員工帳戶(MaskedEmployeeAccount)。 */
+export const EMPLOYEE_ACCOUNT_ROW = rowSchema({
+ id: { type: "number", description: "Pass as toEmployeeAccountId to pay_employee_salary / create_reimbursement." },
+ employeeId: { type: "number" },
+ kind: { type: "string", enum: [...EMPLOYEE_ACCOUNT_KINDS] },
+ bankCode: { type: ["string", "null"], description: "3-digit Taiwan bank code, e.g. 807." },
+ branchCode: { type: ["string", "null"], description: "4-digit branch code." },
+ bankName: { type: ["string", "null"] },
+ accountHolder: { type: ["string", "null"] },
+ accountLast5: {
+ type: "string",
+ description: "Last 5 characters of the account number. The full number is never returned over MCP.",
+ },
+ currency: { type: "string", description: "3-letter code." },
+ label: { type: ["string", "null"] },
+ defaultForSalary: { type: "boolean" },
+ defaultForReimbursement: { type: "boolean" },
+ isActive: { type: "boolean" },
+ note: { type: ["string", "null"] },
+});
+
+/** 輸出用的遮罩摘要:「永豐銀行 807 ••••90123」。 */
+export function maskedAccountSummary(a: MaskedEmployeeAccount): string {
+ const head = [a.bankName, a.bankCode].filter(Boolean).join(" ");
+ const prefix = head ? `${head} ` : "";
+ return `${prefix}••••${a.accountLast5}`;
+}
+
+/** 發薪 / 撥款結果裡的「匯入帳戶」欄位(可為 null)。 */
+export const PAYOUT_ACCOUNT_SCHEMA = {
+ type: ["object", "null"],
+ description:
+ "The employee bank account the money was recorded as going to (masked), or null when none was given and the employee has no default for this purpose.",
+ properties: {
+ id: { type: "number" },
+ summary: { type: "string", description: "Masked, e.g. 永豐銀行 807 ••••90123." },
+ currency: { type: "string" },
+ },
+ required: ["id", "summary", "currency"],
+ additionalProperties: false,
+};
+
+export function payoutAccountOutput(a: MaskedEmployeeAccount | null) {
+ return a ? { id: a.id, summary: maskedAccountSummary(a), currency: a.currency } : null;
+}
+
+const WRITE_PROPS = {
+ kind: {
+ type: "string",
+ enum: [...EMPLOYEE_ACCOUNT_KINDS],
+ description: "bank (Taiwan bank / post office; bankCode required, digits-only number), wise, or other.",
+ },
+ bankCode: { type: "string", description: "3-digit bank code (e.g. 807 永豐, 822 中國信託, 700 中華郵政)." },
+ branchCode: { type: "string", description: "Optional 4-digit branch code." },
+ bankName: { type: "string", description: "Optional; filled from bankCode for common Taiwan banks." },
+ accountHolder: { type: "string" },
+ currency: { type: "string", description: "3-letter; default TWD." },
+ label: { type: "string", description: "Free label, e.g. 薪轉戶." },
+ defaultForSalary: {
+ type: "boolean",
+ description: "Make this the salary default (clears the previous salary default for this employee).",
+ },
+ defaultForReimbursement: {
+ type: "boolean",
+ description: "Make this the reimbursement default (clears the previous one for this employee).",
+ },
+ note: { type: "string" },
+};
+
+/** MCP args → EmployeeAccountInput,只帶有傳進來的欄位(update 的 partial 語意)。 */
+function accountInput(args: Record): EmployeeAccountInput {
+ const input: EmployeeAccountInput = {};
+ for (const k of ["kind", "bankCode", "branchCode", "bankName", "accountHolder", "accountNumber", "currency", "label", "note"] as const) {
+ if (k in args) input[k] = optString(args, k) ?? null;
+ }
+ for (const k of ["defaultForSalary", "defaultForReimbursement", "isActive"] as const) {
+ const v = optBoolean(args, k);
+ if (v !== undefined) input[k] = v;
+ }
+ return input;
+}
+
+export const employeeAccountTools: Record = {
+ list_employee_bank_accounts: {
+ description:
+ "List employees' bank accounts (where salary / reimbursements are paid into). Masked: only the last 5 characters of each account number are returned — the full number is never available over MCP. Omit employeeId for the whole organization.",
+ inputSchema: {
+ type: "object",
+ properties: {
+ employeeId: { type: "number", description: "See list_employees." },
+ ...ORG_ARG,
+ },
+ additionalProperties: false,
+ },
+ outputSchema: listSchema(EMPLOYEE_ACCOUNT_ROW),
+ execute: async (args, ctx) => {
+ const orgId = await resolveOrg(args, ctx);
+ const employeeId = optNumber(args, "employeeId");
+ if (employeeId !== undefined) await assertInOrg(getDb(), employees, employeeId, orgId, "Employee");
+ return listResult(await listEmployeeAccounts(orgId, employeeId));
+ },
+ },
+
+ create_employee_bank_account: {
+ description:
+ "Add a bank account to an employee's record (owner/admin only). The full account number is stored encrypted and is never echoed back — the result shows only the last 5 characters. Spaces and dashes in the number are ignored. The first account is not made a default automatically; pass defaultForSalary / defaultForReimbursement.",
+ inputSchema: {
+ type: "object",
+ properties: {
+ employeeId: { type: "number", description: "See list_employees." },
+ accountNumber: { type: "string", description: "Full account number. Stored encrypted; never returned." },
+ ...WRITE_PROPS,
+ ...ORG_ARG,
+ },
+ required: ["employeeId", "accountNumber"],
+ additionalProperties: false,
+ },
+ outputSchema: EMPLOYEE_ACCOUNT_ROW,
+ execute: async (args, ctx) => {
+ const orgId = await resolveOrg(args, ctx);
+ await assertCanManageEmployees(orgId, ctx.userId);
+ const employeeId = requireNumber(args, "employeeId");
+ await assertInOrg(getDb(), employees, employeeId, orgId, "Employee");
+ return createEmployeeAccount(orgId, employeeId, accountInput(args));
+ },
+ },
+
+ update_employee_bank_account: {
+ description:
+ "Update an employee bank account (owner/admin only; only provided fields change). Pass accountNumber only to replace the number — it is stored encrypted and never echoed back. Set isActive=false to deactivate (also clears its default flags).",
+ inputSchema: {
+ type: "object",
+ properties: {
+ id: { type: "number", description: "See list_employee_bank_accounts." },
+ accountNumber: { type: "string", description: "New full account number (optional). Never returned." },
+ ...WRITE_PROPS,
+ isActive: { type: "boolean" },
+ ...ORG_ARG,
+ },
+ required: ["id"],
+ additionalProperties: false,
+ },
+ outputSchema: EMPLOYEE_ACCOUNT_ROW,
+ execute: async (args, ctx) => {
+ const orgId = await resolveOrg(args, ctx);
+ await assertCanManageEmployees(orgId, ctx.userId);
+ const input = accountInput(args);
+ if (Object.keys(input).length === 0) throw new Error("Nothing to update.");
+ return updateEmployeeAccount(orgId, requireNumber(args, "id"), input);
+ },
+ },
+
+ delete_employee_bank_account: {
+ description:
+ "Delete an employee bank account (owner/admin only). Payslips and reimbursements already recorded against it keep their reference.",
+ inputSchema: {
+ type: "object",
+ properties: { id: { type: "number" }, ...ORG_ARG },
+ required: ["id"],
+ additionalProperties: false,
+ },
+ outputSchema: {
+ type: "object",
+ properties: { deleted: { type: "boolean" }, id: { type: "number" } },
+ required: ["deleted", "id"],
+ additionalProperties: false,
+ },
+ execute: async (args, ctx) => {
+ const orgId = await resolveOrg(args, ctx);
+ await assertCanManageEmployees(orgId, ctx.userId);
+ const id = requireNumber(args, "id");
+ await softDeleteEmployeeAccount(orgId, id);
+ return { deleted: true, id };
+ },
+ },
+};
diff --git a/src/lib/mcp/tools-hr.ts b/src/lib/mcp/tools-hr.ts
index 069d295..a5c7a97 100644
--- a/src/lib/mcp/tools-hr.ts
+++ b/src/lib/mcp/tools-hr.ts
@@ -41,7 +41,26 @@ import {
type ToolDef,
} from "./shared";
import { assertAccountCurrency } from "@/lib/account-currency";
-import { isValidEmail, maskBankAccount, maskNationalId } from "@/lib/pii";
+import { isValidEmail, maskBankAccount } from "@/lib/pii";
+import {
+ createEmployeeAccount,
+ groupAccountsByEmployee,
+ listEmployeeAccounts,
+ nationalIdColumns,
+ readMaskedNationalId,
+ resolvePayoutAccount,
+} from "@/db/employee-accounts";
+import {
+ LEGACY_ACCOUNT_NOTE,
+ parseLegacySalaryAccount,
+ type MaskedEmployeeAccount,
+} from "@/lib/employee-accounts";
+import {
+ EMPLOYEE_ACCOUNT_ROW,
+ PAYOUT_ACCOUNT_SCHEMA,
+ assertCanManageEmployees,
+ payoutAccountOutput,
+} from "./tools-employee-accounts";
const EMPLOYMENT_TYPES = ["full_time", "part_time", "freelancer", "contractor"] as const;
@@ -94,17 +113,52 @@ function checkEmailArg(args: Record, key: string) {
}
// 員工資料離開 MCP 前遮罩身分證字號與薪轉帳戶 — MCP 的用途(payroll、
-// 聯絡資訊查詢)不需要完整值;完整值只在網頁端看得到。
-function redactEmployee(
- e: T,
-): T {
+// 聯絡資訊查詢)不需要完整值;完整值只在網頁端給 owner / admin 看。
+// national_id_enc(密文)不能出現在輸出裡,連密文都不給。
+type EmployeeRowIn = typeof employees.$inferSelect;
+
+async function redactEmployee(e: EmployeeRowIn, accounts: MaskedEmployeeAccount[]) {
+ // 密文欄位整個拿掉(schema 是封閉的,多一個 key 也會讓 structuredContent 不合法)
+ const rest: Partial = { ...e };
+ delete rest.nationalIdEnc;
+ const salaryDefault = accounts.find((a) => a.defaultForSalary && a.isActive);
return {
- ...e,
- nationalId: maskNationalId(e.nationalId),
- salaryAccount: maskBankAccount(e.salaryAccount),
+ ...(rest as Omit),
+ nationalId: await readMaskedNationalId(e),
+ // 相容舊欄位:有薪資預設帳戶就用它的末 5 碼,否則退回舊的自由文字(遮罩)。
+ salaryAccount: salaryDefault
+ ? `****${salaryDefault.accountLast5}`
+ : maskBankAccount(e.salaryAccount),
+ bankAccounts: accounts,
};
}
+/** 單筆員工 → MCP 輸出(含遮罩後的帳戶清單)。 */
+async function employeeOut(orgId: string, e: EmployeeRowIn) {
+ return redactEmployee(e, await listEmployeeAccounts(orgId, e.id));
+}
+
+/**
+ * 舊的 salaryAccount 參數(已淘汰):不再寫進明文欄位,改建一個「薪資預設」帳戶
+ * (帳號加密)。這樣舊的 MCP 呼叫端照樣能用,DB 裡也不會多出明文帳號。
+ */
+async function salaryAccountArgToAccount(
+ orgId: string,
+ employeeId: number,
+ holder: string,
+ raw: string | undefined,
+) {
+ const parsed = raw ? parseLegacySalaryAccount(raw) : null;
+ if (!parsed) return;
+ await createEmployeeAccount(orgId, employeeId, {
+ ...parsed,
+ accountHolder: holder,
+ currency: "TWD",
+ defaultForSalary: true,
+ note: LEGACY_ACCOUNT_NOTE,
+ });
+}
+
// 員工列(employees 全欄位)離開 MCP 時的形狀。drizzle 的 numeric 欄位回傳的是
// 字串而不是數字,date 欄位是 YYYY-MM-DD 字串;nationalId / salaryAccount 描述的
// 是 redactEmployee 遮罩「之後」的值,所以型別仍是字串,只是內容被 * 蓋掉。
@@ -127,7 +181,7 @@ const EMPLOYEE_PROPS = {
salaryAccount: {
type: ["string", "null"],
description:
- "Masked: only the last 5 characters survive, the rest become '*' (e.g. ****12345). Null when unset.",
+ "Deprecated — see bankAccounts. Masked: the last 5 characters of the salary-default account (e.g. ****12345), or of the legacy free-text value. Null when unset.",
},
startDate: { type: ["string", "null"], description: "YYYY-MM-DD." },
endDate: { type: ["string", "null"], description: "YYYY-MM-DD." },
@@ -139,6 +193,12 @@ const EMPLOYEE_PROPS = {
userId: { type: ["string", "null"], description: "Bound login user id, or null." },
createdAt: { type: "string", description: "ISO 8601 timestamp." },
deletedAt: { type: ["string", "null"], description: "ISO 8601 timestamp." },
+ bankAccounts: {
+ type: "array",
+ description:
+ "The employee's bank accounts, masked (last 5 characters only). Manage them with the *_employee_bank_account tools.",
+ items: EMPLOYEE_ACCOUNT_ROW,
+ },
};
// 這些 tool 都用 `.returning()` 回整列,欄位一定到齊(值可能是 null)。
const EMPLOYEE_REQUIRED = Object.keys(EMPLOYEE_PROPS);
@@ -188,6 +248,12 @@ const PAYSLIP_ROW = rowSchema({
type: ["number", "null"],
description: "The salary-expense ledger entry; null while the payslip is still unbooked.",
},
+ paidToAccountId: {
+ type: ["number", "null"],
+ description: "Employee bank account the salary was recorded as paid into; see list_employee_bank_accounts.",
+ },
+ paidToBankName: { type: ["string", "null"] },
+ paidToAccountLast5: { type: ["string", "null"], description: "Masked: last 5 characters only." },
});
const RECONCILIATION_LIST_ROW = rowSchema({
@@ -246,10 +312,9 @@ async function checkEmployeeUserBinding(orgId: string, userId: string, excludeEm
}
// update_employee 可搬運的欄位,依取值方式分三組。
+// nationalId(加密)與 salaryAccount(轉成帳戶)另外處理,不在這裡。
const EMPLOYEE_STRING_FIELDS = [
- "nationalId",
"employmentType",
- "salaryAccount",
"startDate",
"endDate",
"workEmail",
@@ -302,7 +367,7 @@ export const hrTools: Record = {
// ---- employees ----
list_employees: {
description:
- "List employees (for payroll records, advances, reimbursements). National ID and salary account are masked; use the web app when the full values are needed.",
+ "List employees (for payroll records, advances, reimbursements), each with their bank accounts. National ID and account numbers are masked; the full values are only visible to owners/admins in the web app.",
inputSchema: {
type: "object",
properties: { limit: { type: "number", description: "Default 200." }, ...ORG_ARG },
@@ -314,17 +379,22 @@ export const hrTools: Record = {
required: EMPLOYEE_REQUIRED,
additionalProperties: false,
}),
- execute: async (args, ctx) =>
- listResult(
- (await listEmployees(await resolveOrg(args, ctx), optNumber(args, "limit") ?? 200)).map(
- redactEmployee,
- ),
- ),
+ execute: async (args, ctx) => {
+ const orgId = await resolveOrg(args, ctx);
+ const [rows, accounts] = await Promise.all([
+ listEmployees(orgId, optNumber(args, "limit") ?? 200),
+ listEmployeeAccounts(orgId),
+ ]);
+ const byEmployee = groupAccountsByEmployee(accounts);
+ return listResult(
+ await Promise.all(rows.map((e) => redactEmployee(e, byEmployee.get(e.id) ?? []))),
+ );
+ },
},
get_employee: {
description:
- "Get one employee by id. National ID and salary account are masked; use the web app when the full values are needed.",
+ "Get one employee by id, with their bank accounts. National ID and account numbers are masked; the full values are only visible to owners/admins in the web app.",
inputSchema: {
type: "object",
properties: { id: { type: "number" }, ...ORG_ARG },
@@ -341,8 +411,9 @@ export const hrTools: Record = {
additionalProperties: false,
},
execute: async (args, ctx) => {
- const row = await getEmployee(await resolveOrg(args, ctx), requireNumber(args, "id"));
- return row ? redactEmployee(row) : { error: "Not found." };
+ const orgId = await resolveOrg(args, ctx);
+ const row = await getEmployee(orgId, requireNumber(args, "id"));
+ return row ? employeeOut(orgId, row) : { error: "Not found." };
},
},
@@ -355,17 +426,21 @@ export const hrTools: Record = {
},
create_employee: {
- description: "Create an employee.",
+ description:
+ "Create an employee (owner/admin only). nationalId is stored encrypted. salaryAccount is deprecated: when given it becomes an encrypted bank account set as the salary default — prefer create_employee_bank_account.",
inputSchema: {
type: "object",
properties: {
name: { type: "string" },
- nationalId: { type: "string" },
+ nationalId: { type: "string", description: "Stored encrypted; returned masked." },
employmentType: { type: "string", enum: [...EMPLOYMENT_TYPES], description: "Default full_time." },
baseSalary: { type: "number" },
laborInsuredSalary: { type: "number" },
healthInsuredSalary: { type: "number" },
- salaryAccount: { type: "string" },
+ salaryAccount: {
+ type: "string",
+ description: "Deprecated: creates a salary-default bank account instead (see create_employee_bank_account).",
+ },
startDate: { type: "string", description: "YYYY-MM-DD." },
endDate: { type: "string", description: "YYYY-MM-DD." },
workEmail: { type: "string" },
@@ -393,6 +468,7 @@ export const hrTools: Record = {
},
execute: async (args, ctx) => {
const orgId = await resolveOrg(args, ctx);
+ await assertCanManageEmployees(orgId, ctx.userId);
checkEmploymentType(optString(args, "employmentType"));
checkEmailArg(args, "workEmail");
checkEmailArg(args, "personalEmail");
@@ -405,12 +481,11 @@ export const hrTools: Record = {
.values({
organizationId: orgId,
name: requireString(args, "name"),
- nationalId: optString(args, "nationalId") ?? null,
+ ...(await nationalIdColumns(optString(args, "nationalId"))),
employmentType: optString(args, "employmentType") ?? "full_time",
baseSalary: optDecimal(args, "baseSalary") ?? null,
laborInsuredSalary,
healthInsuredSalary,
- salaryAccount: optString(args, "salaryAccount") ?? null,
startDate: optString(args, "startDate") ?? null,
endDate: optString(args, "endDate") ?? null,
workEmail: optString(args, "workEmail") ?? null,
@@ -426,23 +501,28 @@ export const hrTools: Record = {
hasPension: optBoolean(args, "hasPension") ?? false,
})
.returning();
- return redactEmployee(row);
+ await salaryAccountArgToAccount(orgId, row.id, row.name, optString(args, "salaryAccount"));
+ return employeeOut(orgId, row);
},
},
update_employee: {
- description: "Update an employee (only provided fields). Set isActive=false to mark as left.",
+ description:
+ "Update an employee (owner/admin only; only provided fields). Set isActive=false to mark as left. nationalId is stored encrypted. salaryAccount is deprecated: when given it adds an encrypted bank account and makes it the salary default — prefer the *_employee_bank_account tools.",
inputSchema: {
type: "object",
properties: {
id: { type: "number" },
name: { type: "string" },
- nationalId: { type: "string" },
+ nationalId: { type: "string", description: "Stored encrypted; returned masked." },
employmentType: { type: "string", enum: [...EMPLOYMENT_TYPES] },
baseSalary: { type: "number" },
laborInsuredSalary: { type: "number" },
healthInsuredSalary: { type: "number" },
- salaryAccount: { type: "string" },
+ salaryAccount: {
+ type: "string",
+ description: "Deprecated: adds a salary-default bank account instead (see create_employee_bank_account).",
+ },
startDate: { type: "string", description: "YYYY-MM-DD." },
endDate: { type: "string", description: "YYYY-MM-DD." },
workEmail: { type: "string" },
@@ -472,24 +552,40 @@ export const hrTools: Record = {
execute: async (args, ctx) => {
const id = requireNumber(args, "id");
const orgId = await resolveOrg(args, ctx);
+ await assertCanManageEmployees(orgId, ctx.userId);
checkEmploymentType(optString(args, "employmentType"));
checkEmailArg(args, "workEmail");
checkEmailArg(args, "personalEmail");
const patch = buildEmployeePatch(args);
if ("userId" in args) patch.userId = await resolveEmployeeUserId(args, orgId, id);
- if (Object.keys(patch).length === 0) throw new Error("Nothing to update.");
- const [row] = await getDb()
- .update(employees)
- .set(patch)
- .where(and(eq(employees.organizationId, orgId), eq(employees.id, id)))
- .returning();
+ if (optString(args, "nationalId") !== undefined) {
+ Object.assign(patch, await nationalIdColumns(optString(args, "nationalId")));
+ }
+ const salaryAccount = optString(args, "salaryAccount");
+ if (Object.keys(patch).length === 0 && salaryAccount === undefined) {
+ throw new Error("Nothing to update.");
+ }
+ const db = getDb();
+ await assertInOrg(db, employees, id, orgId, "Employee");
+ let row: EmployeeRowIn | undefined;
+ if (Object.keys(patch).length > 0) {
+ [row] = await db
+ .update(employees)
+ .set(patch)
+ .where(and(eq(employees.organizationId, orgId), eq(employees.id, id)))
+ .returning();
+ } else {
+ row = (await getEmployee(orgId, id)) ?? undefined;
+ }
if (!row) throw new Error(`Employee ${id} not found in your organization.`);
- return redactEmployee(row);
+ await salaryAccountArgToAccount(orgId, id, row.name, salaryAccount);
+ return employeeOut(orgId, row);
},
},
delete_employee: {
- description: "Delete an employee. Fails if payroll/transactions reference them — deactivate instead.",
+ description:
+ "Delete an employee (owner/admin only). Fails if payroll/transactions reference them — deactivate instead.",
inputSchema: {
type: "object",
properties: { id: { type: "number" }, ...ORG_ARG },
@@ -505,6 +601,7 @@ export const hrTools: Record = {
execute: async (args, ctx) => {
const id = requireNumber(args, "id");
const orgId = await resolveOrg(args, ctx);
+ await assertCanManageEmployees(orgId, ctx.userId);
const db = getDb();
await assertInOrg(db, employees, id, orgId, "Employee");
await db
@@ -673,6 +770,11 @@ export const hrTools: Record = {
enum: ["both", "internal", "external"],
description: "Ledger book; default both (reported). Use internal to keep it off the tax books.",
},
+ toEmployeeAccountId: {
+ type: "number",
+ description:
+ "Optional: which of the employee's bank accounts the salary went into (see list_employee_bank_accounts). Defaults to the employee's salary-default account when omitted. Must belong to this employee and be active.",
+ },
items: {
type: "array",
description:
@@ -710,6 +812,7 @@ export const hrTools: Record = {
netPay: { type: "number", description: "taxable + nontaxable − deduction, in TWD." },
book: { type: "string", enum: ["both", "internal", "external"] },
transactionId: { type: "number", description: "The salary-expense transaction posted." },
+ paidToAccount: PAYOUT_ACCOUNT_SCHEMA,
// 只有找不到「薪資費用」科目時才會出現,所以不列進 required。
note: { type: "string" },
},
@@ -726,6 +829,7 @@ export const hrTools: Record = {
"netPay",
"book",
"transactionId",
+ "paidToAccount",
],
additionalProperties: false,
},
@@ -759,6 +863,13 @@ export const hrTools: Record = {
const db = getDb();
await assertInOrg(db, employees, employeeId, orgId, "Employee");
await assertInOrg(db, bankAccounts, fromAccountId, orgId, "Account");
+ // 匯入員工的哪個帳戶:有指定就驗歸屬與啟用,沒指定就用薪資預設(可能沒有)。
+ const payout = await resolvePayoutAccount(
+ orgId,
+ employeeId,
+ "salary",
+ optNumber(args, "toEmployeeAccountId") ?? null,
+ );
// Find or create the month's payroll run.
let runId: number;
@@ -816,6 +927,7 @@ export const hrTools: Record = {
description: `${period} 薪資 - ${emp?.name ?? ""}`.trim(),
categoryId: cat?.id ?? null,
settleEmployeeId: employeeId,
+ settleToAccountId: payout?.id ?? null,
amount: String(net),
currency: "TWD",
amountTwd: String(net),
@@ -831,6 +943,7 @@ export const hrTools: Record = {
deductionTotal: String(deduction),
netPay: String(net),
paidTransactionId: txn.id,
+ paidToAccountId: payout?.id ?? null,
};
let payslipId: number;
if (existing) {
@@ -870,6 +983,7 @@ export const hrTools: Record = {
netPay: net,
book,
transactionId: txn.id,
+ paidToAccount: payoutAccountOutput(payout),
note: cat?.id ? undefined : "No '薪資費用' category found — the expense was recorded uncategorized.",
};
},
diff --git a/src/lib/mcp/tools-integrations.ts b/src/lib/mcp/tools-integrations.ts
new file mode 100644
index 0000000..b9f7177
--- /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?.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-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-transactions.ts b/src/lib/mcp/tools-transactions.ts
index 8443403..990432a 100644
--- a/src/lib/mcp/tools-transactions.ts
+++ b/src/lib/mcp/tools-transactions.ts
@@ -37,6 +37,9 @@ import {
rowSchema,
type ToolDef,
} from "./shared";
+import { resolvePayoutAccount } from "@/db/employee-accounts";
+import { PAYOUT_ACCOUNT_SCHEMA, payoutAccountOutput } from "./tools-employee-accounts";
+import { externalSingleLegSide } from "@/lib/external-transfer";
import {
assertAccountCurrency,
findMismatchInMap,
@@ -71,6 +74,22 @@ const TXN_ROW_PROPS: Record = {
billingItemId: { type: ["number", "null"] },
invoiceId: { type: ["number", "null"] },
relatedToId: { type: ["number", "null"], description: "The advance a reimbursement pays back." },
+ externalSource: {
+ type: ["string", "null"],
+ description: "Where an imported row came from (e.g. 'wise'); null for entries typed in by hand.",
+ },
+ externalRef: {
+ type: ["string", "null"],
+ description: "The source's unique reference (Wise referenceNumber); dedupe key with externalSource.",
+ },
+ externalMeta: {
+ type: ["object", "null"],
+ description: "Raw non-secret details from the source (merchant, original amount, rate, fees, card last four).",
+ },
+ needsReview: {
+ type: "boolean",
+ description: "Imported automatically and not yet confirmed by a person (category still to be chosen).",
+ },
deletedAt: { type: ["string", "null"] },
createdAt: { type: "string" },
updatedAt: { type: "string" },
@@ -141,6 +160,22 @@ const TXN_LIST_ROW = rowSchema({
toAccount: { type: ["string", "null"], description: "Name of toAccountId." },
partyName: { type: ["string", "null"] },
settleName: { type: ["string", "null"], description: "Employee who fronted an advance." },
+ settleToAccountId: {
+ type: ["number", "null"],
+ description: "Employee bank account a reimbursement / salary was recorded as paid into.",
+ },
+ settleToBankName: { type: ["string", "null"] },
+ settleToAccountLast5: { type: ["string", "null"], description: "Masked: last 5 characters only." },
+ needsReview: {
+ type: "boolean",
+ description: "Imported automatically (e.g. Wise sync) and not yet confirmed; set a category to clear it.",
+ },
+ 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。
@@ -159,6 +194,10 @@ const ADVANCE_ROW = rowSchema({
currency: { type: "string", description: "3-letter code." },
description: { type: ["string", "null"] },
vendorName: { type: ["string", "null"], description: "Who the employee paid." },
+ settleEmployeeId: {
+ type: ["number", "null"],
+ description: "Employee owed the money back; see list_employee_bank_accounts for where to repay.",
+ },
settleName: { type: ["string", "null"], description: "Employee owed the money back." },
categoryName: { type: ["string", "null"] },
});
@@ -554,6 +593,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;
@@ -565,15 +605,18 @@ 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";
}
}
export const transactionTools: Record = {
list_transactions: {
description:
- "List ledger transactions (內外帳), newest first. Optional filters: book, categoryId, accountId, projectId, period (YYYY-MM).",
+ "List ledger transactions (內外帳), newest first. Optional filters: book, categoryId, accountId, projectId, period (YYYY-MM), needsReview (true = rows imported by an integration such as the Wise sync that nobody has confirmed yet — review them and set a category with update_transaction).",
inputSchema: {
type: "object",
properties: {
@@ -582,6 +625,10 @@ export const transactionTools: Record = {
accountId: { type: "number", description: "Matches from OR to account." },
projectId: { type: "number" },
period: { type: "string", description: "Month filter, YYYY-MM." },
+ needsReview: {
+ type: "boolean",
+ description: "true = only imported rows awaiting review (待確認); false = only confirmed rows.",
+ },
limit: { type: "number", description: "Default 100." },
...ORG_ARG,
},
@@ -596,6 +643,7 @@ export const transactionTools: Record = {
accountId: optNumber(args, "accountId"),
projectId: optNumber(args, "projectId"),
period: optString(args, "period"),
+ needsReview: optBoolean(args, "needsReview"),
};
const db = getDb();
if (filters.categoryId !== undefined)
@@ -903,7 +951,7 @@ export const transactionTools: Record = {
update_transaction: {
description:
- "Edit a transaction's date, amount, currency, description, category (categoryId 0 clears it → 未分類), 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: {
@@ -922,6 +970,10 @@ export const transactionTools: Record = {
},
reported: { type: "boolean" },
billedToCompanyTaxId: { type: "boolean", description: "有報公司統編(進項可扣抵)。" },
+ needsReview: {
+ type: "boolean",
+ description: "待確認 flag of imported rows. false = mark as reviewed. Setting a non-zero categoryId clears it automatically.",
+ },
...ORG_ARG,
},
required: ["id"],
@@ -953,11 +1005,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");
@@ -967,7 +1021,12 @@ export const transactionTools: Record = {
patch.billedToCompanyTaxId = optBoolean(args, "billedToCompanyTaxId");
}
await applyTxnRelationPatch(patch, db, args, orgId);
- applyTxnAmountPatch(patch, args, existing);
+ // 指定了分類 = 有人看過這筆了;自動匯入(Wise 同步)的「待確認」一併清掉。
+ if (typeof patch.categoryId === "number") patch.needsReview = false;
+ if (optBoolean(args, "needsReview") !== undefined) {
+ patch.needsReview = optBoolean(args, "needsReview");
+ }
+ applyTxnAmountPatch(patch, args, existing, singleLeg);
// 這支工具改不了帳戶,但改得了幣別 —— 改完仍要跟原本綁的帳戶對得起來。
if (typeof patch.currency === "string") {
await assertAccountCurrency(db, orgId, patch.currency, [
@@ -1052,6 +1111,11 @@ export const transactionTools: Record = {
fromAccountId: { type: "number", description: "Ledger account the repayment is booked against." },
payDate: { type: "string", description: "YYYY-MM-DD." },
amount: { type: "number" },
+ toEmployeeAccountId: {
+ type: "number",
+ description:
+ "Optional: which of the employee's bank accounts the repayment went into (see list_employee_bank_accounts). Defaults to the employee's reimbursement-default account when omitted. Must belong to the advance's employee and be active.",
+ },
...ORG_ARG,
},
required: ["advanceId", "fromAccountId", "payDate", "amount"],
@@ -1060,8 +1124,8 @@ export const transactionTools: Record = {
// 回的是新建的那一列(type='reimbursement',relatedToId 指回原代墊)。
outputSchema: {
type: "object",
- properties: { ...TXN_ROW_PROPS },
- required: TXN_ROW_REQUIRED,
+ properties: { ...TXN_ROW_PROPS, paidToAccount: PAYOUT_ACCOUNT_SCHEMA },
+ required: [...TXN_ROW_REQUIRED, "paidToAccount"],
},
execute: async (args, ctx) => {
const advanceId = requireNumber(args, "advanceId");
@@ -1087,6 +1151,13 @@ export const transactionTools: Record = {
const currency = adv.currency ?? "TWD";
// 撥款的幣別跟著原代墊走,付款帳戶必須是同一種幣別。
await assertAccountCurrency(db, orgId, currency, [fromAccountId]);
+ // 匯入代墊人的哪個帳戶:有指定就驗歸屬與啟用,沒指定就用報銷預設(可能沒有)。
+ const payout = await resolvePayoutAccount(
+ orgId,
+ adv.settleEmployeeId,
+ "reimbursement",
+ optNumber(args, "toEmployeeAccountId") ?? null,
+ );
const [row] = await db
.insert(transactions)
.values({
@@ -1094,6 +1165,7 @@ export const transactionTools: Record = {
type: "reimbursement",
txnDate: payDate,
settleEmployeeId: adv.settleEmployeeId,
+ settleToAccountId: payout?.id ?? null,
amount,
currency,
amountTwd: currency === "TWD" ? amount : null,
@@ -1103,7 +1175,7 @@ export const transactionTools: Record = {
description: "撥款還代墊",
})
.returning();
- return row;
+ return { ...row, paidToAccount: payoutAccountOutput(payout) };
},
},
};
diff --git a/src/lib/mcp/tools-wise.ts b/src/lib/mcp/tools-wise.ts
new file mode 100644
index 0000000..5de3df6
--- /dev/null
+++ b/src/lib/mcp/tools-wise.ts
@@ -0,0 +1,401 @@
+import { and, eq, inArray, isNull } from "drizzle-orm";
+import { getDb } from "@/db";
+import { bankAccounts } from "@/db/schema";
+import { withWiseClient, profileName } from "@/lib/integrations/wise";
+import {
+ getWiseStatement,
+ isIsoDate,
+ parseWiseConfig,
+ syncWiseTransactions,
+ taipeiDate,
+} from "@/lib/wise-sync";
+import { auditIntegrationCall, requireIntegrationForTool } from "./tools-integrations";
+import {
+ assertInOrg,
+ optBoolean,
+ optNumber,
+ optString,
+ ORG_ARG,
+ requireString,
+ resolveOrg,
+ type ToolDef,
+} from "./shared";
+
+// ---- Wise(唯讀整合)----
+//
+// 三支工具都只對 Wise 發 GET(見 src/lib/integrations/wise.ts 的白名單)。唯一會寫的是
+// wise_sync_transactions,而它寫的是「本組織自己的帳本」,不是 Wise:不建立轉帳、
+// 不換匯、不動任何錢。dryRun 預設 true。
+
+const READ = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
+
+const BALANCE_ROW = {
+ type: "object",
+ properties: {
+ profileId: { type: "number" },
+ profileName: { type: "string" },
+ profileType: { type: "string", description: "PERSONAL | BUSINESS" },
+ balanceId: { type: "number" },
+ currency: { type: "string" },
+ amount: { type: ["number", "null"], description: "Current balance in Wise, live." },
+ mappedAccountId: {
+ type: ["number", "null"],
+ description: "Ledger account (bank_accounts id) this balance syncs into; null = unmapped, skipped by sync.",
+ },
+ mappedAccountName: { type: ["string", "null"] },
+ syncFrom: {
+ type: ["string", "null"],
+ description: "Cutover date (YYYY-MM-DD, Asia/Taipei). Wise transactions before it are never synced (they were booked by hand).",
+ },
+ },
+ required: [
+ "profileId",
+ "profileName",
+ "profileType",
+ "balanceId",
+ "currency",
+ "amount",
+ "mappedAccountId",
+ "mappedAccountName",
+ "syncFrom",
+ ],
+ additionalProperties: false,
+};
+
+const COMPACT_TXN = {
+ type: "object",
+ properties: {
+ date: { type: "string", description: "YYYY-MM-DD in Asia/Taipei." },
+ dateTime: { type: "string", description: "ISO 8601 from Wise." },
+ direction: { type: "string", description: "DEBIT | CREDIT" },
+ detailsType: { type: "string", description: "CARD | TRANSFER | CONVERSION | DEPOSIT | MONEY_ADDED | …" },
+ amount: { type: "number", description: "Signed, in the balance currency (negative = money out, fees included)." },
+ currency: { type: "string" },
+ fee: { type: ["number", "null"] },
+ description: { type: ["string", "null"] },
+ merchant: { type: ["string", "null"], description: "Merchant, sender or recipient name." },
+ originalAmount: {
+ anyOf: [
+ {
+ type: "object",
+ properties: { value: { type: "number" }, currency: { type: "string" } },
+ required: ["value", "currency"],
+ },
+ { type: "null" },
+ ],
+ },
+ referenceNumber: { type: "string", description: "Wise's unique reference, e.g. CARD-4370840324." },
+ runningBalance: { type: ["number", "null"] },
+ },
+ required: [
+ "date",
+ "dateTime",
+ "direction",
+ "detailsType",
+ "amount",
+ "currency",
+ "fee",
+ "description",
+ "merchant",
+ "originalAmount",
+ "referenceNumber",
+ "runningBalance",
+ ],
+};
+
+const SYNC_ACCOUNT_ROW = {
+ type: "object",
+ properties: {
+ bankAccountId: { type: "number" },
+ bankAccountName: { type: "string" },
+ profileId: { type: "number" },
+ profileName: { type: "string" },
+ balanceId: { type: "number" },
+ currency: { type: "string" },
+ syncFrom: { type: "string", description: "Cutover date used." },
+ rangeStart: { type: "string", description: "First date fetched (YYYY-MM-DD)." },
+ rangeEnd: { type: "string", description: "Last date fetched (today, Asia/Taipei)." },
+ fetched: { type: "number", description: "Wise transactions read." },
+ beforeCutover: { type: "number", description: "Ignored because they are before syncFrom." },
+ alreadySynced: { type: "number", description: "Already in the ledger (deduped by reference)." },
+ created: { type: "number", description: "dryRun: would be created. Otherwise: created." },
+ needsReview: { type: "number", description: "Of those, flagged 待確認 (category to be chosen)." },
+ },
+};
+
+const SYNC_OUTPUT = {
+ type: "object" as const,
+ properties: {
+ dryRun: { type: "boolean" },
+ accounts: { type: "array", items: SYNC_ACCOUNT_ROW },
+ skippedBalances: {
+ type: "array",
+ description: "Wise balances not synced: unmapped, no cutover date, ledger account missing or currency mismatch.",
+ items: {
+ type: "object",
+ properties: {
+ profileId: { type: "number" },
+ profileName: { type: "string" },
+ balanceId: { type: "number" },
+ currency: { type: "string" },
+ reason: {
+ type: "string",
+ enum: ["unmapped", "no_sync_from", "account_missing", "currency_mismatch"],
+ },
+ },
+ },
+ },
+ totals: {
+ type: "object",
+ properties: {
+ created: { type: "number" },
+ alreadySynced: { type: "number" },
+ beforeCutover: { type: "number" },
+ },
+ },
+ duplicateRefs: {
+ type: "array",
+ items: { type: "string" },
+ description: "References Wise returned twice in one run; only the first was used.",
+ },
+ sample: {
+ type: "array",
+ description: "First 50 rows that would be / were created, oldest first.",
+ items: {
+ type: "object",
+ properties: {
+ bankAccountId: { type: "number" },
+ txnDate: { type: "string" },
+ type: { type: "string", description: "income | expense | transfer (one leg of a currency conversion)" },
+ amount: { type: "string" },
+ currency: { type: "string" },
+ partyName: { type: ["string", "null"] },
+ description: { type: "string" },
+ externalRef: { type: "string" },
+ needsReview: { type: "boolean" },
+ },
+ },
+ },
+ },
+ required: ["dryRun", "accounts", "skippedBalances", "totals", "duplicateRefs", "sample"],
+};
+
+function optIsoDate(args: Record, key: string): string | undefined {
+ const v = optString(args, key);
+ if (v === undefined) return undefined;
+ if (!isIsoDate(v)) throw new Error(`"${key}" must be a date in YYYY-MM-DD format.`);
+ return v;
+}
+
+export const wiseTools: Record = {
+ wise_list_balances: {
+ description:
+ "Read-only. List the Wise profiles and balances this organization's Wise token can see, with each balance's live amount and the ledger account it is mapped to for syncing (plus its cutover date). Calls Wise with GET requests only — nothing is moved or changed in Wise. Requires the Wise integration to be connected and switched on (設定 › 整合).",
+ inputSchema: { type: "object", properties: { ...ORG_ARG }, additionalProperties: false },
+ outputSchema: {
+ type: "object",
+ properties: {
+ items: { type: "array", items: BALANCE_ROW },
+ count: { type: "number" },
+ },
+ required: ["items", "count"],
+ additionalProperties: false,
+ },
+ annotations: { ...READ, title: "Wise balances" },
+ execute: async (args, ctx) => {
+ const orgId = await resolveOrg(args, ctx);
+ await requireIntegrationForTool(orgId, "wise");
+ const items = await withWiseClient(orgId, async (client, row) => {
+ const cfg = parseWiseConfig(row.config);
+ const profiles = await client.listProfiles();
+ const out = [];
+ for (const p of profiles) {
+ const balances = await client.listBalances(p.id);
+ for (const b of balances) {
+ const m = cfg.accountMappings.find((x) => x.balanceId === b.id);
+ out.push({
+ profileId: p.id,
+ profileName: profileName(p),
+ profileType: String(p.type),
+ balanceId: b.id,
+ currency: b.currency,
+ amount: typeof b.amount?.value === "number" ? b.amount.value : null,
+ mappedAccountId: m?.bankAccountId ?? null,
+ mappedAccountName: null as string | null,
+ syncFrom: m?.bankAccountId ? (m.syncFrom ?? cfg.syncFrom) : null,
+ });
+ }
+ }
+ return out;
+ });
+ const ids = items.map((i) => i.mappedAccountId).filter((x): x is number => x !== null);
+ if (ids.length) {
+ const accts = await getDb()
+ .select({ id: bankAccounts.id, name: bankAccounts.name })
+ .from(bankAccounts)
+ .where(
+ and(
+ eq(bankAccounts.organizationId, orgId),
+ inArray(bankAccounts.id, ids),
+ isNull(bankAccounts.deletedAt),
+ ),
+ );
+ for (const i of items) {
+ i.mappedAccountName = accts.find((a) => a.id === i.mappedAccountId)?.name ?? null;
+ }
+ }
+ await auditIntegrationCall(ctx, orgId, "wise", "read", `列出 ${items.length} 個餘額`);
+ return { items, count: items.length };
+ },
+ },
+
+ wise_get_statement: {
+ description:
+ "Read-only. Fetch Wise balance-statement transactions for a date range (inclusive, Asia/Taipei dates) as compact rows: date, direction, type (CARD/TRANSFER/CONVERSION/…), signed amount incl. fees, fee, merchant/counterparty, original-currency amount, Wise reference and running balance. Identify the balance either by ledger accountId (a bank account mapped to a Wise balance — see wise_list_balances) or by profileId + balanceId. Does not write anything. Max range 400 days.",
+ inputSchema: {
+ type: "object",
+ properties: {
+ accountId: { type: "number", description: "Ledger bank account mapped to a Wise balance." },
+ profileId: { type: "number", description: "Wise profile id (with balanceId)." },
+ balanceId: { type: "number", description: "Wise balance id (with profileId)." },
+ startDate: { type: "string", description: "YYYY-MM-DD (Asia/Taipei)." },
+ endDate: { type: "string", description: "YYYY-MM-DD (Asia/Taipei); default today." },
+ limit: { type: "number", description: "Max rows returned (most recent kept). Default 200." },
+ ...ORG_ARG,
+ },
+ required: ["startDate"],
+ additionalProperties: false,
+ },
+ outputSchema: {
+ type: "object",
+ properties: {
+ profileId: { type: "number" },
+ balanceId: { type: "number" },
+ currency: { type: "string" },
+ startDate: { type: "string" },
+ endDate: { type: "string" },
+ total: { type: "number", description: "Transactions in the range (before limit)." },
+ items: { type: "array", items: COMPACT_TXN },
+ count: { type: "number" },
+ },
+ required: ["profileId", "balanceId", "currency", "startDate", "endDate", "total", "items", "count"],
+ additionalProperties: false,
+ },
+ annotations: { ...READ, title: "Wise statement" },
+ execute: async (args, ctx) => {
+ const orgId = await resolveOrg(args, ctx);
+ const { row } = await requireIntegrationForTool(orgId, "wise");
+ const cfg = parseWiseConfig(row.config);
+ const startDate = optIsoDate(args, "startDate") ?? requireString(args, "startDate");
+ const endDate = optIsoDate(args, "endDate") ?? taipeiDate(new Date());
+ if (endDate < startDate) throw new Error('"endDate" must not be before "startDate".');
+ const days = (Date.parse(endDate) - Date.parse(startDate)) / 86_400_000;
+ if (days > 400) throw new Error("Date range too long; keep it within 400 days.");
+
+ const accountId = optNumber(args, "accountId");
+ let profileId = optNumber(args, "profileId");
+ let balanceId = optNumber(args, "balanceId");
+ if (accountId !== undefined) {
+ await assertInOrg(getDb(), bankAccounts, accountId, orgId, "Account");
+ const m = cfg.accountMappings.find((x) => x.bankAccountId === accountId);
+ if (!m) {
+ throw new Error(
+ `Account ${accountId} is not mapped to a Wise balance. Use wise_list_balances, or pass profileId + balanceId.`,
+ );
+ }
+ profileId = m.profileId;
+ balanceId = m.balanceId;
+ }
+ if (profileId === undefined || balanceId === undefined) {
+ throw new Error('Pass either "accountId" or both "profileId" and "balanceId".');
+ }
+ const bal = cfg.balances.find((b) => b.balanceId === balanceId && b.profileId === profileId);
+ if (!bal) {
+ throw new Error(
+ `Wise balance ${balanceId} (profile ${profileId}) is not known for this organization. Call wise_list_balances, or ask an owner/admin to refresh the balances in 設定 › 整合.`,
+ );
+ }
+ const all = await getWiseStatement(
+ orgId,
+ { profileId, balanceId, currency: bal.currency },
+ startDate,
+ endDate,
+ );
+ const limit = Math.max(1, Math.min(1000, optNumber(args, "limit") ?? 200));
+ const items = all.slice(-limit);
+ await auditIntegrationCall(
+ ctx,
+ orgId,
+ "wise",
+ "read",
+ `讀取對帳單 ${bal.currency} ${startDate}~${endDate}(${all.length} 筆)`,
+ );
+ return {
+ profileId,
+ balanceId,
+ currency: bal.currency,
+ startDate,
+ endDate,
+ total: all.length,
+ items,
+ count: items.length,
+ };
+ },
+ },
+
+ wise_sync_transactions: {
+ description:
+ "Import Wise balance-statement transactions into this organization's own ledger (transactions), for every Wise balance mapped to a ledger account (or only `accountId`). Reads Wise with GET only and writes ONLY to the internal books — it never creates transfers, quotes or conversions in Wise and moves no money. dryRun defaults to TRUE: first call it without dryRun, show the user the preview (rows per account, date ranges, sample rows, skipped/unmapped balances), and only after the user explicitly approves call it again with dryRun:false to write. Rows are deduped by Wise reference (never inserted twice; existing rows are never modified), start at each mapping's cutover date (syncFrom) so hand-entered monthly aggregates are not double counted, are booked as 'internal', uncategorized (未分類) and flagged needsReview (待確認). CREDIT → income, DEBIT → expense (amount already includes Wise fees; the fee is noted in the description); a currency conversion between two mapped balances becomes one single-leg transfer row per balance. startDate (YYYY-MM-DD) overrides the automatic start (last synced date − 3 days) but cannot be earlier than the cutover date.",
+ inputSchema: {
+ type: "object",
+ properties: {
+ accountId: { type: "number", description: "Only sync this ledger account (must be mapped)." },
+ startDate: {
+ type: "string",
+ description: "YYYY-MM-DD (Asia/Taipei). Override the start; must not be before the cutover date.",
+ },
+ dryRun: {
+ type: "boolean",
+ description: "Default true = preview only, nothing written. Pass false only after the user approved the preview.",
+ },
+ ...ORG_ARG,
+ },
+ additionalProperties: false,
+ },
+ outputSchema: SYNC_OUTPUT,
+ annotations: {
+ title: "Sync Wise transactions into the ledger",
+ readOnlyHint: false,
+ destructiveHint: false,
+ idempotentHint: true,
+ openWorldHint: true,
+ },
+ execute: async (args, ctx) => {
+ const orgId = await resolveOrg(args, ctx);
+ await requireIntegrationForTool(orgId, "wise");
+ const accountId = optNumber(args, "accountId");
+ if (accountId !== undefined) {
+ await assertInOrg(getDb(), bankAccounts, accountId, orgId, "Account");
+ }
+ const dryRun = optBoolean(args, "dryRun") ?? true;
+ const result = await syncWiseTransactions(orgId, {
+ accountId,
+ startDate: optIsoDate(args, "startDate"),
+ dryRun,
+ });
+ const ranges = result.accounts
+ .map((a) => `${a.bankAccountName} ${a.rangeStart}~${a.rangeEnd}: ${a.created}`)
+ .join("; ");
+ await auditIntegrationCall(
+ ctx,
+ orgId,
+ "wise",
+ dryRun ? "read" : "create",
+ dryRun
+ ? `同步試算:會新增 ${result.totals.created} 筆(${ranges})`
+ : `同步交易:新增 ${result.totals.created} 筆、略過 ${result.totals.alreadySynced} 筆已存在(${ranges})`,
+ );
+ return result;
+ },
+ },
+};
diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts
index 7a44c7b..e39ec36 100644
--- a/src/lib/mcp/tools.ts
+++ b/src/lib/mcp/tools.ts
@@ -29,8 +29,12 @@ import { accountingTools } from "./tools-accounting";
import { transactionTools } from "./tools-transactions";
import { clientTools } from "./tools-client";
import { hrTools } from "./tools-hr";
+import { employeeAccountTools } from "./tools-employee-accounts";
import { billingItemTools } from "./tools-billing";
import { orgTools } from "./tools-org";
+import { integrationTools } from "./tools-integrations";
+import { wiseTools } from "./tools-wise";
+import { simpanyTools } from "./tools-simpany";
export type { ToolContext, ToolDef } from "./shared";
@@ -715,4 +719,8 @@ export const tools: Record = {
...transactionTools,
...clientTools,
...hrTools,
+ ...integrationTools,
+ ...employeeAccountTools,
+ ...wiseTools,
+ ...simpanyTools,
};
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*******)。 */
diff --git a/src/lib/simpany-issue.ts b/src/lib/simpany-issue.ts
new file mode 100644
index 0000000..7e48ccc
--- /dev/null
+++ b/src/lib/simpany-issue.ts
@@ -0,0 +1,1093 @@
+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 [];
+ // 先依分隔符切成片段,再逐一用 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 會換成全形)。 */
+export function sanitizeItemName(name: string): string {
+ 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(
+ orgId: string,
+ input: PreviewInput,
+ links: DraftLinks,
+ warnings: string[],
+): Promise {
+ const src: Source = { partyId: null, amount: null, currency: "TWD", itemName: null };
+ // 順序有意義:後面的來源會覆蓋前面的金額 / 品名(請款項目、訂閱優先於交易)。
+ if (input.transactionId != null) {
+ await applyTransactionSource(orgId, input.transactionId, src, links, warnings);
+ }
+ const billingItemId = input.billingItemId ?? null;
+ if (billingItemId != null) {
+ await applyBillingItemSource(orgId, billingItemId, src, links, warnings);
+ }
+ if (input.subscriptionId != null) {
+ await applySubscriptionSource(orgId, input.subscriptionId, input.subscriptionPeriod, src, links, warnings);
+ }
+ links.partyId = src.partyId;
+ return src;
+}
+
+/** 本系統的發票顯示用:有號碼用號碼,沒有就用 #id。 */
+function invoiceLabel(r: { id: number; number: string | null }): string {
+ return r.number ?? `#${r.id}`;
+}
+
+/** 本系統內的重複檢查:同請款項目、同訂閱期別、近期同客戶同金額。 */
+async function internalDuplicateWarnings(
+ orgId: string,
+ links: DraftLinks,
+ total: number,
+ since: string,
+): 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(`這個請款項目已經有發票 ${invoiceLabel(r)}`);
+ }
+ 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(`這個訂閱期別已經有發票 ${invoiceLabel(r)}`);
+ }
+ 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} 天內已有同客戶、同金額的發票 ${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",
+ 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)}`);
+ }
+}
+
+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;
+}
+
+/** 大於 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,
+ partyId: number | null,
+ input: PreviewInput,
+ warnings: string[],
+): Promise {
+ let party: { name: string; taxId: string | null; contact: string | null } | null = null;
+ 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, 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 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) {
+ if (!isValidEmail(e)) throw new SimpanyPreviewError(`Email 格式不正確:${e}`);
+ }
+ if (emails.length === 0) {
+ warnings.push("沒有買受人 email:Simpany 不會寄開立通知,請確認這樣可以");
+ }
+ return { vat, name, address, emails };
+}
+
+/** 外幣收款:幣別、外幣金額、水單匯率 → 台幣銷售額;台幣收款回 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;
+ if (!foreignCurrency || foreignCurrency === "TWD") return null;
+ if (!/^[A-Z]{3}$/.test(foreignCurrency)) {
+ throw new SimpanyPreviewError(`幣別「${foreignCurrency}」不是 3 碼代號`);
+ }
+ 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),
+ };
+}
+
+/** 發票類型(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(零稅率)");
+ }
+ 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("只有零稅率發票才能指定零稅率原因");
+ }
+ return { type, taxTreatment };
+}
+
+/** 零稅率原因:優先對 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;
+}
+
+/** 零稅率的原因與通關方式;非零稅率兩者都是 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 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 (!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