diff --git a/.env.example b/.env.example index dbc3610..7f40a71 100644 --- a/.env.example +++ b/.env.example @@ -18,3 +18,9 @@ BETTER_AUTH_URL="http://localhost:3000" # on the project and add the .../auth/calendar scope to the OAuth consent screen. GOOGLE_CLIENT_ID="" GOOGLE_CLIENT_SECRET="" + +# Field-level encryption for integration credentials (Simpany, Wise) and employee +# bank account numbers. 32 random bytes, base64: openssl rand -base64 32 +# Prod: wrangler secret put FIELD_ENCRYPTION_KEY. Losing it makes stored +# credentials unreadable (reconnect integrations, re-enter account numbers). +FIELD_ENCRYPTION_KEY="" diff --git a/docs/deployment.md b/docs/deployment.md index bd8561b..bf0c933 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -30,7 +30,8 @@ This document describes two paths: | Postgres | Any Postgres. Schema is introspect-only (`bun run db:pull`); migrations under [`migrations/`](../migrations) are plain forward-only SQL. | | A Postgres driver that matches your runtime | On serverless/edge you need an **HTTP** driver (e.g. Neon). On a normal Node server you can use a regular TCP driver (`pg`). See [Database driver](#database-driver). | | Object storage *(only for document uploads)* | Cloudflare R2 by default. Swappable — see [Storage portability](#storage-portability). | -| Env vars | `DATABASE_URL`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`, `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`. See [`.env.example`](../.env.example). | +| Env vars | `DATABASE_URL`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`, `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `FIELD_ENCRYPTION_KEY`. See [`.env.example`](../.env.example). | +| `FIELD_ENCRYPTION_KEY` | 32 random bytes, base64 (`openssl rand -base64 32`). AES-256-GCM key for field-level encryption of integration credentials (Simpany, Wise — see [integrations.md](integrations.md)) and other high-sensitivity fields. Without it, connecting an integration fails with a clear error. **Losing or rotating it makes stored credentials unreadable** — every integration must be reconnected. | --- @@ -110,6 +111,7 @@ Local dev reads `.env.local`. For the deployed Worker, set secrets with wrangler echo "$BETTER_AUTH_SECRET" | bunx wrangler secret put BETTER_AUTH_SECRET echo "$DATABASE_URL" | bunx wrangler secret put DATABASE_URL echo "$GOOGLE_CLIENT_ID" | bunx wrangler secret put GOOGLE_CLIENT_ID +echo "$FIELD_ENCRYPTION_KEY" | bunx wrangler secret put FIELD_ENCRYPTION_KEY # …and GOOGLE_CLIENT_SECRET, BETTER_AUTH_URL ``` diff --git a/docs/integrations.md b/docs/integrations.md new file mode 100644 index 0000000..cbd1a05 --- /dev/null +++ b/docs/integrations.md @@ -0,0 +1,287 @@ +# Integrations framework + +Per-organization connections to external services (Simpany e-invoice, Wise, …). +Every integration is **off by default**: an owner/admin *connects* it (enters +credentials, which are tested server-side before being stored), then *switches it +on* separately. Disconnecting deletes the row, credentials included. + +Google Calendar predates this framework and is **not** stored here (its token lives +in better-auth's `account` table, its settings in `calendar_settings`). The +settings page just lists it alongside the others. + +## Pieces + +| Where | What | +| --- | --- | +| `migrations/0023_org_integrations.sql` / `orgIntegrations` in `src/db/schema.ts` | One row per (organization, provider). `credentials_enc` / `token_cache_enc` are ciphertext from `src/lib/crypto.ts` (`FIELD_ENCRYPTION_KEY`). `config` is non-secret jsonb. | +| `src/lib/integrations/types.ts` | Provider ids, field/catalog/provider types, `IntegrationSummary` (the secret-free view). Client-safe. | +| `src/lib/integrations/catalog.ts` | Static catalog: logo + credential/config fields per provider. Drives the settings UI. Client-safe. | +| `src/lib/integrations/registry.ts` | Map of **implementations** (`testConnection`). Simpany and Wise are registered. Server only. | +| `src/lib/integrations/store.ts` | The only code that reads/writes `org_integrations`. Server only. | +| `src/app/dashboard/settings/integrations/` | Settings page, server actions (owner/admin only), connect Sheet. | +| `src/lib/mcp/tools-integrations.ts` | `list_integrations`, plus `requireIntegrationForTool` and `auditIntegrationCall` for provider tools. | + +## Lifecycle + +``` +(no row) --connect: testConnection ok--> connected, enabled=false +connected --toggle--> enabled=true/false +any call gets 401/invalid creds --markNeedsReauth--> needs_reauth (enabled kept) +needs_reauth --reconnect: testConnection ok--> connected (enabled restored as it was) +any --disconnect--> (row deleted) +``` + +"Usable" means `enabled AND status = 'connected'`. `requireEnabledIntegration` +enforces exactly that and throws `IntegrationUnavailableError` whose message tells +the user what to do (e.g. 「Simpany 電子發票 整合尚未連接/未開啟,請 owner 或 admin 到 設定 › 整合 開啟」). + +## Store API (`src/lib/integrations/store.ts`) + +```ts +// secret-free reads +getIntegration(orgId, provider): Promise +listIntegrations(orgId): Promise +integrationDisplayName(provider): Promise + +// secret reads — server memory only, never return/log them +loadCredentials(orgId, provider): Promise +loadTokenCache(orgId, provider): Promise // null if missing/expired (60s skew) +requireEnabledIntegration(orgId, provider): Promise<{ row: IntegrationSummary; credentials: IntegrationCredentials }> + +// runtime reporting from provider code +saveTokenCache(orgId, provider, value: string, expiresAt: Date): Promise +clearTokenCache(orgId, provider): Promise +markNeedsReauth(orgId, provider, error: string): Promise // credentials rejected +recordSyncFailure(orgId, provider, error: string): Promise // transient failure, status unchanged +recordSyncSuccess(orgId, provider): Promise // sets last_synced_at, clears last_error +updateConfig(orgId, provider, patch): Promise // shallow jsonb merge + +// used by the settings actions (they do the role check) +saveConnection({ orgId, provider, userId, credentials, config, tokenCache? }): Promise +setIntegrationEnabled(orgId, provider, enabled): Promise +deleteIntegration(orgId, provider): Promise +``` + +## Adding a provider + +Simpany and Wise are already in the DB `CHECK`, in `INTEGRATION_PROVIDER_IDS`, in +the catalog (Simpany: `account` email + `password`; Wise: `apiToken`) and in i18n. +To bring one to life: + +1. **Implementation** — `src/lib/integrations/.ts`: + + ```ts + import type { IntegrationProvider } from "./types"; + + export const wiseProvider: IntegrationProvider = { + id: "wise", + async testConnection(creds, config) { + const res = await fetch("https://api.wise.com/v2/profiles", { + headers: { Authorization: `Bearer ${creds.apiToken}` }, + }); + if (res.status === 401) return { ok: false, error: "API token 無效或已撤銷" }; + if (!res.ok) return { ok: false, error: `Wise 回應 ${res.status}` }; + const profiles = await res.json(); + return { ok: true, config: { profileId: profiles[0]?.id } }; + }, + }; + ``` + + - Return `{ ok: false, error }` for bad credentials; `error` is shown to the user + and must never contain the credentials. Throwing is reserved for unexpected + failures (the framework turns it into a message). + - Return discovered non-secret settings in `config`; return a session token in + `tokenCache` if the service hands one out (it is encrypted). + - Use `fetch` only — this runs on Cloudflare Workers. + +2. **Register** it: one line in `PROVIDERS` in `registry.ts` + (`wise: wiseProvider,`). The Connect button on 設定 › 整合 turns on + automatically. + +3. **Business logic** (web actions or MCP tools): + + ```ts + const { row, credentials } = await requireEnabledIntegration(orgId, "wise"); + try { + const data = await callWise(credentials, row.config); + await recordSyncSuccess(orgId, "wise"); + } catch (e) { + if (isAuthError(e)) await markNeedsReauth(orgId, "wise", "Wise token 已失效"); + else await recordSyncFailure(orgId, "wise", String(e)); + throw e; + } + ``` + + For session-token providers (Simpany): try `loadTokenCache`, fall back to logging + in with `credentials`, then `saveTokenCache`; on a rejected token + `clearTokenCache` and retry once before `markNeedsReauth`. + +4. **MCP tools** — a new `src/lib/mcp/tools-.ts`, spread into `tools` in + `tools.ts`, and bump `SERVER_VERSION` in `handler.ts`: + - Tools are always listed; in `execute` call + `requireIntegrationForTool(orgId, "")` first so a disconnected/disabled + integration fails with the clear zh-TW message (same as `sync_billing_calendar`). + - Log each external call with `auditIntegrationCall(ctx, orgId, "", action, detail)` + (`detail` must not contain credentials or full PII). + - Anything that reaches the third party must get `openWorldHint: true` in + `OPENWORLD_OVERRIDES` (handler.ts); add title/destructive overrides as needed. + - Never put credentials, tokens or ciphertext in a tool result. + +5. **Extra settings** — declare `configFields` in the catalog entry (same shape as + `credentialFields`, stored in plain `config`) and add labels under + `integrations.fields` in `src/i18n/messages/integrations.ts` (zh-TW + en). + +### A brand-new provider (not simpany/wise) + +Also: a new migration extending `chk_org_integration_provider` (and the matching +`check(...)` in `schema.ts`), the id in `INTEGRATION_PROVIDER_IDS`, a catalog entry ++ `INTEGRATION_ORDER`, and `integrations.providers..name/description` in i18n. + +## Wise(唯讀交易同步) + +Wise 是**唯讀**整合:只把 Wise 對帳單的交易匯入本組織的帳本,讓 Wise 的帳不用再每月手動彙總。 + +### 唯讀保證 + +- 所有對 Wise 的請求都走 `src/lib/integrations/wise.ts` 的 `wiseGet()`:method 寫死 GET, + `assertReadOnly()` 會拒絕任何非 GET,且 path 必須符合唯讀白名單 + (`/v2/profiles`、`/v4/profiles/{id}/balances`、`/v1/profiles/{id}/balance-statements/{balanceId}/statement.json`), + 否則不發請求直接丟錯。**沒有任何建立 quote / transfer / conversion 的程式碼路徑。** +- 同步唯一會寫的是本組織的 `transactions`(內帳),不會寫 Wise。 +- Wise 回 401 → `markNeedsReauth`;回 403 且帶 `x-2fa-approval` header → 丟出「需要 SCA」的清楚錯誤 + (本系統不實作 SCA 簽章);其他錯誤 → `recordSyncFailure`。成功 → `recordSyncSuccess`。 + +### 連接與帳戶對應 + +1. 設定 › 整合 › Wise → 連接,貼上 API token。`testConnection` 呼叫 `GET /v2/profiles` 與各 profile 的 + STANDARD 餘額,把 `profiles` / `balances`(含當下餘額與讀取時間)寫進 `config`。 +2. 開啟整合後,同頁下方的「Wise 帳戶對應」把每個 Wise 餘額對應到**同幣別**的帳本帳戶,並設定切換日 + (`syncFrom`)。沒對應的餘額不同步。一個帳本帳戶只能對應一個 Wise 餘額;帳戶必須屬於本組織且幣別相同 + (`saveWiseMappings` 會驗)。 +3. 「重新整理餘額」再向 Wise 讀一次 profile 與餘額(需整合已開啟)。 + +`config` 形狀(非機密、成員與 MCP 看得到): + +```jsonc +{ + "profiles": [{ "id": 73990862, "type": "BUSINESS", "name": "Cerana Technology" }], + "balances": [{ "profileId": 73990862, "balanceId": 129476973, "currency": "USD", "amount": 1234.5, "fetchedAt": "…" }], + "accountMappings": [{ "profileId": 73990862, "balanceId": 129476973, "currency": "USD", "bankAccountId": 3, "syncFrom": "2026-10-01" }], + "syncFrom": null // 選填:全域切換日,對應本身沒設時用 +} +``` + +### 切換日(cutover) + +過去的 Wise 支出是手動以「月彙總」入帳(對象「Cerana Wise card (彙總)」,book = internal), +所以同步必須從某一天之後才開始,否則會重複記帳: + +- **切換日之前的 Wise 交易永遠不同步**(依台北日期判斷)。 +- 建議值 = 該帳本帳戶上最後一筆**非 Wise 同步**交易所在月份的下個月 1 號(帳戶沒交易時為本月 1 號)。 + 設定頁會顯示建議值;有選帳戶但切換日留空時,儲存會直接套用建議值。 +- 每次同步的起點 = max(切換日, 該帳戶最後一筆 Wise 同步交易日 − 3 天),抓到現在,按台北日曆月切塊 + (Wise 單次上限 469 天)。MCP 的 `startDate` 可以覆寫起點,但不能早於切換日。 + +### 交易對應規則(`src/lib/wise-sync.ts`) + +| Wise | 帳本 | +| --- | --- | +| CREDIT | `income`,入帳到對應帳戶 | +| DEBIT | `expense`,從對應帳戶支出 | +| 金額 | `abs(amount.value)`,餘額幣別;Wise 的金額已含手續費,手續費另記在說明與 `external_meta` | +| 日期 | `date` 換成台北日期 | +| 對象 | `merchant.name` → `senderName` → `recipient.name` → `details.description`(查無就新建 party) | +| 說明 | `details.description`(+ 原幣金額、+ `fee X`) | +| 分類 / 待確認 | 分類留空(未分類)、`needs_review = true` | +| book | `internal`(與過去手動輸入的 Wise 列一致) | +| `external_*` | `external_source = 'wise'`、`external_ref = referenceNumber`、`external_meta` = 商家、原幣金額、匯率、手續費、卡號末四碼、持卡人、Wise 分類 | + +**換匯(CONVERSION)**:帳本一列只有一個幣別,不支援跨幣轉帳。所以換匯的兩腳各記一列: + +- 另一腳的餘額**也有對應**時,每腳記成「單腳轉帳」(`type = transfer`,只填自己這邊的 from / to 帳戶), + 不進損益、兩邊帳戶餘額都正確,`needs_review = false`。 +- 另一腳**沒有對應**時,退回 income / expense(對象「Wise 換匯」)並標 `needs_review`。 +- 兩腳共用同一個 referenceNumber,所以 `external_ref` 加幣別後綴(`BALANCE-123:USD`)。 +- 單腳轉帳可以照常編輯(web 表單只顯示原本那一腳的帳戶;MCP `update_transaction` 不動帳戶): + `src/lib/external-transfer.ts` 的 `externalSingleLegSide()` 只對「有 external_source、type = transfer、 + 只有一邊帳戶」的列放寬,一般手動轉帳仍需兩個帳戶。編輯時保留原本的 book(不套「轉帳固定 both」)。 + 交易列表的類型標籤依 `external_meta` 顯示成「換匯 USD → THB」。 + +**去重**:`(organization_id, external_source, external_ref)` 有部分唯一索引(migration 0026),寫入用 +`ON CONFLICT DO NOTHING`。已存在的列(包括已軟刪除的)永遠不改、不重寫 —— 刪掉一筆同步進來的交易, +下次同步也不會再長回來。同一次抓回來的資料裡鍵重複時只取第一筆,並列在結果的 `duplicateRefs`。 + +**待確認**:交易列表上顯示「待確認」chip,列表上方可切到「只看待確認」(`?review=1`)。在 web 編輯並指定 +分類、或 MCP `update_transaction` 指定分類 / 傳 `needsReview: false`,就會清掉。 + +### 入口 + +- Web:帳戶頁(`/dashboard/bank-accounts`)的「從 Wise 同步」(owner / admin、整合可用且有對應時才出現): + 先跑 dry run,Sheet 顯示每個帳戶的期間、讀到 / 已存在 / 切換日前 / 將新增筆數與前 50 筆樣本, + 按「寫入 N 筆」才寫。對應到 Wise 的帳戶名稱旁有「Wise」標記。 +- MCP:`wise_list_balances`、`wise_get_statement`、`wise_sync_transactions`(`dryRun` 預設 true, + 工具說明要求模型先給使用者看試算、取得同意才以 `dryRun: false` 寫入)。見 [mcp.md](mcp.md)。 + +## Simpany(電子發票) + +Simpany(simpany.co)是公司的電子發票加值中心兼記帳士。**它沒有公開 API**:這裡用的是 +它會員網頁背後的私有 REST API(讀前端 bundle、用真實 session 做唯讀呼叫確認過形狀)。 +Simpany 改版就可能壞,所以所有回應都防禦式解析,認不得就把 Simpany 的原始錯誤訊息 +(截短)丟給使用者,不猜。使用者明確選擇了這條路,並同意把 Simpany 帳密加密存放。 + +| Where | What | +| --- | --- | +| `src/lib/integrations/simpany.ts` | `simpanyProvider`(連接測試)與 `SimpanyClient` / `getSimpanyClient(orgId)` | +| `src/lib/simpany-sync.ts` | Simpany → `invoices` 同步、自動綁定、作廢清理 | +| `src/lib/simpany-issue.ts` | 預覽(`invoice_drafts`)→ 開立、作廢;MCP 與 web 共用 | +| `src/lib/mcp/tools-simpany.ts` | MCP 工具(見 [mcp.md](mcp.md)) | +| `src/app/dashboard/invoices/simpany-*.ts(x)` | 發票頁「從 Simpany 同步」、看板「在 Simpany 開立」、server actions | +| `migrations/0025_invoice_simpany_sync.sql` | invoices 的課稅別 / 零稅率原因 / 外幣匯率 / B2B-B2C / `external_id` / 作廢欄位,`invoice_drafts` 表 | + +**Config**:`companyId` + `companyName`(非機密)。帳號底下只有一家公司時連接時自動選; +多家就要在連接 Sheet 填「公司 ID」(失敗訊息會列出可選的 ID)。 + +**用到的端點**(其他一概不碰): + +| Host | Endpoint | 用途 | +| --- | --- | --- | +| `api.simpany.co/v1` | `POST login` `{account, password}` → `data.token`(JWT,`exp` ≈ 30 天) | 登入 | +| | `GET me` → `data.companies[]` | 選公司 | +| `member2.simpany.co/api/v1/c/{companyId}/` | `GET receipts?status=ALL\|INVALID&startDate&endDate&page&limit[&query]` | 列表(`status` 必填) | +| | `GET receipts/{R-id}` | 明細(id 是 R…,不是發票號碼) | +| | `POST receipts/b2b` / `receipts/b2c` | **開立**(照會員網頁組的 body) | +| | `DELETE receipts/{R-id}` `{reason, emails: []}` | **作廢** | +| | `GET receipts/zero-tax-rate-reasons` | 零稅率原因清單 | +| | `GET track-numbers?year=<民國年>` | 字軌剩餘(形狀未驗證,只用來提示) | + +所有請求帶 `Accept: application/json`、`X-Requested-With: XMLHttpRequest`、 +`Authorization: Bearer `。 + +**重新登入**:`getSimpanyClient` 先用 `loadTokenCache` 的 JWT(到期時間取自 JWT 的 +`exp`);沒有就用解密後的帳密登入並 `saveTokenCache`。請求回 401 → `clearTokenCache`、 +重新登入、重試一次;重新登入本身被拒(密碼改了)→ `markNeedsReauth`,整合轉成「需要 +重新連接」並丟出中文錯誤。網路錯 / 5xx → `recordSyncFailure`(狀態不變);成功 → +`recordSyncSuccess`。帳密與 JWT 不進 log、錯誤訊息或任何回傳值。 + +**開立一定兩段式**:preview 把要送出的 body 原樣存成 `invoice_drafts`(2 小時過期); +開立只收 `draftId`,先以 `pending → issued` 的條件式 update 搶下草稿(按兩次也只會開一張), +再送出。Simpany 明確拒絕(4xx)→ 草稿退回 `pending`;網路中斷 / 5xx(不知道開了沒)→ +草稿改 `cancelled`,請使用者先同步確認再重新預覽,避免重複開立。 + +**稅務規則**(預覽時檢查):B2B 要 8 碼統編;海外買方沒有台灣統編 → B2C、零稅率、 +原因 72 外銷勞務、`NOT_VIA_CUSTOMS`;外幣收款一定要提供取自銀行水單的匯率, +台幣銷售額 = round(外幣 × 匯率);稅額算法同 Simpany(含稅 round(sum − sum/1.05)、 +未稅 round(sum × 0.05))。外銷勞務**不是**免稅(FW10873800 就是開成 B2C 免稅而作廢)。 + +**xlsx 對帳**(`src/lib/simpany-export.ts`、發票 › Simpany 對帳)保留,給沒開整合的組織用; +API 同步取代它。 + +## Security rules + +- Credentials are encrypted with `FIELD_ENCRYPTION_KEY` (see + [deployment.md](deployment.md)). Losing or rotating the key means every + integration has to be reconnected. +- Nothing that reaches a client — server-action results, page props, MCP results — + may contain credentials, tokens or ciphertext. `IntegrationSummary` has no such + fields by construction; don't bypass it with a raw select. +- Credentials are only entered in the web UI by owners/admins, never over MCP. diff --git a/docs/mcp.md b/docs/mcp.md index 4b9e02c..7230437 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -102,9 +102,14 @@ the `tools-*.ts` modules): `create_invoice` → "Record an invoice"); - MCP `annotations`: `readOnlyHint` / `destructiveHint` / `idempotentHint` derived from the verb, plus `openWorldHint`, which is `false` for everything - except `sync_billing_calendar` (the only tool that writes to a third-party - system). Four overrides correct the verb heuristic: `sync_billing_calendar` - gets `openWorldHint: true`; `pay_employee_salary` gets `destructiveHint: true` + except the tools that reach a third-party system: `sync_billing_calendar` + (writes to Google Calendar), the three `wise_*` tools (read-only GETs to + Wise) and the `simpany_*` tools. The overrides that correct the verb heuristic: + `sync_billing_calendar`, every `wise_*` and every `simpany_*` tool get `openWorldHint: true`; + `simpany_void_invoice` gets `destructiveHint: true` (voiding a legal e-invoice + cannot be undone); `simpany_list_*` / `simpany_get_invoice` declare + `readOnlyHint: true` themselves (their names don't start with `list_`/`get_`); + `pay_employee_salary` gets `destructiveHint: true` — it writes the payslip plus the salary-expense ledger entry, the month can't be booked twice and no tool reverses it; and `set_subscription_period` (an upsert) and `unmark_accountant_notified` (clears a flag to null) get @@ -117,8 +122,9 @@ the `tools-*.ts` modules): - `_meta["openai/toolInvocation/invoking" | "invoked"]`, the status line ChatGPT shows while a call is in flight. -**Output schemas.** Every tool declares an `outputSchema` — all 70 of them, as of -server version 1.3.0. When a tool declares one the handler additionally returns +**Output schemas.** Every tool declares an `outputSchema` — all 85 of them, as of +server version 1.6.0 (the Simpany tools whose result shape comes from Simpany's +unofficial API declare an open object schema). When a tool declares one the handler additionally returns the result as MCP `structuredContent` (the JSON text block stays, per MCP's back-compat recommendation), which is what ChatGPT and Codex prefer over parsing JSON out of text. `list_organizations` remains the reference implementation. @@ -180,7 +186,11 @@ board automatically. `sync_billing_calendar` pushes the board to Google Calendar **Ledger (內外帳)** — `list_transactions`, `get_transaction`, `list_outstanding_advances`, `create_transaction` (expense/income/advance/transfer), `update_transaction` (date/amount/category/project/…), `delete_transaction`, -`create_reimbursement` (book an advance as repaid). +`create_reimbursement` (book an advance as repaid). Rows imported by an +integration (the Wise sync) carry `externalSource` / `externalRef` and +`needsReview` (待確認); `list_transactions` takes `needsReview: true` to list only +those, and `update_transaction` clears the flag when it sets a category (or pass +`needsReview: false` explicitly). **Accounting master data** — parties: `list_parties`/`get_party`/`create_party`/ `update_party`/`delete_party`; categories: `list_categories`/`create_category`/ @@ -207,22 +217,72 @@ ask the user before calling `update_contract` with `status='completed'`. Status is never flipped automatically. **HR / payroll / recon** — employees: `list_employees`/`get_employee`/ -`create_employee`/`update_employee`/`delete_employee`. Employee PII is -handled conservatively over MCP: national ID and salary account come back -**masked** (full values are web-app only), and every employee read is written -to the activity log as a `read` entry — so who pulled contact data, and when, -is always answerable. Payroll: +`create_employee`/`update_employee`/`delete_employee`; employee bank accounts +(an employee can have several): `list_employee_bank_accounts` + +`create_employee_bank_account`/`update_employee_bank_account`/`delete_employee_bank_account`. +Employee PII is handled conservatively over MCP: national IDs and account +numbers are stored encrypted, and come back **masked** — account numbers as +their last 5 characters only. There is no way to reveal a full account number +over MCP; that is a web-app action for owners/admins, and each reveal is +audit-logged. Writes accept the full number but never echo it. Employee and +employee-account writes are owner/admin only. Every employee and +employee-account read is written to the activity log as a `read` entry — so +who pulled contact data, and when, is always answerable. `list_employees` / +`get_employee` include each employee's masked `bankAccounts`; the old +`salaryAccount` field is deprecated (on write it now creates a salary-default +account). Payroll: `list_payroll_runs`, `list_payslips`, `list_salary_status` (whose salary is booked for a month + when), `pay_employee_salary` (writes the payslip **and** the -matching salary-expense ledger entry — bookkeeping only, it pays nobody); +matching salary-expense ledger entry — bookkeeping only, it pays nobody; optional +`toEmployeeAccountId` records which employee account it went into, defaulting to +the salary-default account). `create_reimbursement` takes the same optional +`toEmployeeAccountId` (defaulting to the reimbursement-default account); reconciliations: `list_reconciliations` + `create`/`update`/`delete`; accountant notices: `list_accountant_notices`, `mark_accountant_notified`, `unmark_accountant_notified`. +**Integrations** — `list_integrations` shows, per external integration +(Simpany e-invoice, Wise), whether it is available on this server, connected, +switched on, and healthy (`status`, `lastError`, `lastSyncedAt`, +`tokenExpiresAt`) plus its non-secret `config`. It never returns credentials. +Integration business tools live in their own `tools-.ts` and stay in +`tools/list` whether or not the org has connected the integration; at call time +they go through `requireIntegrationForTool()` and fail with a clear zh-TW message +telling an owner/admin to fix it in 設定 › 整合. Every call to the external +service is logged with `auditIntegrationCall()`. See +[`integrations.md`](integrations.md). + +**Wise (read-only)** — `wise_list_balances` (profiles, balances with live amount, +and the ledger account each is mapped to + its cutover date), +`wise_get_statement` (`accountId` **or** `profileId` + `balanceId`, `startDate`, +optional `endDate` / `limit` → compact statement rows), and +`wise_sync_transactions` (`accountId?`, `startDate?`, `dryRun` — **defaults to +true**). The description tells the model to show the dry-run preview and get the +user's explicit approval before calling it with `dryRun: false`. All three only +send GET requests to Wise; the sync writes only this organization's ledger +(internal book, uncategorized, `needsReview`), deduped by Wise reference. Mapping +balances to ledger accounts and setting the cutover date is done in the web app +(設定 › 整合 › Wise). + +**Simpany e-invoice** (`src/lib/mcp/tools-simpany.ts`, unofficial API — see +integrations.md): + +| Tool | Inputs | Notes | +| --- | --- | --- | +| `simpany_list_invoices` | `startDate?`, `endDate?` (default last 90 days), `status?` (`all`/`void`), `query?` | Read straight from Simpany; compact rows incl. invoice number, R-id, type, buyer, total, status, void info. | +| `simpany_get_invoice` | `invoice` (number like `FW10873802` or R-id) | Full detail: items, tax type, zero-rate reason, emails, MOF upload status. | +| `simpany_sync_invoices` | `startDate?`, `endDate?` | Owner/admin. Upserts into `invoices` by `external_id`, auto-links unique same-party / same-amount / ±45-day income transactions and billing items / subscription periods, returns `needsReview` for ambiguous ones, clears 開發票日 of voided invoices. Writes only to these books. | +| `simpany_preview_invoice` | `transactionId?` / `billingItemId?` / `subscriptionId?`+`subscriptionPeriod?`, `type?`, `buyer?{vat,name,address,emails}`, `taxTreatment?`, `zeroRateReason?`, `customsClearance?`, `items?[{name,quantity,price}]`, `isTaxIncluded?`, `remark?`, `foreignCurrency?`, `foreignAmount?`, `exchangeRate?` | Validates and computes amounts exactly like Simpany, warns about duplicates, stores an `invoice_drafts` row (2 h). Does **not** issue. | +| `simpany_issue_invoice` | `draftId`, `notifyEmails?` | Owner/admin. Issues the previewed draft verbatim — a legal e-invoice uploaded to the MOF and emailed to the buyer. Only after the user approved the preview. Saves + links the invoice. | +| `simpany_void_invoice` | `invoice`, `reason` (≤ 20 chars) | Owner/admin, destructive. Voids in Simpany, re-syncs, clears 開發票日 / transaction links. | +| `simpany_list_zero_rate_reasons` | — | Simpany's reason codes (71 外銷貨物, 72 外銷勞務, …). | + **Not exposed (do in the app):** creating an organization, uploading invoice/receipt **files** (R2), multi-currency FX entry, and *connecting* Google -Calendar (the OAuth consent needs a browser — do it once in 組織設定, after which -`sync_billing_calendar` works over MCP). These need file handling or extra UI. +Calendar (the OAuth consent needs a browser — do it once in 設定 › 整合, after which +`sync_billing_calendar` works over MCP), and connecting / switching / disconnecting +integrations (credentials must not pass through an AI conversation). These need +file handling or extra UI. Deletes that would break references return a clear error suggesting deactivation/archiving instead. @@ -282,7 +342,7 @@ things that don't live in this repo: Both directories ask for the same thing in different words — OpenAI wants "test credentials for a fully populated account", Anthropic wants a "fully featured demo account with sample data". An empty workspace fails review: most -of the 70 tools would answer with an empty array and the reviewer has no way to +of the 85 tools would answer with an empty array and the reviewer has no way to tell what the connector does. Two commands produce that account. Run them against the environment you are diff --git a/migrations/0023_org_integrations.sql b/migrations/0023_org_integrations.sql new file mode 100644 index 0000000..b90dede --- /dev/null +++ b/migrations/0023_org_integrations.sql @@ -0,0 +1,61 @@ +-- 0023: 組織層級的外部整合(Simpany 電子發票、Wise …)。 +-- +-- 目的:接外部服務要存「這個組織的帳密 / token」與「這個整合開了沒」。與其每接一家 +-- 就開一張 xxx_settings 表(像 0018 的 calendar_settings),不如一張通用表,一列 = +-- 一個組織的一個整合,框架(src/lib/integrations)統一處理連接、開關、加密、失效。 +-- +-- 規則: +-- - 預設關閉:owner / admin 先「連接」(輸入憑證,server 端實測通過才寫入),寫入時 +-- enabled = false,要另外手動打開。這樣「接上了」與「開始會對外動作」是兩個決定。 +-- - 憑證一律密文:credentials_enc / token_cache_enc 是 src/lib/crypto.ts 的 +-- encryptJson / encryptField 輸出(`v1::`,金鑰 FIELD_ENCRYPTION_KEY), +-- 絕不存明文,也絕不回傳給 client 或 MCP。 +-- - 中斷連接 = 刪列(不軟刪):留著密文沒有意義,重新連接就是重新輸入。 +-- - Google 日曆不搬進來:它的 token 在 better-auth 的 account 表,設定在 +-- calendar_settings,維持原狀;UI 只是把它列在同一個整合清單裡。 +-- +-- provider 的 CHECK 清單就是「系統認得的整合」,新增一家要改這裡(新 migration) +-- 並在 src/lib/integrations/catalog.ts 補一筆。 +-- Forward-only,全部 additive。Run AFTER 0022_subscription_contract.sql. + +CREATE TABLE org_integrations ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + organization_id text NOT NULL REFERENCES "organization"(id) ON DELETE CASCADE, + provider text NOT NULL, + enabled boolean NOT NULL DEFAULT false, + -- connected 憑證有效,可以使用(enabled 另計) + -- needs_reauth 外部服務拒絕了憑證(密碼改了、token 被撤銷),要重新連接 + -- error 其他持續性錯誤(外部服務異常等),細節在 last_error + status text NOT NULL DEFAULT 'connected', + -- 非機密設定:例如 Simpany 的公司 id、Wise 的 profile id / 帳戶對應。可直接顯示。 + config jsonb NOT NULL DEFAULT '{}'::jsonb, + -- encryptJson(憑證物件)。欄位名與內容由各 provider 的 credentialFields 決定。 + credentials_enc text, + -- provider 自己換來的 session token(例如登入後拿到的 cookie / bearer),也要加密。 + token_cache_enc text, + token_expires_at timestamptz, + last_synced_at timestamptz, + last_error text, + last_error_at timestamptz, + connected_by_user_id text REFERENCES "user"(id) ON DELETE SET NULL, + connected_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT uq_org_integration UNIQUE (organization_id, provider), + CONSTRAINT chk_org_integration_provider + CHECK (provider = ANY (ARRAY['simpany'::text, 'wise'::text])), + CONSTRAINT chk_org_integration_status + CHECK (status = ANY (ARRAY['connected'::text, 'needs_reauth'::text, 'error'::text])) + -- enabled 與 status 刻意不綁 CHECK:憑證失效(needs_reauth)時保留使用者的開關意圖, + -- 重新連接後自動恢復原狀。「能不能用」由程式判斷 enabled AND status = 'connected'。 +); + +COMMENT ON TABLE org_integrations IS '組織層級外部整合(一列 = 一個組織的一個 provider);中斷連接即刪列'; +COMMENT ON COLUMN org_integrations.provider IS '整合代號:simpany / wise;對應 src/lib/integrations/catalog.ts'; +COMMENT ON COLUMN org_integrations.enabled IS '是否開啟;連接後預設 false,需 owner/admin 手動開啟;實際可用 = enabled AND status = connected'; +COMMENT ON COLUMN org_integrations.status IS 'connected / needs_reauth(憑證被拒,需重新連接)/ error(其他持續性錯誤)'; +COMMENT ON COLUMN org_integrations.config IS '非機密設定(公司 id、帳戶對應等),可顯示給成員與 MCP'; +COMMENT ON COLUMN org_integrations.credentials_enc IS 'encryptJson(憑證) 密文(FIELD_ENCRYPTION_KEY);絕不存明文、絕不回傳'; +COMMENT ON COLUMN org_integrations.token_cache_enc IS 'provider 快取的 session token 密文;過期或失效可隨時丟棄重換'; +COMMENT ON COLUMN org_integrations.token_expires_at IS 'token_cache_enc 的到期時間'; +COMMENT ON COLUMN org_integrations.last_synced_at IS '最近一次成功呼叫外部服務的時間'; +COMMENT ON COLUMN org_integrations.last_error IS '最近一次失敗的訊息(給人看,不含憑證)'; diff --git a/migrations/0024_employee_bank_accounts.sql b/migrations/0024_employee_bank_accounts.sql new file mode 100644 index 0000000..a25a32a --- /dev/null +++ b/migrations/0024_employee_bank_accounts.sql @@ -0,0 +1,90 @@ +-- 0024: 員工多帳戶(薪轉 / 報銷撥款的收款帳戶)+ 身分證字號加密欄位。 +-- +-- 問題:employees.salary_account 是一格自由文字,一位員工只能記一個帳戶,而且 +-- 帳號與身分證字號都以明文存在資料庫裡;遮罩只發生在 MCP 的輸出層,網頁端任何 +-- 組織成員都拿得到完整值。發薪與撥款也無從記錄「錢匯到員工的哪個帳戶」。 +-- +-- 做法: +-- (1) 新表 employee_bank_accounts:一位員工可有多個帳戶。帳號本體只存密文 +-- (src/lib/crypto.ts 的 encryptField,AES-256-GCM,金鑰為部署 secret +-- FIELD_ENCRYPTION_KEY),另存末 5 碼供列表與遮罩顯示,不需解密。 +-- 「薪資預設 / 報銷預設」各自以 partial unique index 保證同一位員工最多一個。 +-- (2) payslips.paid_to_account_id、transactions.settle_to_account_id:記錄這筆 +-- 薪資 / 撥款匯入員工的哪個帳戶(選填)。與既有的 from_account_id(公司自己的 +-- 帳本帳戶 bank_accounts)是兩回事,互不取代。 +-- (3) employees.national_id_enc:身分證字號的密文。寫入時改寫這欄並清空明文 +-- national_id;讀取時優先讀密文,沒有才退回舊的明文欄位。 +-- +-- 舊欄位(national_id / salary_account)本次不刪:既有資料的搬移由 +-- scripts/migrate-employee-pii.ts 另外執行(需要加密金鑰,SQL 做不到),確認 +-- 搬完之後再用後續 migration 移除。Forward-only,全部 additive。 +-- Run AFTER 0023. + +-- (1) 員工收款帳戶 +-- kind: +-- bank 台灣的銀行 / 郵局帳戶,bank_code 必填(3 碼),帳號只能是數字 +-- wise Wise 等跨境收款帳戶 +-- other 其他(無法歸類的舊資料也放這裡) +CREATE TABLE employee_bank_accounts ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + organization_id text, + employee_id bigint NOT NULL REFERENCES employees(id), + kind text NOT NULL DEFAULT 'bank', + bank_code text, + branch_code text, + bank_name text, + account_holder text, + account_number_enc text NOT NULL, + account_last5 text NOT NULL, + currency text NOT NULL DEFAULT 'TWD', + label text, + default_for_salary boolean NOT NULL DEFAULT false, + default_for_reimbursement boolean NOT NULL DEFAULT false, + is_active boolean NOT NULL DEFAULT true, + note text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + deleted_at timestamptz, + CONSTRAINT chk_emp_acct_kind + CHECK (kind = ANY (ARRAY['bank'::text, 'wise'::text, 'other'::text])), + -- 銀行帳戶一定要有 3 碼銀行代碼;其他種類可留空,但有填就得是 3 碼數字。 + CONSTRAINT chk_emp_acct_bank_code + CHECK ((kind <> 'bank' OR bank_code IS NOT NULL) AND (bank_code IS NULL OR bank_code ~ '^[0-9]{3}$')), + CONSTRAINT chk_emp_acct_branch_code + CHECK (branch_code IS NULL OR branch_code ~ '^[0-9]{4}$'), + CONSTRAINT chk_emp_acct_last5 + CHECK (char_length(account_last5) BETWEEN 1 AND 5), + CONSTRAINT chk_emp_acct_currency + CHECK (currency ~ '^[A-Z]{3}$') +); + +COMMENT ON TABLE employee_bank_accounts IS '員工的收款帳戶(薪轉 / 報銷撥款),一位員工可有多個'; +COMMENT ON COLUMN employee_bank_accounts.bank_code IS '銀行代碼 3 碼(例 807 永豐);kind = bank 時必填'; +COMMENT ON COLUMN employee_bank_accounts.branch_code IS '分行代碼 4 碼,選填'; +COMMENT ON COLUMN employee_bank_accounts.account_number_enc IS '完整帳號的密文(encryptField,v1::);明文只在 owner/admin 明確「顯示完整帳號」時於 server 端解密,並寫入 activity_log'; +COMMENT ON COLUMN employee_bank_accounts.account_last5 IS '帳號末 5 碼(去掉空白與連字號後),列表與遮罩顯示用,不需解密'; +COMMENT ON COLUMN employee_bank_accounts.default_for_salary IS '發薪時預設匯入這個帳戶;同一位員工最多一個'; +COMMENT ON COLUMN employee_bank_accounts.default_for_reimbursement IS '報銷撥款時預設匯入這個帳戶;同一位員工最多一個'; + +CREATE INDEX idx_emp_acct_employee ON employee_bank_accounts (employee_id) WHERE deleted_at IS NULL; +CREATE INDEX idx_emp_acct_org ON employee_bank_accounts (organization_id) WHERE deleted_at IS NULL; +CREATE UNIQUE INDEX uq_emp_acct_default_salary ON employee_bank_accounts (employee_id) + WHERE default_for_salary AND deleted_at IS NULL; +CREATE UNIQUE INDEX uq_emp_acct_default_reimbursement ON employee_bank_accounts (employee_id) + WHERE default_for_reimbursement AND deleted_at IS NULL; + +-- (2) 薪資單 / 交易記錄匯入的員工帳戶 +ALTER TABLE payslips ADD COLUMN paid_to_account_id bigint REFERENCES employee_bank_accounts(id); +ALTER TABLE transactions ADD COLUMN settle_to_account_id bigint REFERENCES employee_bank_accounts(id); +CREATE INDEX idx_payslip_paid_to ON payslips (paid_to_account_id); +CREATE INDEX idx_txn_settle_to ON transactions (settle_to_account_id); + +COMMENT ON COLUMN payslips.paid_to_account_id IS '薪資匯入的員工帳戶(employee_bank_accounts),選填'; +COMMENT ON COLUMN transactions.settle_to_account_id IS '撥款 / 薪資匯入的員工帳戶(employee_bank_accounts),選填;與 from_account_id(公司帳本帳戶)無關'; + +-- (3) 身分證字號密文 +ALTER TABLE employees ADD COLUMN national_id_enc text; + +COMMENT ON COLUMN employees.national_id_enc IS '身分證字號 / 統編的密文(encryptField);讀取時優先於明文 national_id'; +COMMENT ON COLUMN employees.national_id IS '已淘汰:明文身分證字號,改存 national_id_enc。scripts/migrate-employee-pii.ts 搬完後清空,後續 migration 移除'; +COMMENT ON COLUMN employees.salary_account IS '已淘汰:單一自由文字的薪轉帳戶,改用 employee_bank_accounts。scripts/migrate-employee-pii.ts 搬完後清空,後續 migration 移除'; diff --git a/migrations/0025_invoice_simpany_sync.sql b/migrations/0025_invoice_simpany_sync.sql new file mode 100644 index 0000000..6100154 --- /dev/null +++ b/migrations/0025_invoice_simpany_sync.sql @@ -0,0 +1,93 @@ +-- 0025: 發票與 Simpany 的 API 同步 / 開立。 +-- +-- 0019 把 Simpany 當成「人工開票 + 上傳匯出檔對帳」的外部系統;0023 建好整合框架之後, +-- 改用 Simpany 會員網頁背後的(非公開)API 直接同步與開立(src/lib/integrations/simpany.ts、 +-- src/lib/simpany-sync.ts、src/lib/simpany-issue.ts)。xlsx 對帳頁保留,API 同步取代它。 +-- +-- 本 migration: +-- (1) invoices 補上 Simpany 發票的完整事實:課稅別、零稅率原因、外幣與匯率、B2B/B2C、 +-- Simpany 的 R… id、作廢時間與原因、買受人 email、訂閱期別綁定、最後同步時間。 +-- 這些欄位也就是 docs/export-vat-tracking-design.md §4 提議過的那組(twd_sales_amount +-- 不另開欄位:Simpany 開出的發票金額本來就是台幣銷售額,即 amount_gross)。 +-- (2) invoice_drafts:開立前的「預覽草稿」。MCP / web 先 preview 產生一筆草稿(內含要送給 +-- Simpany 的完整 request body),使用者明確確認後才以 draftId 開立 —— 開立只接受 +-- draftId,不接受任何其他內容,確保「看過的」就是「送出去的」。 +-- +-- 既有語意不變:external_ref = 發票號碼(對帳鍵),external_status = pending/issued/void/n_a。 +-- Forward-only,全部 additive。Run AFTER 0023_org_integrations.sql(0024 保留給同期的 Wise 分支)。 + +-- (1) invoices +ALTER TABLE invoices ADD COLUMN tax_treatment text NOT NULL DEFAULT 'taxable'; +ALTER TABLE invoices ADD CONSTRAINT chk_invoice_tax_treatment + CHECK (tax_treatment = ANY (ARRAY['taxable'::text, 'zero_rated'::text, 'exempt'::text])); + +-- Simpany 的零稅率原因代碼('71' 外銷貨物、'72' 外銷勞務 …);tax_treatment = zero_rated 時填 +ALTER TABLE invoices ADD COLUMN zero_rate_reason text; + +-- 外幣收款開零稅率發票時的換算依據:匯率(取自銀行水單)、外幣幣別與金額。 +-- amount_gross 仍是發票上的台幣金額(= round(foreign_amount × exchange_rate))。 +ALTER TABLE invoices ADD COLUMN exchange_rate numeric(12,6); +ALTER TABLE invoices ADD COLUMN foreign_currency text; +ALTER TABLE invoices ADD COLUMN foreign_amount numeric(14,2); + +ALTER TABLE invoices ADD COLUMN invoice_type text; +ALTER TABLE invoices ADD CONSTRAINT chk_invoice_type + CHECK (invoice_type IS NULL OR invoice_type = ANY (ARRAY['B2B'::text, 'B2C'::text])); + +-- Simpany 的發票 id('R260903181235059' 這種),API 取明細 / 作廢都要用它,不是發票號碼 +ALTER TABLE invoices ADD COLUMN external_id text; +ALTER TABLE invoices ADD COLUMN voided_at timestamptz; +ALTER TABLE invoices ADD COLUMN void_reason text; +ALTER TABLE invoices ADD COLUMN buyer_emails text[]; + +-- 訂閱期別綁定:訂閱期別不物化(見 0014 / 0017),所以只存 subscription_id + 期別起日, +-- 與 transactions.subscription_id / subscription_period 同一套。 +ALTER TABLE invoices ADD COLUMN subscription_id bigint REFERENCES subscriptions(id); +ALTER TABLE invoices ADD COLUMN subscription_period date; + +ALTER TABLE invoices ADD COLUMN external_synced_at timestamptz; + +CREATE UNIQUE INDEX uq_invoice_external_id ON invoices (organization_id, external_id) + WHERE external_id IS NOT NULL; +CREATE INDEX idx_invoice_subscription ON invoices (subscription_id, subscription_period) + WHERE subscription_id IS NOT NULL; + +COMMENT ON COLUMN invoices.tax_treatment IS '課稅別:taxable(應稅)/ zero_rated(零稅率)/ exempt(免稅)'; +COMMENT ON COLUMN invoices.zero_rate_reason IS 'Simpany 零稅率原因代碼(71 外銷貨物、72 外銷勞務 …)'; +COMMENT ON COLUMN invoices.exchange_rate IS '外幣換算台幣的匯率(取自銀行水單)'; +COMMENT ON COLUMN invoices.foreign_currency IS '外幣收款的幣別(USD 等)'; +COMMENT ON COLUMN invoices.foreign_amount IS '外幣金額;amount_gross = round(foreign_amount × exchange_rate)'; +COMMENT ON COLUMN invoices.invoice_type IS 'B2B(有統編)/ B2C'; +COMMENT ON COLUMN invoices.external_id IS 'Simpany 發票 id(R…),API 用;external_ref 仍是發票號碼'; +COMMENT ON COLUMN invoices.voided_at IS 'Simpany 作廢時間'; +COMMENT ON COLUMN invoices.void_reason IS 'Simpany 作廢原因'; +COMMENT ON COLUMN invoices.buyer_emails IS '開立通知寄送的買受人 email'; +COMMENT ON COLUMN invoices.subscription_id IS '這張發票對應的訂閱(與 subscription_period 一起用)'; +COMMENT ON COLUMN invoices.subscription_period IS '對應的訂閱期別起日'; +COMMENT ON COLUMN invoices.external_synced_at IS '最後一次從 Simpany API 同步此列的時間'; + +-- (2) invoice_drafts +CREATE TABLE invoice_drafts ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + organization_id text NOT NULL REFERENCES "organization"(id) ON DELETE CASCADE, + created_by_user_id text REFERENCES "user"(id) ON DELETE SET NULL, + -- { type: 'b2b' | 'b2c', body: <送給 Simpany 的 request body 原樣> } + payload jsonb NOT NULL, + -- 給人看的預覽:買受人、品項、未稅 / 稅額 / 總額、課稅別、警示、外幣換算 + summary jsonb NOT NULL DEFAULT '{}'::jsonb, + -- 開立後要回寫的綁定:transactionIds、billingItemId、subscriptionId + subscriptionPeriod、 + -- contractId、partyId + links jsonb NOT NULL DEFAULT '{}'::jsonb, + status text NOT NULL DEFAULT 'pending', + issued_invoice_id bigint REFERENCES invoices(id), + expires_at timestamptz NOT NULL DEFAULT (now() + interval '2 hours'), + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT chk_invoice_draft_status + CHECK (status = ANY (ARRAY['pending'::text, 'issued'::text, 'cancelled'::text, 'expired'::text])) +); + +CREATE INDEX idx_invoice_draft_org ON invoice_drafts (organization_id, created_at DESC); + +COMMENT ON TABLE invoice_drafts IS 'Simpany 開立前的預覽草稿;開立只接受 draft id(看過的 = 送出的),2 小時過期'; +COMMENT ON COLUMN invoice_drafts.payload IS '{ type, body }:送給 Simpany POST receipts/{type} 的原樣內容'; +COMMENT ON COLUMN invoice_drafts.status IS 'pending / issued / cancelled(送出失敗或結果不明)/ expired'; diff --git a/migrations/0026_transaction_external_ref.sql b/migrations/0026_transaction_external_ref.sql new file mode 100644 index 0000000..3074e75 --- /dev/null +++ b/migrations/0026_transaction_external_ref.sql @@ -0,0 +1,41 @@ +-- 0026: 交易的外部來源標記(Wise 交易同步,之後其他銀行 / 卡片 feed 共用)。 +-- +-- 目的:從外部服務自動匯入的交易,要能 (1) 永遠不重複寫入、(2) 留下原始細節供對帳、 +-- (3) 標出「還沒有人看過」的列。手動輸入的交易這四欄都是 NULL / false,行為不變。 +-- +-- - external_source 來源代號,例如 'wise'。手動輸入 = NULL。 +-- - external_ref 來源端的唯一鍵(Wise 的 referenceNumber,例如 'CARD-4370840324'; +-- 換匯兩腳各一列,鍵加上幣別後綴,見 src/lib/wise-sync.ts)。 +-- - external_meta 非機密的原始細節:商家、原幣金額、匯率、手續費、卡號末四碼、持卡人、 +-- Wise 分類。只給人對帳看,程式不依賴它的形狀做判斷。 +-- - needs_review 自動匯入的列預設 true(分類留空、待人確認);在 web 編輯並指定分類、 +-- 或 MCP update_transaction 指定分類時清掉。 +-- +-- 去重:(organization_id, external_source, external_ref) 的部分唯一索引。同步程式用 +-- ON CONFLICT DO NOTHING 寫入,所以就算兩次同步重疊也不會寫兩次;既有的列不會被改動。 +-- +-- Forward-only,全部 additive(新欄位皆可為 NULL 或有預設值)。 +-- Run AFTER 0023_org_integrations.sql(0024 / 0025 保留給同期開發的 Simpany 整合)。 + +ALTER TABLE transactions ADD COLUMN external_source text; +ALTER TABLE transactions ADD COLUMN external_ref text; +ALTER TABLE transactions ADD COLUMN external_meta jsonb; +ALTER TABLE transactions ADD COLUMN needs_review boolean NOT NULL DEFAULT false; + +ALTER TABLE transactions + ADD CONSTRAINT chk_txn_external_ref_source + CHECK (external_ref IS NULL OR external_source IS NOT NULL); + +CREATE UNIQUE INDEX uq_txn_external_ref + ON transactions (organization_id, external_source, external_ref) + WHERE external_ref IS NOT NULL; + +-- 「待確認」清單用:只索引少數 needs_review = true 的列。 +CREATE INDEX idx_txn_needs_review + ON transactions (organization_id) + WHERE needs_review AND deleted_at IS NULL; + +COMMENT ON COLUMN transactions.external_source IS '外部來源代號(wise…);手動輸入為 NULL'; +COMMENT ON COLUMN transactions.external_ref IS '外部來源的唯一鍵(Wise referenceNumber),與 org + source 組成去重鍵'; +COMMENT ON COLUMN transactions.external_meta IS '外部來源的原始細節(商家、原幣、匯率、手續費…),僅供對帳顯示'; +COMMENT ON COLUMN transactions.needs_review IS '自動匯入待人確認;指定分類後清掉'; diff --git a/scripts/migrate-employee-pii.ts b/scripts/migrate-employee-pii.ts new file mode 100644 index 0000000..535b8b1 --- /dev/null +++ b/scripts/migrate-employee-pii.ts @@ -0,0 +1,202 @@ +/** + * 一次性搬移:把員工的明文個資搬進 migrations/0024 的加密欄位。 + * + * (1) employees.salary_account(舊的自由文字薪轉帳戶) + * → 這位員工還沒有任何帳戶:建一個 employee_bank_accounts(帳號加密、設為 + * 薪資預設、戶名帶員工姓名),然後清空 salary_account。 + * → 已經有帳戶:解密比對,若某個帳戶的帳號就是這串舊值,只清空 salary_account; + * 對不上的列入「需人工確認」,不動。 + * (2) employees.national_id(明文身分證字號) + * → national_id_enc 還是空的:加密寫入 national_id_enc,清空 national_id。 + * → 兩欄都有值:解密比對,一致就清空明文;不一致列入「需人工確認」,不動。 + * + * 用法(先確認 0024 已經套用): + * bun run scripts/migrate-employee-pii.ts # dry run,只印筆數 + * bun run scripts/migrate-employee-pii.ts --apply # 真的寫入 + * + * 需要 DATABASE_URL 與 FIELD_ENCRYPTION_KEY(bun 會自動讀 .env.local;要指定別的 + * 環境檔就用 `bun --env-file=.dev.vars run scripts/migrate-employee-pii.ts`)。 + * FIELD_ENCRYPTION_KEY 必須跟正式環境的 Worker secret 是同一把,否則寫進去的密文 + * 網頁端解不開。 + * + * 安全性質(刻意的設計,不要拿掉): + * - 冪等:已經搬過的列不會再被選到,重跑只會處理剩下的。 + * - 每位員工的「建帳戶 + 清空舊欄位」用 db.batch 在同一個交易裡完成,中途失敗 + * 不會留下「帳戶建了但明文還在」或反過來的狀態。 + * - 只印筆數,絕不印出帳號、身分證字號或員工姓名。 + * - 軟刪除的員工一樣處理:明文不該因為人離職被刪除就留在資料庫裡。 + */ +import { and, eq, isNotNull, isNull } from "drizzle-orm"; +import { getDb } from "@/db"; +import { employeeBankAccounts, employees } from "@/db/schema"; +import { decryptField, encryptField } from "@/lib/crypto"; +import { + LEGACY_ACCOUNT_NOTE, + accountLast5, + normalizeAccountNumber, + parseLegacySalaryAccount, +} from "@/lib/employee-accounts"; + +const apply = process.argv.includes("--apply"); + +type Counts = Record; + +function bump(c: Counts, key: string) { + c[key] = (c[key] ?? 0) + 1; +} + +type Db = ReturnType; +type LegacyAccount = NonNullable>; +type EmployeeRow = typeof employees.$inferSelect; +type SalaryRow = Pick; +type NationalIdRow = Pick; + +/** 既有帳戶裡是否已經有這串帳號(解密後正規化比對)。 */ +async function anyAccountMatches(existing: { enc: string }[], accountNumber: string): Promise { + const target = normalizeAccountNumber(accountNumber); + for (const a of existing) { + if (normalizeAccountNumber(await decryptField(a.enc)) === target) return true; + } + return false; +} + +/** 建帳戶 + 清空舊欄位,同一個交易。 */ +async function createAccountFromLegacy(db: Db, e: SalaryRow, parsed: LegacyAccount) { + const insert = db.insert(employeeBankAccounts).values({ + organizationId: e.organizationId, + employeeId: e.id, + kind: parsed.kind, + bankCode: parsed.bankCode, + branchCode: parsed.branchCode, + bankName: parsed.bankName, + accountHolder: e.name, + accountNumberEnc: await encryptField(parsed.accountNumber), + accountLast5: accountLast5(parsed.accountNumber), + currency: "TWD", + defaultForSalary: true, + defaultForReimbursement: false, + isActive: true, + note: LEGACY_ACCOUNT_NOTE, + }); + await db.batch([insert, clearSalaryAccount(db, e.id)]); +} + +function clearSalaryAccount(db: Db, id: number) { + return db.update(employees).set({ salaryAccount: null }).where(eq(employees.id, id)); +} + +/** 處理一位員工的 salary_account,回傳計數用的分類。 */ +async function migrateSalaryAccount(db: Db, e: SalaryRow): Promise { + const parsed = parseLegacySalaryAccount(e.salaryAccount ?? ""); + if (!parsed?.accountNumber) { + // 只有空白:直接清空 + if (apply) await clearSalaryAccount(db, e.id); + return "blankCleared"; + } + + const existing = await db + .select({ enc: employeeBankAccounts.accountNumberEnc }) + .from(employeeBankAccounts) + .where(and(eq(employeeBankAccounts.employeeId, e.id), isNull(employeeBankAccounts.deletedAt))); + + if (existing.length === 0) { + if (apply) await createAccountFromLegacy(db, e, parsed); + return parsed.kind === "bank" ? "createdBank" : "createdOther"; + } + + // 已經有帳戶:舊值若已經在其中之一,就只是還沒清掉的明文 + if (!(await anyAccountMatches(existing, parsed.accountNumber))) return "needsReview"; + if (apply) await clearSalaryAccount(db, e.id); + return "alreadyMigratedCleared"; +} + +async function migrateSalaryAccounts(): Promise { + const db = getDb(); + const counts: Counts = {}; + const rows = await db + .select({ + id: employees.id, + organizationId: employees.organizationId, + name: employees.name, + salaryAccount: employees.salaryAccount, + }) + .from(employees) + .where(isNotNull(employees.salaryAccount)); + + for (const e of rows) bump(counts, await migrateSalaryAccount(db, e)); + return counts; +} + +function clearNationalId(db: Db, id: number) { + return db.update(employees).set({ nationalId: null }).where(eq(employees.id, id)); +} + +/** 處理一位員工的 national_id,回傳計數用的分類。 */ +async function migrateNationalId(db: Db, e: NationalIdRow): Promise { + const plain = e.nationalId?.trim() ?? ""; + if (!plain) { + if (apply) await clearNationalId(db, e.id); + return "blankCleared"; + } + if (!e.nationalIdEnc) { + if (apply) { + await db + .update(employees) + .set({ nationalIdEnc: await encryptField(plain), nationalId: null }) + .where(eq(employees.id, e.id)); + } + return "encrypted"; + } + if ((await decryptField(e.nationalIdEnc)) !== plain) return "needsReview"; + if (apply) await clearNationalId(db, e.id); + return "alreadyEncryptedCleared"; +} + +async function migrateNationalIds(): Promise { + const db = getDb(); + const counts: Counts = {}; + const rows = await db + .select({ + id: employees.id, + nationalId: employees.nationalId, + nationalIdEnc: employees.nationalIdEnc, + }) + .from(employees) + .where(isNotNull(employees.nationalId)); + + for (const e of rows) bump(counts, await migrateNationalId(db, e)); + return counts; +} + +function print(title: string, counts: Counts) { + const entries = Object.entries(counts); + console.log(`\n${title}`); + if (entries.length === 0) { + console.log(" (nothing to do)"); + return; + } + for (const [k, v] of entries) console.log(` ${k}: ${v}`); +} + +async function main() { + if (!process.env.DATABASE_URL) throw new Error("DATABASE_URL is not set"); + if (!process.env.FIELD_ENCRYPTION_KEY) throw new Error("FIELD_ENCRYPTION_KEY is not set"); + // 先試一次加解密,金鑰格式不對就在動任何資料之前失敗 + const probe = await encryptField("probe"); + if ((await decryptField(probe)) !== "probe") throw new Error("FIELD_ENCRYPTION_KEY self-test failed"); + + console.log(apply ? "Mode: APPLY (writing changes)" : "Mode: dry run (no writes; pass --apply to write)"); + print("salary_account → employee_bank_accounts", await migrateSalaryAccounts()); + print("national_id → national_id_enc", await migrateNationalIds()); + console.log( + "\nneedsReview rows were left untouched: fix them in the web app (employee → 帳戶 / 身分證), then re-run.", + ); +} + +main().catch((e) => { + // 只印錯誤訊息,不印可能含資料的物件。drizzle 的查詢錯誤會把參數(姓名、密文) + // 串在訊息後面,一律截掉。 + const msg = e instanceof Error ? e.message : "migration failed"; + console.error(msg.split(/\bparams:/)[0].trim()); + process.exit(1); +}); diff --git a/src/app/dashboard/activity/page.tsx b/src/app/dashboard/activity/page.tsx index 9158da5..c818af8 100644 --- a/src/app/dashboard/activity/page.tsx +++ b/src/app/dashboard/activity/page.tsx @@ -47,6 +47,7 @@ export default async function ActivityPage() { document: t("entity.document"), payroll_run: t("entity.payroll_run"), payslip: t("entity.payslip"), + integration: t("entity.integration"), }; const { orgId } = await requireOrg(); const rows = await listActivity(orgId, { limit: 300 }); diff --git a/src/app/dashboard/advances/page.tsx b/src/app/dashboard/advances/page.tsx index 90c1617..938eebd 100644 --- a/src/app/dashboard/advances/page.tsx +++ b/src/app/dashboard/advances/page.tsx @@ -28,6 +28,7 @@ import { listOutstandingAdvances, listBankAccounts } from "@/db/queries"; import { formatCurrency, formatDate } from "@/lib/format"; import { RecordReimbursementDialog } from "./record-reimbursement-dialog"; import { requireOrg } from "@/lib/session"; +import { groupAccountsByEmployee, listEmployeeAccounts } from "@/db/employee-accounts"; export const dynamic = "force-dynamic"; @@ -38,6 +39,9 @@ export default async function AdvancesPage() { listOutstandingAdvances(orgId), listBankAccounts(orgId), ]); + // 只撈有未還代墊的員工的帳戶(遮罩後),給「匯入帳戶」下拉用 + const settleIds = [...new Set(rows.map((r) => r.settleEmployeeId).filter((id): id is number => id != null))]; + const accountsByEmployee = groupAccountsByEmployee(await listEmployeeAccounts(orgId, settleIds)); const total = rows.reduce((s, r) => s + Number(r.amountTwd ?? r.amount ?? 0), 0); const accountOpts = accounts.map((a) => ({ id: a.id, name: a.name, currency: a.currency })); @@ -111,6 +115,10 @@ export default async function AdvancesPage() { vendorName: r.vendorName ?? "", }} accounts={accountOpts} + employeeAccounts={(r.settleEmployeeId == null + ? [] + : (accountsByEmployee.get(r.settleEmployeeId) ?? []) + ).filter((a) => a.isActive)} /> diff --git a/src/app/dashboard/advances/record-reimbursement-dialog.tsx b/src/app/dashboard/advances/record-reimbursement-dialog.tsx index 6babae4..46b6e9f 100644 --- a/src/app/dashboard/advances/record-reimbursement-dialog.tsx +++ b/src/app/dashboard/advances/record-reimbursement-dialog.tsx @@ -19,15 +19,19 @@ import { } from "@/components/ui/dialog"; import { DatePicker } from "@/components/date-picker"; import { submitAction } from "@/lib/form-action"; +import { formatAccountShort, type MaskedEmployeeAccount } from "@/lib/employee-accounts"; const initial: ActionState = { ok: false }; export function RecordReimbursementDialog({ advance, accounts, + employeeAccounts, }: Readonly<{ advance: { id: number; settleName: string; amount: string; currency: string; vendorName: string }; accounts: { id: number; name: string; currency: string }[]; + /** 代墊人啟用中的收款帳戶(遮罩後),選「匯入帳戶」用 */ + employeeAccounts: MaskedEmployeeAccount[]; }>) { const t = useTranslations("advances"); const [open, setOpen] = useState(false); @@ -100,6 +104,23 @@ export function RecordReimbursementDialog({ : t("dialog.fromAccountHint", { currency: advance.currency })}

