diff --git a/docs/deployment.md b/docs/deployment.md
index bf0c933..2d648b7 100644
--- a/docs/deployment.md
+++ b/docs/deployment.md
@@ -115,6 +115,64 @@ echo "$FIELD_ENCRYPTION_KEY" | bunx wrangler secret put FIELD_ENCRYPTION_KEY
# …and GOOGLE_CLIENT_SECRET, BETTER_AUTH_URL
```
+### Custom worker entry + Cron Trigger (daily integration auto-sync)
+
+`wrangler.jsonc` does **not** point `main` at OpenNext's generated
+`.open-next/worker.js`. It points at [`worker.ts`](../worker.ts) in the repo root,
+which follows OpenNext's
+[custom worker](https://opennext.js.org/cloudflare/howtos/custom-worker) pattern:
+
+- `fetch` is OpenNext's handler, forwarded unchanged.
+- `scheduled` is new — Cloudflare calls it for the Cron Trigger
+ `"triggers": { "crons": ["0 22 * * *"] }` (cron is always UTC: 22:00 UTC =
+ **06:00 Asia/Taipei**). It runs the daily integration auto-sync
+ (see [integrations.md](integrations.md#每日自動同步)).
+- The Durable Object classes OpenNext's worker exports (`DOQueueHandler`,
+ `DOShardedTagCache`, `BucketCachePurge`) are re-exported as-is. None are bound
+ today; they are there so turning on OpenNext caching later needs no change here.
+
+How `scheduled` runs app code: it does **not** import `src/lib/…` directly (that
+code expects a Next request context — cookies/headers for next-intl, the session —
+and wrangler would bundle it a second time). Instead it calls OpenNext's `fetch`
+**as a function in the same isolate** with a synthetic
+`POST /api/cron/integrations-autosync`. So the sync runs in
+an ordinary Next route handler, and:
+
+- **Env / secrets**: OpenNext's `fetch` wrapper copies every string binding
+ (`vars` and `wrangler secret`s: `DATABASE_URL`, `FIELD_ENCRYPTION_KEY`, …) into
+ `process.env` on the isolate's first request — the synthetic request counts —
+ exactly as for web traffic. Nothing extra to configure. (The synthetic request
+ uses the real public origin because OpenNext also derives its origin from the
+ isolate's first request.)
+- **Auth of the internal route**: `scheduled` mints a one-time 256-bit token, keeps
+ it in a `globalThis` set for the duration of the call and sends it in a header;
+ the route consumes it or answers 404. The token never leaves isolate memory, so
+ the route is unreachable from the internet and needs **no new secret**
+ ([`src/lib/cron-token.ts`](../src/lib/cron-token.ts)). `/api/cron` is excluded
+ from the auth proxy's matcher (`src/proxy.ts`), since there is no session.
+- **Limits**: a cron invocation may run up to 15 minutes wall-clock on the paid
+ plan; the sync is sequential and I/O bound. A non-2xx from the route throws, so
+ Cloudflare marks that cron run as failed (Workers → the worker → Settings →
+ Trigger Events / logs). Every org × integration result is also logged
+ (`[autosync] …`) and stored in the integration's `config.lastAutoSync`.
+
+Nothing changes for deploys: `bun run cf:build` still produces `.open-next/`, and
+`wrangler deploy` (Workers Builds' deploy command) bundles `worker.ts`, which
+imports it. The cron schedule is part of `wrangler.jsonc`, so it is created/updated
+by that same deploy. To exercise `scheduled` locally:
+`bun run cf:build && bunx wrangler dev --test-scheduled`, then
+`curl "http://localhost:8787/__scheduled?cron=0+22+*+*+*"` (wrangler's test
+endpoint). **This really syncs** against whatever DB and integrations `.dev.vars`
+points to — for a harmless check of the wiring, point it at a DB with no enabled
+integrations. On production, the owner-facing way to test is 設定 › 整合 →
+「立即執行自動同步」 (or MCP `run_integration_sync`), which runs the same code for
+one organization.
+
+**Migration**: `migrations/0028_activity_channel_system.sql` allows
+`activity_log.channel = 'system'` (the auto-sync's audit entries, no human actor).
+Deploying before running it is safe — those log inserts are silently dropped by
+the CHECK until it runs; the sync itself is unaffected.
+
### Useful scripts
| Script | What it does |
diff --git a/docs/integrations.md b/docs/integrations.md
index 090afa3..4c9a4e2 100644
--- a/docs/integrations.md
+++ b/docs/integrations.md
@@ -19,7 +19,8 @@ settings page just lists it alongside the others.
| `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. |
+| `src/lib/mcp/tools-integrations.ts` | `list_integrations` (incl. `autoSync` / `lastAutoSync`), `run_integration_sync`, plus `requireIntegrationForTool` and `auditIntegrationCall` for provider tools. |
+| `src/lib/integrations/autosync.ts` | Daily auto-sync (see [每日自動同步](#每日自動同步)). |
## Lifecycle
@@ -341,6 +342,69 @@ Simpany 上已不存在的表單 / 員工會從本地刪掉,所以重跑是冪
未指定員工的薪資支出);MCP `simpany_list_salary_declarations`、`simpany_sync_salary_declarations`、
`salary_arrears`(見 [mcp.md](mcp.md))。
+## 每日自動同步
+
+已連接、已開啟、狀態正常的整合每天自動同步一次,不用再有人去按「同步」。
+
+**排程**:Cloudflare Cron Trigger `0 22 * * *`(UTC)= 每天**台北 06:00**。接線方式
+(`worker.ts` 的 `scheduled()` → 內部 route `/api/cron/integrations-autosync`)見
+[deployment.md](deployment.md#custom-worker-entry--cron-trigger-daily-integration-auto-sync)。
+
+**範圍**:`org_integrations` 裡 `enabled = true AND status = 'connected'`、且
+`config.autoSync !== false` 的每一列(每個組織、每個整合)。
+
+| 整合 | 跑什麼 | 等同於 |
+| --- | --- | --- |
+| Simpany | `syncSimpanyInvoices(org, 最近 90 天~今天)`(台北日期)→ `syncSalaryDeclarations(org, 今年)`;一月時另外跑去年 | 發票頁「從 Simpany 同步」+ 薪資頁「Simpany 薪資申報」同步 |
+| Wise | `syncWiseTransactions(org, { dryRun: false })`;還沒設帳戶對應就略過(不算失敗、不打 Wise) | 帳戶頁「從 Wise 同步」按「寫入」 |
+
+**安全**:
+
+- **只同步,不開立**:Simpany 只走列表 / 明細 / 薪資申報的 GET;自動同步沒有任何開立、
+ 作廢發票或其他 Simpany 寫入端點的程式碼路徑。Wise 本來就只有 GET(見上方唯讀保證)。
+- Wise 寫入是安全的:以 referenceNumber 去重(`ON CONFLICT DO NOTHING`,刪掉的也不會長回來)、
+ 永遠不早於切換日、新列一律「待確認」。Simpany 發票與薪資申報同步都是冪等 upsert。
+- **依序、不平行**:一個組織一個整合接著跑,對 Simpany 的非官方 API 溫和一點。
+- **互相隔離**:每個 組織 × 整合(以及 Simpany 的發票 / 薪資各步驟)各自 try/catch,
+ 一個失敗不影響其他。401 / 5xx 照舊由 provider 程式碼 `markNeedsReauth` /
+ `recordSyncFailure`;本地錯誤(寫 DB…)由自動同步補記 `recordSyncFailure`。
+ 整合在跑的途中被關掉或轉成需要重新連接時不覆寫 `last_error`。
+- 發票同步一次最多抓 80 張明細(`MAX_DETAIL_FETCHES`,Workers subrequest 上限);
+ 沒抓完會在摘要註明,隔天接著做。
+
+**結果**:
+
+- `config.lastAutoSync = { at, ok, summary, error, trigger }`(`updateConfig` 淺層合併;非機密,
+ 成員與 MCP 看得到)。`summary` 是 zh-TW 的一行摘要,例如
+ 「發票 2026-07-01~2026-09-29:讀到 12 張、新增 1、更新 0、作廢 0、自動綁定 1、待確認 0;薪資申報 2026:寫入 36 筆」。
+- 操作紀錄每個 組織 × 整合 一筆,entity = `integration`。排程觸發的來源是 **system**(`channel = 'system'`,
+ 操作人欄位全 NULL —— 不冒充任何成員;需要 `migrations/0028`);手動觸發記成按下的那位成員(web / mcp)。
+
+**開關**(owner / admin):設定 › 整合 每個已連接的 Simpany / Wise 列上有「自動同步」開關,
+存成 `config.autoSync`(沒有這個欄位 = 開,預設開);下面一行顯示
+「上次自動同步:YYYY-MM-DD HH:mm · 成功 / 失敗:…」(台北時間)。
+關掉自動同步不影響手動同步與 MCP 工具。
+
+**手動觸發**(測試用,只跑目前組織、同一條程式碼路徑 `runScheduledSync(now, { orgId, trigger: "manual" })`):
+
+- Web:設定 › 整合 上方的「立即執行自動同步」(owner / admin)。
+- MCP:`run_integration_sync`(owner / admin,write,openWorldHint)。
+
+| Where | What |
+| --- | --- |
+| `src/lib/integrations/autosync.ts` | `runScheduledSync(now, { orgId?, trigger?, audit? })`、`autoSyncWindow(now)` |
+| `src/lib/integrations/autosync-config.ts` | client-safe:`isAutoSyncOn(config)`、`parseLastAutoSync(config)`、`AUTO_SYNC_PROVIDERS` |
+| `src/app/api/cron/integrations-autosync/route.ts` | cron 的內部進入點(一次性 token,外部一律 404) |
+| `src/lib/cron-token.ts` | 一次性 token(`worker.ts` 與 route 共用 `globalThis` 上的 Set) |
+| `src/i18n/server-t.ts` | `runAsSystem()` / `getServerT(namespace)`:沒有 request 語系時用 zh-TW 字典 |
+| `worker.ts`、`wrangler.jsonc` `triggers` | Cron Trigger 與自訂 worker 進入點 |
+
+**i18n**:同步路徑上唯一用到翻譯的是 `store.ts`(`requireEnabledIntegration` 的錯誤訊息、
+`integrationDisplayName`)。它們改用 `getServerT()`:在 `runAsSystem()` 裡(自動同步一律如此)
+回傳 zh-TW 的 `createTranslator`,不碰 `cookies()` / `headers()`;其他情況就是原本的
+`getTranslations`,web 與 MCP 行為不變。新增會在自動同步路徑上用到翻譯的程式碼時,
+用 `getServerT` 而不是 `getTranslations`。
+
## Security rules
- Credentials are encrypted with `FIELD_ENCRYPTION_KEY` (see
diff --git a/docs/mcp.md b/docs/mcp.md
index 9e3c9d4..66895fa 100644
--- a/docs/mcp.md
+++ b/docs/mcp.md
@@ -123,8 +123,8 @@ the `tools-*.ts` modules):
- `_meta["openai/toolInvocation/invoking" | "invoked"]`, the status line ChatGPT
shows while a call is in flight.
-**Output schemas.** Every tool declares an `outputSchema` — all 88 of them, as of
-server version 1.7.0 (the Simpany tools whose result shape comes from Simpany's
+**Output schemas.** Every tool declares an `outputSchema` — all 89 of them, as of
+server version 1.8.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
@@ -245,7 +245,12 @@ reconciliations: `list_reconciliations` +
**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.
+`tokenExpiresAt`) plus its non-secret `config`, the daily auto-sync switch
+(`autoSync`) and the last auto-sync result (`lastAutoSync`). It never returns
+credentials. `run_integration_sync` (owner/admin, write, open world) runs the daily
+auto-sync right now for the current organization only — the same code path as the
+06:00 Taipei Cron Trigger (Simpany invoices + salary declarations, Wise
+transactions; never issues or voids invoices).
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
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 9d6f305..96ba8f7 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -15,6 +15,11 @@ const eslintConfig = defineConfig([
// Generated by `bun run cf:typegen` (wrangler). Regenerated wholesale, so
// hand-fixing lint findings in it would just be undone on the next run.
"cloudflare-env.d.ts",
+ // OpenNext / wrangler build output (`bun run cf:build`). worker.ts at the repo
+ // root imports .open-next/worker.js, and without this eslint would crawl the
+ // whole generated server bundle.
+ ".open-next/**",
+ ".wrangler/**",
]),
]);
diff --git a/migrations/0028_activity_channel_system.sql b/migrations/0028_activity_channel_system.sql
new file mode 100644
index 0000000..f5e921d
--- /dev/null
+++ b/migrations/0028_activity_channel_system.sql
@@ -0,0 +1,14 @@
+-- 0028: 操作紀錄加一個來源 'system'。
+--
+-- 整合每日自動同步(src/lib/integrations/autosync.ts,Cloudflare Cron Trigger 觸發)
+-- 不是任何成員發起的,所以不能記成 web 或 mcp、也不能冒充某位成員。它寫進
+-- activity_log 時 channel = 'system',actor_user_id / actor_email / actor_name 皆 NULL。
+--
+-- 在這支 migration 跑之前部署程式碼也安全:src/db/activity.ts 的 record() 會吞掉
+-- CHECK 違規,只是少記那幾筆「系統自動同步」的操作紀錄,同步本身照常。
+--
+-- Forward-only,只放寬 CHECK。Run AFTER 0027。
+
+ALTER TABLE activity_log DROP CONSTRAINT chk_activity_channel;
+ALTER TABLE activity_log
+ ADD CONSTRAINT chk_activity_channel CHECK (channel = ANY (ARRAY['web', 'mcp', 'system']));
diff --git a/src/app/api/cron/integrations-autosync/route.ts b/src/app/api/cron/integrations-autosync/route.ts
new file mode 100644
index 0000000..26bdae5
--- /dev/null
+++ b/src/app/api/cron/integrations-autosync/route.ts
@@ -0,0 +1,21 @@
+import { consumeCronToken, CRON_TOKEN_HEADER } from "@/lib/cron-token";
+import { runScheduledSync } from "@/lib/integrations/autosync";
+
+export const dynamic = "force-dynamic";
+
+/**
+ * 整合每日自動同步的進入點 —— **只給 worker.ts 的 `scheduled()` 呼叫**。
+ *
+ * `scheduled()` 在同一個 isolate 裡以函式呼叫 OpenNext 的 fetch handler 打這條路徑,
+ * 帶一個只存在於記憶體、用完即刪的 token(src/lib/cron-token.ts)。從網際網路打進來的
+ * 請求不可能有那個 token,一律 404(不回 401 / 403,不透露這裡有東西)。
+ *
+ * src/proxy.ts 的 matcher 排除了 /api/cron:這裡沒有 session cookie,不能被導去登入頁。
+ */
+export async function POST(request: Request): Promise {
+ if (!consumeCronToken(request.headers.get(CRON_TOKEN_HEADER))) {
+ return new Response("Not Found", { status: 404 });
+ }
+ const result = await runScheduledSync(new Date(), { trigger: "cron" });
+ return Response.json(result);
+}
diff --git a/src/app/dashboard/activity/page.tsx b/src/app/dashboard/activity/page.tsx
index c818af8..6725724 100644
--- a/src/app/dashboard/activity/page.tsx
+++ b/src/app/dashboard/activity/page.tsx
@@ -80,11 +80,11 @@ export default async function ActivityPage() {
{formatDateTime(r.createdAt)}
- {r.actorName ?? r.actorEmail ?? "—"}
+ {r.actorName ?? r.actorEmail ?? (r.channel === "system" ? t("systemActor") : "—")}
- {r.channel === "mcp" ? (
- {t("source.mcp")}
+ {r.channel === "mcp" || r.channel === "system" ? (
+ {t(`source.${r.channel}`)}
) : (
{t("source.web")}
)}
diff --git a/src/app/dashboard/settings/integrations/actions.ts b/src/app/dashboard/settings/integrations/actions.ts
index ebcdb7b..51fd2cd 100644
--- a/src/app/dashboard/settings/integrations/actions.ts
+++ b/src/app/dashboard/settings/integrations/actions.ts
@@ -12,7 +12,10 @@ import {
getIntegration,
saveConnection,
setIntegrationEnabled,
+ updateConfig,
} from "@/lib/integrations/store";
+import { supportsAutoSync } from "@/lib/integrations/autosync-config";
+import { runScheduledSync } from "@/lib/integrations/autosync";
import {
isIntegrationProviderId,
type IntegrationCatalogEntry,
@@ -246,3 +249,81 @@ export async function disconnectIntegration(
return { ok: false, error: e instanceof Error ? e.message : t("toast.failed") };
}
}
+
+/** 每日自動同步的開關(config.autoSync)。沒存 = 開;關掉存 false。 */
+export async function setIntegrationAutoSyncAction(
+ providerArg: string,
+ autoSync: boolean,
+): Promise {
+ const t = await getTranslations("integrations");
+ // 在 try 外面:未登入時 requireOrg() 會 redirect,那個例外不能被吞掉。
+ const me = await requireManager();
+ if ("error" in me) return { ok: false, error: me.error };
+ try {
+ if (!isIntegrationProviderId(providerArg) || !supportsAutoSync(providerArg)) {
+ return { ok: false, error: t("errors.unknownProvider", { provider: String(providerArg) }) };
+ }
+ const provider: IntegrationProviderId = providerArg;
+ const name = t(`providers.${provider}.name`);
+ const row = await getIntegration(me.orgId, provider);
+ if (!row) return { ok: false, error: t("errors.notConnected", { name }) };
+ if ((row.config.autoSync !== false) !== autoSync) {
+ await updateConfig(me.orgId, provider, { autoSync });
+ await logWeb(
+ me.orgId,
+ "update",
+ "integration",
+ null,
+ autoSync ? t("activity.autoSyncOn", { name }) : t("activity.autoSyncOff", { name }),
+ );
+ }
+ revalidatePath(PAGE);
+ return { ok: true };
+ } catch (e) {
+ return { ok: false, error: e instanceof Error ? e.message : t("toast.failed") };
+ }
+}
+
+export type RunAutoSyncState = IntegrationActionState & {
+ /** 實際跑了幾個 / 失敗幾個(跑了 0 個 = 沒有符合條件的整合)。 */
+ ran?: number;
+ failed?: number;
+};
+
+/**
+ * 「立即執行自動同步」:只跑目前組織,走跟每日 cron 完全相同的 runScheduledSync。
+ * 給 owner / admin 測試用;操作紀錄記成按下按鈕的這位成員(channel = web),不是 system。
+ */
+export async function runAutoSyncNowAction(): Promise {
+ const t = await getTranslations("integrations");
+ // 在 try 外面:未登入時 requireOrg() 會 redirect,那個例外不能被吞掉。
+ const me = await requireManager();
+ if ("error" in me) return { ok: false, error: me.error };
+ try {
+ const run = await runScheduledSync(new Date(), {
+ orgId: me.orgId,
+ trigger: "manual",
+ audit: async (orgId, provider, ok, summary) => {
+ await logWeb(
+ orgId,
+ "update",
+ "integration",
+ null,
+ t("activity.autoSyncRun", {
+ name: t(`providers.${provider}.name`),
+ result: ok ? t("activity.autoSyncOk") : t("activity.autoSyncFailed"),
+ summary,
+ }),
+ );
+ },
+ });
+ revalidatePath(PAGE);
+ return {
+ ok: true,
+ ran: run.results.length,
+ failed: run.results.filter((r) => !r.ok).length,
+ };
+ } catch (e) {
+ return { ok: false, error: e instanceof Error ? e.message : t("toast.failed") };
+ }
+}
diff --git a/src/app/dashboard/settings/integrations/integrations-client.tsx b/src/app/dashboard/settings/integrations/integrations-client.tsx
index 963c5a5..14b6aa1 100644
--- a/src/app/dashboard/settings/integrations/integrations-client.tsx
+++ b/src/app/dashboard/settings/integrations/integrations-client.tsx
@@ -4,7 +4,7 @@ import { useOptimistic, useState, useTransition } from "react";
import Link from "next/link";
import { toast } from "sonner";
import { useTranslations } from "next-intl";
-import { Info, Link2, RefreshCw, Unlink } from "lucide-react";
+import { CalendarClock, Info, Link2, Play, RefreshCw, Unlink } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
@@ -38,6 +38,8 @@ import {
connectIntegration,
disconnectIntegration,
reconnectIntegration,
+ runAutoSyncNowAction,
+ setIntegrationAutoSyncAction,
setIntegrationEnabledAction,
} from "./actions";
@@ -57,6 +59,12 @@ export type IntegrationRowData = {
connectedByName: string | null;
lastSyncedAt: string | null;
lastError: string | null;
+ /** 每日自動同步;這個整合沒有自動同步可跑時為 null。 */
+ autoSync: {
+ on: boolean;
+ /** 上次自動同步;`at` 已格式化成台北時間。 */
+ last: { at: string; ok: boolean; error: string | null; manual: boolean } | null;
+ } | null;
} | null;
};
@@ -65,10 +73,13 @@ type SheetTarget = { row: IntegrationRowData; mode: "connect" | "reconnect"; non
export function IntegrationsList({
rows,
canManage,
+ canRunAutoSync,
calendar,
}: Readonly<{
rows: IntegrationRowData[];
canManage: boolean;
+ /** owner / admin 且至少有一個整合支援自動同步時,顯示「立即執行自動同步」。 */
+ canRunAutoSync: boolean;
calendar: { connected: boolean; ownerLabel: string | null };
}>) {
const t = useTranslations("integrations");
@@ -89,6 +100,7 @@ export function IntegrationsList({
{t("readOnlyNote")}
)}
+