diff --git a/docs/integrations.md b/docs/integrations.md index 4c9a4e2..fc6af5a 100644 --- a/docs/integrations.md +++ b/docs/integrations.md @@ -236,6 +236,7 @@ Simpany 改版就可能壞,所以所有回應都防禦式解析,認不得就 | `src/lib/simpany-sync.ts` | Simpany → `invoices` 同步、自動綁定、作廢清理 | | `src/lib/simpany-issue.ts` | 預覽(`invoice_drafts`)→ 開立、作廢;MCP 與 web 共用 | | `src/lib/simpany-salary.ts` | 薪資申報唯讀同步與欠薪對帳(見下方「薪資申報」) | +| `src/lib/simpany-payroll.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` 表 | @@ -243,7 +244,7 @@ Simpany 改版就可能壞,所以所有回應都防禦式解析,認不得就 **Config**:`companyId` + `companyName`(非機密)。帳號底下只有一家公司時連接時自動選; 多家就要在連接 Sheet 填「公司 ID」(失敗訊息會列出可選的 ID)。 -**用到的端點**(其他一概不碰;薪資申報的唯讀端點另見下方「薪資申報」): +**用到的端點**(其他一概不碰;薪資申報的端點另見下方「薪資申報」與「薪資申報寫入」): | Host | Endpoint | 用途 | | --- | --- | --- | @@ -299,10 +300,10 @@ Simpany 也是公司申報薪資(扣繳、勞健保)的地方。這裡**只 `month` 是**薪資所屬月份**(`yearMonth`),發薪日通常是次月 5 日(`payday`)。 -**唯讀護欄**:薪資請求一律走 `SimpanyClient` 的私有 `salaryGet()` → `assertSalaryReadOnly()`: +**唯讀護欄**:同步與對帳用的薪資請求一律走 `SimpanyClient` 的私有 `salaryGet()` → `assertSalaryReadOnly()`: method 必須是 GET、路徑必須符合白名單(`form/monthly-forms/{yyyy}`、`form`)、query key 只能是 -`year` / `month`,否則**不發請求**直接丟錯。結算、複製、建立、寄薪資單等端點沒有任何程式碼路徑; -要加端點只能加唯讀的 GET 到白名單。 +`year` / `month`,否則**不發請求**直接丟錯。寫入端點另有一條獨立的白名單(`assertSalaryWrite`,見下方 +「薪資申報寫入」),只有 `src/lib/simpany-payroll.ts` 會用;同步、對帳、每日自動同步都不會碰到。 **個資**:Simpany 的回應含身分證字號(`personalId`)、戶籍地址(`address`)、國籍(`nationality`)。 `parseSalaryForm` 只挑白名單欄位組新物件,這三個欄位**從來不會被讀進記憶體裡的結構**,所以不會進 @@ -342,6 +343,85 @@ Simpany 上已不存在的表單 / 員工會從本地刪掉,所以重跑是冪 未指定員工的薪資支出);MCP `simpany_list_salary_declarations`、`simpany_sync_salary_declarations`、 `salary_arrears`(見 [mcp.md](mcp.md))。 +### 薪資申報寫入(準備 → 寫入 → 結算 → 寄薪資單) + +讓 owner / admin 從本系統(薪資頁、MCP)直接在 Simpany 填每月薪資申報,不用再到 Simpany 介面一格一格點。 +流程跟電子發票開立一樣是「預覽成草稿 → 使用者確認 → 只收 draft id 寫入」,結算與寄薪資單各自再要明確確認。 + +| Where | What | +| --- | --- | +| `src/lib/integrations/simpany.ts` | `assertSalaryWrite()`(寫入白名單)、`getSalaryDeclaration` / `getSalarySetting` / `calculateSalaryDeclaration` / `updateSalaryDeclaration` / `copySalaryForm` / `setSalaryPayday` / `setSalaryCompanyOwner` / `settleSalaryForm` / `sendSalaryPayslips` | +| `src/lib/simpany-payroll.ts` | `prepareSalaryFiling`、`applySalaryFiling`、`settleSalaryFiling`、`sendPayslips`、`buildDeclarationPayload`(純函式)、`salaryFilingDefaults`(web 預設值,只讀本地表) | +| `migrations/0029_simpany_salary_drafts.sql` | `simpany_salary_drafts`(pending → applied → settled;cancelled / expired) | +| `src/app/dashboard/payroll/simpany-salary-filing.tsx` | 月份格上的「準備申報 / 薪資單」Sheet | + +**寫入白名單**(`assertSalaryWrite`,相對於 `api.simpany.co/v1/{companyId}/salary-declaration/`;method + 路徑逐條比對,不接受任何 query): + +| Method | Path | 用途 | +| --- | --- | --- | +| GET | `form/{formId}/salary-declaration/{declId}` | 單一申報明細(模板) | +| GET | `form/{formId}/setting` | 加項 / 減項選項、基本工資、投保級距表 | +| POST | `form/{formId}/salary-declaration/{declId}/calculate` | Simpany 的即時試算(不存檔);只在「準備」時呼叫 | +| PUT | `form/{formId}/salary-declaration/{declId}` | 存檔申報明細(整份覆寫);422 → `{errors:{field:[msg]}}` | +| POST | `form/{formId}/copy` `{sourceFormId, employeeIds}` | 從別的月份複製員工與申報明細 | +| PATCH | `form/{formId}/payday` `{payday}` | 發薪日;可能回 `healthInsuranceRangeByPayDateAdjustments` | +| PUT | `form/{formId}/salary-declaration/{declId}/company-owner` `{isCompanyOwner}` | 負責人旗標 | +| POST | `form/{formId}/settle` `{resignedEmployeeIds: []}` | **結算,送給記帳士** | +| POST | `form/{formId}/payslip/send` `{mode, mailContent[, salaryDeclarationIds]}` | 寄薪資單;404 = 薪資單還在產生 | + +員工的建立 / 修改 / 刪除 / pre-check(都需要身分證字號)**刻意不在白名單**:表單上找不到的人一律回報 +`notInSimpany`,請使用者到 Simpany 的介面新增。 + +> ⚠️ 這些端點是從 Simpany 前端 bundle 讀出來的,payload 形狀以真實 GET 的回應比對過,但**寫入端點從未實際呼叫驗證** +> (開發時嚴禁打寫入端點)。第一次上線使用時要人工在 Simpany 介面對一次結果。 + +**1. 準備(`prepareSalaryFiling`)**——對 Simpany 只發 GET 與 POST calculate(allowCopy 時另有 POST copy): + +- `GET form?year&month`(Simpany 對還沒建立的月份會自動建空白表單,它的介面也是這樣)。已結算 → 拒絕。 + `monthSequenceRestriction`(例如 `EXISTING_OUT_OF_SEQUENCE_RECORDS` + `missingMonths`)→ 警示:月份必須依序結算,不繞過。 +- 對象:有給 `employees` 就用(`employeeId` 或與 Simpany 完全相同的姓名);沒給 = 這個月表單上的人 + 最近一個已結算月份的人, + 排除本系統記錄在這個月之前就離職的。 +- 表單上缺人 → 找最近一個有這些人的已結算表單(或 `sourceFormId`),規劃 `POST copy`。**複製是寫入**,只有 + `allowCopy: true` 才做;否則回傳計畫、不產生草稿。 +- 每位員工:`GET` 申報明細當模板 → `buildDeclarationPayload`: + - 只改日期與金額:薪資期間 = 整個月;勞保 / 勞退期間模板有值才改成整個月(沒有就維持 null)。 + - 本薪 = 輸入 → 員工資料的本薪 → 上次申報的本薪。 + - 沿用模板的:投保級距(INSURANCE_RANGE 原樣)、投保旗標、扶養人數、勞退自提 / 提繳率、每月固定的加項(1 免稅伙食津貼、36 經常性獎金)。 + - 一次性的**不沿用**並列在 `droppedItems`:非經常性獎金(11)、員工代墊款(49)、年終、加班費、減項(44 / 50)等;這個月有才用 + `bonus` / `reimbursement` / `otherAllowances` / `otherDeductions` 帶。 + - `salaryDeclarationItems` = 可編輯項目(ALLOWANCE、INSURANCE_RANGE、選項清單內的 DEDUCTION);其餘放 + `calculatedSalaryDeclarationItems`。INSURANCE_FEE(員工補助金額)目前歸在「算出來的」那一邊 —— 未經驗證。 +- `POST calculate` → 用回傳的 `calculatedSalaryDeclarationItems` 組成要 PUT 的 body,抽出應發、個人負擔、公司負擔、扣繳、 + 實發(`實際發薪`)、實際申報薪資、投保級距。本薪高於級距、Simpany 建議級距不同、低於基本工資、月中到離職都只警示,**不自動改級距**。 +- 負責人:`companyOwner` 輸入 → 最近一個已結算月份標記的人 → 這個月表單上標記的人。旗標不符時在寫入時修正(派斯是鄭宇傑: + 負責人健保全額自付、沒有就業保險)。 +- 發薪日預設次月 5 日;不是的話警示(Simpany 介面也會警告)。 +- 全部都算得出來、也沒有待複製的人,才寫一筆 `simpany_salary_drafts`(每份 PUT body 原樣 + 預覽,2 小時過期)。 + +**2. 寫入(`applySalaryFiling(draftId)`)**:條件式搶草稿(`pending → applied`,過期搶不到)→ 再讀一次表單(已結算 / 換了表單 / +申報明細不見 → 取消草稿)→ **負責人旗標 → 發薪日 → 逐一 PUT 申報明細**(旗標與發薪日是 Simpany 試算的輸入,所以先設)→ +讀回來比對每人實發(`verification`、`verified`)→ `syncSalaryDeclarations(org, year)`。任何一步失敗:草稿退回 `pending`, +`apply_result` 與錯誤訊息列出已修正的旗標、是否已改發薪日、已寫入 / 尚未寫入的人。每一步都是覆寫式,可以用同一份草稿重試。 + +**3. 結算(`settleSalaryFiling`)**:`confirmPayday`、`confirmOwner`、`confirmSalary` 三個確認**都必須為 true**(同 Simpany 的三個勾選); +表單未建立 / 已結算 / 沒有任何申報 / Simpany 標記資料不完整(`hasMissing*Data`)/ `canSettle = false`(回傳順序限制與缺的月份) +一律拒絕。`POST settle {resignedEmployeeIds: []}`;網路中斷或 5xx → 回報「結果不明,先同步確認,不要重送」。 +成功後讀回來確認 `isSettled`、把該月 applied 草稿標 `settled`、重新同步。**送出後無法從這裡撤回。** + +**4. 寄薪資單(`sendPayslips`)**:只限已結算的月份;沒指定人 = `COMPANY_SALARY_DECLARATION_FORM`(整張表單), +指定姓名 = `SALARY_DECLARATIONS` + 申報明細 id。信件內容用 Simpany 的預設文字(發薪日取表單的 `payday`)。 +404 = 薪資單 PDF 還在產生,稍後再寄。 + +**個資**:申報明細只以白名單欄位解析(`parseSalaryDeclarationDetail`),身分證字號 / 地址 / 國籍不進記憶體結構、草稿、log、 +回傳值;Simpany 回的調整建議先經 `stripSalaryPii`。請求 / 回應 body 都不寫 log,錯誤訊息只取結構化的 message。 + +**股東往來還款不是薪資**,永遠不填進 Simpany 的薪資申報(工具描述與 Sheet 都有提醒)。 + +入口:薪資頁月份格的「準備申報」(已結算的月份是「薪資單」)Sheet —— 可編輯每人本薪 / 非經常性獎金 / 員工代墊款、 +發薪日(預設次月 5 日)、是否允許複製 →「計算」顯示 Simpany 試算結果與順序限制警示 →「寫入 Simpany」→ 三個勾選 +→「送出給記帳士」→「寄送薪資單」。只對 owner / admin、整合可用、且月份不晚於本月時顯示。 +MCP:`simpany_prepare_salary_filing`、`simpany_apply_salary_filing`、`simpany_settle_salary_filing`、`simpany_send_payslips`。 + ## 每日自動同步 已連接、已開啟、狀態正常的整合每天自動同步一次,不用再有人去按「同步」。 @@ -361,7 +441,7 @@ Simpany 上已不存在的表單 / 員工會從本地刪掉,所以重跑是冪 **安全**: - **只同步,不開立**:Simpany 只走列表 / 明細 / 薪資申報的 GET;自動同步沒有任何開立、 - 作廢發票或其他 Simpany 寫入端點的程式碼路徑。Wise 本來就只有 GET(見上方唯讀保證)。 + 作廢發票、薪資申報寫入 / 結算或其他 Simpany 寫入端點的程式碼路徑。Wise 本來就只有 GET(見上方唯讀保證)。 - Wise 寫入是安全的:以 referenceNumber 去重(`ON CONFLICT DO NOTHING`,刪掉的也不會長回來)、 永遠不早於切換日、新列一律「待確認」。Simpany 發票與薪資申報同步都是冪等 upsert。 - **依序、不平行**:一個組織一個整合接著跑,對 Simpany 的非官方 API 溫和一點。 diff --git a/docs/mcp.md b/docs/mcp.md index 66895fa..1e88e0a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -108,7 +108,8 @@ the `tools-*.ts` modules): 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` / `salary_arrears` declare + cannot be undone), as does `simpany_settle_salary_filing` (submits the month's + salary declarations to the bookkeeper); `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 @@ -123,8 +124,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 89 of them, as of -server version 1.8.0 (the Simpany tools whose result shape comes from Simpany's +**Output schemas.** Every tool declares an `outputSchema` — all 93 of them, as of +server version 1.9.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 @@ -286,9 +287,21 @@ integrations.md): | `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. +| `simpany_prepare_salary_filing` | `year`, `month`, `payday?` (default the 5th of next month), `employees?[{employeeId\|name, baseSalary?, bonus?, reimbursement?, otherAllowances?[{itemId,amount,note?}], otherDeductions?[…], note?}]`, `allowCopy?` (default false), `sourceFormId?`, `companyOwner?` | Owner/admin. Uses each Simpany declaration as a template, changes only dates + amounts (brackets / insurance flags / dependents kept, one-off items not carried over), runs Simpany's `calculate` (no save) and stores a `simpany_salary_drafts` row (2 h). Returns per employee gross / personal & company insurance / withholding / net / declared, the month-sequence restriction and warnings. Employees missing from the form → copy plan from the latest settled month, executed only with `allowCopy: true` (a write); employees not in Simpany at all → `notInSimpany` (add them in Simpany's UI). No draft when anything is unresolved. | +| `simpany_apply_salary_filing` | `draftId` | Owner/admin. Only after the user approved the preview. Owner flags → payday → PUT each declaration verbatim → read back and verify net pay → re-sync. Reports exactly what was written on failure; retryable (all overwrites). The month is still editable (not yet settled). | +| `simpany_settle_salary_filing` | `year`, `month`, `confirmPayday`, `confirmOwner`, `confirmSalary` | Owner/admin, **destructive**: submits the month to the bookkeeper; cannot be undone here. All three confirmations must be true (the user confirmed each). Refuses when `canSettle` is false (returns the missing earlier months), data is incomplete, or the month is already settled. | +| `simpany_send_payslips` | `year`, `month`, `employeeNames?` | Owner/admin. Settled months only. Simpany emails each employee an encrypted payslip PDF (default text); 404 = payslips still generating. | + +Salary reads (list / sync / arrears) are read-only: they use the GET-only +`assertSalaryReadOnly` whitelist and write only this organization's own +`simpany_salary_*` tables. Salary **writes** go only through the four filing tools +above, via a separate method + path whitelist (`assertSalaryWrite`) of exactly the +endpoints Simpany's own UI uses (declaration GET / calculate / PUT, copy, payday, +company-owner, settle, payslip send). Creating, editing or removing Simpany +employees (national-id data) is deliberately not possible. Shareholder loan +repayments (股東往來還款) are not salary and never go into a declaration. The write +endpoints were read from Simpany's frontend and have not been exercised live — +see integrations.md. **Not exposed (do in the app):** creating an organization, uploading invoice/receipt **files** (R2), multi-currency FX entry, and *connecting* Google diff --git a/migrations/0029_simpany_salary_drafts.sql b/migrations/0029_simpany_salary_drafts.sql new file mode 100644 index 0000000..3039895 --- /dev/null +++ b/migrations/0029_simpany_salary_drafts.sql @@ -0,0 +1,56 @@ +-- 0029: Simpany 薪資申報寫入(準備 → 寫入 → 結算 → 寄薪資單)的草稿表。 +-- +-- 0027 把 Simpany 的薪資申報唯讀拉回來對帳;這一版讓 owner / admin 從本系統(web 薪資頁、MCP) +-- 直接在 Simpany 填寫每月薪資申報,不用再到 Simpany 的介面一格一格點。流程與電子發票開立 +-- (0025 invoice_drafts)一樣是兩段式(src/lib/simpany-payroll.ts): +-- +-- 1. prepareSalaryFiling —— 讀 Simpany 的表單與每位員工的申報明細當模板,換上這個月的日期與 +-- 金額,呼叫 Simpany 的試算(calculate,不存檔),把「要 PUT 給 Simpany 的每一份 body」原樣 +-- 存成一筆草稿,回傳每人應發 / 個人負擔 / 公司負擔 / 扣繳 / 實發給使用者確認。 +-- 2. applySalaryFiling(draftId) —— 使用者確認後,才把草稿裡的 body 原封不動寫進 Simpany +-- (PUT 申報明細、PATCH 發薪日、負責人旗標),再讀回來比對實發。寫入只接受 draft id。 +-- 結算(settle,送給記帳士)與寄薪資單是另外兩步,各自要使用者明確確認,不綁草稿。 +-- +-- 狀態:pending(可寫入)→ applied(已寫入 Simpany)→ settled(該月已結算); +-- cancelled(使用者取消 / 表單已變動)、expired(超過 2 小時沒寫入)。 +-- 寫入的每一步都是覆寫式(PUT / PATCH),失敗時草稿退回 pending,apply_result 記下已寫入哪些人。 +-- +-- ⚠️ 個資:payload 只有 Simpany 申報明細的白名單欄位(日期、投保旗標、扶養人數、項目 itemId / +-- 名稱 / 金額 / 備註)與姓名、Simpany 員工 id —— 沒有身分證字號、地址、國籍。 +-- +-- Forward-only,全部 additive。Run AFTER 0028。 + +CREATE TABLE simpany_salary_drafts ( + 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 NOT NULL, + -- 要寫進 Simpany 的發薪日(慣例:次月 5 日) + payday date NOT NULL, + -- { declarations: [{ declarationId, simpanyEmployeeId, name, isCompanyOwner, ownerFlagChange, body }], + -- companyOwner };body = PUT form/{formId}/salary-declaration/{declId} 的原樣內容 + payload jsonb NOT NULL, + -- 給人看的預覽:每人應發 / 個人負擔 / 公司負擔 / 扣繳 / 實發、投保級距、警示、順序限制 + summary jsonb NOT NULL DEFAULT '{}'::jsonb, + -- 寫入結果:written(已寫入的人)、failedStep、error、verification(讀回來的實發比對) + apply_result jsonb NOT NULL DEFAULT '{}'::jsonb, + status text NOT NULL DEFAULT 'pending', + created_by_user_id text REFERENCES "user"(id) ON DELETE SET NULL, + expires_at timestamptz NOT NULL DEFAULT (now() + interval '2 hours'), + created_at timestamptz NOT NULL DEFAULT now(), + applied_at timestamptz, + settled_at timestamptz, + CONSTRAINT chk_simpany_salary_draft_status + CHECK (status = ANY (ARRAY['pending'::text, 'applied'::text, 'settled'::text, 'cancelled'::text, 'expired'::text])), + CONSTRAINT chk_simpany_salary_draft_month CHECK (month BETWEEN 1 AND 12) +); + +CREATE INDEX idx_simpany_salary_draft_org_month + ON simpany_salary_drafts (organization_id, year, month, created_at DESC); + +COMMENT ON TABLE simpany_salary_drafts IS 'Simpany 薪資申報寫入前的預覽草稿;寫入只接受 draft id(看過的 = 送出的),2 小時過期'; +COMMENT ON COLUMN simpany_salary_drafts.payload IS '{ declarations[{ declarationId, simpanyEmployeeId, name, isCompanyOwner, ownerFlagChange, body }], companyOwner }:body 原樣 PUT 給 Simpany'; +COMMENT ON COLUMN simpany_salary_drafts.status IS 'pending / applied(已寫入 Simpany)/ settled(該月已結算)/ cancelled / expired'; +COMMENT ON COLUMN simpany_salary_drafts.apply_result IS '寫入結果:written、failedStep、error、verification'; diff --git a/src/app/dashboard/payroll/simpany-salary-actions.ts b/src/app/dashboard/payroll/simpany-salary-actions.ts index 1fbacaa..52ec1f7 100644 --- a/src/app/dashboard/payroll/simpany-salary-actions.ts +++ b/src/app/dashboard/payroll/simpany-salary-actions.ts @@ -7,6 +7,21 @@ import { canManageOrg, requireOrgWithRole } from "@/lib/session"; import { IntegrationUnavailableError } from "@/lib/integrations/store"; import { SimpanyError } from "@/lib/integrations/simpany"; import { syncSalaryDeclarations, type SalarySyncResult } from "@/lib/simpany-salary"; +import { + applySalaryFiling, + cancelSalaryDraft, + prepareSalaryFiling, + salaryFilingDefaults, + SalaryFilingError, + sendPayslips, + settleSalaryFiling, + type SalaryApplyResult, + type SalaryFilingDefaults, + type SalaryFilingPreview, + type SendPayslipsResult, + type SettleInput, + type SettleResult, +} from "@/lib/simpany-payroll"; /** * 薪資頁「從 Simpany 同步薪資申報」。限 owner / admin(按鈕只對他們顯示,這裡再擋一次)。 @@ -40,3 +55,171 @@ export async function syncSalaryDeclarationsAction( return { ok: false, error: e instanceof Error ? e.message : String(e) }; } } + +// --------------------------------------------------------------------------- +// 薪資申報寫入(src/lib/simpany-payroll.ts):準備 → 寫入 → 結算 → 寄薪資單。 +// 全部限 owner / admin;每一步都是使用者在 Sheet 裡明確按下的。回傳值不含個資。 +// --------------------------------------------------------------------------- + +type Result = { ok: true; data: T } | { ok: false; error: string }; + +async function manager(): Promise<{ orgId: string; userId: string } | { error: string }> { + const t = await getTranslations("integrations"); + const { orgId, userId, role } = await requireOrgWithRole(); + if (!canManageOrg(role)) return { error: t("errors.notAllowed") }; + return { orgId, userId }; +} + +function fail(e: unknown): { ok: false; error: string } { + if ( + e instanceof IntegrationUnavailableError || + e instanceof SimpanyError || + e instanceof SalaryFilingError + ) { + return { ok: false, error: e.message }; + } + return { ok: false, error: e instanceof Error ? e.message : String(e) }; +} + +const ym = (year: number, month: number) => `${year}-${String(month).padStart(2, "0")}`; + +function validYearMonth(year: number, month: number): boolean { + return Number.isInteger(year) && year >= 2000 && year <= 2100 && Number.isInteger(month) && month >= 1 && month <= 12; +} + +/** 開 Sheet 時的預設值(只讀本地表,不打 Simpany)。 */ +export async function loadSalaryFilingDefaultsAction( + year: number, + month: number, +): Promise> { + const me = await manager(); + if ("error" in me) return { ok: false, error: me.error }; + if (!validYearMonth(year, month)) return { ok: false, error: `不合法的月份:${ym(year, month)}` }; + try { + return { ok: true, data: await salaryFilingDefaults(me.orgId, year, month) }; + } catch (e) { + return fail(e); + } +} + +export type SalaryFilingFormInput = { + year: number; + month: number; + payday: string; + allowCopy: boolean; + employees: { name: string; employeeId: number | null; baseSalary: number; bonus: number; reimbursement: number }[]; +}; + +/** 試算 + 存草稿(不存檔申報明細)。allowCopy 時會把缺的人從上個已結算月份複製進來。 */ +export async function prepareSalaryFilingAction(input: SalaryFilingFormInput): Promise> { + const me = await manager(); + if ("error" in me) return { ok: false, error: me.error }; + if (!validYearMonth(input.year, input.month)) return { ok: false, error: `不合法的月份:${ym(input.year, input.month)}` }; + try { + // 只收白名單欄位。 + const preview = await prepareSalaryFiling(me.orgId, me.userId, { + year: input.year, + month: input.month, + payday: input.payday || undefined, + allowCopy: input.allowCopy === true, + employees: input.employees.map((e) => ({ + employeeId: e.employeeId ?? undefined, + name: e.employeeId == null ? e.name : undefined, + baseSalary: e.baseSalary, + bonus: e.bonus || undefined, + reimbursement: e.reimbursement || undefined, + })), + }); + if (preview.copy?.performed) { + await logWeb( + me.orgId, + "update", + "integration", + null, + `simpany: salary ${ym(input.year, input.month)} copied ${preview.copy.employees.length} from form #${preview.copy.sourceFormId}`, + ); + } + return { ok: true, data: preview }; + } catch (e) { + return fail(e); + } +} + +/** 把預覽過的草稿寫進 Simpany(尚未結算)。 */ +export async function applySalaryFilingAction(draftId: number): Promise> { + const me = await manager(); + if ("error" in me) return { ok: false, error: me.error }; + try { + const res = await applySalaryFiling(me.orgId, draftId); + await logWeb( + me.orgId, + "update", + "integration", + null, + `simpany: salary apply draft #${draftId} ${ym(res.year, res.month)}: wrote ${res.written.length}, verified ${res.verified}`, + ); + revalidatePath("/dashboard/payroll"); + return { ok: true, data: res }; + } catch (e) { + await logWeb( + me.orgId, + "update", + "integration", + null, + `simpany: salary apply draft #${draftId} failed: ${(e instanceof Error ? e.message : String(e)).slice(0, 200)}`, + ); + return fail(e); + } +} + +export async function cancelSalaryDraftAction(draftId: number): Promise> { + const me = await manager(); + if ("error" in me) return { ok: false, error: me.error }; + try { + return { ok: true, data: await cancelSalaryDraft(me.orgId, draftId) }; + } catch (e) { + return fail(e); + } +} + +/** 結算(送給記帳士)。三個確認都要勾。 */ +export async function settleSalaryFilingAction(input: SettleInput): Promise> { + const me = await manager(); + if ("error" in me) return { ok: false, error: me.error }; + if (!validYearMonth(input.year, input.month)) return { ok: false, error: `不合法的月份:${ym(input.year, input.month)}` }; + try { + const res = await settleSalaryFiling(me.orgId, { + year: input.year, + month: input.month, + confirmPayday: input.confirmPayday === true, + confirmOwner: input.confirmOwner === true, + confirmSalary: input.confirmSalary === true, + }); + await logWeb(me.orgId, "update", "integration", null, `simpany: salary settle ${ym(input.year, input.month)}`); + revalidatePath("/dashboard/payroll"); + return { ok: true, data: res }; + } catch (e) { + return fail(e); + } +} + +/** 寄薪資單給這個月表單上的所有人(已結算的月份)。 */ +export async function sendPayslipsAction(year: number, month: number): Promise> { + const me = await manager(); + if ("error" in me) return { ok: false, error: me.error }; + if (!validYearMonth(year, month)) return { ok: false, error: `不合法的月份:${ym(year, month)}` }; + try { + const res = await sendPayslips(me.orgId, { year, month }); + await logWeb( + me.orgId, + "update", + "integration", + null, + `simpany: payslips ${ym(year, month)} sent to ${res.recipients.length}`, + ); + revalidatePath("/dashboard/payroll"); + return { ok: true, data: res }; + } catch (e) { + return fail(e); + } +} diff --git a/src/app/dashboard/payroll/simpany-salary-filing.tsx b/src/app/dashboard/payroll/simpany-salary-filing.tsx new file mode 100644 index 0000000..e6c96f2 --- /dev/null +++ b/src/app/dashboard/payroll/simpany-salary-filing.tsx @@ -0,0 +1,732 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { toast } from "sonner"; +import { AlertTriangle, CheckCircle2, Mail, Send } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@/components/ui/sheet"; +import { + Table, + TableBody, + TableCell, + TableFooter, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { formatCurrency, formatDateTime } from "@/lib/format"; +import type { + SalaryApplyResult, + SalaryFilingDefaults, + SalaryFilingPreview, +} from "@/lib/simpany-payroll"; +import { + applySalaryFilingAction, + cancelSalaryDraftAction, + loadSalaryFilingDefaultsAction, + prepareSalaryFilingAction, + sendPayslipsAction, + settleSalaryFilingAction, +} from "./simpany-salary-actions"; + +type Row = { + name: string; + employeeId: number | null; + include: boolean; + base: string; + bonus: string; + reimbursement: string; + isCompanyOwner: boolean; +}; + +type Step = + | { name: "loading" } + | { name: "form" } + | { name: "preview"; preview: SalaryFilingPreview } + | { name: "applied"; preview: SalaryFilingPreview | null; result: SalaryApplyResult | null; appliedAt: string | null; draftId: number | null } + | { name: "settled" }; + +const toInt = (s: string) => { + const n = Number(s.replaceAll(",", "").trim()); + return Number.isFinite(n) ? Math.round(n) : Number.NaN; +}; + +function rowsFrom(d: SalaryFilingDefaults): Row[] { + return d.employees.map((e) => ({ + name: e.name, + employeeId: e.employeeId, + include: true, + base: e.baseSalary == null ? "" : String(e.baseSalary), + bonus: "", + reimbursement: "", + isCompanyOwner: e.isCompanyOwner, + })); +} + +/** 預覽表:每人應發 / 個人負擔 / 公司負擔 / 扣繳 / 實發。 */ +function PreviewTable({ preview }: Readonly<{ preview: SalaryFilingPreview }>) { + const t = useTranslations("payroll.simpany.filing"); + return ( +
+ + + + {t("columns.employee")} + {t("columns.gross")} + {t("columns.personal")} + {t("columns.company")} + {t("columns.withholding")} + {t("columns.net")} + + + + {preview.employees.map((e) => ( + + +
+ {e.name} + {e.isCompanyOwner ? {t("owner")} : null} + {e.ownerFlagChange ? ( + + {t("ownerChange")} + + ) : null} +
+
+ {t("columns.base")} {formatCurrency(e.baseSalary)} + {e.bonus > 0 ? ` · ${t("columns.bonus")} ${formatCurrency(e.bonus)}` : ""} + {e.reimbursement > 0 ? ` · ${t("columns.reimbursement")} ${formatCurrency(e.reimbursement)}` : ""} +
+
+ {formatCurrency(e.gross)} + {formatCurrency(e.personalBurden)} + + {formatCurrency(e.companyInsurance)} + + {formatCurrency(e.withholding)} + + {e.net == null ? "—" : formatCurrency(e.net)} + +
+ ))} +
+ {preview.employees.length > 1 ? ( + + + {t("total")} + {formatCurrency(preview.totals.gross)} + {formatCurrency(preview.totals.personalBurden)} + {formatCurrency(preview.totals.companyInsurance)} + {formatCurrency(preview.totals.withholding)} + + {formatCurrency(preview.totals.net)} + + + + ) : null} +
+
+ ); +} + +function Notice({ + tone, + title, + items, +}: Readonly<{ tone: "warn" | "risk"; title: string; items: string[] }>) { + if (items.length === 0) return null; + const cls = + tone === "risk" + ? "border-destructive/40 bg-destructive/5 text-destructive" + : "border-amber-500/40 bg-amber-500/5 text-amber-800 dark:text-amber-300"; + return ( +
+
+ + {title} +
+
    + {items.map((w) => ( +
  • {w}
  • + ))} +
+
+ ); +} + +/** 預覽的警示、問題、複製計畫、找不到的人。 */ +function PreviewNotices({ preview }: Readonly<{ preview: SalaryFilingPreview }>) { + const t = useTranslations("payroll.simpany.filing"); + const risks = [ + ...preview.problems.map((p) => `${p.name}:${p.message}`), + ...(preview.notInSimpany.length ? [t("notInSimpany", { names: preview.notInSimpany.join("、") })] : []), + ]; + const copy = preview.copy; + const copyKey = copy?.performed ? "copied" : "copyPlan"; + const copyLine = + copy?.required && copy.sourceYear != null && copy.sourceMonth != null + ? t(copyKey, { + names: copy.employees.join("、"), + year: copy.sourceYear, + month: copy.sourceMonth, + }) + : null; + return ( +
+ + {copyLine ? : null} + +
+ ); +} + +/** 寫入後:讀回來的實發比對。 */ +function ApplyResultView({ result }: Readonly<{ result: SalaryApplyResult }>) { + const t = useTranslations("payroll.simpany.filing"); + const bad = result.verification.filter((v) => !v.ok); + return ( +
+

+ + {t("applied", { names: result.written.join("、") })} +

+ {bad.length === 0 ? ( +

{t("verified")}

+ ) : ( +
+

{t("mismatch")}

+
+ + + + {t("columns.employee")} + {t("columns.expected")} + {t("columns.actual")} + + + + {bad.map((v) => ( + + {v.name} + + {v.expectedNet == null ? "—" : formatCurrency(v.expectedNet)} + + + {v.actualNet == null ? "—" : formatCurrency(v.actualNet)} + + + ))} + +
+
+
+ )} +
+ ); +} + +function Check({ + id, + checked, + onChange, + children, +}: Readonly<{ id: string; checked: boolean; onChange: (v: boolean) => void; children: React.ReactNode }>) { + return ( + + ); +} + +/** 結算(送給記帳士):跟 Simpany 一樣要勾三個確認。 */ +function SettlePanel({ + year, + month, + payday, + ownerName, + onSettled, +}: Readonly<{ year: number; month: number; payday: string | null; ownerName: string | null; onSettled: () => void }>) { + const t = useTranslations("payroll.simpany.filing"); + const router = useRouter(); + const [pending, run] = useTransition(); + const [c, setC] = useState({ payday: false, owner: false, salary: false }); + const all = c.payday && c.owner && c.salary; + + function submit() { + run(async () => { + const res = await settleSalaryFilingAction({ + year, + month, + confirmPayday: c.payday, + confirmOwner: c.owner, + confirmSalary: c.salary, + }); + if (!res.ok) { + toast.error(res.error); + return; + } + toast.success(t("settled", { year, month })); + router.refresh(); + onSettled(); + }); + } + + return ( +
+
+

{t("settleTitle")}

+

{t("settleDescription")}

+
+
+ setC({ ...c, payday: v })}> + {t("confirmPayday", { date: payday ?? "—" })} + + setC({ ...c, owner: v })}> + {ownerName ? t("confirmOwner", { name: ownerName }) : t("confirmOwnerUnknown")} + + setC({ ...c, salary: v })}> + {t("confirmSalary")} + +
+ +
+ ); +} + +/** 已結算:寄薪資單。 */ +function PayslipPanel({ year, month }: Readonly<{ year: number; month: number }>) { + const t = useTranslations("payroll.simpany.filing"); + const router = useRouter(); + const [pending, run] = useTransition(); + const [confirm, setConfirm] = useState(false); + + function submit() { + run(async () => { + const res = await sendPayslipsAction(year, month); + if (!res.ok) { + toast.error(res.error); + return; + } + toast.success(t("sent", { count: res.data.recipients.length })); + setConfirm(false); + router.refresh(); + }); + } + + return ( +
+

{t("settledAlready")}

+

{t("payslipsDescription")}

+ + {t("confirmSend")} + + +
+ ); +} + +const AMOUNT_FIELDS = ["base", "bonus", "reimbursement"] as const; +const AMOUNT_LABEL = { + base: "columns.base", + bonus: "columns.bonus", + reimbursement: "columns.reimbursement", +} as const; + +/** 準備申報的輸入表:每人本薪 / 獎金 / 代墊款、發薪日、是否允許複製。 */ +function FilingForm({ + defaults, + rows, + setRows, + payday, + setPayday, + allowCopy, + setAllowCopy, +}: Readonly<{ + defaults: SalaryFilingDefaults | null; + rows: Row[]; + setRows: (r: Row[]) => void; + payday: string; + setPayday: (s: string) => void; + allowCopy: boolean; + setAllowCopy: (v: boolean) => void; +}>) { + const t = useTranslations("payroll.simpany.filing"); + const update = (i: number, patch: Partial) => setRows(rows.map((r, j) => (j === i ? { ...r, ...patch } : r))); + return ( +
+

+ {defaults?.lastFiled + ? t("lastFiled", { year: defaults.lastFiled.year, month: defaults.lastFiled.month }) + : t("noLastFiled")} +

+
+ + setPayday(e.target.value)} + className="w-44" + /> +

{t("paydayHint")}

+
+ {rows.length === 0 ? ( +

{t("noEmployees")}

+ ) : ( +
+ + + + {t("include")} + {t("columns.employee")} + {t("columns.base")} + {t("columns.bonus")} + {t("columns.reimbursement")} + + + + {rows.map((r, i) => ( + + + update(i, { include: e.target.checked })} + className="size-4 accent-primary" + /> + + + {r.name} + {r.isCompanyOwner ? ( + + {t("owner")} + + ) : null} + + {AMOUNT_FIELDS.map((k) => ( + + update(i, { [k]: e.target.value })} + className="h-8 w-28 tabular-nums" + /> + + ))} + + ))} + +
+
+ )} +

