Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
67 changes: 66 additions & 1 deletion docs/integrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,14 +234,15 @@ Simpany 改版就可能壞,所以所有回應都防禦式解析,認不得就
| `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/simpany-salary.ts` | 薪資申報唯讀同步與欠薪對帳(見下方「薪資申報」) |
| `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 | 用途 |
| --- | --- | --- |
Expand Down Expand Up @@ -276,6 +277,70 @@ Simpany 改版就可能壞,所以所有回應都防禦式解析,認不得就
**xlsx 對帳**(`src/lib/simpany-export.ts`、發票 › Simpany 對帳)保留,給沒開整合的組織用;
API 同步取代它。

### 薪資申報(唯讀)+ 欠薪對帳

Simpany 也是公司申報薪資(扣繳、勞健保)的地方。這裡**只讀**它的薪資申報,存到本地,再跟
本系統實際記錄的發薪對帳,算出每位員工每月的欠薪。

| Where | What |
| --- | --- |
| `src/lib/integrations/simpany.ts` | `SimpanyClient.listSalaryMonthlyForms(year)`、`getSalaryForm(year, month)`、`assertSalaryReadOnly()` |
| `src/lib/simpany-salary.ts` | `syncSalaryDeclarations(orgId, year)`、`salaryReconciliation(orgId, opts)`、`listSalaryDeclarationsLive()`、`allocatePayments()` |
| `migrations/0027_simpany_salary_declarations.sql` | `simpany_salary_forms`(一個月一列)、`simpany_salary_declarations`(員工 × 月一列) |
| `src/app/dashboard/payroll/simpany-salary-*.ts(x)` | 薪資頁「Simpany 薪資申報」區塊、同步 server action |

**端點**(不同 host / path:沒有 `c/`,在 `api.simpany.co`;header 與 JWT 同上;回應 `{status, code, data, meta}`):

| Endpoint | 用途 |
| --- | --- |
| `GET api.simpany.co/v1/{companyId}/salary-declaration/form/monthly-forms/{year}` | 一年 12 格 `{id\|null, year, rocYear, month, employees[{id, name}]}`;`id` null = 那個月沒建表單 |
| `GET api.simpany.co/v1/{companyId}/salary-declaration/form?year=YYYY&month=M` | 那個月的表單:`payday`、`isSettled`、每位員工的 `salaryDeclaration.salaryDeclarationItems[{name, type, amount}]` |

`month` 是**薪資所屬月份**(`yearMonth`),發薪日通常是次月 5 日(`payday`)。

**唯讀護欄**:薪資請求一律走 `SimpanyClient` 的私有 `salaryGet()` → `assertSalaryReadOnly()`:
method 必須是 GET、路徑必須符合白名單(`form/monthly-forms/{yyyy}`、`form`)、query key 只能是
`year` / `month`,否則**不發請求**直接丟錯。結算、複製、建立、寄薪資單等端點沒有任何程式碼路徑;
要加端點只能加唯讀的 GET 到白名單。

**個資**:Simpany 的回應含身分證字號(`personalId`)、戶籍地址(`address`)、國籍(`nationality`)。
`parseSalaryForm` 只挑白名單欄位組新物件,這三個欄位**從來不會被讀進記憶體裡的結構**,所以不會進
DB、log、錯誤訊息、server action 或 MCP 結果。薪資請求的錯誤訊息也不附 body 片段(`salaryErrorMessage`)。
本地只存姓名、Simpany 員工 id、金額、日期、旗標;`items` 只有 `{name, type, amount}`(不存 Simpany 的 `note`)。

**資料表**(兩張而不是一張加哨兵列:「沒建表單 / 表單空白 / 已申報」是月份層級的事實,跟員工無關):

- `simpany_salary_forms`:`(org, year, month)` 唯一。`simpany_form_id` NULL = 未建立;
`filed_count = 0` = 表單空白;`is_settled` = 已申報。沒有列 = 沒同步過。
- `simpany_salary_declarations`:`(org, year, month, simpany_employee_id)` 唯一。從明細抽出
本薪、非經常性獎金、實際申報薪資(`gross_declared`)、實際發薪(`net_pay`)、勞健保個人 / 公司負擔、
就業保險;`filed` = 有申報明細。`employee_id` = 同組織姓名完全相同的員工(重名不綁),否則 NULL。

月份狀態:`missing`(未建立)、`empty`(表單空白)、`draft`(有明細但沒結算)、`settled`(已申報)、
`not_synced`。

**同步**(`syncSalaryDeclarations`,owner / admin):1 + (有表單的月數) 個 GET。以唯一鍵 upsert;
Simpany 上已不存在的表單 / 員工會從本地刪掉,所以重跑是冪等的。只寫本組織的這兩張表。

**對帳**(`salaryReconciliation`,只讀本地表):

- 應發 = 已申報月份的 `net_pay`。未申報的月份(沒表單、表單空白、表單上沒有這個人)在任職期間內
(`employees.start_date`,沒有就從第一個有申報的月份起;到 `end_date`)且發薪日已過時,用
`expectedMonthlyNet` 估:預設 = 最近一次申報的實發 − 非經常性獎金,可用姓名覆寫;這些月份標
`estimated: true`,另計在 `estimatedArrears`,不混進 `arrears`。
- 已發 = (1) `payslips`(有 `paid_transaction_id` 的以那筆交易為準;沒有交易但批次 `status = paid` 的以
`net_pay` 計)+ (2) `type = expense`、分類「薪資費用」、`txn_date` 在 `paidFrom ~ paidTo` 的交易,
對象是員工(`settle_employee_id`,或對象 party 名稱 = 員工姓名)。屬於別年 payslip 的交易不算。
- 分配(`allocatePayments`):payslip 有期別的先補那個月;其餘(含超付部分)依付款日期先進先出,
從最舊的欠款月份補起;再有剩 = `credit`。
- 發薪日(表單 `payday`,沒有就假設次月 5 日)還沒到的月份,未付金額算 `notYetDue`,不算欠薪。
- 分類是「薪資費用」卻對不到任何員工的交易 → `unallocatedPayments`,讓 owner 去交易頁指派。
- 預設 `throughMonth` = 今年的本月 / 過去年份 12;`paidFrom` = 1/1;`paidTo` = 今天 / 過去年份為隔年 1/31。

入口:薪資頁(`/dashboard/payroll?year=YYYY`)的「Simpany 薪資申報」區塊(月份狀態格、欠薪表、明細 Sheet、
未指定員工的薪資支出);MCP `simpany_list_salary_declarations`、`simpany_sync_salary_declarations`、
`salary_arrears`(見 [mcp.md](mcp.md))。

## Security rules

- Credentials are encrypted with `FIELD_ENCRYPTION_KEY` (see
Expand Down
18 changes: 13 additions & 5 deletions docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,10 +104,11 @@ the `tools-*.ts` modules):
derived from the verb, plus `openWorldHint`, which is `false` for everything
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:
Wise) and the `simpany_*` tools (`salary_arrears` reads only our own tables
and stays closed-world). 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
cannot be undone); `simpany_list_*` / `simpany_get_invoice` / `salary_arrears` 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
Expand All @@ -122,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 85 of them, as of
server version 1.6.0 (the Simpany tools whose result shape comes from Simpany's
**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
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
Expand Down Expand Up @@ -276,6 +277,13 @@ integrations.md):
| `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 外銷勞務, …). |
| `simpany_list_salary_declarations` | `year?` (default this year), `month?` (1-12, salary month) | Read live from Simpany (GET only, `assertSalaryReadOnly`). Per month: status (`missing`/`empty`/`draft`/`settled`), payday, and per employee name, Simpany id, owner flag, filed flag, base / bonus / declared gross / net paid / insurance amounts and `{name,type,amount}` items. **No national id, address or nationality.** |
| `simpany_sync_salary_declarations` | `year?` | Owner/admin. GETs the year from Simpany and upserts `simpany_salary_forms` / `simpany_salary_declarations` (idempotent; removes forms/employees gone from Simpany). Links employees by exact name; returns `unmatchedNames`. Writes nothing to Simpany. |
| `salary_arrears` | `year?`, `throughMonth?`, `paidFrom?`, `paidTo?`, `estimateUnfiled?` (default true), `expectedMonthlyNet?` (`{name: amount}`) | Reads **only our tables** (closed world). Per employee: `totalDeclaredNet`, `totalPaid`, `arrears`, `estimatedArrears` (unfiled months, `estimated: true`), `notYetDue`, `credit`, monthly rows and payments with allocations; plus the month grid and `unallocatedPayments` (薪資費用 outflows with no employee). Payslip periods first, then FIFO by date. |

