Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
e63ad86
[chore] 新增欄位加密 helper(AES-GCM, FIELD_ENCRYPTION_KEY)
YJack0000 Sep 24, 2026
bb9ec21
[feature] 整合框架:org_integrations 表與加密憑證儲存
YJack0000 Sep 24, 2026
62a9c9f
[feature] 員工多帳戶:migration 0024 與 Drizzle schema(employee_bank_account…
YJack0000 Sep 24, 2026
058860b
[feature] 設定 › 整合:次級導覽、整合清單、連接 Sheet 與開關
YJack0000 Sep 24, 2026
7817e5d
[feature] MCP:list_integrations 與整合工具共用關卡、稽核 helper
YJack0000 Sep 24, 2026
d900bd9
[docs] 整合框架說明與 FIELD_ENCRYPTION_KEY 部署設定
YJack0000 Sep 24, 2026
5cd965f
[feature] 員工多帳戶:資料層、網頁帳戶區塊、發薪/撥款記錄匯入帳戶、員工寫入限 owner/admin
YJack0000 Sep 24, 2026
1936ee7
[feature] 員工多帳戶:MCP 帳戶 tools、發薪/撥款 toEmployeeAccountId、員工輸出帶遮罩帳戶與加密身分證
YJack0000 Sep 24, 2026
8901926
[docs] 員工多帳戶:隱私權政策改寫為欄位加密、處處遮罩、owner/admin 顯示需留稽核紀錄
YJack0000 Sep 24, 2026
529bf49
[feature] 員工多帳戶:一次性搬移腳本 migrate-employee-pii(預設 dry run,--apply 才寫入)
YJack0000 Sep 24, 2026
2cf5000
[feature] Wise 整合:transactions 外部來源欄位與去重索引(migration 0026)
YJack0000 Sep 24, 2026
9d54032
[feature] Wise 整合:唯讀 client、帳戶對應與交易同步(dry run、切換日、去重)
YJack0000 Sep 24, 2026
f5ba1c7
[feature] Wise 整合:MCP wise_* 工具、list_transactions 待確認欄位與篩選
YJack0000 Sep 24, 2026
92c98d4
[feature] Wise 整合:帳戶對應設定、帳戶頁同步試算與寫入、交易待確認標記
YJack0000 Sep 24, 2026
44be502
[feature] Simpany 整合:invoices 同步欄位與 invoice_drafts 表(migration 0025)
YJack0000 Sep 24, 2026
1407d40
[feature] Simpany 整合:provider 與 SimpanyClient(登入、token 快取、自動重新登入)
YJack0000 Sep 24, 2026
4387e18
[feature] Simpany 整合:發票同步、自動綁定與預覽 / 開立 / 作廢
YJack0000 Sep 24, 2026
5a0294a
[feature] Simpany 整合:MCP 工具(list/get/sync/preview/issue/void/zero-rat…
YJack0000 Sep 24, 2026
7c67e3c
[docs] Wise 整合:唯讀保證、切換日與帳戶對應、MCP 工具清單
YJack0000 Sep 24, 2026
76fdf99
[feature] Simpany 整合:發票頁同步 Sheet、課稅別 / 作廢標示,看板「在 Simpany 開立」預覽確認流程
YJack0000 Sep 24, 2026
fe20f60
[docs] Simpany 整合:端點、非官方 API 風險、重新登入與 MCP 工具清單
YJack0000 Sep 24, 2026
a91a6ca
[fix] Wise 換匯單邊轉帳可編輯
YJack0000 Sep 24, 2026
b422471
[chore] 合併員工多帳戶分支
YJack0000 Sep 24, 2026
e9f5179
[chore] 合併 Wise 整合分支
YJack0000 Sep 24, 2026
ca6b4b3
[chore] 合併 Simpany 整合分支;MCP server 1.6.0
YJack0000 Sep 24, 2026
05f15d7
[docs] MCP 工具數更新為 85
YJack0000 Sep 24, 2026
f0a81d7
[refactor] 清 Sonar:拆高認知複雜度函式、巢狀三元、否定條件等 80 條(行為不變)
YJack0000 Sep 24, 2026
7480c7d
[fix] Sonar 收尾:帳戶表單改 fieldset、orExisting 明寫 undefined 判斷
YJack0000 Sep 24, 2026
23be481
[fix] 帳戶表單攔 Enter 改用原生事件委派,避免在非互動元素上掛 keydown(Sonar S6847)
YJack0000 Sep 24, 2026
37b7cf0
[fix] 三處 regex 改成線性寫法,消除回溯風險(Sonar S5852 hotspots)
YJack0000 Sep 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=""
4 changes: 3 additions & 1 deletion docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |

---

Expand Down Expand Up @@ -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
```

Expand Down
287 changes: 287 additions & 0 deletions docs/integrations.md

Large diffs are not rendered by default.

90 changes: 75 additions & 15 deletions docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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`/
Expand All @@ -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-<provider>.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.

Expand Down Expand Up @@ -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
Expand Down
61 changes: 61 additions & 0 deletions migrations/0023_org_integrations.sql
Original file line number Diff line number Diff line change
@@ -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:<iv>:<ct>`,金鑰 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 '最近一次失敗的訊息(給人看,不含憑證)';
Loading
Loading