{t("shareholderNote")}

+ + {t("allowCopy")} + +
+ ); +} + +/** 大於 0(NaN 視為否)。 */ +const isPositive = (n: number) => n > 0; +/** 0 以上(NaN 視為否)。 */ +const isNonNegative = (n: number) => n >= 0; + +type EmployeeAmounts = { name: string; baseSalary: number; bonus: number; reimbursement: number }; + +/** 本薪要大於 0、獎金與代墊款要是 0 以上的數字。 */ +function invalidAmounts(e: EmployeeAmounts): boolean { + return !isPositive(e.baseSalary) || !isNonNegative(e.bonus) || !isNonNegative(e.reimbursement); +} + +/** 開 Sheet 時要停在哪一步:已結算 → 薪資單;已寫入 → 結算;其他 → 填表。 */ +function stepAfterLoad(d: SalaryFilingDefaults, settled: boolean): Step { + if (settled || d.latestDraft?.status === "settled") return { name: "settled" }; + if (d.latestDraft?.status !== "applied") return { name: "form" }; + const s = d.latestDraft.summary as unknown as SalaryFilingPreview; + return { + name: "applied", + preview: Array.isArray(s?.employees) ? s : null, + result: null, + appliedAt: d.latestDraft.appliedAt ?? d.latestDraft.createdAt, + draftId: d.latestDraft.id, + }; +} + +/** 試算結果:警示、每人金額、草稿效期。 */ +function PreviewStep({ preview }: Readonly<{ preview: SalaryFilingPreview }>) { + const t = useTranslations("payroll.simpany.filing"); + return ( +
+ + {preview.employees.length ? : null} + {preview.draftId == null ? ( +

{t("noDraft")}

+ ) : ( +

+ {t("expires", { + id: preview.draftId, + time: preview.expiresAt ? formatDateTime(preview.expiresAt) : "—", + })} +

+ )} +
+ ); +} + +type AppliedStepState = Extract; + +/** 已寫入 Simpany:寫入結果(或先前寫入的紀錄)+ 結算。 */ +function AppliedStep({ + step, + year, + month, + onSettled, +}: Readonly<{ step: AppliedStepState; year: number; month: number; onSettled: () => void }>) { + const t = useTranslations("payroll.simpany.filing"); + return ( +
+ {step.result ? ( + + ) : ( +

+ {t("appliedEarlier", { + id: step.draftId ?? "—", + time: step.appliedAt ? formatDateTime(step.appliedAt) : "—", + })} +

+ )} + {step.preview?.employees.length ? : null} + +
+ ); +} + +/** Sheet 底部依步驟變化的按鈕。 */ +function FilingActions({ + step, + pending, + canCalculate, + onCalculate, + onBack, + onApply, + onReprepare, +}: Readonly<{ + step: Step; + pending: boolean; + canCalculate: boolean; + onCalculate: () => void; + onBack: (p: SalaryFilingPreview) => void; + onApply: (p: SalaryFilingPreview) => void; + onReprepare: () => void; +}>) { + const t = useTranslations("payroll.simpany.filing"); + if (step.name === "form") { + return ( + + ); + } + if (step.name === "preview") { + return ( + <> + + + + ); + } + if (step.name === "applied") { + return ( + + ); + } + return null; +} + +/** + * 一個月的薪資申報 Sheet(owner / admin):準備(Simpany 試算)→ 寫入 Simpany → + * 送出給記帳士(三個確認)→ 寄薪資單。 + */ +export function SalaryFilingSheet({ + year, + month, + settled, +}: Readonly<{ year: number; month: number; settled: boolean }>) { + const t = useTranslations("payroll.simpany.filing"); + const router = useRouter(); + const [open, setOpen] = useState(false); + const [step, setStep] = useState({ name: "loading" }); + const [defaults, setDefaults] = useState(null); + const [rows, setRows] = useState([]); + const [payday, setPayday] = useState(""); + const [allowCopy, setAllowCopy] = useState(false); + const [pending, run] = useTransition(); + + function load() { + setStep({ name: "loading" }); + run(async () => { + const res = await loadSalaryFilingDefaultsAction(year, month); + if (!res.ok) { + toast.error(res.error); + setOpen(false); + return; + } + const d = res.data; + setDefaults(d); + setRows(rowsFrom(d)); + setPayday(d.payday); + setAllowCopy(false); + setStep(stepAfterLoad(d, settled)); + }); + } + + function onOpenChange(v: boolean) { + setOpen(v); + if (v) load(); + } + + function calculate() { + const chosen = rows.filter((r) => r.include); + const employees = chosen.map((r) => ({ + name: r.name, + employeeId: r.employeeId, + baseSalary: toInt(r.base), + bonus: r.bonus.trim() ? toInt(r.bonus) : 0, + reimbursement: r.reimbursement.trim() ? toInt(r.reimbursement) : 0, + })); + const bad = employees.find(invalidAmounts); + if (bad) { + toast.error(`${bad.name}:${t("columns.base")} / ${t("columns.bonus")} / ${t("columns.reimbursement")}`); + return; + } + run(async () => { + const res = await prepareSalaryFilingAction({ year, month, payday, allowCopy, employees }); + if (!res.ok) { + toast.error(res.error); + return; + } + setStep({ name: "preview", preview: res.data }); + }); + } + + function apply(preview: SalaryFilingPreview) { + if (preview.draftId == null) return; + const draftId = preview.draftId; + run(async () => { + const res = await applySalaryFilingAction(draftId); + if (!res.ok) { + toast.error(res.error); + return; + } + router.refresh(); + setStep({ name: "applied", preview, result: res.data, appliedAt: null, draftId }); + }); + } + + function back(preview: SalaryFilingPreview) { + if (preview.draftId != null) { + const id = preview.draftId; + run(async () => { + await cancelSalaryDraftAction(id); + }); + } + setStep({ name: "form" }); + } + + const title = t("title", { year, month }); + return ( + + + + + + + {title} + {t("description")} + +
+ {step.name === "loading" ?

{t("loading")}

: null} + + {step.name === "form" ? ( + + ) : null} + + {step.name === "preview" ? : null} + + {step.name === "applied" ? ( + setStep({ name: "settled" })} /> + ) : null} + + {step.name === "settled" ? : null} +
+ + r.include)} + onCalculate={calculate} + onBack={back} + onApply={apply} + onReprepare={() => setStep({ name: "form" })} + /> + + +
+
+ ); +} diff --git a/src/app/dashboard/payroll/simpany-salary-section.tsx b/src/app/dashboard/payroll/simpany-salary-section.tsx index 7dcecc5..394c4f4 100644 --- a/src/app/dashboard/payroll/simpany-salary-section.tsx +++ b/src/app/dashboard/payroll/simpany-salary-section.tsx @@ -18,8 +18,10 @@ import { import { formatCurrency, formatDateTime } from "@/lib/format"; import type { IntegrationSummary } from "@/lib/integrations/types"; import type { SalaryMonthStatus, SalaryReconciliation } from "@/lib/simpany-salary"; +import { currentYearMonth } from "@/lib/simpany-payroll"; import { cn } from "@/lib/utils"; import { ArrearsDetailSheet, SalarySyncButton } from "./simpany-salary-client"; +import { SalaryFilingSheet } from "./simpany-salary-filing"; const statusClass: Record = { settled: "border-emerald-500/40 bg-emerald-500/5 text-emerald-700 dark:text-emerald-400", @@ -45,6 +47,10 @@ export async function SimpanySalarySection({ const t = await getTranslations("payroll.simpany"); const usable = integration?.status === "connected" && integration.enabled; const { year } = recon; + // 申報寫入只開放給 owner / admin,而且只到本月(還沒發生的月份不能申報)。 + const now = currentYearMonth(); + const canFile = (month: number) => + canManage && usable && (year < now.year || (year === now.year && month <= now.month)); return (
@@ -103,6 +109,9 @@ export async function SimpanySalarySection({ {t("months.filedOf", { filed: m.filedCount, total: m.employeeCount })} ) : null} + {canFile(m.month) ? ( + + ) : null} ))} diff --git a/src/db/schema.ts b/src/db/schema.ts index 0304672..67b8b13 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -789,3 +789,31 @@ export const simpanySalaryDeclarations = pgTable("simpany_salary_declarations", }).onDelete("set null"), check("chk_simpany_salary_decl_month", sql`(month >= 1) AND (month <= 12)`), ]); + +// ---- Simpany 薪資申報寫入的草稿(migrations/0029,src/lib/simpany-payroll.ts)。 +// 準備申報時把要 PUT 給 Simpany 的每份申報明細 body 原樣存起來;寫入只接受 draft id +// (看過的 = 送出的),2 小時過期。⚠️ 不存身分證字號 / 地址 / 國籍。---- +export const simpanySalaryDrafts = pgTable("simpany_salary_drafts", { + id: bigint({ mode: "number" }).primaryKey().generatedAlwaysAsIdentity({ name: "simpany_salary_drafts_id_seq", startWith: 1, increment: 1, minValue: 1, cache: 1 }), + organizationId: text("organization_id").notNull(), + year: integer().notNull(), + month: integer().notNull(), + simpanyFormId: bigint("simpany_form_id", { mode: "number" }).notNull(), + payday: date().notNull(), + // { declarations: [{ declarationId, simpanyEmployeeId, name, isCompanyOwner, ownerFlagChange, body }], companyOwner } + payload: jsonb().$type>().notNull(), + // 給人看的預覽(每人應發 / 個人負擔 / 公司負擔 / 扣繳 / 實發、警示) + summary: jsonb().$type>().default({}).notNull(), + // 寫入結果:written / failedStep / error / verification + applyResult: jsonb("apply_result").$type>().default({}).notNull(), + status: text().default('pending').notNull(), + createdByUserId: text("created_by_user_id"), + expiresAt: timestamp("expires_at", { withTimezone: true, mode: 'string' }).default(sql`(now() + '02:00:00'::interval)`).notNull(), + createdAt: timestamp("created_at", { withTimezone: true, mode: 'string' }).defaultNow().notNull(), + appliedAt: timestamp("applied_at", { withTimezone: true, mode: 'string' }), + settledAt: timestamp("settled_at", { withTimezone: true, mode: 'string' }), +}, (table) => [ + index("idx_simpany_salary_draft_org_month").using("btree", table.organizationId.asc().nullsLast().op("text_ops"), table.year.asc().nullsLast().op("int4_ops"), table.month.asc().nullsLast().op("int4_ops"), table.createdAt.desc().nullsFirst().op("timestamptz_ops")), + check("chk_simpany_salary_draft_status", sql`status = ANY (ARRAY['pending'::text, 'applied'::text, 'settled'::text, 'cancelled'::text, 'expired'::text])`), + check("chk_simpany_salary_draft_month", sql`(month >= 1) AND (month <= 12)`), +]); diff --git a/src/i18n/messages/payroll.ts b/src/i18n/messages/payroll.ts index 2c61023..9081579 100644 --- a/src/i18n/messages/payroll.ts +++ b/src/i18n/messages/payroll.ts @@ -21,8 +21,8 @@ const payroll = { simpany: { title: { "zh-TW": "Simpany 薪資申報", en: "Simpany salary declarations" }, description: { - "zh-TW": "從 Simpany 唯讀同步每月薪資申報,對照實際發出的薪資,算出每位員工的欠薪。", - en: "Read-only sync of monthly salary declarations from Simpany, reconciled against what was actually paid to show each employee's arrears.", + "zh-TW": "同步 Simpany 的每月薪資申報,對照實際發出的薪資算出欠薪;owner / admin 也可以在這裡準備、寫入並送出每月申報。", + en: "Syncs monthly salary declarations from Simpany and reconciles them against what was paid; owners and admins can also prepare, write and submit each month's filing here.", }, prevYear: { "zh-TW": "上一年", en: "Previous year" }, nextYear: { "zh-TW": "下一年", en: "Next year" }, @@ -121,6 +121,102 @@ const payroll = { monthShort: { "zh-TW": "{month} 月", en: "M{month}" }, close: { "zh-TW": "關閉", en: "Close" }, }, + filing: { + prepare: { "zh-TW": "準備申報", en: "Prepare filing" }, + payslips: { "zh-TW": "薪資單", en: "Payslips" }, + title: { "zh-TW": "{year} 年 {month} 月薪資申報", en: "Salary filing {year}-{month}" }, + description: { + "zh-TW": "填好金額後按「計算」,由 Simpany 試算勞健保、扣繳與實發;確認無誤再寫入 Simpany,最後送出給記帳士。", + en: "Enter the amounts and press Calculate — Simpany computes insurance, withholding and net pay. Review, write it to Simpany, then submit to the bookkeeper.", + }, + loading: { "zh-TW": "載入中…", en: "Loading…" }, + lastFiled: { "zh-TW": "預設值取自 {year} 年 {month} 月的申報與員工資料", en: "Defaults from the {year}-{month} filing and employee records" }, + noLastFiled: { "zh-TW": "還沒有同步過申報資料,預設值取自員工資料", en: "No synced filing yet; defaults come from employee records" }, + payday: { "zh-TW": "發薪日", en: "Payday" }, + paydayHint: { "zh-TW": "慣例是次月 5 日", en: "Convention: the 5th of the next month" }, + columns: { + employee: { "zh-TW": "員工", en: "Employee" }, + base: { "zh-TW": "本薪", en: "Base" }, + bonus: { "zh-TW": "非經常性獎金", en: "Bonus" }, + reimbursement: { "zh-TW": "員工代墊款", en: "Reimbursement" }, + gross: { "zh-TW": "應發", en: "Gross" }, + personal: { "zh-TW": "個人負擔", en: "Employee ins." }, + company: { "zh-TW": "公司負擔", en: "Employer ins." }, + withholding: { "zh-TW": "扣繳", en: "Withholding" }, + net: { "zh-TW": "實發", en: "Net" }, + expected: { "zh-TW": "預覽實發", en: "Previewed net" }, + actual: { "zh-TW": "Simpany 實發", en: "Simpany net" }, + }, + include: { "zh-TW": "申報", en: "File" }, + noEmployees: { "zh-TW": "沒有可申報的員工", en: "No employees to file" }, + shareholderNote: { + "zh-TW": "股東往來還款不是薪資,不要填進這裡。", + en: "Shareholder loan repayments are not salary — never put them here.", + }, + allowCopy: { + "zh-TW": "表單上缺人時,從最近一個已結算的月份複製(會寫入 Simpany)", + en: "If employees are missing from the form, copy them from the latest settled month (writes to Simpany)", + }, + calculate: { "zh-TW": "計算", en: "Calculate" }, + calculating: { "zh-TW": "Simpany 試算中…", en: "Calculating in Simpany…" }, + owner: { "zh-TW": "負責人", en: "Owner" }, + ownerChange: { "zh-TW": "旗標將修正", en: "flag will be fixed" }, + total: { "zh-TW": "合計", en: "Total" }, + warnings: { "zh-TW": "注意", en: "Warnings" }, + problems: { "zh-TW": "無法試算", en: "Could not calculate" }, + restriction: { "zh-TW": "順序限制", en: "Sequence restriction" }, + copyPlan: { + "zh-TW": "表單上沒有 {names},可從 {year} 年 {month} 月的表單複製。勾選上面的「複製」再計算一次。", + en: "{names} are not on the form; they can be copied from {year}-{month}. Tick “copy” above and calculate again.", + }, + copied: { "zh-TW": "已從 {year} 年 {month} 月複製:{names}", en: "Copied from {year}-{month}: {names}" }, + notInSimpany: { + "zh-TW": "Simpany 上找不到 {names}:請到 Simpany 的介面新增員工。", + en: "{names} don't exist in Simpany: add them in Simpany's own UI.", + }, + noDraft: { + "zh-TW": "有問題需要先處理,這次沒有產生可寫入的草稿。", + en: "Resolve the issues above first; no draft was created.", + }, + expires: { "zh-TW": "草稿 #{id},{time} 前有效", en: "Draft #{id}, valid until {time}" }, + back: { "zh-TW": "返回修改", en: "Back" }, + apply: { "zh-TW": "寫入 Simpany", en: "Write to Simpany" }, + applying: { "zh-TW": "寫入中…", en: "Writing…" }, + applied: { "zh-TW": "已寫入 Simpany:{names}", en: "Written to Simpany: {names}" }, + verified: { "zh-TW": "讀回來的實發和預覽一致。", en: "Net pay read back from Simpany matches the preview." }, + mismatch: { + "zh-TW": "有人的實發和預覽不同,送出前請先確認:", + en: "Some net amounts differ from the preview — check before submitting:", + }, + appliedEarlier: { + "zh-TW": "這個月已在 {time} 寫入 Simpany(草稿 #{id})。", + en: "This month was written to Simpany at {time} (draft #{id}).", + }, + reprepare: { "zh-TW": "重新準備", en: "Prepare again" }, + settleTitle: { "zh-TW": "送出給記帳士", en: "Submit to the bookkeeper" }, + settleDescription: { + "zh-TW": "送出後這個月就結算了,無法從這裡撤回。請逐項確認:", + en: "This settles the month and cannot be undone from here. Confirm each item:", + }, + confirmPayday: { "zh-TW": "發薪日正確({date})", en: "The payday is correct ({date})" }, + confirmOwner: { "zh-TW": "負責人設定正確({name})", en: "The company owner is correct ({name})" }, + confirmOwnerUnknown: { "zh-TW": "負責人設定正確", en: "The company owner is correct" }, + confirmSalary: { "zh-TW": "每位員工的薪資都正確", en: "Every employee's salary is correct" }, + settle: { "zh-TW": "送出給記帳士", en: "Submit to bookkeeper" }, + settling: { "zh-TW": "送出中…", en: "Submitting…" }, + settled: { "zh-TW": "{year} 年 {month} 月已結算,送出給記帳士。", en: "{year}-{month} is settled and submitted to the bookkeeper." }, + settledAlready: { "zh-TW": "這個月已在 Simpany 結算。", en: "This month is settled in Simpany." }, + payslipsDescription: { + "zh-TW": "Simpany 會寄出加密的薪資單 PDF 給這個月表單上的每位員工(密碼是身分證字號)。", + en: "Simpany emails an encrypted payslip PDF to everyone on this month's form (password: their national id).", + }, + confirmSend: { "zh-TW": "我確認要寄給所有員工", en: "I confirm sending to all employees" }, + send: { "zh-TW": "寄送薪資單", en: "Send payslips" }, + sending: { "zh-TW": "寄送中…", en: "Sending…" }, + sent: { "zh-TW": "已寄出 {count} 份薪資單", en: "Sent {count} payslips" }, + cancel: { "zh-TW": "取消", en: "Cancel" }, + close: { "zh-TW": "關閉", en: "Close" }, + }, unallocated: { title: { "zh-TW": "未指定員工的薪資支出", en: "Salary payments not linked to an employee" }, description: { diff --git a/src/lib/integrations/simpany.ts b/src/lib/integrations/simpany.ts index f831d21..b9b1510 100644 --- a/src/lib/integrations/simpany.ts +++ b/src/lib/integrations/simpany.ts @@ -28,8 +28,10 @@ import type { * 三個 base: * - api.simpany.co/v1 登入、/me(使用者與公司清單) * - member2.simpany.co/api/v1/c/{companyId}/ 電子發票(receipts) - * - api.simpany.co/v1/{companyId}/salary-declaration/ 薪資申報(**只讀**:GET + 路徑白名單, - * 見 assertSalaryReadOnly)。回應含身分證字號 / 地址 / 國籍,解析時一律丟掉。 + * - api.simpany.co/v1/{companyId}/salary-declaration/ 薪資申報。讀取走 assertSalaryReadOnly + * (GET + 路徑白名單);寫入(申報明細、發薪日、負責人、結算、寄薪資單)另走 assertSalaryWrite + * (method + 路徑逐條白名單),只由 src/lib/simpany-payroll.ts 呼叫。回應含身分證字號 / 地址 / + * 國籍,解析時一律丟掉;請求 / 回應 body 一律不寫 log。 */ const AUTH_BASE = "https://api.simpany.co/v1"; @@ -65,6 +67,46 @@ export function assertSalaryReadOnly( } } +/** + * 薪資申報的**寫入**端點白名單(相對於 {companyId}/salary-declaration/),method 與路徑逐條對應。 + * 只有這幾條;員工的建立 / 修改 / 刪除(需要身分證字號)刻意不在清單內。 + * 讀取用的 GET(form、monthly-forms)仍走 assertSalaryReadOnly。 + */ +const SALARY_WRITE_ENDPOINTS: readonly { method: string; path: RegExp }[] = [ + // 讀單一申報明細 / 表單設定(加項 / 減項選項、投保級距)—— 準備寫入時才用到 + { method: "GET", path: /^form\/\d+\/salary-declaration\/\d+$/ }, + { method: "GET", path: /^form\/\d+\/setting$/ }, + // 試算(Simpany UI 的即時預覽;不存檔,但仍是 POST) + { method: "POST", path: /^form\/\d+\/salary-declaration\/\d+\/calculate$/ }, + // 存檔申報明細 + { method: "PUT", path: /^form\/\d+\/salary-declaration\/\d+$/ }, + // 從前一個月的表單複製員工與申報明細 + { method: "POST", path: /^form\/\d+\/copy$/ }, + // 發薪日 + { method: "PATCH", path: /^form\/\d+\/payday$/ }, + // 負責人旗標 + { method: "PUT", path: /^form\/\d+\/salary-declaration\/\d+\/company-owner$/ }, + // 結算(送給記帳士) + { method: "POST", path: /^form\/\d+\/settle$/ }, + // 寄薪資單給員工 + { method: "POST", path: /^form\/\d+\/payslip\/send$/ }, +]; + +/** 薪資申報的寫入護欄:method + 路徑不在白名單、或帶了任何 query,一律不發請求直接丟錯。 */ +export function assertSalaryWrite( + method: string, + path: string, + query?: Record, +): void { + const m = method.toUpperCase(); + if (!SALARY_WRITE_ENDPOINTS.some((e) => e.method === m && e.path.test(path))) { + throw new SimpanyError("config", `Simpany 薪資申報不允許 ${m} ${path}(不在寫入端點白名單內)`); + } + if (Object.keys(query ?? {}).length > 0) { + throw new SimpanyError("config", `Simpany 薪資申報寫入端點不接受 query 參數(${m} ${path})`); + } +} + /** 取不到 JWT exp 時的保守效期。 */ const FALLBACK_TOKEN_TTL_MS = 24 * 60 * 60 * 1000; @@ -192,6 +234,9 @@ export type SimpanySalaryFormEmployee = { } | null; }; +/** 月份必須依序結算:Simpany 列出前面還沒結算的月份(例如 "2026-06")。 */ +export type SimpanyMonthSequenceRestriction = { reason: string; missingMonths: string[] }; + export type SimpanySalaryForm = { id: number | null; year: number; @@ -199,9 +244,96 @@ export type SimpanySalaryForm = { payday: string | null; isSettled: boolean; canSettle: boolean | null; + /** null = 沒有順序限制。 */ + monthSequenceRestriction: SimpanyMonthSequenceRestriction | null; employees: SimpanySalaryFormEmployee[]; }; +/** 申報明細的一個項目,含寫回 Simpany 需要的 itemId / note。 */ +export type SimpanySalaryPayloadItem = { + itemId: number; + amount: number; + note: string | null; + name: string; + type: string; +}; + +export type SimpanySalarySubtotal = { + allowance: number | null; + deduction: number | null; + personalBurden: number | null; + withholdingTax: number | null; +}; + +/** + * GET form/{formId}/salary-declaration/{declId} 的白名單欄位(寫入時當模板用)。 + * 不含任何身分證字號 / 地址 / 國籍。 + */ +export type SimpanySalaryDeclarationDetail = { + id: number; + isCompanyOwner: boolean; + payday: string | null; + yearMonth: string | null; + payStartDate: string | null; + payEndDate: string | null; + laborInsuranceStartDate: string | null; + laborInsuranceEndDate: string | null; + laborPensionStartDate: string | null; + laborPensionEndDate: string | null; + shouldAskIfTerminated: boolean | null; + hasPensionPreparationFundByCompany: boolean; + pensionPreparationFundByCompanyRate: string; + pensionPreparationFundBySelfRate: string; + withholdingTaxDependents: number; + healthInsuranceDependents: number; + hasOrdinaryAccidentInsurance: boolean; + hasEmploymentInsurance: boolean; + hasOccupationalAccidentInsurance: boolean; + items: SimpanySalaryPayloadItem[]; + subtotal: SimpanySalarySubtotal | null; +}; + +/** POST …/calculate 與 PUT …/salary-declaration/{declId} 的 body(Simpany 會員網頁送的形狀)。 */ +export type SimpanySalaryDeclarationPayload = { + payStartDate: string; + payEndDate: string; + hasEmploymentInsurance: boolean; + hasOrdinaryAccidentInsurance: boolean; + hasPensionPreparationFundByCompany: boolean; + healthInsuranceDependents: number; + pensionPreparationFundByCompanyRate: string; + pensionPreparationFundBySelfRate: string; + withholdingTaxDependents: number; + /** 使用者可編輯的項目:ALLOWANCE、INSURANCE_RANGE、可選的 DEDUCTION(應稅其他減項、公司代墊款)。 */ + salaryDeclarationItems: SimpanySalaryPayloadItem[]; + /** Simpany 算出來的項目(保費、扣繳、小計…),來自上一次試算或 GET。 */ + calculatedSalaryDeclarationItems: SimpanySalaryPayloadItem[]; + laborInsuranceStartDate: string | null; + laborInsuranceEndDate: string | null; + laborPensionStartDate: string | null; + laborPensionEndDate: string | null; + hasOccupationalAccidentInsurance: boolean; +}; + +export type SimpanySalaryCalculation = { + calculatedItems: SimpanySalaryPayloadItem[]; + subtotal: SimpanySalarySubtotal | null; + /** 形狀未經驗證,只留基本型別的值(去個資)。 */ + suggestedInsuranceRange: unknown; +}; + +export type SimpanySalaryOption = { id: number; name: string }; + +export type SimpanySalarySetting = { + minimumSalary: number | null; + allowanceOptions: SimpanySalaryOption[]; + deductionOptions: SimpanySalaryOption[]; +}; + +export type SimpanyPayslipSendBody = + | { mode: "COMPANY_SALARY_DECLARATION_FORM"; mailContent: string } + | { mode: "SALARY_DECLARATIONS"; salaryDeclarationIds: number[]; mailContent: string }; + // --------------------------------------------------------------------------- // Errors // --------------------------------------------------------------------------- @@ -475,10 +607,139 @@ export function parseSalaryForm(data: unknown, year: number, month: number): Sim payday: dateStr(data.payday), isSettled: data.isSettled === true, canSettle: bool(data.canSettle), + monthSequenceRestriction: parseMonthSequenceRestriction(data.monthSequenceRestriction), employees, }; } +/** missingMonths 的每一格可能是 "2026-06"、數字或 {year, month};都轉成 "YYYY-MM"(認不得的丟掉)。 */ +function missingMonthLabel(v: unknown): string | null { + if (typeof v === "string") return v.trim() || null; + if (typeof v === "number" && Number.isFinite(v)) return String(v); + if (isObj(v)) { + const y = numOrNull(v.year); + const m = numOrNull(v.month); + if (y != null && m != null) return `${y}-${String(m).padStart(2, "0")}`; + return str(v.yearMonth); + } + return null; +} + +export function parseMonthSequenceRestriction(v: unknown): SimpanyMonthSequenceRestriction | null { + if (!isObj(v)) return null; + const missingMonths = Array.isArray(v.missingMonths) + ? v.missingMonths.map(missingMonthLabel).filter((x): x is string => x !== null) + : []; + return { reason: str(v.reason) ?? "UNKNOWN", missingMonths }; +} + +function parsePayloadItems(v: unknown): SimpanySalaryPayloadItem[] { + if (!Array.isArray(v)) return []; + return v.filter(isObj).flatMap((it): SimpanySalaryPayloadItem[] => { + const itemId = numOrNull(it.itemId); + if (itemId == null) return []; + return [ + { + itemId, + amount: num(it.amount), + note: str(it.note), + name: str(it.name)?.trim() ?? "", + type: str(it.type) ?? "", + }, + ]; + }); +} + +function parseSubtotal(v: unknown): SimpanySalarySubtotal | null { + if (!isObj(v)) return null; + return { + allowance: numOrNull(v.allowance), + deduction: numOrNull(v.deduction), + personalBurden: numOrNull(v.personalBurden), + withholdingTax: numOrNull(v.withholdingTax), + }; +} + +/** 解析單一申報明細:只挑白名單欄位組新物件。 */ +export function parseSalaryDeclarationDetail(v: unknown): SimpanySalaryDeclarationDetail | null { + if (!isObj(v)) return null; + const id = numOrNull(v.id); + if (id == null) return null; + return { + id, + isCompanyOwner: v.isCompanyOwner === true, + payday: dateStr(v.payday), + yearMonth: str(v.yearMonth), + payStartDate: dateStr(v.payStartDate), + payEndDate: dateStr(v.payEndDate), + laborInsuranceStartDate: dateStr(v.laborInsuranceStartDate), + laborInsuranceEndDate: dateStr(v.laborInsuranceEndDate), + laborPensionStartDate: dateStr(v.laborPensionStartDate), + laborPensionEndDate: dateStr(v.laborPensionEndDate), + shouldAskIfTerminated: bool(v.shouldAskIfTerminated), + hasPensionPreparationFundByCompany: v.hasPensionPreparationFundByCompany === true, + pensionPreparationFundByCompanyRate: str(v.pensionPreparationFundByCompanyRate) ?? "0.00", + pensionPreparationFundBySelfRate: str(v.pensionPreparationFundBySelfRate) ?? "0.00", + withholdingTaxDependents: num(v.withholdingTaxDependents), + healthInsuranceDependents: num(v.healthInsuranceDependents), + hasOrdinaryAccidentInsurance: v.hasOrdinaryAccidentInsurance === true, + hasEmploymentInsurance: v.hasEmploymentInsurance === true, + hasOccupationalAccidentInsurance: v.hasOccupationalAccidentInsurance === true, + items: parsePayloadItems(v.salaryDeclarationItems), + subtotal: parseSubtotal(v.subtotal), + }; +} + +const PII_KEY = /personal|address|nationality|birth|passport|resident|email|phone|bank|account/i; + +/** + * 形狀未知的回應(調整建議等):遞迴複製,丟掉看起來像個資的欄位、只留基本型別,限制深度與長度。 + * 用在回傳給使用者看的「Simpany 自動調整了什麼」。 + */ +export function stripSalaryPii(v: unknown, depth = 0): unknown { + if (v === null || typeof v === "boolean" || typeof v === "number") return v; + if (typeof v === "string") return v.length > 200 ? `${v.slice(0, 199)}…` : v; + if (depth >= 4) return null; + if (Array.isArray(v)) return v.slice(0, 50).map((x) => stripSalaryPii(x, depth + 1)); + if (isObj(v)) { + const out: Record = {}; + for (const [k, x] of Object.entries(v)) { + if (PII_KEY.test(k)) continue; + out[k] = stripSalaryPii(x, depth + 1); + } + return out; + } + return null; +} + +export function parseSalaryCalculation(v: unknown): SimpanySalaryCalculation | null { + if (!isObj(v)) return null; + if (!Array.isArray(v.calculatedSalaryDeclarationItems)) return null; + return { + calculatedItems: parsePayloadItems(v.calculatedSalaryDeclarationItems), + subtotal: parseSubtotal(v.subtotal), + suggestedInsuranceRange: + v.suggestedInsuranceRange === undefined ? null : stripSalaryPii(v.suggestedInsuranceRange), + }; +} + +function parseOptions(v: unknown): SimpanySalaryOption[] { + if (!Array.isArray(v)) return []; + return v + .filter(isObj) + .map((o) => ({ id: numOrNull(o.id), name: str(o.name)?.trim() ?? "" })) + .filter((o): o is SimpanySalaryOption => o.id != null && o.name !== ""); +} + +export function parseSalarySetting(v: unknown): SimpanySalarySetting | null { + if (!isObj(v)) return null; + return { + minimumSalary: numOrNull(v.salaryMonthMinimumSalary), + allowanceOptions: parseOptions(v.salaryBasicItemAllowanceOptions), + deductionOptions: parseOptions(v.salaryBasicItemDeductionOptions), + }; +} + /** `{ data: {...} }` 或直接是物件,兩種都接受。 */ function unwrapData(body: unknown): unknown { return isObj(body) && "data" in body ? body.data : body; @@ -638,13 +899,16 @@ async function loginAndCache(orgId: string, credentials: IntegrationCredentials) } type RequestOptions = { - method?: "GET" | "POST" | "DELETE"; + method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; query?: Record; body?: unknown; }; -/** einvoice = member2 的電子發票;salary = api 的薪資申報(唯讀)。 */ -type ApiBase = "einvoice" | "salary"; +/** + * einvoice = member2 的電子發票;salary = api 的薪資申報唯讀端點(assertSalaryReadOnly); + * salaryWrite = 同一個 host 的薪資申報寫入端點(assertSalaryWrite)。 + */ +type ApiBase = "einvoice" | "salary" | "salaryWrite"; /** * 已登入、已選定公司的 Simpany client。用 getSimpanyClient(orgId) 取得。 @@ -689,9 +953,9 @@ export class SimpanyClient { private url(base: ApiBase, path: string, query?: RequestOptions["query"]): string { const u = new URL( - base === "salary" - ? `${SALARY_BASE}/${this.companyId}/salary-declaration/${path}` - : `${EINVOICE_BASE}/${this.companyId}/${path}`, + base === "einvoice" + ? `${EINVOICE_BASE}/${this.companyId}/${path}` + : `${SALARY_BASE}/${this.companyId}/salary-declaration/${path}`, ); for (const [k, v] of Object.entries(query ?? {})) { if (v !== undefined && v !== "") u.searchParams.set(k, String(v)); @@ -706,6 +970,7 @@ export class SimpanyClient { opts: RequestOptions, ): Promise { if (base === "salary") assertSalaryReadOnly(opts.method ?? "GET", path, opts.query); + if (base === "salaryWrite") assertSalaryWrite(opts.method ?? "GET", path, opts.query); const headers: Record = { ...BASE_HEADERS, Authorization: `Bearer ${token}` }; if (opts.body !== undefined) headers["Content-Type"] = "application/json"; return safeFetch(this.url(base, path, opts.query), { @@ -754,7 +1019,7 @@ export class SimpanyClient { const body = await readBody(res); // 薪資申報的回應可能含個資:錯誤訊息只取結構化的 message,不附 body 片段。 const errorText = (b: unknown) => - base === "salary" ? salaryErrorMessage(b) : simpanyErrorMessage(b); + base === "einvoice" ? simpanyErrorMessage(b) : salaryErrorMessage(b); if (res.ok) { // 業務錯誤有時仍是 2xx:{ status: "error", error: {...} } if (isObj(body) && body.status === "error") { @@ -816,6 +1081,113 @@ export class SimpanyClient { return form; } + // ---- salary declarations(寫入;只由 src/lib/simpany-payroll.ts 呼叫)---- + // + // 全部走 salaryWrite → assertSalaryWrite(method + 路徑白名單)。請求 / 回應 body 不寫 log; + // 錯誤訊息只取 Simpany 的結構化 message(salaryErrorMessage),不附 body 片段。 + + private async salaryWrite( + method: "GET" | "POST" | "PUT" | "PATCH", + path: string, + body?: unknown, + ): Promise { + assertSalaryWrite(method, path); + return this.requestAt("salaryWrite", path, { method, body }); + } + + /** GET form/{formId}/salary-declaration/{declId}:單一申報明細(寫入時的模板)。 */ + async getSalaryDeclaration(formId: number, declId: number): Promise { + const body = await this.salaryWrite( + "GET", + `form/${positiveId(formId, "formId")}/salary-declaration/${positiveId(declId, "declarationId")}`, + ); + const detail = parseSalaryDeclarationDetail(unwrapData(body)); + if (!detail) throw new SimpanyError("business", "Simpany 回傳的薪資申報明細格式無法辨識"); + return detail; + } + + /** GET form/{formId}/setting:加項 / 減項選項、基本工資。 */ + async getSalarySetting(formId: number): Promise { + const body = await this.salaryWrite("GET", `form/${positiveId(formId, "formId")}/setting`); + const setting = parseSalarySetting(unwrapData(body)); + if (!setting) throw new SimpanyError("business", "Simpany 回傳的薪資申報設定格式無法辨識"); + return setting; + } + + /** + * POST …/calculate:Simpany 會員網頁的即時試算(不存檔)。只在「準備申報」時呼叫。 + */ + async calculateSalaryDeclaration( + formId: number, + declId: number, + payload: SimpanySalaryDeclarationPayload, + ): Promise { + const body = await this.salaryWrite( + "POST", + `form/${positiveId(formId, "formId")}/salary-declaration/${positiveId(declId, "declarationId")}/calculate`, + payload, + ); + const calc = parseSalaryCalculation(unwrapData(body)); + if (!calc) throw new SimpanyError("business", "Simpany 回傳的薪資試算結果格式無法辨識"); + return calc; + } + + /** PUT …/salary-declaration/{declId}:存檔申報明細(整份覆寫,重送同一份結果相同)。 */ + async updateSalaryDeclaration( + formId: number, + declId: number, + payload: SimpanySalaryDeclarationPayload, + ): Promise { + await this.salaryWrite( + "PUT", + `form/${positiveId(formId, "formId")}/salary-declaration/${positiveId(declId, "declarationId")}`, + payload, + ); + } + + /** POST form/{formId}/copy:把來源表單的員工與申報明細複製進來。回傳 Simpany 的級距調整(去個資)。 */ + async copySalaryForm(formId: number, sourceFormId: number, employeeIds: number[]): Promise { + const ids = employeeIds.map((id) => positiveId(id, "employeeId")); + if (ids.length === 0) throw new SimpanyError("config", "複製薪資申報至少要指定一位員工"); + const body = await this.salaryWrite("POST", `form/${positiveId(formId, "formId")}/copy`, { + sourceFormId: positiveId(sourceFormId, "sourceFormId"), + employeeIds: ids, + }); + const data = unwrapData(body); + return isObj(data) && data.rangeAdjustments !== undefined ? stripSalaryPii(data.rangeAdjustments) : null; + } + + /** PATCH form/{formId}/payday。回傳 Simpany 依發薪日做的健保級距調整(去個資)。 */ + async setSalaryPayday(formId: number, payday: string): Promise { + if (!/^\d{4}-\d{2}-\d{2}$/.test(payday)) throw new SimpanyError("config", `不合法的發薪日:${payday}`); + const body = await this.salaryWrite("PATCH", `form/${positiveId(formId, "formId")}/payday`, { payday }); + const data = unwrapData(body); + return isObj(data) && data.healthInsuranceRangeByPayDateAdjustments !== undefined + ? stripSalaryPii(data.healthInsuranceRangeByPayDateAdjustments) + : null; + } + + /** PUT …/salary-declaration/{declId}/company-owner。 */ + async setSalaryCompanyOwner(formId: number, declId: number, isCompanyOwner: boolean): Promise { + await this.salaryWrite( + "PUT", + `form/${positiveId(formId, "formId")}/salary-declaration/${positiveId(declId, "declarationId")}/company-owner`, + { isCompanyOwner }, + ); + } + + /** POST form/{formId}/settle:把這個月送給記帳士。**送出後無法從這裡撤回。** */ + async settleSalaryForm(formId: number, resignedEmployeeIds: number[] = []): Promise { + await this.salaryWrite("POST", `form/${positiveId(formId, "formId")}/settle`, { + resignedEmployeeIds: resignedEmployeeIds.map((id) => positiveId(id, "employeeId")), + }); + } + + /** POST form/{formId}/payslip/send:寄薪資單給員工(Simpany 寄信)。 */ + async sendSalaryPayslips(formId: number, body: SimpanyPayslipSendBody): Promise { + await this.salaryWrite("POST", `form/${positiveId(formId, "formId")}/payslip/send`, body); + } + // ---- receipts ---- async listReceipts(params: SimpanyListParams): Promise> { @@ -915,6 +1287,12 @@ export class SimpanyClient { } } +/** 路徑裡的 id:必須是正整數(擋掉路徑注入)。 */ +function positiveId(v: number, what: string): number { + if (!Number.isSafeInteger(v) || v <= 0) throw new SimpanyError("config", `不合法的 ${what}:${v}`); + return v; +} + /** 在未知形狀裡找「剩餘」類欄位加總;找不到回 null。 */ function sumRemaining(data: unknown): number | null { const KEYS = ["remaining", "remainingCount", "remaining_count", "availableCount", "available", "unusedCount", "remain"]; diff --git a/src/lib/mcp/handler.ts b/src/lib/mcp/handler.ts index e7743bd..9cd2898 100644 --- a/src/lib/mcp/handler.ts +++ b/src/lib/mcp/handler.ts @@ -75,7 +75,7 @@ function deriveMcpAudit(name: string, out: unknown): McpAudit | null { /** Bump on every published change to tools, schemas or instructions. Clients * (and OpenAI's plugin "Scan Tools") key their cached snapshot off this. */ -export const SERVER_VERSION = "1.8.0"; +export const SERVER_VERSION = "1.9.0"; /** Public base URL of this deployment; doubles as the OAuth issuer. * Keep in sync with the `resource` passed to `mcp()` in src/lib/auth.ts. */ @@ -281,6 +281,13 @@ const OPENWORLD_OVERRIDES: Record> = { // our tables, so it keeps the closed-world default. simpany_list_salary_declarations: { openWorldHint: true }, simpany_sync_salary_declarations: { openWorldHint: true }, + // Salary filing writes (src/lib/simpany-payroll.ts, assertSalaryWrite whitelist): + // prepare (calculate + optional copy), apply (PUT declarations), settle (submits + // the month to the bookkeeper), send payslips (emails employees). + simpany_prepare_salary_filing: { openWorldHint: true }, + simpany_apply_salary_filing: { openWorldHint: true }, + simpany_settle_salary_filing: { openWorldHint: true }, + simpany_send_payslips: { openWorldHint: true }, // Runs the daily auto-sync for the caller's org: Simpany invoice + salary sync // (GET-only for this path) and Wise transaction sync (GET-only); writes only our books. run_integration_sync: { openWorldHint: true }, @@ -291,6 +298,8 @@ const OPENWORLD_OVERRIDES: Record> = { const DESTRUCTIVE_OVERRIDES: Record> = { // Voids a legal e-invoice at the Ministry of Finance; cannot be undone. simpany_void_invoice: { destructiveHint: true }, + // Submits the month's salary declarations to the bookkeeper; cannot be undone here. + simpany_settle_salary_filing: { destructiveHint: true }, // Writes the payslip AND the matching salary-expense ledger entry; the month // cannot be recorded twice and there is no tool that reverses the entry. pay_employee_salary: { destructiveHint: true }, @@ -340,7 +349,11 @@ const TITLE_OVERRIDES: Record = { simpany_list_invoices: "List Simpany e-invoices", simpany_list_salary_declarations: "List Simpany salary declarations", simpany_list_zero_rate_reasons: "Simpany zero-rate reasons", + simpany_apply_salary_filing: "Write a salary filing draft to Simpany", + simpany_prepare_salary_filing: "Prepare a Simpany salary filing", simpany_preview_invoice: "Preview a Simpany e-invoice", + simpany_send_payslips: "Email payslips via Simpany", + simpany_settle_salary_filing: "Submit a salary month to the bookkeeper", simpany_sync_invoices: "Sync invoices from Simpany", simpany_sync_salary_declarations: "Sync salary declarations from Simpany", simpany_void_invoice: "Void a Simpany e-invoice", diff --git a/src/lib/mcp/tools-simpany.ts b/src/lib/mcp/tools-simpany.ts index 043275f..531f85d 100644 --- a/src/lib/mcp/tools-simpany.ts +++ b/src/lib/mcp/tools-simpany.ts @@ -21,6 +21,15 @@ import { salaryReconciliation, syncSalaryDeclarations, } from "@/lib/simpany-salary"; +import { + applySalaryFiling, + prepareSalaryFiling, + sendPayslips, + settleSalaryFiling, + type SalaryFilingEmployeeInput, + type SalaryFilingPreview, + type SalaryItemInput, +} from "@/lib/simpany-payroll"; import { auditIntegrationCall, requireIntegrationForTool } from "./tools-integrations"; import { listResult, @@ -147,6 +156,96 @@ function optNumberMap(v: unknown, key: string): Record | undefin return out; } + +function parseSalaryItems(v: unknown, key: string): SalaryItemInput[] | undefined { + if (v === undefined || v === null) return undefined; + if (!Array.isArray(v)) throw new Error(`"${key}" must be an array.`); + return v.map((raw, i) => { + if (!raw || typeof raw !== "object") throw new Error(`${key}[${i}] must be an object.`); + const it = raw as Record; + return { itemId: requireNumber(it, "itemId"), amount: requireNumber(it, "amount"), note: optString(it, "note") }; + }); +} + +function parseSalaryEmployees(v: unknown): SalaryFilingEmployeeInput[] | undefined { + if (v === undefined || v === null) return undefined; + if (!Array.isArray(v)) throw new Error('"employees" must be an array.'); + return v.map((raw, i) => { + if (!raw || typeof raw !== "object") throw new Error(`employees[${i}] must be an object.`); + const e = raw as Record; + return { + employeeId: optNumber(e, "employeeId"), + name: optString(e, "name"), + baseSalary: optNumber(e, "baseSalary"), + bonus: optNumber(e, "bonus"), + reimbursement: optNumber(e, "reimbursement"), + otherAllowances: parseSalaryItems(e.otherAllowances, `employees[${i}].otherAllowances`), + otherDeductions: parseSalaryItems(e.otherDeductions, `employees[${i}].otherDeductions`), + note: optString(e, "note"), + }; + }); +} + +function requireMonth(args: Record): number { + const m = monthArg(args, "month"); + if (m === undefined || m === 0) throw new Error('"month" is required (1-12).'); + return m; +} + +function prepareAuditLine(year: number, month: number, preview: SalaryFilingPreview): string { + const copied = preview.copy?.performed + ? `, copied ${preview.copy.employees.length} from form #${preview.copy.sourceFormId}` + : ""; + return `salary prepare ${year}-${String(month).padStart(2, "0")}: draft ${preview.draftId ?? "none"}, ${preview.employees.length} employees, net ${preview.totals.net}${copied}`; +} + +function prepareNextStep(preview: SalaryFilingPreview): string { + if (!preview.draftId) { + return "No draft was created. Resolve the problems / copy plan / missing employees shown here (e.g. re-run with allowCopy: true after the user agrees to copy, or add employees in Simpany's UI), then prepare again."; + } + const missing = preview.notInSimpany.length + ? `NOT in this draft (they don't exist in Simpany — the user must add them in Simpany's own UI first): ${preview.notInSimpany.join(", ")}. ` + : ""; + return ( + missing + + "Show this preview to the user (per employee: base, bonus, gross, personal burden, company burden, withholding, net; payday; owner; warnings). Only after they explicitly approve it in this conversation, call simpany_apply_salary_filing({ draftId }). The draft expires at expiresAt." + ); +} + +const SALARY_ITEM_SCHEMA = { + type: "object", + properties: { + itemId: { type: "number", description: "Simpany item id from the form settings." }, + amount: { type: "number", description: "Whole TWD, >= 0." }, + note: { type: "string" }, + }, + required: ["itemId", "amount"], + additionalProperties: false, +} as const; + +const SALARY_EMPLOYEE_SCHEMA = { + type: "object", + properties: { + employeeId: { type: "number", description: "Internal employee id (list_employees); or give name." }, + name: { type: "string", description: "Exact name as on Simpany." }, + baseSalary: { type: "number", description: "本薪 in whole TWD. Default: the employee record's base salary, else the last filed 本薪." }, + bonus: { type: "number", description: "非經常性獎金 (Simpany item 11) this month; one-off, never carried over." }, + reimbursement: { type: "number", description: "員工代墊款 (item 49) this month; one-off." }, + otherAllowances: { + type: "array", + items: SALARY_ITEM_SCHEMA, + description: "Replaces all other allowances: 1 免稅伙食津貼, 5 應稅其他加項, 6 特休未休代金, 8 免稅加班費, 10 年終獎金, 36 經常性獎金, 51 免稅資遣費. Default: keep the template's recurring ones (1, 36).", + }, + otherDeductions: { + type: "array", + items: SALARY_ITEM_SCHEMA, + description: "Editable deductions: 44 應稅其他減項, 50 公司代墊款. Default: none.", + }, + note: { type: "string", description: "Note printed on the bonus / reimbursement items added this month." }, + }, + additionalProperties: false, +} as const; + export const simpanyTools: Record = { simpany_list_invoices: { description: @@ -548,6 +647,192 @@ export const simpanyTools: Record = { }, }, + // ---- 薪資申報寫入(src/lib/simpany-payroll.ts;assertSalaryWrite 白名單)---- + // 準備(試算 + 草稿)→ 使用者同意 → 寫入(只收 draftId)→ 使用者三個確認 → 結算 → 寄薪資單。 + + simpany_prepare_salary_filing: { + description: + "Prepare (but do NOT save) a month's salary declaration (薪資申報) in Simpany, the company's payroll filing to its bookkeeper. Loads the month's Simpany form and each employee's declaration as a template, changes only the pay/insurance dates and the amounts (本薪, optional 非經常性獎金 / 員工代墊款 / other items; insurance brackets, insurance flags, dependents and pension settings are kept), runs Simpany's own calculation (POST …/calculate, which does not save), and stores a draft valid for 2 hours. Returns draftId plus per employee: gross, personal insurance, company insurance, withholding, net pay (實際發薪), declared salary, insured brackets, owner flag, and warnings (month-sequence restriction, payday not the 5th of next month, brackets, one-off items not carried over). If employees are missing from the month's form it plans a copy from the latest settled month; the copy writes to Simpany and only runs with allowCopy: true (otherwise draftId is null and the plan is returned). Employees that don't exist in Simpany are reported — they must be added in Simpany's own UI (this tool never creates employees). Never put shareholder loan repayments (股東往來還款) into a salary declaration — they are not salary. Show the preview to the user and get explicit approval before simpany_apply_salary_filing. Owner/admin only.", + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, + inputSchema: { + type: "object", + properties: { + year: { type: "number", description: "Western year of the salary month." }, + month: { type: "number", description: "Salary month 1-12 (the month the salary is for, not the payday month)." }, + payday: { type: "string", description: "YYYY-MM-DD. Default: the 5th of the next month (the convention)." }, + employees: { + type: "array", + items: SALARY_EMPLOYEE_SCHEMA, + description: "Who to file and with what amounts. Default: everyone on this month's form plus the latest settled month, excluding employees whose end date is before this month.", + }, + allowCopy: { type: "boolean", description: "Copy missing employees from the latest settled month's form into this month (a write to Simpany). Default false: only return the plan." }, + sourceFormId: { type: "number", description: "Simpany form id to copy from instead of the latest settled one." }, + companyOwner: { type: "string", description: "Name of the company owner (負責人; pays the full health premium, no employment insurance). Default: whoever is flagged in the latest settled month." }, + ...ORG_ARG, + }, + required: ["year", "month"], + additionalProperties: false, + }, + outputSchema: LOOSE_OBJECT, + execute: async (args, ctx) => { + const orgId = await resolveOrg(args, ctx); + await requireIntegrationForTool(orgId, "simpany"); + await requireManager(orgId, ctx, "準備 Simpany 薪資申報"); + const year = yearArg(args); + const month = requireMonth(args); + const preview = await prepareSalaryFiling(orgId, ctx.userId, { + year, + month, + payday: optDate(args, "payday"), + employees: parseSalaryEmployees(args.employees), + allowCopy: optBoolean(args, "allowCopy") ?? false, + sourceFormId: optNumber(args, "sourceFormId"), + companyOwner: optString(args, "companyOwner"), + }); + await auditIntegrationCall( + ctx, + orgId, + "simpany", + preview.copy?.performed ? "update" : "read", + prepareAuditLine(year, month, preview), + ); + return { ...preview, nextStep: prepareNextStep(preview) }; + }, + }, + + simpany_apply_salary_filing: { + description: + "Write a prepared salary declaration draft into Simpany: fixes the company-owner flags, sets the payday, then saves each employee's declaration exactly as previewed (PUT), reads the month back to verify each net pay (實際發薪) matches the preview, and re-syncs the year locally. Only call after the user has explicitly approved the preview from simpany_prepare_salary_filing in this conversation. Takes ONLY the draftId. The month stays editable in Simpany (not submitted to the bookkeeper until simpany_settle_salary_filing). On failure it reports exactly which declarations were written; every step overwrites, so the same draft can be retried. Owner/admin only.", + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, + inputSchema: { + type: "object", + properties: { + draftId: { type: "number", description: "From simpany_prepare_salary_filing." }, + ...ORG_ARG, + }, + required: ["draftId"], + additionalProperties: false, + }, + outputSchema: LOOSE_OBJECT, + execute: async (args, ctx) => { + const orgId = await resolveOrg(args, ctx); + await requireIntegrationForTool(orgId, "simpany"); + await requireManager(orgId, ctx, "寫入 Simpany 薪資申報"); + const draftId = requireNumber(args, "draftId"); + try { + const res = await applySalaryFiling(orgId, draftId); + await auditIntegrationCall( + ctx, + orgId, + "simpany", + "update", + `salary apply draft #${draftId} ${res.year}-${String(res.month).padStart(2, "0")}: wrote ${res.written.length}, verified ${res.verified}`, + ); + return { + ...res, + nextStep: res.verified + ? "Written. When the user is ready to submit this month to the bookkeeper, ask them to confirm the payday, the company owner and the salary amounts, then call simpany_settle_salary_filing with all three confirmations." + : "Written, but some net amounts read back from Simpany differ from the preview (see verification). Show the differences to the user before settling.", + }; + } catch (e) { + await auditIntegrationCall( + ctx, + orgId, + "simpany", + "update", + `salary apply draft #${draftId} failed: ${(e instanceof Error ? e.message : String(e)).slice(0, 200)}`, + ); + throw e; + } + }, + }, + + simpany_settle_salary_filing: { + description: + "Settle (結算) a month's salary declarations in Simpany, which SUBMITS THEM TO THE BOOKKEEPER for withholding / insurance filing. Cannot be undone from here. Requires three explicit confirmations from the user — exactly like Simpany's own dialog: the payday is correct (confirmPayday), the company owner flag is correct (confirmOwner), the salary amounts are correct (confirmSalary); all three must be true, and only set them after the user confirmed each in this conversation. Refuses when Simpany says the month cannot be settled yet (months must be settled in order — returns the missing earlier months) or when data is incomplete. Owner/admin only.", + annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true }, + inputSchema: { + type: "object", + properties: { + year: { type: "number" }, + month: { type: "number", description: "Salary month 1-12." }, + confirmPayday: { type: "boolean", description: "The user confirmed the payday is correct." }, + confirmOwner: { type: "boolean", description: "The user confirmed the company owner (負責人) flag is correct." }, + confirmSalary: { type: "boolean", description: "The user confirmed every employee's salary is correct." }, + ...ORG_ARG, + }, + required: ["year", "month", "confirmPayday", "confirmOwner", "confirmSalary"], + additionalProperties: false, + }, + outputSchema: LOOSE_OBJECT, + execute: async (args, ctx) => { + const orgId = await resolveOrg(args, ctx); + await requireIntegrationForTool(orgId, "simpany"); + await requireManager(orgId, ctx, "送出 Simpany 薪資申報結算"); + const year = yearArg(args); + const month = requireMonth(args); + const label = `${year}-${String(month).padStart(2, "0")}`; + try { + const res = await settleSalaryFiling(orgId, { + year, + month, + confirmPayday: optBoolean(args, "confirmPayday") === true, + confirmOwner: optBoolean(args, "confirmOwner") === true, + confirmSalary: optBoolean(args, "confirmSalary") === true, + }); + await auditIntegrationCall(ctx, orgId, "simpany", "update", `salary settle ${label}: settled ${res.settled}`); + return res; + } catch (e) { + await auditIntegrationCall( + ctx, + orgId, + "simpany", + "update", + `salary settle ${label} failed: ${(e instanceof Error ? e.message : String(e)).slice(0, 200)}`, + ); + throw e; + } + }, + }, + + simpany_send_payslips: { + description: + "Email payslips (薪資單) for a settled month through Simpany: each employee receives an encrypted PDF (password = their national id). Only for months already settled. Without employeeNames it sends to everyone on the month's form. Uses Simpany's default email text. Only call after the user explicitly asked to send them. Owner/admin only.", + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, + inputSchema: { + type: "object", + properties: { + year: { type: "number" }, + month: { type: "number", description: "Salary month 1-12." }, + employeeNames: { type: "array", items: { type: "string" }, description: "Only these employees (exact Simpany names). Default: everyone." }, + ...ORG_ARG, + }, + required: ["year", "month"], + additionalProperties: false, + }, + outputSchema: LOOSE_OBJECT, + execute: async (args, ctx) => { + const orgId = await resolveOrg(args, ctx); + await requireIntegrationForTool(orgId, "simpany"); + await requireManager(orgId, ctx, "寄送 Simpany 薪資單"); + const year = yearArg(args); + const month = requireMonth(args); + const res = await sendPayslips(orgId, { + year, + month, + employeeNames: optStringArray(args.employeeNames, "employeeNames"), + }); + await auditIntegrationCall( + ctx, + orgId, + "simpany", + "update", + `payslips ${year}-${String(month).padStart(2, "0")} sent to ${res.recipients.length}`, + ); + return res; + }, + }, + salary_arrears: { description: "[read] Salary arrears (欠薪) per employee: what was declared in Simpany (net paid 實際發薪, per month) versus what this organization actually recorded as paid (payslips + expense transactions in the 薪資費用 category linked to the employee by settle-employee or by party name). Reads only this organization's tables — run simpany_sync_salary_declarations first to refresh. Payments tied to a payslip period go to that month first; everything else is applied oldest-month-first (FIFO). Returns per employee: totalDeclaredNet, totalPaid, arrears (due, declared months still unpaid), estimatedArrears (months not declared in Simpany, estimated from the latest declared month's net minus its one-off bonus, flagged estimated: true), notYetDue, credit (paid with nothing to apply to), monthly rows and payments with their allocation; plus the month grid (missing / empty / draft / settled / not_synced) and unallocatedPayments — 薪資費用 outflows not linked to any employee, for the owner to assign.", diff --git a/src/lib/simpany-payroll.ts b/src/lib/simpany-payroll.ts new file mode 100644 index 0000000..128cebd --- /dev/null +++ b/src/lib/simpany-payroll.ts @@ -0,0 +1,1712 @@ +import { and, desc, eq, gt, inArray, isNull, lte, or, sql } from "drizzle-orm"; +import { getDb } from "@/db"; +import { employees, simpanySalaryDeclarations, simpanySalaryDrafts } from "@/db/schema"; +import { + getSimpanyClient, + SimpanyError, + type SimpanyClient, + type SimpanyMonthSequenceRestriction, + type SimpanyPayslipSendBody, + type SimpanySalaryDeclarationDetail, + type SimpanySalaryDeclarationPayload, + type SimpanySalaryForm, + type SimpanySalaryFormEmployee, + type SimpanySalaryOption, + type SimpanySalaryPayloadItem, + type SimpanySalarySubtotal, +} from "@/lib/integrations/simpany"; +import { summarizeDeclaration, syncSalaryDeclarations } from "@/lib/simpany-salary"; +import { taipeiDate } from "@/lib/simpany-sync"; + +/** + * 在 Simpany 填寫每月薪資申報(migrations/0029)。MCP(tools-simpany.ts)與 web 的 server + * actions(dashboard/payroll/simpany-salary-actions.ts)共用這一支。 + * + * 流程(每一步都要使用者明確動作): + * 1. prepareSalaryFiling —— 讀表單與每位員工的申報明細當模板,只換日期與金額,呼叫 Simpany + * 的試算(calculate,不存檔),把要 PUT 的 body 原樣存成草稿,回傳每人的應發 / 個人負擔 / + * 公司負擔 / 扣繳 / 實發。表單缺人時規劃從最近一個已結算的月份複製(複製是寫入,只有 + * allowCopy 才做)。 + * 2. applySalaryFiling(draftId) —— 只收 draftId:負責人旗標 → 發薪日 → 逐一 PUT 申報明細, + * 讀回來比對實發,再同步回本地。 + * 3. settleSalaryFiling —— 三個確認(發薪日、負責人、薪資)都為 true 才送出結算(給記帳士)。 + * 4. sendPayslips —— 已結算的月份才能寄薪資單。 + * + * 不做的事:建立 / 修改 / 刪除 Simpany 員工(需要身分證字號)—— 表單上找不到的人一律回報, + * 請使用者到 Simpany 的介面新增。股東往來還款不是薪資,永遠不寫進 Simpany。 + * + * ⚠️ 個資:只碰姓名、Simpany 員工 / 申報 id、金額、日期、旗標。請求 / 回應 body 不寫 log。 + */ + +export class SalaryFilingError extends Error { + constructor(message: string) { + super(message); + this.name = "SalaryFilingError"; + } +} + +const DRAFT_TTL_MS = 2 * 60 * 60 * 1000; +/** 往前找模板 / 複製來源時,最多讀幾張表單(每張一個 GET)。 */ +const MAX_FORM_LOOKUPS = 6; + +/** Simpany 的項目 id(從會員網頁 bundle 與實際 GET 確認)。 */ +export const SALARY_ITEM_ID = { BASE: 24, BONUS: 11, REIMBURSEMENT: 49 } as const; + +/** 每月固定的加項:沒有另外指定時沿用模板(本薪、免稅伙食津貼、經常性獎金)。其餘視為一次性,不沿用。 */ +const RECURRING_ALLOWANCE_IDS: ReadonlySet = new Set([24, 1, 36]); + +/** Simpany 拿不到設定時的後備選項(form/{id}/setting 的 salaryBasicItem*Options)。 */ +export const DEFAULT_ALLOWANCE_OPTIONS: SimpanySalaryOption[] = [ + { id: 1, name: "免稅伙食津貼" }, + { id: 5, name: "應稅其他加項" }, + { id: 6, name: "特休未休代金" }, + { id: 8, name: "免稅加班費" }, + { id: 10, name: "年終獎金" }, + { id: 11, name: "非經常性獎金" }, + { id: 36, name: "經常性獎金" }, + { id: 49, name: "員工代墊款" }, + { id: 51, name: "免稅資遣費" }, +]; +export const DEFAULT_DEDUCTION_OPTIONS: SimpanySalaryOption[] = [ + { id: 44, name: "應稅其他減項" }, + { id: 50, name: "公司代墊款" }, +]; + +const pad2 = (n: number) => String(n).padStart(2, "0"); +const norm = (s: string) => s.replaceAll(/\s+/g, ""); +const DATE_RE = /^\d{4}-\d{2}-\d{2}$/; + +function assertYearMonth(year: number, month: number): void { + if (!Number.isInteger(year) || year < 2000 || year > 2100) { + throw new SalaryFilingError(`不合法的年份:${year}`); + } + if (!Number.isInteger(month) || month < 1 || month > 12) { + throw new SalaryFilingError(`不合法的月份:${month}`); + } +} + +/** 薪資所屬月份的第一天與最後一天。 */ +export function monthRange(year: number, month: number): { start: string; end: string } { + const last = new Date(Date.UTC(year, month, 0)).getUTCDate(); + return { start: `${year}-${pad2(month)}-01`, end: `${year}-${pad2(month)}-${pad2(last)}` }; +} + +/** 慣例:M 月的薪資在 M+1 月 5 日發。 */ +export function defaultSalaryPayday(year: number, month: number): string { + return month === 12 ? `${year + 1}-01-05` : `${year}-${pad2(month + 1)}-05`; +} + +/** 「2026 年 10 月 5 日」 */ +function zhDate(ymd: string): string { + const [y, m, d] = ymd.split("-").map(Number); + return `${y} 年 ${m} 月 ${d} 日`; +} + +/** Simpany 會員網頁寄薪資單的預設信件內容。 */ +export function defaultPayslipMail(year: number, month: number, payday: string): string { + return [ + "您好,", + `${year} 年 ${month} 月的薪資已於 ${zhDate(payday)} 發放。附件為薪資明細,密碼為您的身分證件號碼或統一證號(居留證號碼),英文字母須大寫。若有疑問請聯繫相關人員。`, + "此為系統通知請勿直接回覆。", + ].join("\n\n"); +} + +// --------------------------------------------------------------------------- +// Payload builder(純函式,可單獨測) +// --------------------------------------------------------------------------- + +export type SalaryItemInput = { itemId: number; amount: number; note?: string | null }; + +export type SalaryAmounts = { + baseSalary: number; + /** 非經常性獎金(itemId 11)。 */ + bonus?: number; + /** 員工代墊款(itemId 49)。 */ + reimbursement?: number; + /** 給了就整組取代模板裡本薪以外的其他加項(不能含 11 / 49,那兩個用 bonus / reimbursement)。 */ + otherAllowances?: SalaryItemInput[]; + /** 給了就整組取代模板裡可編輯的減項(應稅其他減項 44、公司代墊款 50)。 */ + otherDeductions?: SalaryItemInput[]; + /** 印在這次新增的獎金 / 代墊款項目上的備註。 */ + note?: string | null; +}; + +export type SalaryOptions = { + allowanceOptions: SimpanySalaryOption[]; + deductionOptions: SimpanySalaryOption[]; +}; + +export type BuiltDeclaration = { + payload: SimpanySalaryDeclarationPayload; + /** 模板裡有、這次沒有沿用的項目(一次性的獎金、代墊款、減項…)。 */ + dropped: SimpanySalaryPayloadItem[]; +}; + +function assertAmount(n: number | undefined, what: string, opts: { positive?: boolean } = {}): void { + if (n === undefined) return; + if (!Number.isInteger(n) || n < 0) throw new SalaryFilingError(`${what}必須是 0 以上的整數台幣(收到 ${n})`); + if (opts.positive && n === 0) throw new SalaryFilingError(`${what}必須大於 0`); +} + +const typeOf = (it: SimpanySalaryPayloadItem) => it.type.toUpperCase(); + +/** 使用者可編輯的項目(送進 salaryDeclarationItems);其他都是 Simpany 算出來的。 */ +export function isEditableItem(it: SimpanySalaryPayloadItem, deductionIds: ReadonlySet): boolean { + const t = typeOf(it); + return t === "ALLOWANCE" || t === "INSURANCE_RANGE" || (t === "DEDUCTION" && deductionIds.has(it.itemId)); +} + +function isBaseItem(it: SimpanySalaryPayloadItem): boolean { + return typeOf(it) === "ALLOWANCE" && (it.itemId === SALARY_ITEM_ID.BASE || norm(it.name) === "本薪"); +} + +function optionItems( + inputs: SalaryItemInput[], + options: Map, + kind: "ALLOWANCE" | "DEDUCTION", + forbidden: ReadonlySet, +): SimpanySalaryPayloadItem[] { + const seen = new Set(); + return inputs.map((x) => { + if (!Number.isInteger(x.itemId) || !options.has(x.itemId) || forbidden.has(x.itemId)) { + const allowed = [...options].filter(([id]) => !forbidden.has(id)).map(([id, n]) => `${id} ${n}`); + throw new SalaryFilingError(`${kind === "ALLOWANCE" ? "加項" : "減項"} itemId ${x.itemId} 不能用;可用:${allowed.join("、")}`); + } + if (seen.has(x.itemId)) throw new SalaryFilingError(`itemId ${x.itemId} 重複`); + seen.add(x.itemId); + assertAmount(x.amount, `項目 ${x.itemId} 的金額`); + return { itemId: x.itemId, amount: x.amount, note: x.note?.trim() || null, name: options.get(x.itemId) ?? "", type: kind }; + }); +} + +/** 本薪以外、不能用 otherAllowances 帶的加項(獎金 / 代墊款有自己的欄位)。 */ +const RESERVED_ALLOWANCE_IDS: ReadonlySet = new Set([ + SALARY_ITEM_ID.BONUS, + SALARY_ITEM_ID.REIMBURSEMENT, + SALARY_ITEM_ID.BASE, +]); + +type ItemSplit = { items: SimpanySalaryPayloadItem[]; dropped: SimpanySalaryPayloadItem[] }; + +/** 本薪項目:沿用模板的 itemId / 名稱 / 備註,只換金額。 */ +function baseItem(baseTpl: SimpanySalaryPayloadItem | undefined, amount: number): SimpanySalaryPayloadItem { + return { + itemId: baseTpl?.itemId ?? SALARY_ITEM_ID.BASE, + amount, + note: baseTpl?.note ?? null, + name: baseTpl?.name || "本薪", + type: "ALLOWANCE", + }; +} + +/** 其他加項:有指定就整組取代;沒指定只沿用每月固定的,其餘列為 dropped。 */ +function otherAllowanceItems( + otherTpl: SimpanySalaryPayloadItem[], + given: SalaryItemInput[] | undefined, + allowNames: Map, +): ItemSplit { + if (given) { + const items = optionItems(given, allowNames, "ALLOWANCE", RESERVED_ALLOWANCE_IDS); + return { items, dropped: otherTpl.filter((it) => !items.some((g) => g.itemId === it.itemId)) }; + } + return { + items: otherTpl.filter((it) => RECURRING_ALLOWANCE_IDS.has(it.itemId)).map((it) => ({ ...it })), + dropped: otherTpl.filter((it) => !RECURRING_ALLOWANCE_IDS.has(it.itemId)), + }; +} + +/** 這個月才有的一次性加項(獎金 / 代墊款);金額是 0 或沒給就不放。 */ +function oneOffItem( + itemId: number, + amount: number | undefined, + note: string | null, + allowNames: Map, + fallbackName: string, +): SimpanySalaryPayloadItem[] { + if (!amount || amount <= 0) return []; + return [{ itemId, amount, note, name: allowNames.get(itemId) ?? fallbackName, type: "ALLOWANCE" }]; +} + +/** 可編輯的減項:一律一次性,有指定才放;模板裡的列為 dropped。 */ +function deductionItems( + dedTpl: SimpanySalaryPayloadItem[], + given: SalaryItemInput[] | undefined, + dedNames: Map, +): ItemSplit { + const items = given ? optionItems(given, dedNames, "DEDUCTION", new Set()) : []; + return { items, dropped: dedTpl.filter((it) => !items.some((d) => d.itemId === it.itemId)) }; +} + +/** 模板有勞保 / 勞退期間才改成整個月,沒有就維持 null。 */ +function periodDates( + decl: SimpanySalaryDeclarationDetail, + range: { start: string; end: string }, +): Pick< + SimpanySalaryDeclarationPayload, + "laborInsuranceStartDate" | "laborInsuranceEndDate" | "laborPensionStartDate" | "laborPensionEndDate" +> { + const hasLabor = decl.laborInsuranceStartDate != null || decl.laborInsuranceEndDate != null; + const hasPension = decl.laborPensionStartDate != null || decl.laborPensionEndDate != null; + return { + laborInsuranceStartDate: hasLabor ? range.start : null, + laborInsuranceEndDate: hasLabor ? range.end : null, + laborPensionStartDate: hasPension ? range.start : null, + laborPensionEndDate: hasPension ? range.end : null, + }; +} + +/** + * 以 Simpany 的申報明細為模板,組出這個月要試算 / 存檔的 body。 + * 只改:薪資期間與勞保 / 勞退期間(模板有值才改成整個月)、本薪、獎金、代墊款、其他加減項。 + * 投保級距(INSURANCE_RANGE)、投保旗標、扶養人數、勞退自提 / 雇主提繳率一律沿用模板。 + */ +export function buildDeclarationPayload( + decl: SimpanySalaryDeclarationDetail, + year: number, + month: number, + amounts: SalaryAmounts, + opts: SalaryOptions, +): BuiltDeclaration { + assertYearMonth(year, month); + assertAmount(amounts.baseSalary, "本薪", { positive: true }); + assertAmount(amounts.bonus, "非經常性獎金"); + assertAmount(amounts.reimbursement, "員工代墊款"); + const allowNames = new Map(opts.allowanceOptions.map((o) => [o.id, o.name])); + const dedNames = new Map(opts.deductionOptions.map((o) => [o.id, o.name])); + const dedIds = new Set(dedNames.keys()); + const range = monthRange(year, month); + + const editable = decl.items.filter((it) => isEditableItem(it, dedIds)); + const calculated = decl.items.filter((it) => !isEditableItem(it, dedIds)).map((it) => ({ ...it })); + + const baseTpl = editable.find(isBaseItem); + const others = otherAllowanceItems( + editable.filter((it) => typeOf(it) === "ALLOWANCE" && it !== baseTpl), + amounts.otherAllowances, + allowNames, + ); + const note = amounts.note?.trim() || null; + const allowances: SimpanySalaryPayloadItem[] = [ + baseItem(baseTpl, amounts.baseSalary), + ...others.items, + ...oneOffItem(SALARY_ITEM_ID.BONUS, amounts.bonus, note, allowNames, "非經常性獎金"), + ...oneOffItem(SALARY_ITEM_ID.REIMBURSEMENT, amounts.reimbursement, note, allowNames, "員工代墊款"), + ]; + const deductions = deductionItems( + editable.filter((it) => typeOf(it) === "DEDUCTION"), + amounts.otherDeductions, + dedNames, + ); + // 投保級距:原樣沿用 + const ranges = editable.filter((it) => typeOf(it) === "INSURANCE_RANGE").map((it) => ({ ...it })); + const dates = periodDates(decl, range); + + const payload: SimpanySalaryDeclarationPayload = { + payStartDate: range.start, + payEndDate: range.end, + hasEmploymentInsurance: decl.hasEmploymentInsurance, + hasOrdinaryAccidentInsurance: decl.hasOrdinaryAccidentInsurance, + hasPensionPreparationFundByCompany: decl.hasPensionPreparationFundByCompany, + healthInsuranceDependents: decl.healthInsuranceDependents, + pensionPreparationFundByCompanyRate: decl.pensionPreparationFundByCompanyRate, + pensionPreparationFundBySelfRate: decl.pensionPreparationFundBySelfRate, + withholdingTaxDependents: decl.withholdingTaxDependents, + salaryDeclarationItems: [...allowances, ...deductions.items, ...ranges], + calculatedSalaryDeclarationItems: calculated, + laborInsuranceStartDate: dates.laborInsuranceStartDate, + laborInsuranceEndDate: dates.laborInsuranceEndDate, + laborPensionStartDate: dates.laborPensionStartDate, + laborPensionEndDate: dates.laborPensionEndDate, + hasOccupationalAccidentInsurance: decl.hasOccupationalAccidentInsurance, + }; + const dropped = [...others.dropped, ...deductions.dropped]; + return { + payload, + dropped: dropped.filter( + (it) => !allowances.some((a) => a.itemId === it.itemId) && !deductions.items.some((d) => d.itemId === it.itemId), + ), + }; +} + +// --------------------------------------------------------------------------- +// Figures(從 Simpany 算好的項目抽出給人看的數字) +// --------------------------------------------------------------------------- + +export type SalaryFigures = { + /** 應發(加項合計)。 */ + gross: number; + /** 個人負擔(勞健保自付)。 */ + personalBurden: number; + /** 公司負擔(勞健保、就業保險、勞退提繳…)。 */ + companyInsurance: number; + /** 薪資扣繳。 */ + withholding: number; + /** 實際發薪(實發)。 */ + net: number | null; + /** 實際申報薪資。 */ + declared: number | null; + insuredRanges: { name: string; amount: number }[]; +}; + +export function salaryFigures( + items: SimpanySalaryPayloadItem[], + subtotal: SimpanySalarySubtotal | null, +): SalaryFigures { + const plain = items.map((it) => ({ name: it.name, type: it.type, amount: it.amount })); + const s = summarizeDeclaration(plain); + const isAmount = (it: SimpanySalaryPayloadItem) => { + const t = typeOf(it); + return t !== "INSURANCE_RANGE" && t !== "PAYSLIP_SUMMARY"; + }; + const allowanceSum = items.filter((it) => typeOf(it) === "ALLOWANCE").reduce((a, it) => a + it.amount, 0); + const withholdingItem = items.find((it) => typeOf(it) === "DEDUCTION" && norm(it.name) === "薪資扣繳"); + const companyInsurance = items + .filter((it) => isAmount(it) && !norm(it.name).includes("代墊")) + .filter((it) => norm(it.name).includes("公司負擔") || norm(it.name).includes("公司提繳")) + .reduce((a, it) => a + it.amount, 0); + return { + gross: subtotal?.allowance ?? allowanceSum, + personalBurden: subtotal?.personalBurden ?? (s.laborInsPersonal ?? 0) + (s.healthInsPersonal ?? 0), + companyInsurance, + withholding: subtotal?.withholdingTax ?? withholdingItem?.amount ?? 0, + net: s.netPay, + declared: s.grossDeclared, + insuredRanges: items + .filter((it) => typeOf(it) === "INSURANCE_RANGE") + .map((it) => ({ name: it.name, amount: it.amount })), + }; +} + +/** 形狀未知的建議級距裡所有的數字。 */ +function numericLeaves(v: unknown, out: number[] = [], depth = 0): number[] { + if (depth > 4) return out; + if (typeof v === "number" && Number.isFinite(v)) out.push(v); + else if (Array.isArray(v)) for (const x of v) numericLeaves(x, out, depth + 1); + else if (v && typeof v === "object") for (const x of Object.values(v)) numericLeaves(x, out, depth + 1); + return out; +} + +// --------------------------------------------------------------------------- +// Internal data +// --------------------------------------------------------------------------- + +type InternalEmployee = { + id: number; + name: string; + baseSalary: string | null; + startDate: string | null; + endDate: string | null; + isActive: boolean; + employmentType: string; +}; + +async function loadInternalEmployees(orgId: string): Promise { + return getDb() + .select({ + id: employees.id, + name: employees.name, + baseSalary: employees.baseSalary, + startDate: employees.startDate, + endDate: employees.endDate, + isActive: employees.isActive, + employmentType: employees.employmentType, + }) + .from(employees) + .where(and(eq(employees.organizationId, orgId), isNull(employees.deletedAt))); +} + +/** 姓名 → 員工;重名的不綁(回 null)。 */ +function byUniqueName(list: InternalEmployee[]): Map { + const m = new Map(); + for (const e of list) { + const k = e.name.trim(); + m.set(k, m.has(k) ? null : e); + } + return m; +} + +function positiveNumber(v: string | null | undefined): number | null { + const n = Number(v); + return v != null && Number.isFinite(n) && n > 0 ? n : null; +} + +// --------------------------------------------------------------------------- +// Simpany lookups +// --------------------------------------------------------------------------- + +type FoundForm = { form: SimpanySalaryForm; year: number; month: number }; + +/** + * 往前找最近一個已結算的表單(本年早於 month 的月份,再往前一年)。wantNames 有給時只看 + * 表單上有其中任何一人的月份。最多讀 MAX_FORM_LOOKUPS 張表單。 + */ +async function findLatestSettledForm( + client: SimpanyClient, + year: number, + month: number, + wantNames: ReadonlySet | null, +): Promise { + let lookups = 0; + for (const y of [year, year - 1]) { + const monthly = await client.listSalaryMonthlyForms(y); + const candidates = monthly + .filter((m) => m.id != null && (y < year || m.month < month)) + .filter((m) => !wantNames || m.employees.some((e) => wantNames.has(e.name))) + .sort((a, b) => b.month - a.month); + for (const m of candidates) { + if (lookups >= MAX_FORM_LOOKUPS) return null; + lookups++; + const form = await client.getSalaryForm(y, m.month); + if (form.isSettled) return { form, year: y, month: m.month }; + } + } + return null; +} + +/** sourceFormId → 它是哪一年哪一月(查本年與前一年的 monthly-forms)。 */ +async function findFormById(client: SimpanyClient, year: number, formId: number): Promise { + for (const y of [year, year - 1]) { + const monthly = await client.listSalaryMonthlyForms(y); + const hit = monthly.find((m) => m.id === formId); + if (hit) return { form: await client.getSalaryForm(y, hit.month), year: y, month: hit.month }; + } + return null; +} + +function ownerOf(form: SimpanySalaryForm | null | undefined): string | null { + return form?.employees.find((e) => e.declaration?.isCompanyOwner)?.name ?? null; +} + +function baseFromForm(emp: SimpanySalaryFormEmployee | undefined): number | null { + const items = emp?.declaration?.items ?? []; + const s = summarizeDeclaration(items); + return s.baseSalary != null && s.baseSalary > 0 ? s.baseSalary : null; +} + +function restrictionText(r: SimpanyMonthSequenceRestriction): string { + const months = r.missingMonths.length ? `:${r.missingMonths.join("、")}` : ""; + const why = r.reason === "EXISTING_OUT_OF_SEQUENCE_RECORDS" ? "前面還有沒結算的月份" : r.reason; + return `Simpany 要求依序結算(${why}${months})。要先把這些月份結算,這個月才能送出。`; +} + +// --------------------------------------------------------------------------- +// Prepare +// --------------------------------------------------------------------------- + +export type SalaryFilingEmployeeInput = { + /** 本系統員工 id(或用 name)。 */ + employeeId?: number; + /** 姓名,要和 Simpany 上的一模一樣。 */ + name?: string; + baseSalary?: number; + bonus?: number; + reimbursement?: number; + otherAllowances?: SalaryItemInput[]; + otherDeductions?: SalaryItemInput[]; + note?: string; +}; + +export type PrepareSalaryInput = { + year: number; + month: number; + /** YYYY-MM-DD,預設次月 5 日。 */ + payday?: string; + /** 沒給 = 這個月表單上的人 + 最近一個已結算月份的人(排除本系統已離職的)。 */ + employees?: SalaryFilingEmployeeInput[]; + /** 表單缺人時從哪張表單複製;預設最近一個有這些人的已結算表單。 */ + sourceFormId?: number; + /** 表單缺人時真的執行複製(寫入 Simpany)。預設 false:只回傳計畫。 */ + allowCopy?: boolean; + /** 負責人姓名;預設沿用最近一個已結算月份(沒有就這個月)標記的負責人。 */ + companyOwner?: string; +}; + +export type SalaryFilingEmployeePreview = { + name: string; + employeeId: number | null; + simpanyEmployeeId: number; + declarationId: number; + isCompanyOwner: boolean; + /** 寫入時會修正 Simpany 上的負責人旗標。 */ + ownerFlagChange: boolean; + baseSalary: number; + baseSource: "input" | "employee" | "last_filed"; + bonus: number; + reimbursement: number; + otherItems: { itemId: number; name: string; type: string; amount: number }[]; + droppedItems: { itemId: number; name: string; amount: number }[]; + suggestedInsuranceRange: unknown; +} & SalaryFigures; + +export type SalaryCopyPlan = { + required: boolean; + performed: boolean; + sourceFormId: number | null; + sourceYear: number | null; + sourceMonth: number | null; + employees: string[]; + adjustments: unknown; +}; + +export type SalaryFilingPreview = { + draftId: number | null; + expiresAt: string | null; + year: number; + month: number; + formId: number; + payday: string; + currentPayday: string | null; + canSettle: boolean | null; + sequenceRestriction: SimpanyMonthSequenceRestriction | null; + companyOwner: { name: string; source: "input" | "last_settled" | "current_form" } | null; + copy: SalaryCopyPlan | null; + /** 在 Simpany 找不到(也沒有可複製的來源)的人:請到 Simpany 的介面新增員工。 */ + notInSimpany: string[]; + /** 算不出來的人(Simpany 拒絕試算、表單上沒有申報明細…);有問題就不會產生草稿。 */ + problems: { name: string; message: string }[]; + employees: SalaryFilingEmployeePreview[]; + totals: { gross: number; personalBurden: number; companyInsurance: number; withholding: number; net: number }; + warnings: string[]; + summary: string; +}; + +type Target = { name: string; internal: InternalEmployee | null; input: SalaryFilingEmployeeInput | null }; + +function explicitTargets(inputs: SalaryFilingEmployeeInput[], internal: InternalEmployee[]): Target[] { + const byId = new Map(internal.map((e) => [e.id, e])); + const byName = byUniqueName(internal); + const seen = new Set(); + return inputs.map((x, i) => { + let name: string; + let emp: InternalEmployee | null; + if (x.employeeId != null) { + emp = byId.get(x.employeeId) ?? null; + if (!emp) throw new SalaryFilingError(`找不到員工 #${x.employeeId}`); + name = emp.name.trim(); + } else if (x.name?.trim()) { + name = x.name.trim(); + emp = byName.get(name) ?? null; + } else { + throw new SalaryFilingError(`第 ${i + 1} 位員工要給 employeeId 或 name`); + } + if (seen.has(name)) throw new SalaryFilingError(`${name} 重複出現`); + seen.add(name); + return { name, internal: emp, input: x }; + }); +} + +/** 預設對象:這個月表單上的人 + 模板月份的人,排除本系統記錄在這個月之前就離職的。 */ +function defaultTargets( + form: SimpanySalaryForm, + template: SimpanySalaryForm | null, + internal: InternalEmployee[], + monthStart: string, +): Target[] { + const byName = byUniqueName(internal); + const names = new Set([ + ...form.employees.map((e) => e.name), + ...(template?.employees.filter((e) => e.declaration?.items.length).map((e) => e.name) ?? []), + ]); + const out: Target[] = []; + for (const name of names) { + const emp = byName.get(name) ?? null; + if (emp?.endDate && emp.endDate < monthStart) continue; + out.push({ name, internal: emp, input: null }); + } + return out; +} + +type Resolved = { base: number; source: SalaryFilingEmployeePreview["baseSource"] } | null; + +function resolveBase(t: Target, decl: SimpanySalaryDeclarationDetail, templateEmp: SimpanySalaryFormEmployee | undefined): Resolved { + if (t.input?.baseSalary != null) return { base: t.input.baseSalary, source: "input" }; + const fromEmployee = positiveNumber(t.internal?.baseSalary); + if (fromEmployee != null) return { base: Math.round(fromEmployee), source: "employee" }; + const inForm = decl.items.find(isBaseItem)?.amount; + const last = inForm && inForm > 0 ? inForm : baseFromForm(templateEmp); + return last == null ? null : { base: last, source: "last_filed" }; +} + +type CopyContext = { + client: SimpanyClient; + input: PrepareSalaryInput; + form: SimpanySalaryForm; + template: FoundForm | null; + missing: string[]; + warnings: string[]; +}; + +/** 表單缺人:找來源、規劃複製;allowCopy 才真的寫入。回傳(可能已更新的)表單與計畫。 */ +async function planCopy(ctx: CopyContext): Promise<{ + form: SimpanySalaryForm; + plan: SalaryCopyPlan; + notInSimpany: string[]; +}> { + const { client, input, missing } = ctx; + const want = new Set(missing); + let source: FoundForm | null = null; + if (input.sourceFormId != null) { + source = await findFormById(client, input.year, input.sourceFormId); + if (!source) throw new SalaryFilingError(`在 ${input.year - 1}~${input.year} 年找不到 Simpany 表單 #${input.sourceFormId}`); + if (!source.form.isSettled) ctx.warnings.push(`複製來源 ${source.year}-${pad2(source.month)} 還沒結算`); + } else if (ctx.template?.form.employees.some((e) => want.has(e.name))) { + source = ctx.template; + } else { + source = await findLatestSettledForm(client, input.year, input.month, want); + } + const srcEmps = source?.form.employees.filter((e) => want.has(e.name)) ?? []; + const copyNames = srcEmps.map((e) => e.name); + const notInSimpany = missing.filter((n) => !copyNames.includes(n)); + const plan: SalaryCopyPlan = { + required: srcEmps.length > 0, + performed: false, + sourceFormId: source?.form.id ?? null, + sourceYear: source?.year ?? null, + sourceMonth: source?.month ?? null, + employees: copyNames, + adjustments: null, + }; + if (!plan.required || !input.allowCopy || source?.form.id == null || ctx.form.id == null) { + return { form: ctx.form, plan, notInSimpany }; + } + plan.adjustments = await client.copySalaryForm(ctx.form.id, source.form.id, srcEmps.map((e) => e.id)); + plan.performed = true; + return { form: await client.getSalaryForm(input.year, input.month), plan, notInSimpany }; +} + +function sum(xs: number[]): number { + return Math.round(xs.reduce((a, x) => a + x, 0) * 100) / 100; +} + +type EmployeeCalc = { preview: SalaryFilingEmployeePreview; body: SimpanySalaryDeclarationPayload }; + +type CalcContext = { + client: SimpanyClient; + formId: number; + year: number; + month: number; + options: SalaryOptions; + minimumSalary: number | null; + templateByName: Map; + ownerName: string | null; + monthStart: string; + monthEnd: string; + warnings: string[]; +}; + +async function calculateEmployee(t: Target, emp: SimpanySalaryFormEmployee, ctx: CalcContext): Promise { + const declId = emp.declaration?.id; + if (declId == null) { + throw new SalaryFilingError("表單上有這個人但沒有申報明細,請到 Simpany 確認"); + } + const decl = await ctx.client.getSalaryDeclaration(ctx.formId, declId); + const base = resolveBase(t, decl, ctx.templateByName.get(t.name)); + if (!base) throw new SalaryFilingError("沒有本薪:請提供 baseSalary,或在員工資料填本薪"); + const x = t.input; + const built = buildDeclarationPayload( + decl, + ctx.year, + ctx.month, + { + baseSalary: base.base, + bonus: x?.bonus, + reimbursement: x?.reimbursement, + otherAllowances: x?.otherAllowances, + otherDeductions: x?.otherDeductions, + note: x?.note, + }, + ctx.options, + ); + const calc = await ctx.client.calculateSalaryDeclaration(ctx.formId, declId, built.payload); + const body: SimpanySalaryDeclarationPayload = { + ...built.payload, + calculatedSalaryDeclarationItems: calc.calculatedItems, + }; + const figures = salaryFigures([...body.salaryDeclarationItems, ...calc.calculatedItems], calc.subtotal); + const isOwner = ctx.ownerName ? t.name === ctx.ownerName : decl.isCompanyOwner; + + // ---- warnings ---- + const w = ctx.warnings; + if (!figures.insuredRanges.length) w.push(`${t.name}:申報明細沒有投保級距,請在 Simpany 確認`); + const maxRange = Math.max(0, ...figures.insuredRanges.map((r) => r.amount)); + if (figures.insuredRanges.length && base.base > maxRange) { + w.push(`${t.name}:本薪 ${base.base} 高於目前的投保級距 ${maxRange},可能要在 Simpany 調高級距(這裡不會自動改)`); + } + const suggested = numericLeaves(calc.suggestedInsuranceRange); + const current = new Set(figures.insuredRanges.map((r) => r.amount)); + if (suggested.some((n) => n > 0 && !current.has(n))) { + w.push(`${t.name}:Simpany 建議的投保級距與目前不同(${suggested.join("、")}),這裡沿用目前級距`); + } + if (ctx.minimumSalary && base.base < ctx.minimumSalary && t.internal?.employmentType === "full_time") { + w.push(`${t.name}:本薪 ${base.base} 低於基本工資 ${ctx.minimumSalary}`); + } + if (t.internal?.startDate && t.internal.startDate > ctx.monthStart && t.internal.startDate <= ctx.monthEnd) { + w.push(`${t.name}:${t.internal.startDate} 到職(月中),薪資期間仍以整月計,請確認`); + } + if (t.internal?.endDate && t.internal.endDate >= ctx.monthStart && t.internal.endDate < ctx.monthEnd) { + w.push(`${t.name}:${t.internal.endDate} 離職(月中),薪資期間仍以整月計,請確認`); + } + if (built.dropped.length) { + const droppedText = built.dropped.map((d) => d.name + " " + String(d.amount)).join("、"); + w.push(`${t.name}:模板裡的 ${droppedText} 是一次性項目,這個月沒有沿用`); + } + + const bonus = body.salaryDeclarationItems.find((it) => it.itemId === SALARY_ITEM_ID.BONUS)?.amount ?? 0; + const reimbursement = + body.salaryDeclarationItems.find((it) => it.itemId === SALARY_ITEM_ID.REIMBURSEMENT)?.amount ?? 0; + const baseIds = new Set([SALARY_ITEM_ID.BASE, SALARY_ITEM_ID.BONUS, SALARY_ITEM_ID.REIMBURSEMENT]); + return { + body, + preview: { + name: t.name, + employeeId: t.internal?.id ?? null, + simpanyEmployeeId: emp.id, + declarationId: declId, + isCompanyOwner: isOwner, + ownerFlagChange: isOwner !== decl.isCompanyOwner, + baseSalary: base.base, + baseSource: base.source, + bonus, + reimbursement, + otherItems: body.salaryDeclarationItems + .filter((it) => typeOf(it) !== "INSURANCE_RANGE" && !baseIds.has(it.itemId)) + .map((it) => ({ itemId: it.itemId, name: it.name, type: it.type, amount: it.amount })), + droppedItems: built.dropped.map((d) => ({ itemId: d.itemId, name: d.name, amount: d.amount })), + suggestedInsuranceRange: calc.suggestedInsuranceRange, + ...figures, + }, + }; +} + +function resolvePayday(input: PrepareSalaryInput, warnings: string[]): string { + const conventional = defaultSalaryPayday(input.year, input.month); + const payday = input.payday?.trim() || conventional; + if (!DATE_RE.test(payday) || Number.isNaN(Date.parse(payday))) { + throw new SalaryFilingError(`發薪日「${payday}」不是 YYYY-MM-DD`); + } + if (payday < monthRange(input.year, input.month).start) { + throw new SalaryFilingError(`發薪日 ${payday} 早於薪資月份`); + } + if (payday !== conventional) { + warnings.push(`發薪日 ${payday} 不是慣例的次月 5 日(${conventional})`); + } + return payday; +} + +async function loadOptions(client: SimpanyClient, formId: number, warnings: string[]) { + try { + const s = await client.getSalarySetting(formId); + return { + options: { + allowanceOptions: s.allowanceOptions.length ? s.allowanceOptions : DEFAULT_ALLOWANCE_OPTIONS, + deductionOptions: s.deductionOptions.length ? s.deductionOptions : DEFAULT_DEDUCTION_OPTIONS, + }, + minimumSalary: s.minimumSalary, + }; + } catch (e) { + warnings.push(`讀不到 Simpany 的薪資設定,改用已知的加減項清單:${e instanceof Error ? e.message : String(e)}`); + return { + options: { allowanceOptions: DEFAULT_ALLOWANCE_OPTIONS, deductionOptions: DEFAULT_DEDUCTION_OPTIONS }, + minimumSalary: null, + }; + } +} + +function resolveOwner( + input: PrepareSalaryInput, + template: FoundForm | null, + form: SimpanySalaryForm, +): SalaryFilingPreview["companyOwner"] { + const given = input.companyOwner?.trim(); + if (given) return { name: given, source: "input" }; + const fromTemplate = ownerOf(template?.form); + if (fromTemplate) return { name: fromTemplate, source: "last_settled" }; + const fromForm = ownerOf(form); + return fromForm ? { name: fromForm, source: "current_form" } : null; +} + +function summaryLine(p: Pick): string { + return [ + `${p.year}-${pad2(p.month)} 薪資`, + `發薪日 ${p.payday}`, + `${p.employees.length} 人`, + `應發 ${p.totals.gross}`, + `個人負擔 ${p.totals.personalBurden}`, + `扣繳 ${p.totals.withholding}`, + `實發 ${p.totals.net}`, + `公司負擔 ${p.totals.companyInsurance}`, + ].join("|"); +} + +/** 讀這個月的表單;沒有表單或已結算就拒絕。 */ +async function loadOpenForm(client: SimpanyClient, year: number, month: number): Promise { + const form = await client.getSalaryForm(year, month); + if (form.id == null) throw new SalaryFilingError(`Simpany 沒有 ${year}-${pad2(month)} 的薪資申報表單`); + if (form.isSettled) { + throw new SalaryFilingError(`${year}-${pad2(month)} 在 Simpany 已經結算,不能再修改`); + } + return form; +} + +/** 對象裡有表單上沒有的人:規劃(allowCopy 時執行)複製。沒有缺人就原樣回傳表單。 */ +async function resolveMissing( + client: SimpanyClient, + input: PrepareSalaryInput, + form: SimpanySalaryForm, + template: FoundForm | null, + targets: Target[], + warnings: string[], +): Promise<{ form: SimpanySalaryForm; copy: SalaryCopyPlan | null; notInSimpany: string[] }> { + const missing = targets.map((t) => t.name).filter((n) => !form.employees.some((e) => e.name === n)); + if (missing.length === 0) return { form, copy: null, notInSimpany: [] }; + const res = await planCopy({ client, input, form, template, missing, warnings }); + return { form: res.form, copy: res.plan, notInSimpany: res.notInSimpany }; +} + +/** 逐一試算表單上的對象;Simpany 拒絕或資料不足的人收進 problems(不中斷其他人)。 */ +async function calculateTargets( + targets: Target[], + form: SimpanySalaryForm, + ctx: CalcContext, +): Promise<{ results: EmployeeCalc[]; problems: SalaryFilingPreview["problems"] }> { + const results: EmployeeCalc[] = []; + const problems: SalaryFilingPreview["problems"] = []; + for (const t of targets) { + const emp = form.employees.find((e) => e.name === t.name); + if (!emp) continue; // 已列在 copy / notInSimpany + try { + results.push(await calculateEmployee(t, emp, ctx)); + } catch (e) { + if (!(e instanceof SalaryFilingError || e instanceof SimpanyError)) throw e; + problems.push({ name: t.name, message: e.message }); + } + } + return { results, problems }; +} + +/** 寫入時才會生效的變更(負責人旗標、發薪日)先警示。 */ +function pushApplyWarnings( + warnings: string[], + results: EmployeeCalc[], + owner: SalaryFilingPreview["companyOwner"], + currentPayday: string | null, + payday: string, +): void { + if (results.some((r) => r.preview.ownerFlagChange)) { + warnings.push( + `負責人旗標會在寫入時修正(負責人:${owner?.name})。預覽金額是以 Simpany 目前的旗標試算的;寫入後會讀回來比對實發。`, + ); + } + if (currentPayday && currentPayday !== payday) { + warnings.push(`Simpany 目前的發薪日是 ${currentPayday},寫入時會改成 ${payday}(Simpany 可能依發薪日調整健保級距,寫入後會比對實發)`); + } +} + +function sumTotals(emps: SalaryFilingEmployeePreview[]): SalaryFilingPreview["totals"] { + return { + gross: sum(emps.map((e) => e.gross)), + personalBurden: sum(emps.map((e) => e.personalBurden)), + companyInsurance: sum(emps.map((e) => e.companyInsurance)), + withholding: sum(emps.map((e) => e.withholding)), + net: sum(emps.map((e) => e.net ?? 0)), + }; +} + +/** 存草稿:每份要 PUT 的 body 原樣 + 預覽,2 小時過期。 */ +async function saveDraft(d: { + orgId: string; + userId: string | null; + formId: number; + payday: string; + owner: SalaryFilingPreview["companyOwner"]; + results: EmployeeCalc[]; + base: Omit; +}): Promise<{ draftId: number; expiresAt: string }> { + const expiresAt = new Date(Date.now() + DRAFT_TTL_MS).toISOString(); + const [draft] = await getDb() + .insert(simpanySalaryDrafts) + .values({ + organizationId: d.orgId, + year: d.base.year, + month: d.base.month, + simpanyFormId: d.formId, + payday: d.payday, + payload: { + companyOwner: d.owner?.name ?? null, + declarations: d.results.map((r) => ({ + declarationId: r.preview.declarationId, + simpanyEmployeeId: r.preview.simpanyEmployeeId, + name: r.preview.name, + isCompanyOwner: r.preview.isCompanyOwner, + ownerFlagChange: r.preview.ownerFlagChange, + expectedNet: r.preview.net, + body: r.body, + })), + }, + summary: { expiresAt, ...d.base } as unknown as Record, + status: "pending", + createdByUserId: d.userId, + expiresAt, + }) + .returning({ id: simpanySalaryDrafts.id }); + return { draftId: draft.id, expiresAt }; +} + +/** + * 準備某個月的薪資申報:讀 Simpany、試算、存草稿,回傳預覽。 + * 會對 Simpany 發:GET(表單、明細、設定)與 POST calculate(試算,不存檔); + * allowCopy 時另外 POST copy(寫入)。**不會存檔申報明細、不會結算。** + * 注意:GET form?year&month 對還沒建立的月份會由 Simpany 自動建立空白表單(它的介面也是這樣)。 + */ +export async function prepareSalaryFiling( + orgId: string, + userId: string | null, + input: PrepareSalaryInput, + clientArg?: SimpanyClient, +): Promise { + assertYearMonth(input.year, input.month); + const warnings: string[] = []; + const payday = resolvePayday(input, warnings); + const { start: monthStart, end: monthEnd } = monthRange(input.year, input.month); + const client = clientArg ?? (await getSimpanyClient(orgId)); + + const openForm = await loadOpenForm(client, input.year, input.month); + const restriction = openForm.monthSequenceRestriction; + if (restriction) warnings.push(restrictionText(restriction)); + + const internal = await loadInternalEmployees(orgId); + const template = await findLatestSettledForm(client, input.year, input.month, null); + const targets = input.employees?.length + ? explicitTargets(input.employees, internal) + : defaultTargets(openForm, template?.form ?? null, internal, monthStart); + if (targets.length === 0) throw new SalaryFilingError("沒有要申報的員工"); + + // 表單缺人 → 複製計畫(複製計畫與找不到的人放在 copy / notInSimpany 欄位,不重複塞進 warnings) + const { form, copy, notInSimpany } = await resolveMissing(client, input, openForm, template, targets, warnings); + const formId = form.id as number; + + const { options, minimumSalary } = await loadOptions(client, formId, warnings); + const owner = resolveOwner(input, template, form); + if (!owner) warnings.push("找不到負責人:Simpany 上沒有任何人被標為負責人,請確認"); + const ctx: CalcContext = { + client, + formId, + year: input.year, + month: input.month, + options, + minimumSalary, + templateByName: new Map(template?.form.employees.map((e) => [e.name, e]) ?? []), + ownerName: owner?.name ?? null, + monthStart, + monthEnd, + warnings, + }; + + const { results, problems } = await calculateTargets(targets, form, ctx); + pushApplyWarnings(warnings, results, owner, form.payday, payday); + + const emps = results.map((r) => r.preview); + const totals = sumTotals(emps); + const base = { + year: input.year, + month: input.month, + formId, + payday, + currentPayday: form.payday, + canSettle: form.canSettle, + sequenceRestriction: restriction, + companyOwner: owner, + copy, + notInSimpany, + problems, + employees: emps, + totals, + warnings, + summary: summaryLine({ year: input.year, month: input.month, payday, employees: emps, totals }), + }; + + const blocked = problems.length > 0 || (copy?.required && !copy.performed) || results.length === 0; + if (blocked) return { draftId: null, expiresAt: null, ...base }; + + const saved = await saveDraft({ orgId, userId, formId, payday, owner, results, base }); + return { draftId: saved.draftId, expiresAt: saved.expiresAt, ...base }; +} + +// --------------------------------------------------------------------------- +// Apply +// --------------------------------------------------------------------------- + +type DraftDeclaration = { + declarationId: number; + simpanyEmployeeId: number; + name: string; + isCompanyOwner: boolean; + ownerFlagChange: boolean; + expectedNet: number | null; + body: SimpanySalaryDeclarationPayload; +}; + +function parseDraftDeclarations(payload: Record): DraftDeclaration[] | null { + const list = payload.declarations; + if (!Array.isArray(list) || list.length === 0) return null; + const out: DraftDeclaration[] = []; + for (const d of list) { + if (!d || typeof d !== "object") return null; + const x = d as Record; + if (typeof x.declarationId !== "number" || typeof x.name !== "string") return null; + if (!x.body || typeof x.body !== "object") return null; + out.push({ + declarationId: x.declarationId, + simpanyEmployeeId: Number(x.simpanyEmployeeId), + name: x.name, + isCompanyOwner: x.isCompanyOwner === true, + ownerFlagChange: x.ownerFlagChange === true, + expectedNet: typeof x.expectedNet === "number" ? x.expectedNet : null, + body: x.body as SimpanySalaryDeclarationPayload, + }); + } + return out; +} + +async function explainUnavailableDraft(orgId: string, draftId: number): Promise { + const [d] = await getDb() + .select({ status: simpanySalaryDrafts.status, expiresAt: simpanySalaryDrafts.expiresAt }) + .from(simpanySalaryDrafts) + .where(and(eq(simpanySalaryDrafts.organizationId, orgId), eq(simpanySalaryDrafts.id, draftId))) + .limit(1); + if (!d) throw new SalaryFilingError(`找不到薪資申報草稿 #${draftId}`); + if (d.status === "applied") { + throw new SalaryFilingError(`草稿 #${draftId} 已經寫入 Simpany 了;要改內容請重新準備,確認無誤就可以送出結算`); + } + if (d.status === "settled") throw new SalaryFilingError(`草稿 #${draftId} 的月份已經結算`); + if (d.status === "cancelled") throw new SalaryFilingError(`草稿 #${draftId} 已取消,請重新準備`); + if (d.status === "pending" && new Date(d.expiresAt).getTime() <= Date.now()) { + await getDb() + .update(simpanySalaryDrafts) + .set({ status: "expired" }) + .where(and(eq(simpanySalaryDrafts.id, draftId), eq(simpanySalaryDrafts.status, "pending"))); + } + throw new SalaryFilingError(`草稿 #${draftId} 已過期(2 小時),請重新準備`); +} + +async function setDraft(draftId: number, set: Partial): Promise { + await getDb().update(simpanySalaryDrafts).set(set).where(eq(simpanySalaryDrafts.id, draftId)); +} + +export type SalaryApplyResult = { + draftId: number; + year: number; + month: number; + formId: number; + payday: string; + written: string[]; + ownerFlagsChanged: { name: string; isCompanyOwner: boolean }[]; + paydayChanged: boolean; + paydayAdjustments: unknown; + verification: { name: string; expectedNet: number | null; actualNet: number | null; ok: boolean }[]; + verified: boolean; + canSettle: boolean | null; + sequenceRestriction: SimpanyMonthSequenceRestriction | null; + sync: { ok: true; declarationsUpserted: number } | { ok: false; error: string }; +}; + +type ApplyProgress = { + step: "precheck" | "owner" | "payday" | "declarations"; + written: string[]; + ownerFlagsChanged: SalaryApplyResult["ownerFlagsChanged"]; + paydayChanged: boolean; + paydayAdjustments: unknown; +}; + +/** 草稿已經不適用的原因(表單已結算、換了表單、申報明細不見);還能寫入回 null。 */ +function staleFormReason(form: SimpanySalaryForm, formId: number, decls: DraftDeclaration[]): string | null { + if (form.isSettled) return "這個月在 Simpany 已經結算"; + if (form.id !== formId) return "Simpany 上這個月的表單已經換了"; + const gone = decls.filter((d) => !form.employees.some((e) => e.declaration?.id === d.declarationId)); + return gone.length ? `表單上已經沒有 ${gone.map((d) => d.name).join("、")} 的申報明細` : null; +} + +/** 寫入前再確認一次:表單沒被結算、沒換表單、每份申報明細都還在。不符就取消草稿。 */ +async function precheckForm( + client: SimpanyClient, + draftId: number, + row: { year: number; month: number; simpanyFormId: number }, + decls: DraftDeclaration[], +): Promise { + const form = await client.getSalaryForm(row.year, row.month); + const reason = staleFormReason(form, row.simpanyFormId, decls); + if (reason) { + await setDraft(draftId, { status: "cancelled", appliedAt: null, applyResult: { error: reason } }); + throw new SalaryFilingError(`${reason},草稿 #${draftId} 已取消,請重新準備`); + } + return form; +} + +async function writeDraft( + client: SimpanyClient, + form: SimpanySalaryForm, + payday: string, + decls: DraftDeclaration[], + p: ApplyProgress, +): Promise { + const formId = form.id as number; + // 負責人旗標與發薪日是 Simpany 試算的輸入,先設好再寫申報明細。 + p.step = "owner"; + for (const d of decls) { + const current = form.employees.find((e) => e.declaration?.id === d.declarationId)?.declaration?.isCompanyOwner; + if (current === d.isCompanyOwner) continue; + await client.setSalaryCompanyOwner(formId, d.declarationId, d.isCompanyOwner); + p.ownerFlagsChanged.push({ name: d.name, isCompanyOwner: d.isCompanyOwner }); + } + p.step = "payday"; + if (form.payday !== payday) { + p.paydayAdjustments = await client.setSalaryPayday(formId, payday); + p.paydayChanged = true; + } + p.step = "declarations"; + for (const d of decls) { + await client.updateSalaryDeclaration(formId, d.declarationId, d.body); + p.written.push(d.name); + } +} + +const STEP_LABEL: Record = { + precheck: "寫入前檢查", + owner: "設定負責人", + payday: "設定發薪日", + declarations: "寫入申報明細", +}; + +/** 先搶下草稿(pending → applied):按兩次,第二次搶不到(回 undefined)。 */ +async function claimDraft(orgId: string, draftId: number) { + const [row] = await getDb() + .update(simpanySalaryDrafts) + .set({ status: "applied", appliedAt: sql`now()` }) + .where( + and( + eq(simpanySalaryDrafts.organizationId, orgId), + eq(simpanySalaryDrafts.id, draftId), + eq(simpanySalaryDrafts.status, "pending"), + gt(simpanySalaryDrafts.expiresAt, sql`now()`), + ), + ) + .returning(); + return row; +} + +/** 取 Simpany client;拿不到(整合不可用)就把草稿放回 pending 再丟錯。 */ +async function clientOrRelease(orgId: string, draftId: number): Promise { + try { + return await getSimpanyClient(orgId); + } catch (e) { + await setDraft(draftId, { status: "pending", appliedAt: null }); + throw e; + } +} + +/** 寫入中途失敗:草稿退回 pending、記下進度,回傳列出已寫入 / 未寫入的錯誤。 */ +async function recordApplyFailure( + draftId: number, + payday: string, + decls: DraftDeclaration[], + p: ApplyProgress, + e: unknown, +): Promise { + const msg = e instanceof Error ? e.message : String(e); + const notWritten = decls.map((d) => d.name).filter((n) => !p.written.includes(n)); + await setDraft(draftId, { + status: "pending", + appliedAt: null, + applyResult: { + failedStep: p.step, + error: msg.slice(0, 500), + written: p.written, + ownerFlagsChanged: p.ownerFlagsChanged, + paydayChanged: p.paydayChanged, + }, + }); + const ownerNames = p.ownerFlagsChanged.map((o) => o.name).join("、"); + return new SalaryFilingError( + [ + `在「${STEP_LABEL[p.step]}」失敗:${msg}`, + p.ownerFlagsChanged.length ? `已修正負責人旗標:${ownerNames}` : null, + p.paydayChanged ? `已把發薪日改成 ${payday}` : null, + `已寫入申報明細:${p.written.length ? p.written.join("、") : "(無)"}`, + `尚未寫入:${notWritten.join("、") || "(無)"}`, + `每一步都是覆寫式,確認原因後可以用同一份草稿 #${draftId} 重試(已退回可寫入),或重新準備。`, + ] + .filter(Boolean) + .join("。"), + ); +} + +/** 讀回來的每人實發 vs 預覽。 */ +function verifyNets(after: SimpanySalaryForm, decls: DraftDeclaration[]): SalaryApplyResult["verification"] { + return decls.map((d) => { + const emp = after.employees.find((e) => e.declaration?.id === d.declarationId); + const actualNet = emp ? summarizeDeclaration(emp.declaration?.items ?? []).netPay : null; + const ok = actualNet != null && d.expectedNet != null && Math.abs(actualNet - d.expectedNet) < 0.5; + return { name: d.name, expectedNet: d.expectedNet, actualNet, ok }; + }); +} + +/** 寫入 / 結算後重新同步這一年;失敗不影響主流程,只回報。 */ +async function syncYear(orgId: string, year: number): Promise { + try { + const res = await syncSalaryDeclarations(orgId, year); + return { ok: true, declarationsUpserted: res.declarationsUpserted }; + } catch (e) { + return { ok: false, error: e instanceof Error ? e.message : String(e) }; + } +} + +/** + * 把預覽過的草稿寫進 Simpany。**會修改 Simpany 上的薪資申報(尚未結算,仍可再改)。** + * 只在使用者明確確認預覽之後呼叫。順序:負責人旗標 → 發薪日 → 逐一 PUT 申報明細 → + * 讀回來比對實發 → 同步回本地。每一步都是覆寫式:失敗時草稿退回 pending,可用同一份重試。 + */ +export async function applySalaryFiling(orgId: string, draftId: number): Promise { + const row = await claimDraft(orgId, draftId); + if (!row) return explainUnavailableDraft(orgId, draftId); + + const decls = parseDraftDeclarations(row.payload); + if (!decls) { + await setDraft(draftId, { status: "cancelled", appliedAt: null }); + throw new SalaryFilingError(`草稿 #${draftId} 內容損毀,請重新準備`); + } + + const client = await clientOrRelease(orgId, draftId); + const p: ApplyProgress = { + step: "precheck", + written: [], + ownerFlagsChanged: [], + paydayChanged: false, + paydayAdjustments: null, + }; + let form: SimpanySalaryForm; + try { + form = await precheckForm(client, draftId, row, decls); + } catch (e) { + if (!(e instanceof SalaryFilingError)) await setDraft(draftId, { status: "pending", appliedAt: null }); + throw e; + } + try { + await writeDraft(client, form, row.payday, decls, p); + } catch (e) { + throw await recordApplyFailure(draftId, row.payday, decls, p, e); + } + + // ---- 讀回來比對實發 ---- + const after = await client.getSalaryForm(row.year, row.month); + const verification = verifyNets(after, decls); + const sync = await syncYear(orgId, row.year); + const result: SalaryApplyResult = { + draftId, + year: row.year, + month: row.month, + formId: row.simpanyFormId, + payday: row.payday, + written: p.written, + ownerFlagsChanged: p.ownerFlagsChanged, + paydayChanged: p.paydayChanged, + paydayAdjustments: p.paydayAdjustments, + verification, + verified: verification.every((v) => v.ok), + canSettle: after.canSettle, + sequenceRestriction: after.monthSequenceRestriction, + sync, + }; + await setDraft(draftId, { + applyResult: { + written: p.written, + ownerFlagsChanged: p.ownerFlagsChanged, + paydayChanged: p.paydayChanged, + verification, + verified: result.verified, + }, + }); + return result; +} + +/** 取消還沒寫入的草稿。 */ +export async function cancelSalaryDraft(orgId: string, draftId: number): Promise { + const rows = await getDb() + .update(simpanySalaryDrafts) + .set({ status: "cancelled" }) + .where( + and( + eq(simpanySalaryDrafts.organizationId, orgId), + eq(simpanySalaryDrafts.id, draftId), + eq(simpanySalaryDrafts.status, "pending"), + ), + ) + .returning({ id: simpanySalaryDrafts.id }); + return rows.length > 0; +} + +// --------------------------------------------------------------------------- +// Settle +// --------------------------------------------------------------------------- + +export type SettleInput = { + year: number; + month: number; + confirmPayday: boolean; + confirmOwner: boolean; + confirmSalary: boolean; +}; + +export type SettleResult = { + year: number; + month: number; + formId: number; + payday: string | null; + settled: boolean; + employees: { name: string; isCompanyOwner: boolean; net: number | null }[]; + sync: SalaryApplyResult["sync"]; +}; + +function isDefiniteRejection(e: unknown): e is SimpanyError { + return ( + e instanceof SimpanyError && + (e.kind === "validation" || e.kind === "business" || e.kind === "auth" || e.kind === "config") + ); +} + +/** 結算前的檢查:三個確認、表單狀態、順序限制、缺資料。 */ +function assertSettleable(input: SettleInput, form: SimpanySalaryForm): number { + const missing = [ + input.confirmPayday === true ? null : "發薪日正確", + input.confirmOwner === true ? null : "負責人正確", + input.confirmSalary === true ? null : "薪資正確", + ].filter(Boolean); + if (missing.length) { + throw new SalaryFilingError(`送出結算前要確認三件事,還沒確認:${missing.join("、")}`); + } + const label = `${input.year}-${pad2(input.month)}`; + if (form.id == null) throw new SalaryFilingError(`Simpany 沒有 ${label} 的薪資申報表單`); + if (form.isSettled) throw new SalaryFilingError(`${label} 已經結算過了`); + const filed = form.employees.filter((e) => e.declaration?.items.length); + if (filed.length === 0) throw new SalaryFilingError(`${label} 的表單上沒有任何申報明細,不能結算`); + const incomplete = form.employees.filter((e) => e.hasMissingSalaryData || e.hasMissingEmployeeData); + if (incomplete.length) { + throw new SalaryFilingError( + `Simpany 表示這些人的資料不完整:${incomplete.map((e) => e.name).join("、")},請先在 Simpany 補齊`, + ); + } + if (form.canSettle === false) { + throw new SalaryFilingError( + form.monthSequenceRestriction + ? restrictionText(form.monthSequenceRestriction) + : `Simpany 表示 ${label} 目前不能結算(canSettle = false)`, + ); + } + return form.id; +} + +/** + * 結算(送給記帳士)。**送出後無法從這裡撤回。** 三個確認(發薪日、負責人、薪資)都必須為 true。 + * Simpany 表示不能結算(canSettle = false,例如前面月份還沒結算)就拒絕,並回傳它給的原因。 + */ +export async function settleSalaryFiling(orgId: string, input: SettleInput): Promise { + assertYearMonth(input.year, input.month); + const client = await getSimpanyClient(orgId); + const form = await client.getSalaryForm(input.year, input.month); + const formId = assertSettleable(input, form); + + try { + await client.settleSalaryForm(formId, []); + } catch (e) { + if (isDefiniteRejection(e)) throw e; + const msg = e instanceof Error ? e.message : String(e); + throw new SimpanyError( + "http", + `送出結算後沒有收到明確結果(${msg})。可能已經送出:請先「從 Simpany 同步」或到 Simpany 確認這個月是否已結算,不要直接重送。`, + ); + } + + const after = await client.getSalaryForm(input.year, input.month); + if (after.isSettled) { + await getDb() + .update(simpanySalaryDrafts) + .set({ status: "settled", settledAt: sql`now()` }) + .where( + and( + eq(simpanySalaryDrafts.organizationId, orgId), + eq(simpanySalaryDrafts.year, input.year), + eq(simpanySalaryDrafts.month, input.month), + eq(simpanySalaryDrafts.status, "applied"), + ), + ); + } + const sync = await syncYear(orgId, input.year); + if (!after.isSettled) { + throw new SimpanyError( + "business", + "Simpany 接受了結算請求,但讀回來仍顯示未結算。請到 Simpany 確認,不要直接重送。", + ); + } + return { + year: input.year, + month: input.month, + formId, + payday: after.payday, + settled: after.isSettled, + employees: after.employees + .filter((e) => e.declaration?.items.length) + .map((e) => ({ + name: e.name, + isCompanyOwner: e.declaration?.isCompanyOwner ?? false, + net: summarizeDeclaration(e.declaration?.items ?? []).netPay, + })), + sync, + }; +} + +// --------------------------------------------------------------------------- +// Payslips +// --------------------------------------------------------------------------- + +export type SendPayslipsInput = { + year: number; + month: number; + /** 只寄給這些人(姓名要和 Simpany 上的一樣);沒給 = 整張表單。 */ + employeeNames?: string[]; + /** 信件內容;預設 Simpany 的預設文字。 */ + mailContent?: string; +}; + +export type SendPayslipsResult = { + year: number; + month: number; + mode: "COMPANY_SALARY_DECLARATION_FORM" | "SALARY_DECLARATIONS"; + recipients: { name: string; payslipSentAt: string | null }[]; +}; + +/** 寄薪資單的 body 與收件人:有指定姓名就只寄那些人的申報明細,否則整張表單。 */ +function payslipRequest( + form: SimpanySalaryForm, + names: string[], + label: string, + mailContent: string, +): { body: SimpanyPayslipSendBody; recipients: string[] } { + if (names.length === 0) { + return { + body: { mode: "COMPANY_SALARY_DECLARATION_FORM", mailContent }, + recipients: form.employees.filter((e) => e.declaration?.items.length).map((e) => e.name), + }; + } + const ids: number[] = []; + const unknown: string[] = []; + for (const n of names) { + const id = form.employees.find((e) => e.name === n)?.declaration?.id; + if (id == null) unknown.push(n); + else ids.push(id); + } + if (unknown.length) throw new SalaryFilingError(`${label} 的表單上沒有這些人的申報明細:${unknown.join("、")}`); + return { body: { mode: "SALARY_DECLARATIONS", salaryDeclarationIds: ids, mailContent }, recipients: names }; +} + +/** 讀表單;失敗回 null(只用來補充結果,不影響主流程)。 */ +async function formOrNull(client: SimpanyClient, year: number, month: number): Promise { + try { + return await client.getSalaryForm(year, month); + } catch { + return null; + } +} + +/** 寄薪資單給員工(Simpany 寄信,附加密 PDF)。只能寄已結算的月份。 */ +export async function sendPayslips(orgId: string, input: SendPayslipsInput): Promise { + assertYearMonth(input.year, input.month); + const label = `${input.year}-${pad2(input.month)}`; + const client = await getSimpanyClient(orgId); + const form = await client.getSalaryForm(input.year, input.month); + if (form.id == null) throw new SalaryFilingError(`Simpany 沒有 ${label} 的薪資申報表單`); + if (!form.isSettled) throw new SalaryFilingError(`${label} 還沒結算,結算後才能寄薪資單`); + const payday = form.payday ?? defaultSalaryPayday(input.year, input.month); + const mailContent = input.mailContent?.trim() || defaultPayslipMail(input.year, input.month, payday); + + const names = (input.employeeNames ?? []).map((n) => n.trim()).filter(Boolean); + const req = payslipRequest(form, names, label, mailContent); + try { + await client.sendSalaryPayslips(form.id, req.body); + } catch (e) { + if (e instanceof SimpanyError && e.status === 404) { + throw new SalaryFilingError("Simpany 的薪資單還在產生中(結算後需要一點時間),請稍後再寄"); + } + throw e; + } + + const after = await formOrNull(client, input.year, input.month); + return { + year: input.year, + month: input.month, + mode: req.body.mode, + recipients: req.recipients.map((n) => ({ + name: n, + payslipSentAt: after?.employees.find((e) => e.name === n)?.payslipSentAt ?? null, + })), + }; +} + +// --------------------------------------------------------------------------- +// Web defaults(只讀本地表,不打 Simpany) +// --------------------------------------------------------------------------- + +export type SalaryFilingDefaults = { + year: number; + month: number; + payday: string; + lastFiled: { year: number; month: number } | null; + employees: { + name: string; + employeeId: number | null; + baseSalary: number | null; + baseSource: "employee" | "last_filed" | null; + isCompanyOwner: boolean; + }[]; + latestDraft: { + id: number; + status: string; + createdAt: string; + expiresAt: string; + appliedAt: string | null; + summary: Record; + applyResult: Record; + } | null; +}; + +/** 本系統員工是否在這個月在職、而且是要申報薪資的類型(正職 / 兼職)。 */ +function employedInMonth(e: InternalEmployee, start: string, end: string): boolean { + if (!e.isActive && !e.endDate) return false; + if (e.employmentType !== "full_time" && e.employmentType !== "part_time") return false; + if (e.startDate && e.startDate > end) return false; + return !(e.endDate && e.endDate < start); +} + +/** 本地同步表裡、這個月(含)以前有申報的列,新的在前。 */ +async function loadFiledRows(orgId: string, year: number, month: number) { + return getDb() + .select({ + year: simpanySalaryDeclarations.year, + month: simpanySalaryDeclarations.month, + name: simpanySalaryDeclarations.employeeName, + employeeId: simpanySalaryDeclarations.employeeId, + baseSalary: simpanySalaryDeclarations.baseSalary, + isCompanyOwner: simpanySalaryDeclarations.isCompanyOwner, + }) + .from(simpanySalaryDeclarations) + .where( + and( + eq(simpanySalaryDeclarations.organizationId, orgId), + eq(simpanySalaryDeclarations.filed, true), + or( + sql`${simpanySalaryDeclarations.year} < ${year}`, + and(eq(simpanySalaryDeclarations.year, year), lte(simpanySalaryDeclarations.month, month)), + ), + ), + ) + .orderBy(desc(simpanySalaryDeclarations.year), desc(simpanySalaryDeclarations.month)); +} + +type FiledRow = Awaited>[number]; +type DefaultEmployee = SalaryFilingDefaults["employees"][number]; + +function baseSourceOf(fromEmp: number | null, last: number | null): DefaultEmployee["baseSource"] { + if (fromEmp != null) return "employee"; + return last == null ? null : "last_filed"; +} + +/** 上次申報的人(排除已離職)+ 這個月在職、有本薪的正職 / 兼職。 */ +function defaultEmployees( + lastRows: FiledRow[], + internal: InternalEmployee[], + start: string, + end: string, +): DefaultEmployee[] { + const byName = byUniqueName(internal); + const out: DefaultEmployee[] = []; + const seen = new Set(); + for (const r of lastRows) { + const byId = r.employeeId == null ? null : internal.find((e) => e.id === r.employeeId); + const emp = byId ?? byName.get(r.name) ?? null; + if (emp?.endDate && emp.endDate < start) continue; + const fromEmp = positiveNumber(emp?.baseSalary); + const last = positiveNumber(r.baseSalary); + out.push({ + name: r.name, + employeeId: emp?.id ?? null, + baseSalary: fromEmp ?? last, + baseSource: baseSourceOf(fromEmp, last), + isCompanyOwner: r.isCompanyOwner, + }); + seen.add(r.name.trim()); + } + for (const e of internal) { + if (seen.has(e.name.trim()) || !employedInMonth(e, start, end)) continue; + const base = positiveNumber(e.baseSalary); + if (base == null) continue; + out.push({ name: e.name.trim(), employeeId: e.id, baseSalary: base, baseSource: "employee", isCompanyOwner: false }); + } + return out; +} + +/** 這個月最新一份還有效的草稿(過期的 pending 不算)。 */ +async function latestLiveDraft(orgId: string, year: number, month: number): Promise { + const [draft] = await getDb() + .select({ + id: simpanySalaryDrafts.id, + status: simpanySalaryDrafts.status, + createdAt: simpanySalaryDrafts.createdAt, + expiresAt: simpanySalaryDrafts.expiresAt, + appliedAt: simpanySalaryDrafts.appliedAt, + summary: simpanySalaryDrafts.summary, + applyResult: simpanySalaryDrafts.applyResult, + }) + .from(simpanySalaryDrafts) + .where( + and( + eq(simpanySalaryDrafts.organizationId, orgId), + eq(simpanySalaryDrafts.year, year), + eq(simpanySalaryDrafts.month, month), + inArray(simpanySalaryDrafts.status, ["pending", "applied", "settled"]), + ), + ) + .orderBy(desc(simpanySalaryDrafts.createdAt)) + .limit(1); + if (!draft) return null; + const expiredPending = draft.status === "pending" && new Date(draft.expiresAt).getTime() <= Date.now(); + return expiredPending ? null : draft; +} + +/** + * 「準備申報」表單的預設值:最近一個有申報的月份(本地同步表)的人 + 這個月在職的正職 / 兼職, + * 本薪 = 員工資料的本薪,沒有就用上次申報的本薪。另附這個月最新的一份草稿(可以接著寫入 / 結算)。 + */ +export async function salaryFilingDefaults(orgId: string, year: number, month: number): Promise { + assertYearMonth(year, month); + const { start, end } = monthRange(year, month); + const [internal, filedRows] = await Promise.all([ + loadInternalEmployees(orgId), + loadFiledRows(orgId, year, month), + ]); + const top = filedRows[0]; + const lastFiled = top ? { year: top.year, month: top.month } : null; + const lastRows = top ? filedRows.filter((r) => r.year === top.year && r.month === top.month) : []; + + return { + year, + month, + payday: defaultSalaryPayday(year, month), + lastFiled, + employees: defaultEmployees(lastRows, internal, start, end), + latestDraft: await latestLiveDraft(orgId, year, month), + }; +} + +/** 今天(台北)所在的年月 —— 預設只讓人準備「本月以前」的薪資。 */ +export function currentYearMonth(): { year: number; month: number } { + const t = taipeiDate(); + return { year: Number(t.slice(0, 4)), month: Number(t.slice(5, 7)) }; +}