Salary declarations are read-only end to end: the salary endpoints are GET-only and
path-whitelisted in `src/lib/integrations/simpany.ts`, and the only writes are to
this organization's own `simpany_salary_*` tables.

**Not exposed (do in the app):** creating an organization, uploading
invoice/receipt **files** (R2), multi-currency FX entry, and *connecting* Google
Expand Down Expand Up @@ -342,7 +350,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 85 tools would answer with an empty array and the reviewer has no way to
of the 88 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
85 changes: 85 additions & 0 deletions migrations/0027_simpany_salary_declarations.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
-- 0027: Simpany 薪資申報(唯讀同步)+ 欠薪對帳。
--
-- Simpany 除了電子發票,也是公司申報薪資(扣繳、勞健保)的地方。這裡把它的「薪資申報」
-- 表單**唯讀**拉回來(src/lib/integrations/simpany.ts 的 listSalaryMonthlyForms /
-- getSalaryForm,只允許 GET + 路徑白名單),存成兩張表,再拿去和本系統實際發出的薪資
-- (payslips + 薪資費用交易)對帳,算出每位員工每個月還欠多少(src/lib/simpany-salary.ts)。
--
-- 為什麼是兩張表而不是在明細表塞「這個月沒有表單」的哨兵列:
-- 「某月沒建表單」「表單建了但沒人申報」「已申報」是**月份層級**的事實,和員工無關;
-- 硬塞進 (org, year, month, simpany_employee_id) 唯一鍵的表裡就得用 NULL 員工當哨兵,
-- 唯一索引、查詢、FK 都會變醜。所以:
-- simpany_salary_forms 一個月一列(Simpany 的 monthly-forms 12 格都存),
-- simpany_form_id NULL = 那個月沒建表單(UI 顯示「未建立」)。
-- simpany_salary_declarations 一位員工一個月一列(只有表單存在的月份才有)。
-- 沒有 forms 列的月份 = 從來沒同步過。
--
-- ⚠️ 個資:Simpany 的回應裡有身分證字號、戶籍地址、國籍 —— **一律不存**(解析時就丟掉,
-- 不進 DB、不進 log、不進任何回傳值)。這裡只有姓名、Simpany 員工 id、金額、日期、旗標。
-- items 只存 { name, type, amount },不存 Simpany 的 note。
--
-- Forward-only,全部 additive。Run AFTER 0026。