+ {employeeAccounts.length > 0 ? ( + a.defaultForReimbursement)?.id ?? "none", + )} + > + {t("dialog.toAccountNone")} + {employeeAccounts.map((a) => ( + + {formatAccountShort(a)} + {a.currency === advance.currency ? "" : ` · ${a.currency}`} + + ))} + + ) : null}
{formatCurrency(advance.amount, advance.currency)} diff --git a/src/app/dashboard/bank-accounts/page.tsx b/src/app/dashboard/bank-accounts/page.tsx index aba0628..f260db9 100644 --- a/src/app/dashboard/bank-accounts/page.tsx +++ b/src/app/dashboard/bank-accounts/page.tsx @@ -19,18 +19,36 @@ import { listAccountBalances } from "@/db/queries"; import { formatCurrency } from "@/lib/format"; import { CurrencyFlag } from "@/components/currency-flag"; import { NewBankAccountDialog } from "./new-bank-account-dialog"; -import { requireOrg } from "@/lib/session"; +import { canManageOrg, requireOrgWithRole } from "@/lib/session"; +import { getIntegration } from "@/lib/integrations/store"; +import { parseWiseConfig } from "@/lib/wise-sync"; +import { WiseSyncButton } from "./wise-sync-sheet"; export const dynamic = "force-dynamic"; export default async function BankAccountsPage() { const t = await getTranslations("bankAccounts"); - const { orgId } = await requireOrg(); - const rows = await listAccountBalances(orgId, { includeInactive: true }); + const tw = await getTranslations("wise"); + const { orgId, role } = await requireOrgWithRole(); + const [rows, wise] = await Promise.all([ + listAccountBalances(orgId, { includeInactive: true }), + getIntegration(orgId, "wise"), + ]); + // 對應到 Wise 餘額的帳本帳戶(名稱旁標 Wise);整合可用且有對應時,owner / admin 看得到同步按鈕。 + const wiseMapped = new Set( + parseWiseConfig(wise?.config) + .accountMappings.map((m) => m.bankAccountId) + .filter((id): id is number => id !== null), + ); + const canSyncWise = + canManageOrg(role) && + Boolean(wise?.enabled && wise.status === "connected") && + wiseMapped.size > 0; return ( <> + {canSyncWise ? : null} @@ -56,7 +74,16 @@ export default async function BankAccountsPage() { description={t("rowDialogDescription")} cells={ <> - {a.name} + + + {a.name} + {wiseMapped.has(a.id) ? ( + + {tw("sync.mappedBadge")} + + ) : null} + + {a.kind === "bank" ? t("physical") : t("virtual")} diff --git a/src/app/dashboard/bank-accounts/wise-sync-actions.ts b/src/app/dashboard/bank-accounts/wise-sync-actions.ts new file mode 100644 index 0000000..14acfc3 --- /dev/null +++ b/src/app/dashboard/bank-accounts/wise-sync-actions.ts @@ -0,0 +1,49 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { getTranslations } from "next-intl/server"; +import { canManageOrg, requireOrgWithRole } from "@/lib/session"; +import { logWeb } from "@/db/activity"; +import { syncWiseTransactions, type SyncResult } from "@/lib/wise-sync"; + +/** + * 帳戶頁「從 Wise 同步」:先 preview(dryRun)給人看,確認後 apply 才寫入。 + * 限 owner / admin。只讀 Wise、只寫本組織帳本。 + */ + +export type WiseSyncActionResult = { ok: true; result: SyncResult } | { ok: false; error: string }; + +async function run(accountId: number | null, dryRun: boolean): Promise { + const t = await getTranslations("wise"); + const { orgId, role } = await requireOrgWithRole(); + if (!canManageOrg(role)) return { ok: false, error: t("errors.notAllowed") }; + try { + const result = await syncWiseTransactions(orgId, { + accountId: accountId ?? undefined, + dryRun, + }); + if (!dryRun) { + await logWeb( + orgId, + "create", + "transaction", + null, + t("sync.activity.applied", { count: result.totals.created }), + ); + revalidatePath("/dashboard/transactions"); + revalidatePath("/dashboard/bank-accounts"); + revalidatePath("/dashboard"); + } + return { ok: true, result }; + } catch (e) { + return { ok: false, error: e instanceof Error ? e.message : t("errors.failed") }; + } +} + +export async function previewWiseSync(accountId: number | null): Promise { + return run(accountId, true); +} + +export async function applyWiseSync(accountId: number | null): Promise { + return run(accountId, false); +} diff --git a/src/app/dashboard/bank-accounts/wise-sync-sheet.tsx b/src/app/dashboard/bank-accounts/wise-sync-sheet.tsx new file mode 100644 index 0000000..0e62f08 --- /dev/null +++ b/src/app/dashboard/bank-accounts/wise-sync-sheet.tsx @@ -0,0 +1,210 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; +import { useTranslations } from "next-intl"; +import { Loader2, RefreshCw } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { formatCurrency } from "@/lib/currency"; +import type { SyncResult } from "@/lib/wise-sync"; +import { applyWiseSync, previewWiseSync } from "./wise-sync-actions"; + +/** 樣本金額前的正負號:收入 +、支出 −、轉帳不加。 */ +function amountSign(type: string): string { + if (type === "income") return "+"; + if (type === "expense") return "−"; + return ""; +} + +/** + * 帳戶頁「從 Wise 同步」:打開就先跑一次試算(dry run,不寫入),列出每個帳戶會新增 + * 幾筆與前 50 筆樣本;使用者按「寫入 N 筆」才真的寫。只給 owner / admin 看到。 + */ +export function WiseSyncButton() { + const t = useTranslations("wise"); + const router = useRouter(); + const [open, setOpen] = useState(false); + const [preview, setPreview] = useState(null); + const [error, setError] = useState(null); + const [loading, startLoading] = useTransition(); + const [applying, startApplying] = useTransition(); + + function runPreview() { + setError(null); + setPreview(null); + startLoading(async () => { + const res = await previewWiseSync(null); + if (res.ok) setPreview(res.result); + else setError(res.error); + }); + } + + function openSheet() { + setOpen(true); + runPreview(); + } + + function apply() { + startApplying(async () => { + const res = await applyWiseSync(null); + if (!res.ok) { + setError(res.error); + return; + } + toast.success(t("sync.applied", { count: res.result.totals.created })); + setOpen(false); + router.refresh(); + }); + } + + const busy = loading || applying; + const count = preview?.totals.created ?? 0; + const skipped = preview?.skippedBalances ?? []; + + return ( + <> + + !applying && setOpen(o)}> + + + {t("sync.title")} + {t("sync.description")} + +
+ {loading ? ( +

+ {t("sync.loading")} +

+ ) : null} + {error ? ( +
+

{error}

+ +
+ ) : null} + + {preview ? ( + <> +
+ + + + + + + + + + + + + {preview.accounts.map((a) => ( + + + + + + + + + ))} + +
{t("sync.columns.account")}{t("sync.columns.range")}{t("sync.columns.fetched")}{t("sync.columns.existing")}{t("sync.columns.beforeCutover")}{t("sync.columns.toCreate")}
+
{a.bankAccountName}
+
+ {a.profileName} · {a.currency} +
+
+ {a.rangeStart} ~ {a.rangeEnd} + {a.fetched}{a.alreadySynced}{a.beforeCutover}{a.created}
+
+ + {skipped.length > 0 ? ( +

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

+ ) : null} + + {count === 0 ? ( +

{t("sync.nothing")}

+ ) : ( +
+

+ {t("sync.sampleTitle", { count: preview.sample.length })} +

+
+ + + + + + + + + + + {preview.sample.map((r) => ( + + + + + + + ))} + +
{t("sync.columns.date")}{t("sync.columns.party")}{t("sync.columns.description")}{t("sync.columns.amount")}
+ {r.txnDate} + +
+ {r.partyName ?? "—"} + + {t(`sync.type.${r.type as "income" | "expense" | "transfer"}`)} + +
+
{r.description} + {amountSign(r.type)} + {formatCurrency(r.amount, r.currency)} +
+
+
+ )} + + ) : null} +
+ + + + +
+
+ + ); +} diff --git a/src/app/dashboard/billing/issue-invoice-dialog.tsx b/src/app/dashboard/billing/issue-invoice-dialog.tsx index f1fbdd2..269d084 100644 --- a/src/app/dashboard/billing/issue-invoice-dialog.tsx +++ b/src/app/dashboard/billing/issue-invoice-dialog.tsx @@ -1,6 +1,7 @@ "use client"; import * as React from "react"; +import Link from "next/link"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; import { toast } from "sonner"; @@ -40,7 +41,8 @@ function todayISO() { /** * 看板上的「開發票」:把這一期的資料預填成一張發票草稿,存檔後回填開發票日。 * - * 只做本系統這一半 —— Simpany 那邊還是要人去開。所以外部狀態預設 pending, + * Simpany 整合沒開(或不是 owner / admin)時的手動流程:只做本系統這一半 —— + * Simpany 那邊還是要人去開。所以外部狀態預設 pending, * 開好之後把 Simpany 的號碼填進來(或在發票頁補),對帳表才對得起來。 * * 請款金額是未稅還是含稅,各家合約寫法不同,這裡讓人選,選了就即時換算。 @@ -63,6 +65,7 @@ export function IssueInvoiceDialog({ currency: string; }>) { const t = useTranslations("billing.issueInvoice"); + const tInv = useTranslations("invoices.simpany"); const tCommon = useTranslations("billing.common"); const [open, setOpen] = React.useState(false); const [basis, setBasis] = React.useState<"gross" | "net">("gross"); @@ -104,6 +107,11 @@ export function IssueInvoiceDialog({ {t("dialog.title")} {t("dialog.description")} +

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

{/* 綁定關係與品名沿用這一期的資料,不讓人重打。 */} diff --git a/src/app/dashboard/billing/page.tsx b/src/app/dashboard/billing/page.tsx index c96cd05..c05d19f 100644 --- a/src/app/dashboard/billing/page.tsx +++ b/src/app/dashboard/billing/page.tsx @@ -26,7 +26,8 @@ import { } from "@/db/queries"; import { deleteBillingItem } from "@/db/mutations"; import { formatCurrency, formatDate } from "@/lib/format"; -import { requireOrg } from "@/lib/session"; +import { canManageOrg, requireOrgWithRole } from "@/lib/session"; +import { getIntegration } from "@/lib/integrations/store"; import { getCalendarSettings } from "@/lib/google-calendar"; import { cn } from "@/lib/utils"; import { BillingStatusBadge } from "./billing-status"; @@ -37,6 +38,7 @@ import { QuickMark } from "./quick-actions"; import { IssueInvoiceDialog } from "./issue-invoice-dialog"; import { RecordPaymentDialog } from "./record-payment-dialog"; import { SyncCalendarButton } from "./sync-calendar-button"; +import { SimpanyIssueSheet, type SimpanyIssueSource } from "../invoices/simpany-issue-sheet"; export const dynamic = "force-dynamic"; @@ -107,8 +109,9 @@ function InvoiceCell({ row, t }: Readonly<{ row: BillingRow; t: T }>) { * 還沒請款 → 標記已請款 * 已請款 → 登記收款(有缺口時)+ 開發票(該開未開時) * - * 開發票走預填草稿,只有 billing_items 有實體列可綁;訂閱期別沒有 id 可綁, - * 退回單純的日期標記,發票本身到發票頁建立。 + * 開發票:Simpany 整合開啟時(owner / admin),請款項目與訂閱期別都走「在 Simpany 開立」 + * (預覽 → 確認 → 開立,並回填開發票日);否則請款項目走預填草稿、訂閱期別退回單純的 + * 日期標記,發票本身到 Simpany 手開。 */ /** * 項目底下那行來源說明:訂閱期別連回訂閱、掛了合約的一次性項目連回合約, @@ -140,8 +143,50 @@ function SourceLine({ row, t }: Readonly<{ row: BillingRow; t: T }>) { return <>{row.projectName ?? t("table.oneTimeSource")}; } -function RowActions({ row }: Readonly<{ row: BillingRow }>) { +/** 這一列在 Simpany 開票時對應的來源:單次款項或訂閱的某一期;都對不上就回 null。 */ +function simpanySourceFor(row: BillingRow, itemId: number | null): SimpanyIssueSource | null { + if (itemId != null) return { kind: "billing_item", billingItemId: itemId }; + if (row.subscriptionId != null && row.periodStart) { + return { kind: "subscription", subscriptionId: row.subscriptionId, periodStart: row.periodStart }; + } + return null; +} + +/** 這一列「開發票」要用哪個流程:Simpany 直接開立,或本系統記錄 + 人去 Simpany 開。 */ +function InvoiceAction({ row, simpany }: Readonly<{ row: BillingRow; simpany: boolean }>) { const itemId = row.source === "billing_item" ? row.billingItemId : null; + if (simpany) { + const source = simpanySourceFor(row, itemId); + if (source) { + return ( + + ); + } + } + if (itemId == null) { + return ; + } + return ( + + ); +} + +function RowActions({ row, simpany }: Readonly<{ row: BillingRow; simpany: boolean }>) { const outstanding = row.expected - row.paid; if (!row.billedOn) { @@ -160,20 +205,7 @@ function RowActions({ row }: Readonly<{ row: BillingRow }>) { customerName={row.customerName} /> )} - {row.needsInvoice && - (itemId == null ? ( - - ) : ( - - ))} + {row.needsInvoice && } ); @@ -182,19 +214,24 @@ function RowActions({ row }: Readonly<{ row: BillingRow }>) { export default async function BillingPage({ searchParams, }: Readonly<{ searchParams: Promise<{ filter?: string }> }>) { - const { orgId } = await requireOrg(); + const { orgId, role } = await requireOrgWithRole(); const t = await getTranslations("billing"); const { filter: rawFilter } = await searchParams; const filter = rawFilter && FILTERS.has(rawFilter as BoardFilter) ? (rawFilter as BoardFilter) : null; - const [rows, parties, projects, contracts, calendar] = await Promise.all([ + const [rows, parties, projects, contracts, calendar, simpanyIntegration] = await Promise.all([ listBillingBoard(orgId), listParties(orgId), listProjects(orgId), listContracts(orgId), getCalendarSettings(orgId), + getIntegration(orgId, "simpany"), ]); + // 「在 Simpany 開立」只給 owner / admin,且整合要已開啟;其餘沿用手動流程。 + const simpany = + canManageOrg(role) && + Boolean(simpanyIntegration?.enabled && simpanyIntegration.status === "connected"); // 摘要一律用全部資料算,篩選只影響下方表格 —— 否則點了卡片其他數字會跟著歸零。 const summary = summarizeBilling(rows); @@ -296,7 +333,7 @@ export default async function BillingPage({
- +
diff --git a/src/app/dashboard/billing/sync-calendar-button.tsx b/src/app/dashboard/billing/sync-calendar-button.tsx index 7c1be19..657a641 100644 --- a/src/app/dashboard/billing/sync-calendar-button.tsx +++ b/src/app/dashboard/billing/sync-calendar-button.tsx @@ -7,7 +7,7 @@ import { useTranslations } from "next-intl"; import { toast } from "sonner"; import { CalendarPlus, RefreshCw } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { syncCalendar } from "../settings/calendar-actions"; +import { syncCalendar } from "../settings/integrations/calendar-actions"; /** * 尚未連結時直接指向設定頁,而不是按了才報錯 —— 讓「還沒設定」這件事在按下去之前 @@ -21,7 +21,7 @@ export function SyncCalendarButton({ connected }: Readonly<{ connected: boolean if (!connected) { return ( diff --git a/src/app/dashboard/employees/account-actions.ts b/src/app/dashboard/employees/account-actions.ts new file mode 100644 index 0000000..4f47432 --- /dev/null +++ b/src/app/dashboard/employees/account-actions.ts @@ -0,0 +1,157 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { getTranslations } from "next-intl/server"; +import { canManageOrg, requireOrgWithRole } from "@/lib/session"; +import { logWeb } from "@/db/activity"; +import { + convertLegacySalaryAccount, + createEmployeeAccount, + getEmployeeAccount, + revealEmployeeAccountNumber, + softDeleteEmployeeAccount, + updateEmployeeAccount, +} from "@/db/employee-accounts"; +import { EmployeeAccountError, formatAccountShort } from "@/lib/employee-accounts"; +import type { ActionState } from "@/db/mutations"; + +/** + * 員工收款帳戶的網頁端 action。全部只給 owner / admin: + * 隱藏按鈕只擋得住誤按,擋不住直接呼叫 action,所以每一支都在 server 端重驗角色。 + * + * 帳戶區塊放在員工編輯表單「裡面」(不能巢狀
),所以這些 action 收的是 + * 一般物件而不是 FormData,由區塊自己的「儲存」按鈕呼叫。 + */ + +export type EmployeeAccountFormInput = { + /** 有值 = 更新;沒有 = 新增 */ + id?: number | null; + employeeId: number; + kind: string; + bankCode: string; + branchCode: string; + bankName: string; + accountHolder: string; + /** 更新時留空 = 不改帳號 */ + accountNumber: string; + currency: string; + label: string; + defaultForSalary: boolean; + defaultForReimbursement: boolean; + isActive: boolean; + note: string; +}; + +async function requireManager(): Promise<{ orgId: string } | { error: string }> { + const ctx = await requireOrgWithRole(); + if (!canManageOrg(ctx.role)) { + const t = await getTranslations("errors"); + return { error: t("forbidden.manageEmployees") }; + } + return { orgId: ctx.orgId }; +} + +/** EmployeeAccountError → 已翻譯字串;其他錯誤照舊回傳訊息。 */ +async function accountErrorMessage(e: unknown, fallbackKey: "create" | "update" | "delete") { + const t = await getTranslations("errors"); + if (e instanceof EmployeeAccountError) return t(`employeeAccount.${e.code}`); + return e instanceof Error ? e.message : t(`failed.${fallbackKey}`); +} + +function revalidateEmployees() { + revalidatePath("/dashboard/employees"); + revalidatePath("/dashboard/payroll"); + revalidatePath("/dashboard/advances"); +} + +export async function saveEmployeeAccount(input: EmployeeAccountFormInput): Promise { + const auth = await requireManager(); + if ("error" in auth) return { ok: false, error: auth.error }; + const { orgId } = auth; + const isUpdate = input.id != null; + try { + const fields = { + kind: input.kind, + bankCode: input.bankCode, + branchCode: input.branchCode, + bankName: input.bankName, + accountHolder: input.accountHolder, + accountNumber: input.accountNumber, + currency: input.currency, + label: input.label, + defaultForSalary: input.defaultForSalary, + defaultForReimbursement: input.defaultForReimbursement, + isActive: input.isActive, + note: input.note, + }; + const saved = isUpdate + ? await updateEmployeeAccount(orgId, input.id as number, fields) + : await createEmployeeAccount(orgId, input.employeeId, fields); + // 摘要只放遮罩後的字樣,完整帳號絕不進 activity_log。 + await logWeb(orgId, isUpdate ? "update" : "create", "employee_bank_account", saved.id, formatAccountShort(saved)); + revalidateEmployees(); + return { ok: true }; + } catch (e) { + return { ok: false, error: await accountErrorMessage(e, isUpdate ? "update" : "create") }; + } +} + +export async function deleteEmployeeAccount(id: number): Promise { + const auth = await requireManager(); + if ("error" in auth) return { ok: false, error: auth.error }; + try { + const removed = await softDeleteEmployeeAccount(auth.orgId, id); + await logWeb(auth.orgId, "delete", "employee_bank_account", id, formatAccountShort(removed)); + revalidateEmployees(); + return { ok: true }; + } catch (e) { + return { ok: false, error: await accountErrorMessage(e, "delete") }; + } +} + +/** + * 顯示完整帳號:唯一會把明文送到瀏覽器的路徑。只給 owner / admin,每一次都寫 + * activity_log(action = read),事後查得到誰在什麼時候看過哪個帳戶。 + */ +export async function revealEmployeeAccount( + id: number, +): Promise<{ ok: true; accountNumber: string } | { ok: false; error: string }> { + const auth = await requireManager(); + if ("error" in auth) return { ok: false, error: auth.error }; + const t = await getTranslations("errors"); + const tRec = await getTranslations("lib"); + try { + const accountNumber = await revealEmployeeAccountNumber(auth.orgId, id); + const acct = await getEmployeeAccount(auth.orgId, id); + // 摘要只記遮罩後的帳戶,不記完整帳號。 + await logWeb( + auth.orgId, + "read", + "employee_bank_account", + id, + tRec("activity.accountRevealed", { account: acct ? formatAccountShort(acct) : `#${id}` }), + ); + return { ok: true, accountNumber }; + } catch (e) { + if (e instanceof EmployeeAccountError) return { ok: false, error: t(`employeeAccount.${e.code}`) }; + return { ok: false, error: t("employeeAccount.revealFailed") }; + } +} + +/** 把舊的 salary_account 自由文字轉成一個「薪資預設」帳戶,並清空舊欄位。 */ +export async function convertLegacySalaryAccountAction(employeeId: number): Promise { + const auth = await requireManager(); + if ("error" in auth) return { ok: false, error: auth.error }; + try { + const created = await convertLegacySalaryAccount(auth.orgId, employeeId); + if (!created) { + const t = await getTranslations("errors"); + return { ok: false, error: t("employeeAccount.nothingToConvert") }; + } + await logWeb(auth.orgId, "create", "employee_bank_account", created.id, formatAccountShort(created)); + revalidateEmployees(); + return { ok: true }; + } catch (e) { + return { ok: false, error: await accountErrorMessage(e, "create") }; + } +} diff --git a/src/app/dashboard/employees/edit-employee-form.tsx b/src/app/dashboard/employees/edit-employee-form.tsx index 6ad3f17..a8a2258 100644 --- a/src/app/dashboard/employees/edit-employee-form.tsx +++ b/src/app/dashboard/employees/edit-employee-form.tsx @@ -8,6 +8,8 @@ import { type EmployeeFormValues, type MemberOption, } from "./employee-fields"; +import { EmployeeAccountsSection } from "./employee-accounts-section"; +import type { MaskedEmployeeAccount } from "@/lib/employee-accounts"; export type { MemberOption } from "./employee-fields"; @@ -16,10 +18,19 @@ type Employee = EmployeeFormValues & { id: number }; export function EditEmployeeForm({ employee, members, + accounts, + legacySalaryAccount, + canManage, footer, }: Readonly<{ employee: Employee; members: MemberOption[]; + /** 這位員工的收款帳戶(遮罩後) */ + accounts: MaskedEmployeeAccount[]; + /** 舊 salary_account(遮罩後),沒有就是 null */ + legacySalaryAccount: string | null; + /** owner / admin 才能改;成員看到的是唯讀表單 */ + canManage: boolean; footer?: React.ReactNode; }>) { const t = useTranslations("employees"); @@ -32,9 +43,22 @@ export function EditEmployeeForm({ submitLabel={t("form.saveChanges")} submittingLabel={t("form.saving")} footer={footer} + readOnly={!canManage} > - + + } + /> ); } diff --git a/src/app/dashboard/employees/employee-accounts-section.tsx b/src/app/dashboard/employees/employee-accounts-section.tsx new file mode 100644 index 0000000..7752af5 --- /dev/null +++ b/src/app/dashboard/employees/employee-accounts-section.tsx @@ -0,0 +1,522 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState, useTransition } from "react"; +import { toast } from "sonner"; +import { useTranslations } from "next-intl"; +import { Eye, EyeOff, Pencil, Plus, Trash2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Combobox } from "@/components/combobox"; +import { CopyButton } from "@/components/copy-button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { CURRENCIES } from "@/lib/currency"; +import { + EMPLOYEE_ACCOUNT_KINDS, + TW_BANKS, + bankNameForCode, + type MaskedEmployeeAccount, +} from "@/lib/employee-accounts"; +import { + convertLegacySalaryAccountAction, + deleteEmployeeAccount, + revealEmployeeAccount, + saveEmployeeAccount, + type EmployeeAccountFormInput, +} from "./account-actions"; + +/** + * 員工編輯表單裡的「帳戶」區塊。 + * + * 這個區塊渲染在員工表單()裡面,而 HTML 不能巢狀 ,所以: + * - 輸入框一律不給 name,不會混進員工表單送出的 FormData + * - 帳戶有自己的「儲存」按鈕(type="button"),直接用物件呼叫 server action + * - 在帳戶輸入框按 Enter 不會觸發員工表單送出 + * + * 列表只拿得到遮罩後的資料;完整帳號只有 owner / admin 按「顯示完整帳號」才會 + * 向 server 要一次(每次都會寫入操作紀錄),也只存在這個元件的 state 裡。 + */ + +const BANK_OPTIONS = TW_BANKS.map((b) => `${b.code} ${b.name}`); + +type Draft = Omit & { bankInput: string }; + +function emptyDraft(holder: string, isFirst: boolean): Draft { + return { + id: null, + kind: "bank", + bankInput: "", + bankCode: "", + branchCode: "", + bankName: "", + accountHolder: holder, + accountNumber: "", + currency: "TWD", + label: "", + // 第一個帳戶預設就是薪資與報銷的預設帳戶,省得再勾 + defaultForSalary: isFirst, + defaultForReimbursement: isFirst, + isActive: true, + note: "", + }; +} + +function draftFrom(a: MaskedEmployeeAccount): Draft { + return { + id: a.id, + kind: a.kind, + bankInput: a.bankCode ? `${a.bankCode} ${a.bankName ?? ""}`.trim() : "", + bankCode: a.bankCode ?? "", + branchCode: a.branchCode ?? "", + bankName: a.bankName ?? "", + accountHolder: a.accountHolder ?? "", + accountNumber: "", + currency: a.currency, + label: a.label ?? "", + defaultForSalary: a.defaultForSalary, + defaultForReimbursement: a.defaultForReimbursement, + isActive: a.isActive, + note: a.note ?? "", + }; +} + +/** 銀行欄位的自由輸入 → 代碼與名稱。開頭 3 碼數字就是代碼,其餘文字當名稱。 */ +function parseBankInput(v: string): { bankCode: string; bankName: string } { + // 先 trim 再只比對開頭 3 碼,剩下用 slice 取:不讓兩個可重疊的量詞夾住同一段空白(ReDoS,S5852)。 + const t = v.trim(); + if (!/^\d{3}/.test(t)) return { bankCode: "", bankName: t }; + const bankCode = t.slice(0, 3); + return { bankCode, bankName: t.slice(3).trim() || (bankNameForCode(bankCode) ?? "") }; +} + +/** 在帳戶輸入框按 Enter 不要送出外層的員工表單。 */ +function swallowEnter(e: KeyboardEvent) { + if (e.key === "Enter" && (e.target as HTMLElement | null)?.tagName === "INPUT") e.preventDefault(); +} + +export function EmployeeAccountsSection({ + employeeId, + employeeName, + accounts, + canManage, + legacySalaryAccount, +}: Readonly<{ + employeeId: number; + employeeName: string; + accounts: MaskedEmployeeAccount[]; + canManage: boolean; + /** 舊 salary_account(已遮罩);只有還沒有任何帳戶時才會傳進來 */ + legacySalaryAccount: string | null; +}>) { + const t = useTranslations("employees.accounts"); + const [draft, setDraft] = useState(null); + const [pending, start] = useTransition(); + + function save() { + if (!draft) return; + const { bankInput, ...rest } = draft; + const bank = draft.kind === "bank" ? parseBankInput(bankInput) : { bankCode: "", bankName: draft.bankName }; + start(async () => { + const res = await saveEmployeeAccount({ ...rest, ...bank, employeeId }); + if (res.ok) { + toast.success(draft.id ? t("toast.updated") : t("toast.created")); + setDraft(null); + } else if (res.error) { + toast.error(res.error); + } + }); + } + + function convertLegacy() { + start(async () => { + const res = await convertLegacySalaryAccountAction(employeeId); + if (res.ok) toast.success(t("toast.converted")); + else if (res.error) toast.error(res.error); + }); + } + + return ( +
+
+
{t("title")}
+ {canManage && !draft ? ( + + ) : null} +
+ + {legacySalaryAccount && accounts.length === 0 ? ( +
+ + {t("legacy.notice", { value: legacySalaryAccount })} + + {canManage ? ( + + ) : null} +
+ ) : null} + + {accounts.length === 0 && !legacySalaryAccount ? ( +

{t("empty")}

+ ) : null} + +
    + {accounts.map((a) => ( + setDraft(draftFrom(a))} + /> + ))} +
+ + {draft ? ( + setDraft(null)} + /> + ) : null} +
+ ); +} + +function AccountRow({ + account: a, + canManage, + editing, + onEdit, +}: Readonly<{ + account: MaskedEmployeeAccount; + canManage: boolean; + editing: boolean; + onEdit: () => void; +}>) { + const t = useTranslations("employees.accounts"); + const [revealed, setRevealed] = useState(null); + const [pending, start] = useTransition(); + + function reveal() { + start(async () => { + const res = await revealEmployeeAccount(a.id); + if (res.ok) setRevealed(res.accountNumber); + else toast.error(res.error); + }); + } + + // 「永豐銀行 807 · 0180 分行 · •••• 90123 · TWD」 + const head = [a.bankName ?? t(`kind.${a.kind as "bank" | "wise" | "other"}`), a.bankCode] + .filter(Boolean) + .join(" "); + const parts = [ + head, + a.branchCode ? t("branchSuffix", { code: a.branchCode }) : null, + `•••• ${a.accountLast5}`, + a.currency, + ].filter(Boolean); + + return ( +
  • +
    + + {parts.join(" · ")} + + {a.defaultForSalary ? {t("chips.salary")} : null} + {a.defaultForReimbursement ? ( + {t("chips.reimbursement")} + ) : null} + {a.isActive ? null : {t("chips.inactive")}} +
    + {a.accountHolder || a.label || a.note ? ( +
    + {[a.accountHolder, a.label, a.note].filter(Boolean).join(" · ")} +
    + ) : null} + {revealed ? ( +
    + {revealed} + + +
    + ) : null} + {canManage ? ( +
    + {revealed ? null : ( + + )} + + +
    + ) : null} +
  • + ); +} + +/** + * 刪除確認。不用共用的 DeleteButton:那顆刪除成功後會把整個員工編輯面板關掉, + * 這裡只是刪掉面板裡的一列。 + */ +function DeleteAccountButton({ id }: Readonly<{ id: number }>) { + const t = useTranslations("employees.accounts"); + const [open, setOpen] = useState(false); + const [pending, start] = useTransition(); + + function onConfirm() { + start(async () => { + const res = await deleteEmployeeAccount(id); + if (res.ok) { + setOpen(false); + toast.success(t("toast.deleted")); + } else if (res.error) { + toast.error(res.error); + } + }); + } + + return ( + + + + + + + {t("deleteConfirm.title")} + {t("deleteConfirm.description")} + + + {t("cancel")} + { + e.preventDefault(); + onConfirm(); + }} + disabled={pending} + > + {t("delete")} + + + + + ); +} + +function AccountForm({ + draft, + setDraft, + pending, + onSave, + onCancel, +}: Readonly<{ + draft: Draft; + setDraft: (d: Draft) => void; + pending: boolean; + onSave: () => void; + onCancel: () => void; +}>) { + const t = useTranslations("employees.accounts"); + const set = (k: K, v: Draft[K]) => setDraft({ ...draft, [k]: v }); + const isBank = draft.kind === "bank"; + const currencyCodes = useMemo(() => { + const codes = CURRENCIES.map((c) => c.code); + return codes.includes(draft.currency) ? codes : [draft.currency, ...codes]; + }, [draft.currency]); + + const groupRef = useRef(null); + useEffect(() => { + const el = groupRef.current; + if (!el) return; + el.addEventListener("keydown", swallowEnter); + return () => el.removeEventListener("keydown", swallowEnter); + }, []); + + return ( + // 攔 Enter:這塊在員工表單裡面,按 Enter 會把整張員工表單送出去。 + // fieldset 當群組容器(語意上就是「表單裡的一組欄位」);keydown 以原生 listener 做事件委派 + // (見上方 useEffect),接住內層輸入框(含 Combobox 內部 input)冒泡上來的 Enter。 +
    +
    {draft.id ? t("form.editTitle") : t("form.addTitle")}
    +
    + + + + {isBank ? ( + + set("bankInput", v)} + placeholder={t("form.bankPlaceholder")} + emptyText={t("form.bankFreeEntry")} + /> + + ) : ( + + set("bankName", e.target.value)} + placeholder={draft.kind === "wise" ? "Wise" : t("form.optional")} + /> + + )} + {isBank ? ( + + set("branchCode", e.target.value)} + inputMode="numeric" + maxLength={4} + placeholder={t("form.branchPlaceholder")} + /> + + ) : null} + + set("accountHolder", e.target.value)} /> + + + set("accountNumber", e.target.value)} + inputMode={isBank ? "numeric" : "text"} + autoComplete="off" + placeholder={draft.id ? t("form.numberKeep") : t("form.numberPlaceholder")} + /> + + + + + + set("label", e.target.value)} + placeholder={t("form.labelPlaceholder")} + /> + + + set("note", e.target.value)} placeholder={t("form.optional")} /> + +
    +
    + set("defaultForSalary", v)} + label={t("form.defaultForSalary")} + disabled={!draft.isActive} + /> + set("defaultForReimbursement", v)} + label={t("form.defaultForReimbursement")} + disabled={!draft.isActive} + /> + {draft.id ? ( + set("isActive", v)} label={t("form.isActive")} /> + ) : null} +
    +
    + + +
    +
    + ); +} + +function FormRow({ + label, + required, + children, +}: Readonly<{ label: string; required?: boolean; children: React.ReactNode }>) { + return ( + // 用 div 而不是