CREATE TABLE simpany_salary_forms (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
organization_id text NOT NULL REFERENCES "organization"(id) ON DELETE CASCADE,
year integer NOT NULL,
month integer NOT NULL,
-- NULL = Simpany 那個月沒有建立薪資申報表單
simpany_form_id bigint,
payday date,
is_settled boolean NOT NULL DEFAULT false,
-- 表單上的員工數 / 其中真的有申報明細的人數(0 = 表單空白)
employee_count integer NOT NULL DEFAULT 0,
filed_count integer NOT NULL DEFAULT 0,
synced_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT uq_simpany_salary_form UNIQUE (organization_id, year, month),
CONSTRAINT chk_simpany_salary_form_month CHECK (month BETWEEN 1 AND 12)
);

COMMENT ON TABLE simpany_salary_forms IS 'Simpany 薪資申報的月份狀態(唯讀同步);simpany_form_id NULL = 那個月沒建表單';
COMMENT ON COLUMN simpany_salary_forms.month IS '薪資所屬月份(Simpany yearMonth),不是發薪日的月份';
COMMENT ON COLUMN simpany_salary_forms.payday IS 'Simpany 表單上的發薪日(通常是次月 5 日)';
COMMENT ON COLUMN simpany_salary_forms.is_settled IS 'Simpany isSettled:已結算 / 已申報';
COMMENT ON COLUMN simpany_salary_forms.filed_count IS '表單上有申報明細(salaryDeclarationItems)的員工數;0 且 employee_count > 0 = 表單空白';

CREATE TABLE simpany_salary_declarations (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
organization_id text NOT NULL REFERENCES "organization"(id) ON DELETE CASCADE,
year integer NOT NULL,
month integer NOT NULL,
simpany_form_id bigint,
payday date,
is_settled boolean NOT NULL DEFAULT false,
simpany_employee_id bigint NOT NULL,
employee_name text NOT NULL,
-- 本系統的員工:同組織、姓名完全相同才綁;對不到就 NULL
employee_id bigint REFERENCES employees(id) ON DELETE SET NULL,
is_company_owner boolean NOT NULL DEFAULT false,
base_salary numeric(18,2),
bonus numeric(18,2),
gross_declared numeric(18,2),
net_pay numeric(18,2),
labor_ins_personal numeric(18,2),
health_ins_personal numeric(18,2),
labor_ins_company numeric(18,2),
health_ins_company numeric(18,2),
employment_ins_company numeric(18,2),
items jsonb NOT NULL DEFAULT '[]'::jsonb,
filed boolean NOT NULL DEFAULT false,
synced_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT uq_simpany_salary_decl UNIQUE (organization_id, year, month, simpany_employee_id),
CONSTRAINT chk_simpany_salary_decl_month CHECK (month BETWEEN 1 AND 12)
);

CREATE INDEX idx_simpany_salary_decl_employee ON simpany_salary_declarations (employee_id)
WHERE employee_id IS NOT NULL;

COMMENT ON TABLE simpany_salary_declarations IS 'Simpany 薪資申報明細(唯讀同步),一位員工一個月一列;不含身分證字號 / 地址 / 國籍';
COMMENT ON COLUMN simpany_salary_declarations.employee_id IS '本系統 employees.id;同組織姓名完全相同才綁,否則 NULL';
COMMENT ON COLUMN simpany_salary_declarations.base_salary IS '本薪';
COMMENT ON COLUMN simpany_salary_declarations.bonus IS '非經常性薪資(獎金)';
COMMENT ON COLUMN simpany_salary_declarations.gross_declared IS '實際申報薪資(應發總額)';
COMMENT ON COLUMN simpany_salary_declarations.net_pay IS '實際發薪(扣除個人負擔勞健保、扣繳後的實發)';
COMMENT ON COLUMN simpany_salary_declarations.items IS 'Simpany salaryDeclarationItems 去識別化:[{ name, type, amount }]';
COMMENT ON COLUMN simpany_salary_declarations.filed IS 'true = 這位員工這個月有申報明細;false = 表單上有人但沒申報';
35 changes: 31 additions & 4 deletions src/app/dashboard/payroll/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,34 @@ import { listPayslipRecords } from "@/db/queries";
import { formatCurrency, formatYearMonth } from "@/lib/format";
import { DeleteButton } from "@/components/delete-button";
import { deletePayslip } from "@/db/mutations";
import { requireOrg } from "@/lib/session";
import { canManageOrg, requireOrgWithRole } from "@/lib/session";
import { formatAccountShort } from "@/lib/employee-accounts";
import { getIntegration } from "@/lib/integrations/store";
import { salaryReconciliation } from "@/lib/simpany-salary";
import { taipeiDate } from "@/lib/simpany-sync";
import { SimpanySalarySection } from "./simpany-salary-section";

export const dynamic = "force-dynamic";

export default async function PayrollPage() {
export default async function PayrollPage({
searchParams,
}: Readonly<{ searchParams: Promise<{ year?: string }> }>) {
const t = await getTranslations("payroll");
const { orgId } = await requireOrg();
const rows = await listPayslipRecords(orgId);
const { orgId, role } = await requireOrgWithRole();
const { year: yearParam } = await searchParams;
const thisYear = Number(taipeiDate().slice(0, 4));
const parsedYear = Number(yearParam);
const year =
Number.isInteger(parsedYear) && parsedYear >= 2000 && parsedYear <= 2100 ? parsedYear : thisYear;
const [rows, integration, recon] = await Promise.all([
listPayslipRecords(orgId),
getIntegration(orgId, "simpany"),
// 對帳失敗(例如 migration 0027 還沒跑)不該讓整個薪資頁掛掉:記 log、不顯示該區塊。
salaryReconciliation(orgId, { year }).catch((e: unknown) => {
console.error("salaryReconciliation failed", e);
return null;
}),
]);

return (
<>
Expand Down Expand Up @@ -108,6 +127,14 @@ export default async function PayrollPage() {
</TableBody>
</Table>
</TableCard>

{recon && (integration || recon.lastSyncedAt) ? (
<SimpanySalarySection
recon={recon}
integration={integration}
canManage={canManageOrg(role)}
/>
) : null}
</>
);
}
Loading
Loading