Phase 5 Payroll P1 — Salaries, Pay Items, the Monthly Run, Payslips Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Give an organization append-only salaries, pay item types and recurring pay items, a monthly payroll run that computes and freezes every payslip (proration, unpaid leave from H3, manual statutory lines), approval through a payroll_run workflow that posts by semantic tags, net pay paid payslip by payslip through ordinary payment batches with sealed verified bank details, void by reversal, and “My payslips” for every member.
Architecture: The domain is a new platform package internal/platform/payroll on migrations 00900–00903 (00904–00919 stay free). A run is a snapshot: preparing writes a numbered calculation generation of payslip and payslip_line rows that a trigger freezes once the run leaves draft. The run’s workflow (payroll_run, an accrual-only kind) posts one accrual through a posting rule that credits a new control kind payroll_payable. Approval starts one payslip workflow instance (cash-only: unpaid → paid) per payslip with net pay, which makes each payslip a payment-batch source exactly like C4’s claims and advances: payables imports payroll (never the reverse), the batch seals the payee’s verified details at approval, and marking the batch paid moves each payslip through its payout edge, whose posting rule clears the payroll payable against the bank on the batch value date. internal/modules/payroll is the HTTP surface; web/src/features/payroll the screens.
Tech Stack: Go, pgx v5, Postgres 16 (goose migrations), gin, oapi-codegen, React + TanStack Router/Query + i18next, vitest + msw.
Spec: docs/superpowers/specs/2026-10-01-payroll-p1-design.md (PD-1–PD-6, approved by the owner 2026-10-01). Architecture: docs/superpowers/specs/2026-09-20-erp-architecture-design.md §5.5, §10.3, §19. HR layer: docs/superpowers/specs/2026-09-30-hr-layer-design.md HD-5. Patterns copied: Phase 2 completion C4 (db/migrations/00450–00452, internal/platform/payables/batch_payout.go), Phase 5 H3 (docs/superpowers/plans/2026-09-30-phase-5-h3-leave.md).
Global Constraints
Section titled “Global Constraints”- Migration range:
00900–00919(checked free on 2026-10-01: the newest migration is00842). This plan uses00900(payroll schema, includingpayslip_payment),00901(payroll_payablecontrol kind),00902(payslips in payment batches),00903(Owner grants and the module key).00904–00919are reserved for P1 fixes; P2 and P3 take their own ranges. - A float never touches money, in any layer:
money.Amountin Go (MulDivCentsfor every proration, which rounds once, half away from zero — half-up for the positive amounts payroll prorates),NUMERIC(19,4)in Postgres with a whole-sen CHECK (amount = round(amount, 2)), a string on the wire (Moneyschema),@/lib/moneyhelpers on the web (thenofloatdrift test). Days arehr.Days(half-day units, anint) in Go,NUMERIC(5,1)in Postgres, a decimal string on the wire. - Every business table has
org_id,ENABLEandFORCE ROW LEVEL SECURITY, a policy namedorg_isolation, and every tenant foreign key is composite on(org_id, id)(TestEveryTableIsTenantIsolated,TestEveryTenantForeignKeyIsCompositeOnOrgID). Every query namesorg_id = $n(TestEveryTenantQueryCarriesAnExplicitOrgPredicate). - Every constraint is named; every function pins
SET search_path = pg_catalog, public, pg_tempand schema-qualifies what it reads. NoSECURITY DEFINER. - Retention (spec “For the owner to confirm” 4): no payroll table grants
DELETEtoerp_app.salary,payslip_line,payslip_paymentare append-only (REVOKE UPDATE, DELETE);payroll_setting,pay_item_type,employee_pay_item,payroll_run,payroll_run_input,payslipare amendable (REVOKE DELETE), each amendment narrowed by a trigger.TestOnlyOperationalTablesGrantDeleteToTheAppRolelists each. - Vocabulary values are strings: run statuses (
draft,submitted,approved,paid— derived, never stored —rejected,voided), item kinds (earning,deduction), recurrences (recurring,one_off), posting tags (salary_expense,statutory_payable,net_pay_payable), line kinds (basic,item,unpaid_leave,statutory,adjustment), statutory codes (epf,socso,eis,pcb), input kinds (item,statutory,adjustment), warnings (no_member,no_salary,outside_band,no_payout_details,bank_unverified). - The ledger is append-only. A void is the engine’s reversal of the run’s accrual; a correction after approval is an
adjustmentinput on a later run. Nothing updates a payslip line. accounting/reacts to semantic tags, never to state names. The run’s accrual rule runs onposting.Accrualof kindpayroll_run, the payslip’s payout rule onposting.Payoutof kindpayslip;payrollmaps a run’s stored status from the entered state’s semantic.- Modules never write journal lines.
platform/payrollreturnsaccounting.Drafts from twoaccounting.PostingRules;internal/modules/payroll/boot.goregisters them. - Every user-facing name is a translation row: pay item type names (
translation,entity_kindpay_item_type, fieldname;enandmsrequired,zhoptional). Linedescriptionis a record-language snapshot (English or Bahasa Malaysia,recordtext.Check), not a display name: the screens label lines by kind and type. - A claimant never picks their own approver. The
payroll_runapprover is the OWNER-seat grant (administrator-configurable), resolved by the engine; the preparer (the instance’s reporter) is refused (self_approval) except under the sole-owner exception, and the database refusesapproved_by = prepared_bywithout it (payroll_run_approver_differs). - Business dates come from
clock.Today(ctx, c), nevertime.Now()orCURRENT_DATE. - Layering:
platform/payrollmay importplatform/hr,platform/orgstruct,platform/workflow,platform/posting,platform/accounting(+chart,ledger,fiscal),platform/company,platform/payout,platform/numbering,platform/provisioning,platform/identity/member. It never importsplatform/payables;platform/payablesimportsplatform/payroll(the C4 direction).modules/payrollimports platform packages only. - The contract is
api/openapi.yaml. Endpoints are declared there first;make generatemust leave the tree clean (internal/gen/drift_test.go,web/src/drift/schema.test.ts,internal/kernel/vocab/vocab_test.go). Generated files conflicting on merge are regenerated, never hand-merged. - Drift tests: presence before absence; each new guard watched failing once — the sabotage and the expected message are written into its task. While iterating run only the touched packages; run
go test ./...andpnpm -C web testonce, in Task 13. module_install_key_knownandimport_job_kind_knownare rebuilt from the CURRENT latest list, never from an older migration’s. The H3 merge once dropped Phase 4 values from these lists. Before writing00903, rungrep -l module_install_key_known db/migrations/*.sql | sort | tail -1and copy that file’s Up list verbatim, adding'payroll'(on 2026-10-01 it is00842:expenses, advances, pettycash, payables, receivables, banking, einvoice, taxreturns, inventory, procurement, sales, assets, hr, leave). P1 adds no import kind, soimport_job_kind_knownis not touched; a later payroll migration that adds one copiesgrep -l import_job_kind_known db/migrations/*.sql | sort | tail -1the same way.workflow.CommitmentKindsis for kinds that post nothing.payroll_runposts, so it is not added there; it joinsworkflow.AccrualOnlyKinds(one accrual, never paid by its own flow), andpayslipis aCashOnlykind.- Lock order (added to
docs/LOCK_ORDER.mdin Task 13; written at each function): see Ruling PD-27. - Commits: one per task, the message ending with a blank line and
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>. Merging and pushing are the owner’s.
Rulings (where the spec is silent or unbuildable as written)
Section titled “Rulings (where the spec is silent or unbuildable as written)”- PD-7 Where the code lives.
internal/platform/payroll(packagepayroll) owns every rule and writes every audit row;internal/modules/payrollis HTTP only;web/src/features/payrollthe screens.platform/hrgains two reads it owns:hr.EmployedBetween(who was employed in a date range) andhr.UnpaidLeaveByDate(approved unpaid leave per date).platform/payablesgains the payslip as a fourth batch source and importspayroll. - PD-8 Who a run pays. Every employee whose current employment spell (
employee_spell: hire date on or before the month’s last day, last day absent or on or after its first) overlaps the month and who has a member login (hr.Employee.MemberID). Payout profiles, the batch’s payee and “My payslips” are all per member, so an employee without one is left out and listed (no_member). A rehire inside the same month as an earlier leaving pays the current spell only (employee_spellreads one spell; recorded limitation). - PD-9 Salaries. Append-only rows in the base currency, whole sen, > 0. The amount in force on a day is the latest row with
effective_fromon or before it; two rows with the same date: the later-created wins (that is how a same-day mistake is corrected). A mid-month change splits the month: onebasicline per salary row in force, each carrying itssalary_idand days. - PD-10 Proration is by calendar days of the month (owner question 2; amended 2026-10-02, divisor review I-1: with the divisor setting empty the basic is prorated by working days of the same calendar,
amount × working days in force ÷ working days in month; a fixed divisor keeps calendar days; PROVISIONAL; the payslip stores which inproration,working_daysorcalendar_days, and the payslip view captions the basic accordingly). Basic:amount × days in force ÷ days in month, per salary row. A recurring item:amount × days employed while it is effective ÷ days in month. One-off inputs are not prorated. Every line is rounded once to the sen (money.Amount.MulDivCents). Payslip storesdays_in_month,days_employedand thedivisorused. - PD-11 Unpaid leave. “Approved H3 unpaid leave” is a
leave_requestwhose instance is in thecommittedsemantic and whose type haspaid = false(kind = 'unpaid'or an unpaidother). Days count as H3 counts them — working days, a half at a half-day endpoint — recomputed per date at preparation (a holiday added or retired after the request was made can make the split differ from the request’s storeddays; accepted). Deduction per half day = salary in force that day ÷ (2 × divisor); the divisor ispayroll_setting.unpaid_leave_divisor(NULL= the month’s calendar days, owner question 1; amended 2026-10-02, final review I-3:NULL= the month’s working days under the leave working calendar, PROVISIONAL), so changing it is not a migration. The month’s unpaid deduction never exceeds its basic (capped on the last line). - PD-12 Pay item types.
kind(earning|deduction),recurrence(recurring|one_off), four liability flags stored for P2,posting_tagfixed by kind:earning→salary_expense,deduction→statutory_payable(in P1 every withheld amount is credited to the one deductions-payable account; P2 widens the CHECK). Code, kind, recurrence and tag never change (trigger); types are retired, never deleted. Employee pay items take recurring types only;effective_to(inclusive) is set once, when the item is ended; a changed amount is an end plus a new item. - PD-13 Run inputs. One-off items (a
one_offtype, amount > 0), statutory amounts (epf|socso|eis|pcb, manual, the employee’s share, a deduction) and adjustments (signed, non-zero, to salary expense — a correction of an earlier month) live inpayroll_run_input, are added and removed only while the run is a draft, and each change recalculates. - PD-14 Calculation generations. A recalculation never deletes: it increments
payroll_run.calc_noand writes a new generation of payslips and lines; only the run’s current generation is ever read. That keeps every payroll table free ofDELETE(retention). - PD-15 The
payroll_runworkflow.draft(draft) →submitted(pending) →approved(accrual, controlpayroll_payable);submitted→rejected(rejected, reason);rejected→draft(revise);draft→voided(cancelled, withdraw, reason);approved→voided(cancelled, void, reason, reverses). Grant: the OWNER seat onapproved(the void is source-gated on it). The preparer is the reporter, so the engine refuses them as approver;workflow.AllowSoleOwnerException(payroll_run, true)covers a one-member organization.payroll_run.statusis stored only because “one non-voided run per organization per month” needs a partial unique index; it mirrors the entered semantic (draft,submitted,approved,rejected,voided).paidis derived — approved, and every payslip with net pay has apayslip_payment— never stored: storing it would make mark-paid lock the run after its payslips, the reverse of the void’s run-then-payslips order. - PD-16 Submission recalculates, then validates: at least one payslip, a positive total gross, no payslip with negative net (
NegativeNetErrorlists the references). What the approver sees is exactly what was computed at submission. Fromsubmittedon, payslips and lines are frozen by trigger (payslip_frozen). - PD-17 Months.
period_monthis the month’s first day; a run is for the current month or an earlier one (ErrMonthFuture); creating one needs a configured setting (ErrNotConfigured: payout channel, salary expense account, deductions payable account). Referencenumbering.Format("PR", year, n); payslip reference = run reference +-+ its three-digit sequence (PR-2026-0001-003). - PD-18 Posting. A new control kind
payroll_payable(liability, subledger: manual lines refused; owned bypayroll_runinworkflow.OwnedControlKinds). The accrual, per payslip: Dr salary expense (gross, memoGross pay <ref>), Cr deductions payable (deductions,Deductions <ref>), Crpayroll_payable(net,Net pay <ref>), each with the employee’s party; zero amounts are skipped; dated the period’s last day or the approval day if earlier; misc journal. A provisioning step (payroll_accounts, order 84) gives every organization2180 Salaries payable(payroll_payable),2190 Statutory deductions payableand6210 Salaries and wages, and seedspayroll_settingwith the two plain accounts; codes6200and below are left alone (fixtures use6200). - PD-19 Cost dimensions. None in P1. No mapping from an org unit (department) to an analytic account exists in the codebase, so “department split where an organization has them” cannot apply to anyone yet. The accrual posts one line per payslip with the employee’s party, which a later mapping can split without a schema change to payroll. Flagged to the owner.
- PD-20 A payslip is a payment-batch source (spec PD-2, C4). “Approval creates one payable line per payslip” is built as: approval starts one
payslipworkflow instance per payslip with net > 0 (reporter = the payee member, amount = net), which makes it a batch candidate. A line itself is not inserted at approval because a line belongs to a draft batch whose bank account only the batch preparer chooses. The batch’s controls apply unchanged (verified details sealed at approval, preparer ≠ approver, approver and marker ≠ payee, one file). The payout channel ispayroll_setting.payout_channel_id, copied onto each payslip at calculation; a channel restricted by claim types must listpayslip(the channel editor offers it). - PD-21 Paid. Mark paid calls
payroll.PayPayslipfor each payslip line: it checks the payslip is on a live line of that approved batch with a sealed snapshot (the C4 “held” check), recordspayslip_payment(credit account = the batch’s bank account, the batch reference), then moves the payslip’s instance through its payout edge. The payout rule debits the account the accrual’sNet pay <ref>line credited and credits the bank, memo<ref> <batch ref>, dated the batch value date — one bank line per payslip, which the bank reconciliation batch rule needs. - PD-22 Void.
approved→voidedreverses the accrual (the engine’s mirror). It is refused while any payslip of the run is paid or on a live line of any batch — draft or approved. That is stricter than the spec’s “approved batch”: a payslip in a draft batch is held by C4’spayoutHold, so it must be removed from that batch first (the refusal says so). A voided run’s payslip instances stayunpaidand are never payable: the candidates query,payables’ payout hold rule and a database trigger all require the run to beapproved. - PD-23 Permissions. The spec’s five keys plus
payroll.run.read(read runs and other people’s payslips): an approver must see what they approve and a route enforces one key. All six are Owner-only (spec PD-5). The transition route is guarded bypayroll.run.read; the handler then requirespayroll.run.approvefor an approval-side move (into the accrual or rejected semantic, or out of the accrual) andpayroll.run.preparefor every other. The payroll settings are underpayroll.item.manage. - PD-24 “My payslips”.
GET /payroll/me/payslipsandGET /payroll/me/payslips/{id}are inapi/public_routes.txt’s[no-permission]section (as/payout/me/*are), behind thepayrollmodule switch, and return only the caller’s own payslips ofapprovedorvoidedruns (current generation). Anyone else’s id answers 404. - PD-25 Audited reads. Reading an employee’s salary history and reading another person’s payslip each write an audit row (action
read, ids only, never an amount), thepayout.VerifierDetailsprecedent. - PD-26 Module. Optional module
payroll,Requires: hr, payables; its permission keys’ first segment is its key. - PD-27 Lock order. Run create, recalculate, inputs:
payroll_runFOR UPDATE→numbering(create) →workflow.Start/workflow.SetAmount(the run’s own instance) → payslip and line inserts (rows this transaction creates) → audit last. Transition:workflow.LockSoleOwner→payroll_runFOR UPDATE→ the current generation’spaysliprowsFOR UPDATEinidorder (submission recalculates first; approval and void lock them) → void’s unlocked read ofpayment_batch_line/payslip_payment→workflow.Transition(instance, Poster) → on approval, oneworkflow.Startper payslip and thepayslip.instance_idwrites (rows already held or created here, after the transition’s audit — the C4 M2 shape) → the run’s status row (held) → audit last. Batch:payables’ order with payslips after advances: … →expense_claim→advance→paysliprowsFOR UPDATEinidorder → profilesFOR SHARE→ …; mark paid’sPayPaysliptakes its payslip (held) →payslip_paymentinsert →workflow.Transition→ audit. The batch never lockspayroll_run; the void takes the run and then the payslips; the two serialize on the payslip rows. - PD-28 Statutory lines in P1 are manual (owner question 3): the run screen carries a standing notice that statutory contributions are not calculated until P2 and real payroll must not run on P1 alone.
- PD-29 A one-member organization’s Owner cannot batch-pay their own payslip: C4’s
ErrBatchMarkerIsPayeerefuses it, as it refuses the Owner’s own claims. The payslip stays a candidate; paying it outside a batch is not built. Flagged. - PD-30 The run screen’s warnings are computed on read (they change as bank details are verified):
no_memberandno_salary(employees left out),outside_band(a salary in force in the month outside the band of the grade of the employee’s primary seat on the month’s last employed day; H2 never enforced bands),no_payout_detailsandbank_unverified(the payslip will not be batch-eligible until fixed). Warnings never block.
Interfaces this plan produces
Section titled “Interfaces this plan produces”All in github.com/mcom/erp/internal/platform/.... Each task’s Interfaces block repeats what it produces.
// package hr (Task 4)func EmployedBetween(ctx context.Context, tx pgx.Tx, from, to time.Time) ([]Employee, error)func UnpaidLeaveByDate(ctx context.Context, tx pgx.Tx, employeeID uuid.UUID, from, to time.Time) (map[time.Time]Days, error)
// package payrollconst ( RunKind = "payroll_run" PayslipKind = "payslip" ControlKindPayrollPayable = "payroll_payable"
RunDraft = "draft"; RunSubmitted = "submitted"; RunApproved = "approved"; RunPaid = "paid" RunRejected = "rejected"; RunVoided = "voided"
KindEarning = "earning"; KindDeduction = "deduction" RecurRecurring = "recurring"; RecurOneOff = "one_off" TagSalaryExpense = "salary_expense"; TagStatutoryPayable = "statutory_payable"; TagNetPayPayable = "net_pay_payable" LineBasic = "basic"; LineItem = "item"; LineUnpaidLeave = "unpaid_leave"; LineStatutory = "statutory"; LineAdjustment = "adjustment" InputItem = "item"; InputStatutory = "statutory"; InputAdjustment = "adjustment" StatEPF = "epf"; StatSOCSO = "socso"; StatEIS = "eis"; StatPCB = "pcb" WarnNoMember = "no_member"; WarnNoSalary = "no_salary"; WarnOutsideBand = "outside_band" WarnNoPayoutDetails = "no_payout_details"; WarnBankUnverified = "bank_unverified"
EntitySetting = "payroll_setting"; EntitySalary = "salary"; EntityItemType = "pay_item_type" EntityEmployeeItem = "employee_pay_item"; EntityRun = "payroll_run"; EntityRunInput = "payroll_run_input"; EntityPayslip = "payslip"
PermissionSalaryRead = "payroll.salary.read"; PermissionSalaryWrite = "payroll.salary.write" PermissionItemManage = "payroll.item.manage"; PermissionRunRead = "payroll.run.read" PermissionRunPrepare = "payroll.run.prepare"; PermissionRunApprove = "payroll.run.approve")var StatutoryCodes = []string{StatEPF, StatSOCSO, StatEIS, StatPCB}var (ErrNotFound, ErrNotConfigured, ErrRunExists, ErrMonthFuture, ErrRunNotDraft, ErrEmptyRun, ErrRunInPayment, ErrRunNotApproved, ErrPayslipPaid, ErrNotInBatch, ErrNoSalary, ErrDuplicateItemType error)type FieldError struct{ Field, Rule, Detail string }type RevisionMismatchError struct{ Current int }type NegativeNetError struct{ References []string }type Names map[string]string
// Task 3type Setting struct { UnpaidLeaveDivisor *int; PayoutChannelID, SalaryExpenseAccountID, StatutoryPayableAccountID *uuid.UUID; Revision int }func GetSetting(ctx context.Context, tx pgx.Tx) (Setting, error)func PutSetting(ctx context.Context, tx pgx.Tx, c clock.Clock, s Setting, expectedRevision int, actor uuid.UUID) (Setting, error)type Salary struct { ID, EmployeeID uuid.UUID; Amount money.Amount; EffectiveFrom time.Time; Reason *string; CreatedBy uuid.UUID; CreatedAt time.Time }type SalaryInput struct { EmployeeID uuid.UUID; Amount money.Amount; EffectiveFrom time.Time; Reason *string; Actor uuid.UUID }func AddSalary(ctx context.Context, tx pgx.Tx, c clock.Clock, in SalaryInput) (Salary, error)func SalaryHistory(ctx context.Context, tx pgx.Tx, c clock.Clock, employeeID, actor uuid.UUID) ([]Salary, error)type ItemType struct { ID uuid.UUID; Code string; Names Names; Kind, Recurrence, PostingTag string; EPF, SOCSO, EIS, PCB, Active bool; Revision int; CreatedAt, UpdatedAt time.Time }type ItemTypeInput struct { Code string; Names Names; Kind, Recurrence string; EPF, SOCSO, EIS, PCB bool; Actor uuid.UUID }type ItemTypeUpdate struct { Names Names; EPF, SOCSO, EIS, PCB, Active bool; Actor uuid.UUID }func CreateItemType(ctx context.Context, tx pgx.Tx, c clock.Clock, in ItemTypeInput) (ItemType, error)func UpdateItemType(ctx context.Context, tx pgx.Tx, c clock.Clock, id uuid.UUID, expectedRevision int, in ItemTypeUpdate) (ItemType, error)func GetItemType(ctx context.Context, tx pgx.Tx, id uuid.UUID) (ItemType, error)func ListItemTypes(ctx context.Context, tx pgx.Tx, activeOnly bool) ([]ItemType, error)type EmployeeItem struct { ID, EmployeeID, TypeID uuid.UUID; Amount money.Amount; EffectiveFrom time.Time; EffectiveTo *time.Time; CreatedBy uuid.UUID; CreatedAt time.Time; EndedBy *uuid.UUID; EndedAt *time.Time }type EmployeeItemInput struct { EmployeeID, TypeID uuid.UUID; Amount money.Amount; EffectiveFrom time.Time; EffectiveTo *time.Time; Actor uuid.UUID }func AddEmployeeItem(ctx context.Context, tx pgx.Tx, c clock.Clock, in EmployeeItemInput) (EmployeeItem, error)func EndEmployeeItem(ctx context.Context, tx pgx.Tx, c clock.Clock, id uuid.UUID, lastDay time.Time, actor uuid.UUID) (EmployeeItem, error)func EmployeeItems(ctx context.Context, tx pgx.Tx, employeeID uuid.UUID) ([]EmployeeItem, error)
// Task 4 (pure)type CalcSalary struct { ID uuid.UUID; Amount money.Amount; From time.Time }type CalcItem struct { ID, TypeID uuid.UUID; Description string; Deduction bool; Amount money.Amount; From time.Time; To *time.Time }type CalcEntry struct { InputID uuid.UUID; Kind string; TypeID *uuid.UUID; StatutoryCode *string; Description string; Deduction bool; Amount money.Amount }type CalcInput struct { Month, First, Last time.Time; Salaries []CalcSalary; Items []CalcItem; Entries []CalcEntry; Unpaid map[time.Time]hr.Days; Divisor int }type CalcLine struct { Kind string; SalaryID, TypeID, EmployeeItemID, InputID *uuid.UUID; StatutoryCode *string; Description string; Days *hr.Days; Deduction bool; Amount money.Amount }type CalcResult struct { Lines []CalcLine; Gross, Deductions, Net money.Amount; DaysInMonth, DaysEmployed, Divisor int }func Calculate(in CalcInput) (CalcResult, error)
// Task 5type Run struct { ID uuid.UUID; Reference string; Month time.Time; Status string; InstanceID uuid.UUID; State string; CalcNo, Revision int; PreparedBy uuid.UUID; ApprovedBy *uuid.UUID; ApprovedSoleOwner bool; Gross, Deductions, Net money.Amount; PayslipCount, PaidCount int; CalculatedAt *time.Time; CreatedAt time.Time }type Payslip struct { ID, RunID, EmployeeID, MemberID, PartyID, PayoutChannelID uuid.UUID; EmployeeNo, EmployeeName, Reference string; Seq, CalcNo int; Gross, Deductions, Net money.Amount; DaysInMonth, DaysEmployed, Divisor int; InstanceID *uuid.UUID; RunStatus string; Month time.Time; Paid bool; PaidAt *time.Time; BatchReference *string; Lines []Line }type Line struct { ID uuid.UUID; LineNo int; Kind string; SalaryID, TypeID, EmployeeItemID, InputID *uuid.UUID; StatutoryCode *string; Description string; Days *hr.Days; Deduction bool; Amount money.Amount }type Input struct { ID, RunID, EmployeeID uuid.UUID; Kind string; TypeID *uuid.UUID; StatutoryCode *string; Description string; Amount money.Amount; CreatedBy uuid.UUID; CreatedAt time.Time }type InputInput struct { RunID, EmployeeID uuid.UUID; Kind string; TypeID *uuid.UUID; StatutoryCode *string; Description string; Amount money.Amount; Actor uuid.UUID; ExpectedRevision int }type Warning struct { EmployeeID uuid.UUID; EmployeeNo, EmployeeName, Code string }func CreateRun(ctx context.Context, tx pgx.Tx, c clock.Clock, month time.Time, actor uuid.UUID) (Run, error)func Recalculate(ctx context.Context, tx pgx.Tx, c clock.Clock, runID, actor uuid.UUID, expectedRevision int) (Run, error)func AddInput(ctx context.Context, tx pgx.Tx, c clock.Clock, in InputInput) (Run, Input, error)func RemoveInput(ctx context.Context, tx pgx.Tx, c clock.Clock, runID, inputID, actor uuid.UUID, expectedRevision int) (Run, error)func GetRun(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Run, error)func ListRuns(ctx context.Context, tx pgx.Tx) ([]Run, error)func Inputs(ctx context.Context, tx pgx.Tx, runID uuid.UUID) ([]Input, error)func Payslips(ctx context.Context, tx pgx.Tx, runID uuid.UUID) ([]Payslip, error)func GetPayslip(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Payslip, error)func LockPayslip(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Payslip, error)func RunWarnings(ctx context.Context, tx pgx.Tx, run Run) ([]Warning, error)
// Task 6func RunGraph(ownerSeat uuid.UUID) workflow.Graphfunc PayslipGraph(ownerSeat uuid.UUID) workflow.Graphtype TransitionInput struct { RunID uuid.UUID; From, To string; Actor uuid.UUID; Reason string; ExpectedRevision int }func TransitionRun(ctx context.Context, tx pgx.Tx, c clock.Clock, in TransitionInput) (Run, workflow.Decision, error)func RequiredPermission(ctx context.Context, tx pgx.Tx, runID uuid.UUID, to string) (string, error)
// Task 7type AccrualRule struct{}; type PayoutRule struct{}func Rules() []accounting.PostingRule
// Task 8type PayInput struct { PayslipID uuid.UUID; From, To string; Actor uuid.UUID; BatchLineID uuid.UUID; BatchReference, Reason string }func PayPayslip(ctx context.Context, tx pgx.Tx, c clock.Clock, in PayInput) (Payslip, workflow.Decision, error)// package payables gains: SourcePayslip; BatchInput.PayslipIDs; BatchLine.PayslipID
// Task 10func MyPayslips(ctx context.Context, tx pgx.Tx, member uuid.UUID) ([]Payslip, error)func ReadPayslip(ctx context.Context, tx pgx.Tx, c clock.Clock, id, actor uuid.UUID) (Payslip, error)
// package payrolltest (Task 3, extended in 5, 6, 8)type Env struct { *hrtest.Env; Pay *payablestest.Env }func New(t *testing.T) *EnvFile map
Section titled “File map”| File | Responsibility | Task |
|---|---|---|
db/migrations/00900_payroll.sql |
the nine payroll tables (incl. payslip_payment), RLS, grants, freeze and fixed-field triggers |
1 |
db/migrations/00901_payroll_payable_control_kind.sql |
payroll_payable in the three control-kind CHECKs |
2 |
db/migrations/00902_payslip_batch_line.sql |
payment_batch_line.payslip_id, the line guard, the approved-run rule |
8 |
db/migrations/00903_payroll_grants.sql |
Owner grants for existing organizations; payroll in module_install_key_known |
9 |
internal/platform/payroll/payroll.go |
constants, errors, names, small helpers | 3 |
internal/platform/payroll/setting.go, salary.go, items.go |
setting, salaries, item types, employee items | 3 |
internal/platform/payroll/step.go |
provisioning step payroll_accounts |
2 |
internal/platform/payroll/calc.go |
Calculate, pure |
4 |
internal/platform/payroll/run.go, warnings.go |
runs, inputs, generations, payslip reads, warnings | 5 |
internal/platform/payroll/transition.go |
graphs, TransitionRun, RequiredPermission |
6 |
internal/platform/payroll/posting.go |
the accrual and payout rules | 7 |
internal/platform/payroll/pay.go |
PayPayslip |
8 |
internal/platform/payroll/me.go |
MyPayslips, ReadPayslip |
10 |
internal/platform/payroll/doc.go |
package doc and lock order | 3 |
internal/platform/payroll/payrolltest/env.go |
the test world | 3 |
internal/platform/hr/employee.go, leave_calendar.go |
EmployedBetween, UnpaidLeaveByDate |
4 |
internal/platform/payables/batch.go, batch_payout.go |
the payslip source | 8 |
internal/modules/payroll/* |
HTTP, permissions, posting-rule boot | 9, 10 |
api/openapi.yaml, api/public_routes.txt |
the contract | 10 |
web/src/features/payroll/* |
screens | 11, 12 |
docs/ROADMAP.md, docs/DEPLOY_NOTES.md, docs/LOCK_ORDER.md |
docs | 13 |
Task 1: The payroll schema (00900), its isolation and retention
Section titled “Task 1: The payroll schema (00900), its isolation and retention”Files:
- Create:
db/migrations/00900_payroll.sql - Create:
internal/platform/payroll/doc.go - Modify:
internal/kernel/db/retention_test.go(theappendOnlyandamendablelists) - Test:
internal/platform/payroll/schema_test.go
Interfaces:
-
Consumes:
employee(00800),organization_member,workflow_instance(00033),account,payout_channel— all withUNIQUE (org_id, id);hrtest.New,(*hrtest.Env).Hired,(*advancestest.Env).Another,(*expensestest.Env).Tx/TxErr/Count. -
Produces: tables
payroll_setting,salary,pay_item_type,employee_pay_item,payroll_run,payroll_run_input,payslip,payslip_line,payslip_payment(the batch line’spayslip_idcomes in Task 8); constraint namespayslip_payment_matches,payslip_frozen,payroll_run_fixed,payroll_run_input_draft_only,payroll_run_input_fixed,payroll_run_input_one_off_type,pay_item_type_fixed,employee_pay_item_fixed,employee_pay_item_recurring_type,payroll_run_one_per_month(unique index),payroll_run_approver_differs. -
Step 1: Write the failing schema test
Create internal/platform/payroll/schema_test.go:
package payroll_test
// Phase 5 payroll P1, Task 1: the schema's own rules, before any Go uses it.
import ( "context" "errors" "testing"
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/hr/hrtest")
func pgCode(err error) (code, constraint string) { var pe *pgconn.PgError if errors.As(err, &pe) { return pe.Code, pe.ConstraintName } return "", ""}
func TestSalaryRowsAreAppendOnlyAndTenantIsolated(t *testing.T) { e := hrtest.New(t) other := e.Env.Another(t) emp := e.Hired(t, e.Staff, "E-100") e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { _, err := tx.Exec(ctx, `INSERT INTO salary (org_id, employee_id, amount, effective_from, created_by, created_at) VALUES ($1, $2, 3100, '2026-01-01', $3, now())`, e.Org, emp.ID, e.HR) return err })
// PRESENCE: the organization's own transaction sees its row. var mine, theirs int e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { return tx.QueryRow(ctx, `SELECT count(*) FROM salary WHERE org_id = $1`, e.Org).Scan(&mine) }) require.Equal(t, 1, mine, "fixture: the salary row exists; the absence below would be vacuous") // ABSENCE: another organization's transaction does not, even naming the org. other.Tx(t, func(ctx context.Context, tx pgx.Tx) error { return tx.QueryRow(ctx, `SELECT count(*) FROM salary WHERE org_id = $1`, e.Org).Scan(&theirs) }) require.Zero(t, theirs)
// Append-only: the app role may neither amend nor erase a salary. for _, sql := range []string{`UPDATE salary SET amount = 1 WHERE org_id = $1`, `DELETE FROM salary WHERE org_id = $1`} { err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := tx.Exec(ctx, sql, e.Org); return err }) code, _ := pgCode(err) require.Equal(t, "42501", code, sql) } // Whole sen only. err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := tx.Exec(ctx, `INSERT INTO salary (org_id, employee_id, amount, effective_from, created_by, created_at) VALUES ($1, $2, 3100.005, '2026-02-01', $3, now())`, e.Org, emp.ID, e.HR) return err }) _, constraint := pgCode(err) require.Equal(t, "salary_amount_positive_sen", constraint)}
func TestAPayItemTypeKeepsItsCodeKindAndTag(t *testing.T) { e := hrtest.New(t) e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { _, err := tx.Exec(ctx, `INSERT INTO pay_item_type (org_id, code, kind, recurrence, posting_tag, created_at, updated_at) VALUES ($1, 'TRANS', 'earning', 'recurring', 'salary_expense', now(), now())`, e.Org) return err }) // PRESENCE: an amendable field changes. e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { _, err := tx.Exec(ctx, `UPDATE pay_item_type SET epf = true, revision = revision + 1 WHERE org_id = $1 AND code = 'TRANS'`, e.Org) return err }) // ABSENCE: the kind does not. err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := tx.Exec(ctx, `UPDATE pay_item_type SET kind = 'deduction', posting_tag = 'statutory_payable' WHERE org_id = $1 AND code = 'TRANS'`, e.Org) return err }) _, constraint := pgCode(err) require.Equal(t, "pay_item_type_fixed", constraint) // The tag follows the kind. err = e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := tx.Exec(ctx, `INSERT INTO pay_item_type (org_id, code, kind, recurrence, posting_tag, created_at, updated_at) VALUES ($1, 'LOAN', 'deduction', 'recurring', 'salary_expense', now(), now())`, e.Org) return err }) _, constraint = pgCode(err) require.Equal(t, "pay_item_type_tag_by_kind", constraint)}
func TestAnEmployeePayItemIsRecurringAndEndsOnce(t *testing.T) { e := hrtest.New(t) emp := e.Hired(t, e.Staff, "E-100") e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { _, err := tx.Exec(ctx, `INSERT INTO pay_item_type (org_id, code, kind, recurrence, posting_tag, created_at, updated_at) VALUES ($1, 'TRANS', 'earning', 'recurring', 'salary_expense', now(), now()), ($1, 'BONUS', 'earning', 'one_off', 'salary_expense', now(), now())`, e.Org) return err }) add := func(code string) error { return e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := tx.Exec(ctx, `INSERT INTO employee_pay_item (org_id, employee_id, pay_item_type_id, amount, effective_from, created_by, created_at) SELECT $1, $2, t.id, 200, '2026-01-01', $3, now() FROM pay_item_type t WHERE t.org_id = $1 AND t.code = $4`, e.Org, emp.ID, e.HR, code) return err }) } require.NoError(t, add("TRANS"), "PRESENCE: a recurring type is an employee's item") _, constraint := pgCode(add("BONUS")) require.Equal(t, "employee_pay_item_recurring_type", constraint)
end := func(day string) error { return e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := tx.Exec(ctx, `UPDATE employee_pay_item SET effective_to = $2::date, ended_by = $3, ended_at = now() WHERE org_id = $1`, e.Org, day, e.HR) return err }) } require.NoError(t, end("2026-06-30"), "PRESENCE: the item ends once") _, constraint = pgCode(end("2026-07-31")) require.Equal(t, "employee_pay_item_fixed", constraint)}- Step 2: Run it to verify it fails
Run: go test ./internal/platform/payroll/ -run 'TestSalaryRows|TestAPayItemType|TestAnEmployeePayItem' -count=1
Expected: FAIL — relation "salary" does not exist (and the package has no non-test file yet: create doc.go in Step 3 first if go refuses to build).
- Step 3: Write
doc.goand the migration
Create internal/platform/payroll/doc.go:
// Package payroll is Phase 5 payroll P1 (docs/superpowers/specs/2026-10-01-payroll-p1-design.md,// plan docs/superpowers/plans/2026-10-01-phase-5-payroll-p1.md, Rulings PD-7–PD-30): salaries,// pay item types and recurring items, the monthly run (a snapshot: each preparation writes a new// calculation generation; submission freezes it), the payroll_run workflow that posts by semantic// tags, and the payslip workflow that a payment batch pays (C4's pattern).//// Lock order (PD-27; docs/LOCK_ORDER.md):// - create, recalculate, inputs: payroll_run FOR UPDATE → numbering (create) → the run's own// workflow instance (Start, SetAmount) → payslip and line inserts → audit last;// - TransitionRun: workflow.LockSoleOwner → payroll_run FOR UPDATE → the current generation's// payslip rows FOR UPDATE in id order → workflow.Transition → payslip workflow starts and// instance_id writes (rows held or created here) → the run row (held) → audit last;// - PayPayslip (inside payables.MarkBatchPaid): the payslip (already held by the batch) →// payslip_payment insert → workflow.Transition → audit.// The batch never locks payroll_run; a void takes the run, then its payslips.package payrollCreate db/migrations/00900_payroll.sql:
-- +goose Up-- Phase 5 payroll P1 (docs/superpowers/specs/2026-10-01-payroll-p1-design.md; plan Rulings-- PD-7–PD-30). Salaries, pay item types, recurring items, the monthly run, its inputs, and-- its payslips. Money is NUMERIC(19,4) in whole sen; nothing here grants DELETE (retention,-- owner question 4); every amendment is narrowed by a trigger.
-- One row per organization (PD-11, PD-17, PD-18, PD-20). NULL divisor: the month's calendar days.CREATE TABLE payroll_setting ( org_id UUID PRIMARY KEY REFERENCES organization (id), unpaid_leave_divisor INT CONSTRAINT payroll_setting_divisor_range CHECK (unpaid_leave_divisor BETWEEN 1 AND 31), payout_channel_id UUID, salary_expense_account_id UUID, statutory_payable_account_id UUID, revision INT NOT NULL DEFAULT 1 CONSTRAINT payroll_setting_revision_positive CHECK (revision >= 1), updated_by UUID, updated_at TIMESTAMPTZ NOT NULL, CONSTRAINT payroll_setting_channel_fk FOREIGN KEY (org_id, payout_channel_id) REFERENCES payout_channel (org_id, id), CONSTRAINT payroll_setting_expense_fk FOREIGN KEY (org_id, salary_expense_account_id) REFERENCES account (org_id, id), CONSTRAINT payroll_setting_payable_fk FOREIGN KEY (org_id, statutory_payable_account_id) REFERENCES account (org_id, id), CONSTRAINT payroll_setting_updated_by_fk FOREIGN KEY (org_id, updated_by) REFERENCES organization_member (org_id, id));ALTER TABLE payroll_setting ENABLE ROW LEVEL SECURITY;ALTER TABLE payroll_setting FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON payroll_setting USING (org_id = current_setting('app.current_org', true)::uuid) WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);REVOKE DELETE ON payroll_setting FROM erp_app;
-- PD-9: append-only; the latest row on or before a day is in force (ties: the later-created).CREATE TABLE salary ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID NOT NULL REFERENCES organization (id), employee_id UUID NOT NULL, amount NUMERIC(19,4) NOT NULL CONSTRAINT salary_amount_positive_sen CHECK (amount > 0 AND amount = round(amount, 2)), effective_from DATE NOT NULL, reason TEXT CONSTRAINT salary_reason_shape CHECK (reason IS NULL OR (reason = btrim(reason) AND reason <> '' AND length(reason) <= 500 AND reason !~ '[-ヿ㐀-䶿一-鿿가-]')), created_by UUID NOT NULL, created_at TIMESTAMPTZ NOT NULL, CONSTRAINT salary_tenant_id UNIQUE (org_id, id), CONSTRAINT salary_employee_fk FOREIGN KEY (org_id, employee_id) REFERENCES employee (org_id, id), CONSTRAINT salary_created_by_fk FOREIGN KEY (org_id, created_by) REFERENCES organization_member (org_id, id));CREATE INDEX salary_by_employee ON salary (org_id, employee_id, effective_from, created_at);ALTER TABLE salary ENABLE ROW LEVEL SECURITY;ALTER TABLE salary FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON salary USING (org_id = current_setting('app.current_org', true)::uuid) WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);REVOKE UPDATE, DELETE ON salary FROM erp_app;
-- PD-12: names are translation rows (entity_kind pay_item_type); the tag follows the kind.CREATE TABLE pay_item_type ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID NOT NULL REFERENCES organization (id), code TEXT NOT NULL CONSTRAINT pay_item_type_code_shape CHECK (code ~ '^[A-Z0-9][A-Z0-9_-]{0,19}$'), kind TEXT NOT NULL CONSTRAINT pay_item_type_kind_known CHECK (kind IN ('earning', 'deduction')), recurrence TEXT NOT NULL CONSTRAINT pay_item_type_recurrence_known CHECK (recurrence IN ('recurring', 'one_off')), posting_tag TEXT NOT NULL CONSTRAINT pay_item_type_tag_by_kind CHECK ((kind = 'earning' AND posting_tag = 'salary_expense') OR (kind = 'deduction' AND posting_tag = 'statutory_payable')), epf BOOLEAN NOT NULL DEFAULT false, socso BOOLEAN NOT NULL DEFAULT false, eis BOOLEAN NOT NULL DEFAULT false, pcb BOOLEAN NOT NULL DEFAULT false, active BOOLEAN NOT NULL DEFAULT true, revision INT NOT NULL DEFAULT 1 CONSTRAINT pay_item_type_revision_positive CHECK (revision >= 1), created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL, CONSTRAINT pay_item_type_tenant_id UNIQUE (org_id, id), CONSTRAINT pay_item_type_code_unique UNIQUE (org_id, code));ALTER TABLE pay_item_type ENABLE ROW LEVEL SECURITY;ALTER TABLE pay_item_type FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON pay_item_type USING (org_id = current_setting('app.current_org', true)::uuid) WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);REVOKE DELETE ON pay_item_type FROM erp_app;-- +goose StatementBeginCREATE FUNCTION pay_item_type_fixed() RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$BEGIN IF (NEW.id, NEW.org_id, NEW.code, NEW.kind, NEW.recurrence, NEW.posting_tag, NEW.created_at) IS DISTINCT FROM (OLD.id, OLD.org_id, OLD.code, OLD.kind, OLD.recurrence, OLD.posting_tag, OLD.created_at) THEN RAISE EXCEPTION 'pay item type %: its code, kind, recurrence and posting tag never change', OLD.id USING ERRCODE = '23514', CONSTRAINT = 'pay_item_type_fixed'; END IF; RETURN NEW;END $$;-- +goose StatementEndCREATE TRIGGER pay_item_type_fixed BEFORE UPDATE ON pay_item_type FOR EACH ROW EXECUTE FUNCTION pay_item_type_fixed();
-- PD-12: a recurring item of one employee; effective_to (inclusive) is written once, by an end.CREATE TABLE employee_pay_item ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID NOT NULL REFERENCES organization (id), employee_id UUID NOT NULL, pay_item_type_id UUID NOT NULL, amount NUMERIC(19,4) NOT NULL CONSTRAINT employee_pay_item_amount_positive_sen CHECK (amount > 0 AND amount = round(amount, 2)), effective_from DATE NOT NULL, effective_to DATE, created_by UUID NOT NULL, created_at TIMESTAMPTZ NOT NULL, ended_by UUID, ended_at TIMESTAMPTZ, CONSTRAINT employee_pay_item_tenant_id UNIQUE (org_id, id), CONSTRAINT employee_pay_item_dates CHECK (effective_to IS NULL OR effective_to >= effective_from), CONSTRAINT employee_pay_item_end_complete CHECK ((ended_at IS NULL) = (ended_by IS NULL)), CONSTRAINT employee_pay_item_employee_fk FOREIGN KEY (org_id, employee_id) REFERENCES employee (org_id, id), CONSTRAINT employee_pay_item_type_fk FOREIGN KEY (org_id, pay_item_type_id) REFERENCES pay_item_type (org_id, id), CONSTRAINT employee_pay_item_created_by_fk FOREIGN KEY (org_id, created_by) REFERENCES organization_member (org_id, id), CONSTRAINT employee_pay_item_ended_by_fk FOREIGN KEY (org_id, ended_by) REFERENCES organization_member (org_id, id));CREATE INDEX employee_pay_item_by_employee ON employee_pay_item (org_id, employee_id, effective_from);ALTER TABLE employee_pay_item ENABLE ROW LEVEL SECURITY;ALTER TABLE employee_pay_item FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON employee_pay_item USING (org_id = current_setting('app.current_org', true)::uuid) WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);REVOKE DELETE ON employee_pay_item FROM erp_app;-- +goose StatementBeginCREATE FUNCTION employee_pay_item_guard() RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$DECLARE r text;BEGIN IF TG_OP = 'INSERT' THEN SELECT t.recurrence INTO r FROM public.pay_item_type t WHERE t.org_id = NEW.org_id AND t.id = NEW.pay_item_type_id AND t.active; IF r IS DISTINCT FROM 'recurring' OR NEW.ended_at IS NOT NULL THEN RAISE EXCEPTION 'employee pay item: an employee''s item is of an active recurring type' USING ERRCODE = '23514', CONSTRAINT = 'employee_pay_item_recurring_type'; END IF; RETURN NEW; END IF; IF OLD.ended_at IS NOT NULL OR NEW.ended_at IS NULL OR NEW.effective_to IS NULL OR (NEW.id, NEW.org_id, NEW.employee_id, NEW.pay_item_type_id, NEW.amount, NEW.effective_from, NEW.created_by, NEW.created_at) IS DISTINCT FROM (OLD.id, OLD.org_id, OLD.employee_id, OLD.pay_item_type_id, OLD.amount, OLD.effective_from, OLD.created_by, OLD.created_at) OR (OLD.effective_to IS NOT NULL AND NEW.effective_to > OLD.effective_to) THEN RAISE EXCEPTION 'employee pay item %: only its end is written, once, no later than an end it already had', OLD.id USING ERRCODE = '23514', CONSTRAINT = 'employee_pay_item_fixed'; END IF; RETURN NEW;END $$;-- +goose StatementEndCREATE TRIGGER employee_pay_item_guard BEFORE INSERT OR UPDATE ON employee_pay_item FOR EACH ROW EXECUTE FUNCTION employee_pay_item_guard();
-- PD-15, PD-17: one non-voided run per organization per month. status mirrors the entered-- semantic; paid is derived, never stored.CREATE TABLE payroll_run ( id UUID PRIMARY KEY, org_id UUID NOT NULL REFERENCES organization (id), reference TEXT NOT NULL CONSTRAINT payroll_run_reference_shape CHECK (reference ~ '^PR-[0-9]{4}-[0-9]{4,}$'), period_month DATE NOT NULL CONSTRAINT payroll_run_month_first CHECK (period_month = date_trunc('month', period_month)::date), status TEXT NOT NULL DEFAULT 'draft' CONSTRAINT payroll_run_status_known CHECK (status IN ('draft', 'submitted', 'approved', 'rejected', 'voided')), instance_id UUID NOT NULL, calc_no INT NOT NULL DEFAULT 0 CONSTRAINT payroll_run_calc_no_nonnegative CHECK (calc_no >= 0), calculated_by UUID, calculated_at TIMESTAMPTZ, prepared_by UUID NOT NULL, approved_by UUID, approved_at TIMESTAMPTZ, approved_sole_owner BOOLEAN NOT NULL DEFAULT false, revision INT NOT NULL DEFAULT 1 CONSTRAINT payroll_run_revision_positive CHECK (revision >= 1), created_at TIMESTAMPTZ NOT NULL, updated_at TIMESTAMPTZ NOT NULL, CONSTRAINT payroll_run_tenant_id UNIQUE (org_id, id), CONSTRAINT payroll_run_reference_unique UNIQUE (org_id, reference), CONSTRAINT payroll_run_instance_once UNIQUE (org_id, instance_id), CONSTRAINT payroll_run_calculation_complete CHECK ((calculated_at IS NULL) = (calculated_by IS NULL)), CONSTRAINT payroll_run_approval_complete CHECK ((approved_by IS NULL) = (approved_at IS NULL)), CONSTRAINT payroll_run_approver_differs CHECK (approved_by IS NULL OR approved_by <> prepared_by OR approved_sole_owner), CONSTRAINT payroll_run_instance_fk FOREIGN KEY (org_id, instance_id) REFERENCES workflow_instance (org_id, id), CONSTRAINT payroll_run_calculated_by_fk FOREIGN KEY (org_id, calculated_by) REFERENCES organization_member (org_id, id), CONSTRAINT payroll_run_prepared_by_fk FOREIGN KEY (org_id, prepared_by) REFERENCES organization_member (org_id, id), CONSTRAINT payroll_run_approved_by_fk FOREIGN KEY (org_id, approved_by) REFERENCES organization_member (org_id, id));CREATE UNIQUE INDEX payroll_run_one_per_month ON payroll_run (org_id, period_month) WHERE status <> 'voided';ALTER TABLE payroll_run ENABLE ROW LEVEL SECURITY;ALTER TABLE payroll_run FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON payroll_run USING (org_id = current_setting('app.current_org', true)::uuid) WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);REVOKE DELETE ON payroll_run FROM erp_app;-- +goose StatementBeginCREATE FUNCTION payroll_run_fixed() RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$BEGIN IF (NEW.id, NEW.org_id, NEW.reference, NEW.period_month, NEW.instance_id, NEW.prepared_by, NEW.created_at) IS DISTINCT FROM (OLD.id, OLD.org_id, OLD.reference, OLD.period_month, OLD.instance_id, OLD.prepared_by, OLD.created_at) OR OLD.status = 'voided' OR (NEW.calc_no <> OLD.calc_no AND (OLD.status <> 'draft' OR NEW.calc_no < OLD.calc_no)) OR (OLD.approved_by IS NOT NULL AND (NEW.approved_by, NEW.approved_at, NEW.approved_sole_owner) IS DISTINCT FROM (OLD.approved_by, OLD.approved_at, OLD.approved_sole_owner)) THEN RAISE EXCEPTION 'payroll run %: its identity is fixed, a voided run is final, only a draft is recalculated, and an approval is written once', OLD.id USING ERRCODE = '23514', CONSTRAINT = 'payroll_run_fixed'; END IF; RETURN NEW;END $$;-- +goose StatementEndCREATE TRIGGER payroll_run_fixed BEFORE UPDATE ON payroll_run FOR EACH ROW EXECUTE FUNCTION payroll_run_fixed();
-- PD-13: what the preparer enters on a draft run; removed, never deleted.CREATE TABLE payroll_run_input ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID NOT NULL REFERENCES organization (id), run_id UUID NOT NULL, employee_id UUID NOT NULL, kind TEXT NOT NULL CONSTRAINT payroll_run_input_kind_known CHECK (kind IN ('item', 'statutory', 'adjustment')), pay_item_type_id UUID, statutory_code TEXT CONSTRAINT payroll_run_input_statutory_known CHECK (statutory_code IN ('epf', 'socso', 'eis', 'pcb')), description TEXT NOT NULL CONSTRAINT payroll_run_input_description_shape CHECK (description = btrim(description) AND description <> '' AND length(description) <= 200 AND description !~ '[-ヿ㐀-䶿一-鿿가-]'), amount NUMERIC(19,4) NOT NULL CONSTRAINT payroll_run_input_amount_sen CHECK (amount <> 0 AND amount = round(amount, 2)), created_by UUID NOT NULL, created_at TIMESTAMPTZ NOT NULL, removed_by UUID, removed_at TIMESTAMPTZ, CONSTRAINT payroll_run_input_tenant_id UNIQUE (org_id, id), CONSTRAINT payroll_run_input_shape CHECK ( (kind = 'item' AND pay_item_type_id IS NOT NULL AND statutory_code IS NULL AND amount > 0) OR (kind = 'statutory' AND pay_item_type_id IS NULL AND statutory_code IS NOT NULL AND amount > 0) OR (kind = 'adjustment' AND pay_item_type_id IS NULL AND statutory_code IS NULL)), CONSTRAINT payroll_run_input_removal_complete CHECK ((removed_at IS NULL) = (removed_by IS NULL)), CONSTRAINT payroll_run_input_run_fk FOREIGN KEY (org_id, run_id) REFERENCES payroll_run (org_id, id), CONSTRAINT payroll_run_input_employee_fk FOREIGN KEY (org_id, employee_id) REFERENCES employee (org_id, id), CONSTRAINT payroll_run_input_type_fk FOREIGN KEY (org_id, pay_item_type_id) REFERENCES pay_item_type (org_id, id), CONSTRAINT payroll_run_input_created_by_fk FOREIGN KEY (org_id, created_by) REFERENCES organization_member (org_id, id), CONSTRAINT payroll_run_input_removed_by_fk FOREIGN KEY (org_id, removed_by) REFERENCES organization_member (org_id, id));CREATE INDEX payroll_run_input_by_run ON payroll_run_input (org_id, run_id);ALTER TABLE payroll_run_input ENABLE ROW LEVEL SECURITY;ALTER TABLE payroll_run_input FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON payroll_run_input USING (org_id = current_setting('app.current_org', true)::uuid) WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);REVOKE DELETE ON payroll_run_input FROM erp_app;-- +goose StatementBeginCREATE FUNCTION payroll_run_input_guard() RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$DECLARE s text; r text;BEGIN SELECT x.status INTO s FROM public.payroll_run x WHERE x.org_id = NEW.org_id AND x.id = NEW.run_id; IF s IS DISTINCT FROM 'draft' THEN RAISE EXCEPTION 'payroll run %: inputs change only while it is a draft', NEW.run_id USING ERRCODE = '23514', CONSTRAINT = 'payroll_run_input_draft_only'; END IF; IF TG_OP = 'INSERT' THEN IF NEW.removed_at IS NOT NULL THEN RAISE EXCEPTION 'payroll run input: inserted live' USING ERRCODE = '23514', CONSTRAINT = 'payroll_run_input_fixed'; END IF; IF NEW.kind = 'item' THEN SELECT t.recurrence INTO r FROM public.pay_item_type t WHERE t.org_id = NEW.org_id AND t.id = NEW.pay_item_type_id AND t.active; IF r IS DISTINCT FROM 'one_off' THEN RAISE EXCEPTION 'payroll run input: an item entered on a run is of an active one-off type' USING ERRCODE = '23514', CONSTRAINT = 'payroll_run_input_one_off_type'; END IF; END IF; RETURN NEW; END IF; IF OLD.removed_at IS NOT NULL OR NEW.removed_at IS NULL OR (NEW.id, NEW.org_id, NEW.run_id, NEW.employee_id, NEW.kind, NEW.pay_item_type_id, NEW.statutory_code, NEW.description, NEW.amount, NEW.created_by, NEW.created_at) IS DISTINCT FROM (OLD.id, OLD.org_id, OLD.run_id, OLD.employee_id, OLD.kind, OLD.pay_item_type_id, OLD.statutory_code, OLD.description, OLD.amount, OLD.created_by, OLD.created_at) THEN RAISE EXCEPTION 'payroll run input %: only its removal is written, once', OLD.id USING ERRCODE = '23514', CONSTRAINT = 'payroll_run_input_fixed'; END IF; RETURN NEW;END $$;-- +goose StatementEndCREATE TRIGGER payroll_run_input_guard BEFORE INSERT OR UPDATE ON payroll_run_input FOR EACH ROW EXECUTE FUNCTION payroll_run_input_guard();
-- PD-14, PD-16: one payslip per employee per calculation generation. Written only into a draft-- run's current generation; afterwards only its payment instance is written, once (PD-20).CREATE TABLE payslip ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID NOT NULL REFERENCES organization (id), run_id UUID NOT NULL, calc_no INT NOT NULL CONSTRAINT payslip_calc_no_positive CHECK (calc_no >= 1), seq INT NOT NULL CONSTRAINT payslip_seq_positive CHECK (seq >= 1), reference TEXT NOT NULL CONSTRAINT payslip_reference_shape CHECK (reference ~ '^PR-[0-9]{4}-[0-9]{4,}-[0-9]{3,}$'), employee_id UUID NOT NULL, member_id UUID NOT NULL, payout_channel_id UUID NOT NULL, days_in_month INT NOT NULL CONSTRAINT payslip_days_in_month_range CHECK (days_in_month BETWEEN 28 AND 31), days_employed INT NOT NULL CONSTRAINT payslip_days_employed_range CHECK (days_employed BETWEEN 1 AND days_in_month), divisor INT NOT NULL CONSTRAINT payslip_divisor_range CHECK (divisor BETWEEN 1 AND 31), gross NUMERIC(19,4) NOT NULL CONSTRAINT payslip_gross_sen CHECK (gross = round(gross, 2)), deductions NUMERIC(19,4) NOT NULL CONSTRAINT payslip_deductions_sen CHECK (deductions >= 0 AND deductions = round(deductions, 2)), net NUMERIC(19,4) NOT NULL, instance_id UUID, created_at TIMESTAMPTZ NOT NULL, CONSTRAINT payslip_tenant_id UNIQUE (org_id, id), CONSTRAINT payslip_one_per_employee UNIQUE (org_id, run_id, calc_no, employee_id), CONSTRAINT payslip_seq_unique UNIQUE (org_id, run_id, calc_no, seq), CONSTRAINT payslip_instance_once UNIQUE (org_id, instance_id), CONSTRAINT payslip_net_is_gross_less_deductions CHECK (net = gross - deductions), CONSTRAINT payslip_instance_only_with_net CHECK (instance_id IS NULL OR net > 0), CONSTRAINT payslip_run_fk FOREIGN KEY (org_id, run_id) REFERENCES payroll_run (org_id, id), CONSTRAINT payslip_employee_fk FOREIGN KEY (org_id, employee_id) REFERENCES employee (org_id, id), CONSTRAINT payslip_member_fk FOREIGN KEY (org_id, member_id) REFERENCES organization_member (org_id, id), CONSTRAINT payslip_channel_fk FOREIGN KEY (org_id, payout_channel_id) REFERENCES payout_channel (org_id, id), CONSTRAINT payslip_instance_fk FOREIGN KEY (org_id, instance_id) REFERENCES workflow_instance (org_id, id));CREATE INDEX payslip_by_member ON payslip (org_id, member_id);ALTER TABLE payslip ENABLE ROW LEVEL SECURITY;ALTER TABLE payslip FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON payslip USING (org_id = current_setting('app.current_org', true)::uuid) WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);REVOKE DELETE ON payslip FROM erp_app;-- +goose StatementBeginCREATE FUNCTION payslip_guard() RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$DECLARE r public.payroll_run%ROWTYPE;BEGIN IF TG_OP = 'INSERT' THEN SELECT * INTO r FROM public.payroll_run x WHERE x.org_id = NEW.org_id AND x.id = NEW.run_id; IF r.status IS DISTINCT FROM 'draft' OR NEW.calc_no <> r.calc_no OR NEW.instance_id IS NOT NULL THEN RAISE EXCEPTION 'payslip_frozen: run %: payslips are written only into a draft run''s current calculation', NEW.run_id USING ERRCODE = '23514', CONSTRAINT = 'payslip_frozen'; END IF; RETURN NEW; END IF; IF OLD.instance_id IS NOT NULL OR NEW.instance_id IS NULL OR (NEW.id, NEW.org_id, NEW.run_id, NEW.calc_no, NEW.seq, NEW.reference, NEW.employee_id, NEW.member_id, NEW.payout_channel_id, NEW.days_in_month, NEW.days_employed, NEW.divisor, NEW.gross, NEW.deductions, NEW.net, NEW.created_at) IS DISTINCT FROM (OLD.id, OLD.org_id, OLD.run_id, OLD.calc_no, OLD.seq, OLD.reference, OLD.employee_id, OLD.member_id, OLD.payout_channel_id, OLD.days_in_month, OLD.days_employed, OLD.divisor, OLD.gross, OLD.deductions, OLD.net, OLD.created_at) THEN RAISE EXCEPTION 'payslip_frozen: payslip %: a payslip never changes; its payment instance is written once', OLD.id USING ERRCODE = '23514', CONSTRAINT = 'payslip_frozen'; END IF; RETURN NEW;END $$;-- +goose StatementEndCREATE TRIGGER payslip_guard BEFORE INSERT OR UPDATE ON payslip FOR EACH ROW EXECUTE FUNCTION payslip_guard();
-- The spec's frozen lines: append-only, and inserted only into a draft run's current generation.CREATE TABLE payslip_line ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID NOT NULL REFERENCES organization (id), payslip_id UUID NOT NULL, line_no INT NOT NULL CONSTRAINT payslip_line_no_positive CHECK (line_no >= 1), kind TEXT NOT NULL CONSTRAINT payslip_line_kind_known CHECK (kind IN ('basic', 'item', 'unpaid_leave', 'statutory', 'adjustment')), deduction BOOLEAN NOT NULL, salary_id UUID, pay_item_type_id UUID, employee_pay_item_id UUID, run_input_id UUID, statutory_code TEXT CONSTRAINT payslip_line_statutory_known CHECK (statutory_code IN ('epf', 'socso', 'eis', 'pcb')), description TEXT NOT NULL CONSTRAINT payslip_line_description_shape CHECK (description <> '' AND length(description) <= 200), days NUMERIC(5,1) CONSTRAINT payslip_line_days_shape CHECK (days IS NULL OR (days > 0 AND days * 2 = trunc(days * 2))), amount NUMERIC(19,4) NOT NULL CONSTRAINT payslip_line_amount_sen CHECK (amount = round(amount, 2)), created_at TIMESTAMPTZ NOT NULL, CONSTRAINT payslip_line_tenant_id UNIQUE (org_id, id), CONSTRAINT payslip_line_no_unique UNIQUE (org_id, payslip_id, line_no), CONSTRAINT payslip_line_shape CHECK ( (kind = 'basic' AND NOT deduction AND salary_id IS NOT NULL AND days IS NOT NULL AND amount >= 0) OR (kind = 'unpaid_leave' AND NOT deduction AND salary_id IS NOT NULL AND days IS NOT NULL AND amount <= 0) OR (kind = 'item' AND pay_item_type_id IS NOT NULL AND amount > 0 AND num_nonnulls(employee_pay_item_id, run_input_id) = 1) OR (kind = 'statutory' AND deduction AND statutory_code IS NOT NULL AND run_input_id IS NOT NULL AND amount > 0) OR (kind = 'adjustment' AND NOT deduction AND run_input_id IS NOT NULL AND amount <> 0)), CONSTRAINT payslip_line_payslip_fk FOREIGN KEY (org_id, payslip_id) REFERENCES payslip (org_id, id), CONSTRAINT payslip_line_salary_fk FOREIGN KEY (org_id, salary_id) REFERENCES salary (org_id, id), CONSTRAINT payslip_line_type_fk FOREIGN KEY (org_id, pay_item_type_id) REFERENCES pay_item_type (org_id, id), CONSTRAINT payslip_line_item_fk FOREIGN KEY (org_id, employee_pay_item_id) REFERENCES employee_pay_item (org_id, id), CONSTRAINT payslip_line_input_fk FOREIGN KEY (org_id, run_input_id) REFERENCES payroll_run_input (org_id, id));ALTER TABLE payslip_line ENABLE ROW LEVEL SECURITY;ALTER TABLE payslip_line FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON payslip_line USING (org_id = current_setting('app.current_org', true)::uuid) WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);REVOKE UPDATE, DELETE ON payslip_line FROM erp_app;-- +goose StatementBeginCREATE FUNCTION payslip_line_guard() RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$DECLARE ok boolean;BEGIN SELECT r.status = 'draft' AND p.calc_no = r.calc_no INTO ok FROM public.payslip p JOIN public.payroll_run r ON r.org_id = p.org_id AND r.id = p.run_id WHERE p.org_id = NEW.org_id AND p.id = NEW.payslip_id; IF ok IS NOT TRUE THEN RAISE EXCEPTION 'payslip_frozen: payslip %: lines are written only while its run is a draft', NEW.payslip_id USING ERRCODE = '23514', CONSTRAINT = 'payslip_frozen'; END IF; RETURN NEW;END $$;-- +goose StatementEndCREATE TRIGGER payslip_line_guard BEFORE INSERT ON payslip_line FOR EACH ROW EXECUTE FUNCTION payslip_line_guard();
-- PD-21: a payslip's payment, written once by payroll.PayPayslip inside a payment batch's mark-- paid. The batch line (00330) is the payment; this row is the payslip's settlement record.CREATE TABLE payslip_payment ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID NOT NULL REFERENCES organization (id), payslip_id UUID NOT NULL, batch_line_id UUID NOT NULL, batch_reference TEXT NOT NULL CONSTRAINT payslip_payment_batch_reference_token CHECK (batch_reference ~ '^\S{1,40}$'), credit_account_id UUID NOT NULL, amount NUMERIC(19,4) NOT NULL CONSTRAINT payslip_payment_amount_positive CHECK (amount > 0), paid_by UUID NOT NULL, paid_at TIMESTAMPTZ NOT NULL, CONSTRAINT payslip_payment_tenant_id UNIQUE (org_id, id), CONSTRAINT payslip_payment_once UNIQUE (org_id, payslip_id), CONSTRAINT payslip_payment_payslip_fk FOREIGN KEY (org_id, payslip_id) REFERENCES payslip (org_id, id), CONSTRAINT payslip_payment_line_fk FOREIGN KEY (org_id, batch_line_id) REFERENCES payment_batch_line (org_id, id), CONSTRAINT payslip_payment_account_fk FOREIGN KEY (org_id, credit_account_id) REFERENCES account (org_id, id), CONSTRAINT payslip_payment_paid_by_fk FOREIGN KEY (org_id, paid_by) REFERENCES organization_member (org_id, id));ALTER TABLE payslip_payment ENABLE ROW LEVEL SECURITY;ALTER TABLE payslip_payment FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON payslip_payment USING (org_id = current_setting('app.current_org', true)::uuid) WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);REVOKE UPDATE, DELETE ON payslip_payment FROM erp_app;-- +goose StatementBeginCREATE FUNCTION payslip_payment_guard() RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$DECLARE net numeric; s text;BEGIN SELECT p.net, r.status INTO net, s FROM public.payslip p JOIN public.payroll_run r ON r.org_id = p.org_id AND r.id = p.run_id WHERE p.org_id = NEW.org_id AND p.id = NEW.payslip_id; IF s IS DISTINCT FROM 'approved' OR net IS DISTINCT FROM NEW.amount THEN RAISE EXCEPTION 'payslip %: a payment is its net pay, of an approved run', NEW.payslip_id USING ERRCODE = '23514', CONSTRAINT = 'payslip_payment_matches'; END IF; RETURN NEW;END $$;-- +goose StatementEndCREATE TRIGGER payslip_payment_guard BEFORE INSERT ON payslip_payment FOR EACH ROW EXECUTE FUNCTION payslip_payment_guard();
-- +goose Down-- Drops every payroll record; roll the code back, not this migration, once payroll is used.DROP TABLE payslip_payment;DROP FUNCTION payslip_payment_guard();DROP TABLE payslip_line;DROP FUNCTION payslip_line_guard();DROP TABLE payslip;DROP FUNCTION payslip_guard();DROP TABLE payroll_run_input;DROP FUNCTION payroll_run_input_guard();DROP TABLE payroll_run;DROP FUNCTION payroll_run_fixed();DROP TABLE employee_pay_item;DROP FUNCTION employee_pay_item_guard();DROP TABLE pay_item_type;DROP FUNCTION pay_item_type_fixed();DROP TABLE salary;DROP TABLE payroll_setting;- Step 4: Run the schema tests
Run: go test ./internal/platform/payroll/ -count=1
Expected: PASS (3 tests).
- Step 5: Watch the retention guard fail, then classify the tables
Run: go test ./internal/kernel/db/ -run TestOnlyOperationalTablesGrantDeleteToTheAppRole -count=1
Expected: FAIL naming each new table as unlisted (e.g. table "salary" is in no list). Read the message.
In internal/kernel/db/retention_test.go, append to appendOnly (after "depreciation_line",):
// Phase 5 payroll P1 (00900): a salary change is a new row (PD-9); a payslip's lines are // the frozen calculation (PD-14, PD-16); a payment is written once (PD-21). "salary", "payslip_line", "payslip_payment",and to amendable (after "purchase_match_tolerance", ...):
// Phase 5 payroll P1 (00900): the payroll setting; pay item types (renamed, retired); // employee items (ended once); runs (status, approval); run inputs (removed once); payslips // (their payment instance written once). Payslips are financial records: never erased. "payroll_setting", "pay_item_type", "employee_pay_item", "payroll_run", "payroll_run_input", "payslip",Run: go test ./internal/kernel/db/ -run 'TestOnlyOperationalTablesGrantDeleteToTheAppRole|TestEveryTableIsTenantIsolated|TestEveryTenantForeignKeyIsCompositeOnOrgID' -count=1
Expected: PASS.
- Step 6: Commit
git add db/migrations/00900_payroll.sql internal/platform/payroll/doc.go internal/platform/payroll/schema_test.go internal/kernel/db/retention_test.gogit commit -m "Payroll P1: the payroll schema with RLS, retention and freeze triggers (00900)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"Task 2: The payroll_payable control kind (00901) and the payroll accounts step
Section titled “Task 2: The payroll_payable control kind (00901) and the payroll accounts step”Files:
- Modify:
constants.yaml(controlKinds), thenmake generate(internal/kernel/vocab/vocab.gen.go,packages/contracts/vocab.ts) - Create:
db/migrations/00901_payroll_payable_control_kind.sql - Modify:
internal/platform/accounting/chart/account.go(IsSubledgerKind),internal/platform/accounting/chart/account_test.go - Modify:
api/openapi.yaml(WorkflowState.control_kindenum, line ~14150), thenmake generate - Modify:
web/src/features/accounting/controlKinds.ts(MANUAL_REFUSED),web/src/lib/i18n/locales/{en,ms,zh}/accounts.jsonandworkflowEditor.json(controlKinds) - Create:
internal/platform/payroll/step.go - Test:
internal/platform/payroll/step_test.go
Interfaces:
-
Consumes:
chart.ResolveControl,chart.GetAccountByCode,chart.CreateAccount,chart.AccountInput,chart.TypeLiability,chart.TypeExpense,provisioning.Register,provisioning.Step,ledger.Post,ledger.SemanticManual,*ledger.ControlAccountManualError. -
Produces:
const payroll.ControlKindPayrollPayable = "payroll_payable";const payroll.StepKeyAccounts = "payroll_accounts",payroll.StepOrderAccounts = 84; accounts2180(controlpayroll_payable),2190,6210; a seededpayroll_settingrow. -
Step 1: Write the failing test
Create internal/platform/payroll/step_test.go:
package payroll_test
import ( "context" "testing"
"github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/accounting/chart" "github.com/mcom/erp/internal/platform/accounting/ledger" "github.com/mcom/erp/internal/platform/hr/hrtest" "github.com/mcom/erp/internal/platform/payroll")
func TestEveryOrganizationGetsThePayrollAccounts(t *testing.T) { e := hrtest.New(t) e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { payable, err := chart.ResolveControl(ctx, tx, payroll.ControlKindPayrollPayable) require.NoError(t, err) require.Equal(t, "2180", payable.Code) for _, code := range []string{"2190", "6210"} { _, err := chart.GetAccountByCode(ctx, tx, code) require.NoError(t, err, code) } return nil }) require.Equal(t, 1, e.Count(t, `SELECT count(*) FROM payroll_setting s JOIN account x ON x.org_id = s.org_id AND x.id = s.salary_expense_account_id AND x.code = '6210' JOIN account y ON y.org_id = s.org_id AND y.id = s.statutory_payable_account_id AND y.code = '2190' WHERE s.org_id = $1 AND s.payout_channel_id IS NULL AND s.unpaid_leave_divisor IS NULL`, e.Org))}
func TestAManualLineCannotMoveThePayrollPayable(t *testing.T) { e := hrtest.New(t) post := func(debit string) error { return e.TxErr(func(ctx context.Context, tx pgx.Tx) error { dr, err := chart.GetAccountByCode(ctx, tx, debit) require.NoError(t, err) cr, err := chart.GetAccountByCode(ctx, tx, "2190") require.NoError(t, err) _, err = ledger.Post(ctx, tx, e.Clock, ledger.EntryInput{JournalType: chart.JournalMisc, EntryDate: hrtest.Day(2026, 9, 21), Currency: "MYR", Description: "test", Semantic: ledger.SemanticManual, Lines: []ledger.Line{ {AccountID: dr.ID, Debit: money.MustParse("10.00")}, {AccountID: cr.ID, Credit: money.MustParse("10.00")}}}) return err }) } // PRESENCE: a manual line may move the plain accounts. require.NoError(t, post("6210")) // ABSENCE: not the payroll payable, a sub-ledger control. var refused *ledger.ControlAccountManualError require.ErrorAs(t, post("2180"), &refused)}- Step 2: Run it to verify it fails
Run: go test ./internal/platform/payroll/ -run 'TestEveryOrganizationGetsThePayrollAccounts|TestAManualLine' -count=1
Expected: FAIL to compile — undefined: payroll.ControlKindPayrollPayable.
- Step 3: Add the control kind
In constants.yaml, append to controlKinds: after - grni:
- payroll_payableRun: make generate
Expected: internal/kernel/vocab/vocab.gen.go and packages/contracts/vocab.ts gain payroll_payable.
Create db/migrations/00901_payroll_payable_control_kind.sql:
-- +goose Up-- Phase 5 payroll P1 (Ruling PD-18): net pay owed to employees is a control kind. A run's-- accrual credits it, each payslip's payout clears it; no manual line may move it-- (chart.IsSubledgerKind). The lists are 00721's, which is the latest for all three.ALTER TABLE account DROP CONSTRAINT account_control_kind_known, ADD CONSTRAINT account_control_kind_known CHECK (control_kind IN ('ap','employee_advances','petty_cash_ap','ar','bank','fx_gain_loss','ap_trade','sst_payable','wht_payable','grni','payroll_payable'));ALTER TABLE account DROP CONSTRAINT account_control_kind_type, ADD CONSTRAINT account_control_kind_type CHECK (control_kind IS NULL OR (control_kind IN ('ap','petty_cash_ap','ap_trade','sst_payable','wht_payable','grni','payroll_payable') AND type = 'liability') OR (control_kind IN ('employee_advances','ar','bank') AND type = 'asset') OR (control_kind = 'fx_gain_loss' AND type IN ('income','expense')));ALTER TABLE workflow_state DROP CONSTRAINT workflow_state_control_kind_known, ADD CONSTRAINT workflow_state_control_kind_known CHECK (control_kind IN ('ap','employee_advances','petty_cash_ap','ar','bank','fx_gain_loss','ap_trade','sst_payable','wht_payable','grni','payroll_payable'));
-- +goose Down-- Fails while an account or a workflow state carries payroll_payable: roll the code back first.ALTER TABLE workflow_state DROP CONSTRAINT workflow_state_control_kind_known, ADD CONSTRAINT workflow_state_control_kind_known CHECK (control_kind IN ('ap','employee_advances','petty_cash_ap','ar','bank','fx_gain_loss','ap_trade','sst_payable','wht_payable','grni'));ALTER TABLE account DROP CONSTRAINT account_control_kind_type, ADD CONSTRAINT account_control_kind_type CHECK (control_kind IS NULL OR (control_kind IN ('ap','petty_cash_ap','ap_trade','sst_payable','wht_payable','grni') AND type = 'liability') OR (control_kind IN ('employee_advances','ar','bank') AND type = 'asset') OR (control_kind = 'fx_gain_loss' AND type IN ('income','expense')));ALTER TABLE account DROP CONSTRAINT account_control_kind_known, ADD CONSTRAINT account_control_kind_known CHECK (control_kind IN ('ap','employee_advances','petty_cash_ap','ar','bank','fx_gain_loss','ap_trade','sst_payable','wht_payable','grni'));Before writing it, confirm 00721 is still the latest for each list: grep -l 'account_control_kind_known\|account_control_kind_type\|workflow_state_control_kind_known' db/migrations/*.sql | sort | tail -1 must print 00721_grni_and_stock_accounts.sql; if a later file appears, copy its lists instead and add payroll_payable.
In internal/platform/accounting/chart/account.go, IsSubledgerKind:
case "ap", "employee_advances", "petty_cash_ap", "ar", "ap_trade", "sst_payable", "wht_payable", "grni", "payroll_payable": return true- Step 4: Watch the two control-kind drift tests fail, then classify
Run: go test ./internal/platform/accounting/chart/ -run 'TestControlKindsMatchTheVocabulary|TestEveryControlKindSaysWhetherManualLinesMayTouchIt' -count=1
Expected: FAIL — every vocab.ControlKinds entry must have a type fixture here and classify every vocab.ControlKinds entry here.
In account_test.go: add "payroll_payable": chart.TypeLiability, to typeFor; add "payroll_payable": true, to refused. The account code in TestControlKindsMatchTheVocabulary is "CK" + string(rune('0'+i)), which becomes CK: for the eleventh kind; change it to fmt.Sprintf("CK%02d", i) (add "fmt" to the imports).
Run the same command. Expected: PASS.
- Step 5: The contract and the web mirror
In api/openapi.yaml, the WorkflowState control_kind enum (line ~14150) becomes:
enum: [ap, employee_advances, petty_cash_ap, ar, bank, fx_gain_loss, ap_trade, sst_payable, wht_payable, grni, payroll_payable]Run make generate. In web/src/features/accounting/controlKinds.ts add payroll_payable: true, to MANUAL_REFUSED (after grni: true,). Add to the controlKinds object of each locale’s accounts.json and workflowEditor.json:
- en:
"payroll_payable": "Salaries payable" - ms:
"payroll_payable": "Gaji belum bayar" - zh:
"payroll_payable": "应付工资"
Run: pnpm -C web exec tsc --noEmit && pnpm -C web test -- --run src/drift src/features/accounting
Expected: PASS.
- Step 6: Write the accounts step
Create internal/platform/payroll/step.go:
package payroll
import ( "context" "errors" "fmt"
"github.com/google/uuid" "github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/audit" "github.com/mcom/erp/internal/kernel/clock" "github.com/mcom/erp/internal/kernel/tenancy" "github.com/mcom/erp/internal/platform/accounting/chart" "github.com/mcom/erp/internal/platform/provisioning")
const ( // ControlKindPayrollPayable is net pay owed to employees (PD-18): the run's accrual state's // control, cleared by each payslip's payout. ControlKindPayrollPayable = "payroll_payable"
// StepKeyAccounts (order 84, after inventory's 81 and 83): a new organization gets the // payroll accounts on its first provisioning, an existing one on its next POST /v1/org/provision. StepKeyAccounts = "payroll_accounts" StepOrderAccounts = 84
codeSalaryExpense = "6210" codeDeductionsPayable = "2190")
func init() { provisioning.Register(accountsStep{}) }
type payrollAccount struct { code, parent, typ string controlKind *string names map[string]string}
func strp(s string) *string { return &s }
// payrollAccounts avoid 6200, which test fixtures create (assets, imports).var payrollAccounts = []payrollAccount{ {code: "2180", parent: "2100", typ: chart.TypeLiability, controlKind: strp(ControlKindPayrollPayable), names: map[string]string{"en": "Salaries payable", "ms": "Gaji belum bayar", "zh": "应付工资"}}, {code: codeDeductionsPayable, parent: "2100", typ: chart.TypeLiability, names: map[string]string{"en": "Statutory deductions payable", "ms": "Potongan berkanun belum bayar", "zh": "应付法定扣款"}}, {code: codeSalaryExpense, parent: "6000", typ: chart.TypeExpense, names: map[string]string{"en": "Salaries and wages", "ms": "Gaji dan upah", "zh": "工资及薪金"}},}
// present: the control kind resolves, or the code is taken (by this account or by something// else — chart_of_accounts' control_kind_unmapped warning is then what an administrator sees).func (a payrollAccount) present(ctx context.Context, tx pgx.Tx) (bool, error) { if a.controlKind != nil { if _, err := chart.ResolveControl(ctx, tx, *a.controlKind); err == nil { return true, nil } } _, err := chart.GetAccountByCode(ctx, tx, a.code) if errors.Is(err, chart.ErrNotFound) { return false, nil } return err == nil, err}
type accountsStep struct{}
func (accountsStep) Key() string { return StepKeyAccounts }func (accountsStep) Order() int { return StepOrderAccounts }
func (accountsStep) Check(ctx context.Context, tx pgx.Tx, _ clock.Clock) (bool, []provisioning.Warning, error) { org, ok := tenancy.OrgFromContext(ctx) if !ok { return false, nil, fmt.Errorf("payroll: accounts check: %w", tenancy.ErrNoOrg) } for _, a := range payrollAccounts { ok, err := a.present(ctx, tx) if err != nil || !ok { return false, nil, err } } var n int if err := tx.QueryRow(ctx, `SELECT count(*) FROM payroll_setting WHERE org_id = $1`, org).Scan(&n); err != nil { return false, nil, fmt.Errorf("payroll: accounts check: %w", err) } return n == 1, nil, nil}
func (accountsStep) Run(ctx context.Context, tx pgx.Tx, c clock.Clock, _ provisioning.Input) error { org, ok := tenancy.OrgFromContext(ctx) if !ok { return fmt.Errorf("payroll: accounts: %w", tenancy.ErrNoOrg) } for _, s := range payrollAccounts { ok, err := s.present(ctx, tx) if err != nil { return fmt.Errorf("payroll: accounts: %w", err) } if ok { continue } in := chart.AccountInput{Code: s.code, Name: s.names["en"], Type: s.typ, ControlKind: s.controlKind} if parent, err := chart.GetAccountByCode(ctx, tx, s.parent); err == nil && !parent.IsPostable && parent.Type == s.typ { in.ParentID = &parent.ID } a, err := chart.CreateAccount(ctx, tx, c, in) if err != nil { return fmt.Errorf("payroll: create account %s: %w", s.code, err) } for locale, name := range s.names { if _, err := tx.Exec(ctx, ` INSERT INTO translation (org_id, entity_kind, entity_id, field, locale, value) VALUES ($1, 'account', $2, 'name', $3, $4) ON CONFLICT (org_id, entity_kind, entity_id, field, locale) WHERE org_id IS NOT NULL DO NOTHING`, org, a.ID, locale, name); err != nil { return fmt.Errorf("payroll: account %s translation: %w", s.code, err) } } if err := audit.Write(ctx, tx, c, audit.Entry{EntityKind: "account", EntityID: a.ID, Action: "translate", After: s.names}); err != nil { return err } } // The setting names the two plain accounts when they are what the codes hold (a code taken // by something else leaves the field for an administrator). expense, payable := accountIfType(ctx, tx, codeSalaryExpense, chart.TypeExpense), accountIfType(ctx, tx, codeDeductionsPayable, chart.TypeLiability) tag, err := tx.Exec(ctx, `INSERT INTO payroll_setting (org_id, salary_expense_account_id, statutory_payable_account_id, updated_at) VALUES ($1, $2, $3, $4) ON CONFLICT (org_id) DO NOTHING`, org, expense, payable, c.Now()) if err != nil { return fmt.Errorf("payroll: seed setting: %w", err) } if tag.RowsAffected() == 0 { return nil } return audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntitySetting, EntityID: org, Action: "provision", After: map[string]any{"salary_expense_account_id": expense, "statutory_payable_account_id": payable}})}
func accountIfType(ctx context.Context, tx pgx.Tx, code, typ string) *uuid.UUID { a, err := chart.GetAccountByCode(ctx, tx, code) if err != nil || a.Type != typ || !a.IsPostable || a.ControlKind != nil { return nil } return &a.ID}EntitySetting is declared in Task 3’s payroll.go; until then add const EntitySetting = "payroll_setting" at the bottom of step.go and move it in Task 3.
- Step 7: Run the step tests
Run: go test ./internal/platform/payroll/ -count=1
Expected: PASS.
- Step 8: Commit
git add constants.yaml internal/kernel/vocab packages/contracts/vocab.ts db/migrations/00901_payroll_payable_control_kind.sql \ internal/platform/accounting/chart api/openapi.yaml internal/gen web/src/lib/api/schema.d.ts \ web/src/features/accounting/controlKinds.ts web/src/lib/i18n/locales internal/platform/payroll/step.go internal/platform/payroll/step_test.gogit commit -m "Payroll P1: payroll_payable control kind and the payroll accounts step (00901)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"Task 3: Settings, salaries, pay item types and employee pay items
Section titled “Task 3: Settings, salaries, pay item types and employee pay items”Files:
- Create:
internal/platform/payroll/payroll.go,setting.go,salary.go,items.go - Create:
internal/platform/payroll/payrolltest/env.go - Modify:
internal/platform/payroll/step.go(moveEntitySettingtopayroll.go) - Test:
internal/platform/payroll/setting_test.go,salary_test.go,items_test.go
Interfaces:
-
Consumes: Task 1’s tables; Task 2’s step (seeded setting);
hr.Get(an employee exists),payout.GetDefinition,payout.ErrChannelNotFound,payout.BehaviourExternal,chart.GetAccount,chart.ErrNotFound,recordtext.Check(field, s string) error,audit.Write,payablestest.Env(VerifiedPayee,Account,Channel). -
Produces: everything in “Interfaces this plan produces” under
// Task 3, the package’s constants, errors,Names, and:type payrolltest.Env struct { *hrtest.Env; Pay *payablestest.Env },func payrolltest.New(t *testing.T) *Env(the setting’s payout channel set to the seededbank_transfer)func (e *Env) Employee(t *testing.T, member uuid.UUID, no string, salary string) hr.Employee— hired 2026-01-01, salary from 2026-01-01func (e *Env) ItemType(t *testing.T, code, kind, recurrence string) payroll.ItemType- package-internal helpers
orgOf,dateOnly,checkText,checkNames,writeNames,loadNames,isConstraint,checkAmount,monthEnd.
-
Step 1: Write the fixture
Create internal/platform/payroll/payrolltest/env.go:
// Package payrolltest is payroll P1's test world: hrtest's provisioned organization (Owner,// Staff, HR) with the payroll accounts and a payroll setting paying through the seeded// bank_transfer channel (crediting 1520), plus payablestest's batch helpers over the same// organization.package payrolltest
import ( "context" "testing"
"github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/hr" "github.com/mcom/erp/internal/platform/hr/hrtest" "github.com/mcom/erp/internal/platform/payables/payablestest" "github.com/mcom/erp/internal/platform/payroll")
type Env struct { *hrtest.Env Pay *payablestest.Env}
func New(t *testing.T) *Env { t.Helper() h := hrtest.New(t) e := &Env{Env: h, Pay: &payablestest.Env{Env: h.Env.Env}} ch := e.Channel(t, "bank_transfer") e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { s, err := payroll.GetSetting(ctx, tx) if err != nil { return err } s.PayoutChannelID = &ch _, err = payroll.PutSetting(ctx, tx, e.Clock, s, s.Revision, e.Owner) return err }) return e}
// Employee hires member (2026-01-01, by HR) and gives them salary from 2026-01-01.func (e *Env) Employee(t *testing.T, member uuid.UUID, no, salary string) hr.Employee { t.Helper() emp := e.Hired(t, member, no) e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { _, err := payroll.AddSalary(ctx, tx, e.Clock, payroll.SalaryInput{EmployeeID: emp.ID, Amount: money.MustParse(salary), EffectiveFrom: hrtest.Day(2026, 1, 1), Actor: e.HR}) return err }) return emp}
// ItemType creates a pay item type named after its code, by HR.func (e *Env) ItemType(t *testing.T, code, kind, recurrence string) payroll.ItemType { t.Helper() var out payroll.ItemType e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { var err error out, err = payroll.CreateItemType(ctx, tx, e.Clock, payroll.ItemTypeInput{Code: code, Names: payroll.Names{"en": code + " pay", "ms": "Bayaran " + code}, Kind: kind, Recurrence: recurrence, Actor: e.HR}) return err }) require.NotEqual(t, uuid.Nil, out.ID) return out}- Step 2: Write the failing tests
Create internal/platform/payroll/setting_test.go:
package payroll_test
import ( "context" "testing"
"github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/accounting/chart" "github.com/mcom/erp/internal/platform/payroll" "github.com/mcom/erp/internal/platform/payroll/payrolltest")
func TestTheSettingIsValidatedAndRevisioned(t *testing.T) { e := payrolltest.New(t) var s payroll.Setting e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { var err error s, err = payroll.GetSetting(ctx, tx) return err }) require.True(t, s.Configured(), "fixture: the world's setting names a channel and both accounts")
put := func(s payroll.Setting, rev int) (payroll.Setting, error) { var out payroll.Setting err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error { var err error out, err = payroll.PutSetting(ctx, tx, e.Clock, s, rev, e.Owner) return err }) return out, err } d := 26 s.UnpaidLeaveDivisor = &d next, err := put(s, s.Revision) require.NoError(t, err) require.Equal(t, 26, *next.UnpaidLeaveDivisor) require.Equal(t, s.Revision+1, next.Revision)
_, err = put(s, s.Revision) // stale var stale *payroll.RevisionMismatchError require.ErrorAs(t, err, &stale)
bad := 32 next.UnpaidLeaveDivisor = &bad _, err = put(next, next.Revision) var fe *payroll.FieldError require.ErrorAs(t, err, &fe) require.Equal(t, "unpaid_leave_divisor", fe.Field)
// The payroll payable (a control) is not a deductions account. next.UnpaidLeaveDivisor = nil e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { a, err := chart.GetAccountByCode(ctx, tx, "2180") next.StatutoryPayableAccountID = &a.ID return err }) _, err = put(next, next.Revision) require.ErrorAs(t, err, &fe) require.Equal(t, "statutory_payable_account_id", fe.Field)}Create internal/platform/payroll/salary_test.go:
package payroll_test
import ( "context" "testing"
"github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/hr/hrtest" "github.com/mcom/erp/internal/platform/payroll" "github.com/mcom/erp/internal/platform/payroll/payrolltest")
func TestASalaryChangeIsANewRowAndAReadIsAudited(t *testing.T) { e := payrolltest.New(t) emp := e.Employee(t, e.Staff, "E-100", "3000.00") e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { _, err := payroll.AddSalary(ctx, tx, e.Clock, payroll.SalaryInput{EmployeeID: emp.ID, Amount: money.MustParse("3100.00"), EffectiveFrom: hrtest.Day(2026, 8, 16), Actor: e.HR}) return err }) before := e.Count(t, `SELECT count(*) FROM audit_log WHERE org_id = $1 AND entity_kind = 'salary' AND action = 'read'`, e.Org) var rows []payroll.Salary e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { var err error rows, err = payroll.SalaryHistory(ctx, tx, e.Clock, emp.ID, e.Owner) return err }) require.Len(t, rows, 2) require.Equal(t, "3100.0000", rows[0].Amount.String(), "newest first") require.Equal(t, before+1, e.Count(t, `SELECT count(*) FROM audit_log WHERE org_id = $1 AND entity_kind = 'salary' AND action = 'read'`, e.Org)) require.Zero(t, e.Count(t, `SELECT count(*) FROM audit_log WHERE org_id = $1 AND entity_kind = 'salary' AND action = 'read' AND (SELECT array_agg(k ORDER BY k) FROM jsonb_object_keys(after) k) <> ARRAY['employee_id', 'rows']`, e.Org), "a read audit carries ids and a count, never an amount")
err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := payroll.AddSalary(ctx, tx, e.Clock, payroll.SalaryInput{EmployeeID: emp.ID, Amount: money.MustParse("3100.005"), EffectiveFrom: hrtest.Day(2026, 9, 1), Actor: e.HR}) return err }) var fe *payroll.FieldError require.ErrorAs(t, err, &fe) require.Equal(t, "sen", fe.Rule)}Create internal/platform/payroll/items_test.go:
package payroll_test
import ( "context" "testing"
"github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/hr/hrtest" "github.com/mcom/erp/internal/platform/payroll" "github.com/mcom/erp/internal/platform/payroll/payrolltest")
func TestItemTypesAndAnEmployeesRecurringItems(t *testing.T) { e := payrolltest.New(t) emp := e.Employee(t, e.Staff, "E-100", "3000.00") trans := e.ItemType(t, "TRANS", payroll.KindEarning, payroll.RecurRecurring) bonus := e.ItemType(t, "BONUS", payroll.KindEarning, payroll.RecurOneOff) require.Equal(t, payroll.TagSalaryExpense, trans.PostingTag) loan := e.ItemType(t, "LOAN", payroll.KindDeduction, payroll.RecurRecurring) require.Equal(t, payroll.TagStatutoryPayable, loan.PostingTag)
err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := payroll.CreateItemType(ctx, tx, e.Clock, payroll.ItemTypeInput{Code: "TRANS", Names: payroll.Names{"en": "Again", "ms": "Lagi"}, Kind: payroll.KindEarning, Recurrence: payroll.RecurRecurring, Actor: e.HR}) return err }) require.ErrorIs(t, err, payroll.ErrDuplicateItemType)
add := func(typeID string) (payroll.EmployeeItem, error) { var out payroll.EmployeeItem err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error { var err error out, err = payroll.AddEmployeeItem(ctx, tx, e.Clock, payroll.EmployeeItemInput{EmployeeID: emp.ID, TypeID: uuidOf(t, typeID), Amount: money.MustParse("200.00"), EffectiveFrom: hrtest.Day(2026, 1, 1), Actor: e.HR}) return err }) return out, err } item, err := add(trans.ID.String()) require.NoError(t, err) _, err = add(bonus.ID.String()) var fe *payroll.FieldError require.ErrorAs(t, err, &fe) require.Equal(t, "type_id", fe.Field) require.Equal(t, "recurring", fe.Rule)
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { ended, err := payroll.EndEmployeeItem(ctx, tx, e.Clock, item.ID, hrtest.Day(2026, 6, 30), e.HR) require.NoError(t, err) require.Equal(t, hrtest.Day(2026, 6, 30), *ended.EffectiveTo) return nil }) err = e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := payroll.EndEmployeeItem(ctx, tx, e.Clock, item.ID, hrtest.Day(2026, 5, 31), e.HR) return err }) require.ErrorAs(t, err, &fe) require.Equal(t, "ended", fe.Rule)}Add to items_test.go:
func uuidOf(t *testing.T, s string) uuid.UUID { t.Helper(); id, err := uuid.Parse(s); require.NoError(t, err); return id }(and "github.com/google/uuid" to its imports).
- Step 3: Run them to verify they fail
Run: go test ./internal/platform/payroll/ -count=1
Expected: FAIL to compile — undefined: payroll.GetSetting and the rest.
- Step 4: Write
payroll.go
Create internal/platform/payroll/payroll.go (and delete the temporary EntitySetting from step.go):
package payroll
import ( "context" "errors" "fmt" "slices" "strings" "time" "unicode/utf8"
"github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn"
"github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/kernel/recordtext" "github.com/mcom/erp/internal/kernel/tenancy")
const ( RunKind = "payroll_run" // the run's workflow kind and document kind PayslipKind = "payslip" // the payslip's payout workflow kind and batch source kind
// Run statuses (PD-15): stored from the entered semantic; RunPaid is derived. RunDraft = "draft" RunSubmitted = "submitted" RunApproved = "approved" RunPaid = "paid" RunRejected = "rejected" RunVoided = "voided"
KindEarning = "earning" KindDeduction = "deduction" RecurRecurring = "recurring" RecurOneOff = "one_off"
// Posting tags (spec "Accounting and payment"): what accounting reacts to. TagSalaryExpense = "salary_expense" TagStatutoryPayable = "statutory_payable" TagNetPayPayable = "net_pay_payable" // the payroll_payable control
LineBasic = "basic" LineItem = "item" LineUnpaidLeave = "unpaid_leave" LineStatutory = "statutory" LineAdjustment = "adjustment"
InputItem = "item" InputStatutory = "statutory" InputAdjustment = "adjustment"
StatEPF = "epf" StatSOCSO = "socso" StatEIS = "eis" StatPCB = "pcb"
WarnNoMember = "no_member" WarnNoSalary = "no_salary" WarnOutsideBand = "outside_band" WarnNoPayoutDetails = "no_payout_details" WarnBankUnverified = "bank_unverified"
// Audit entity kinds. EntitySetting = "payroll_setting" EntitySalary = "salary" EntityItemType = "pay_item_type" EntityEmployeeItem = "employee_pay_item" EntityRun = "payroll_run" EntityRunInput = "payroll_run_input" EntityPayslip = "payslip"
// The module registers these (internal/modules/payroll, PD-23); they live here because // payroll's own read rules name them. PermissionSalaryRead = "payroll.salary.read" PermissionSalaryWrite = "payroll.salary.write" PermissionItemManage = "payroll.item.manage" PermissionRunRead = "payroll.run.read" PermissionRunPrepare = "payroll.run.prepare" PermissionRunApprove = "payroll.run.approve")
// StatutoryCodes is the statutory line vocabulary, in display order.var StatutoryCodes = []string{StatEPF, StatSOCSO, StatEIS, StatPCB}
var ( ErrNotFound = errors.New("payroll: no such record in this organization") ErrNotConfigured = errors.New("payroll: choose the payout channel, the salary expense account and the deductions payable account in the payroll settings first") ErrRunExists = errors.New("payroll: this month already has a payroll run that is not voided") ErrMonthFuture = errors.New("payroll: a run is for the current month or an earlier one") ErrRunNotDraft = errors.New("payroll: the run is not a draft") ErrEmptyRun = errors.New("payroll: the run pays nobody") ErrRunInPayment = errors.New("payroll: a payslip of this run is paid or in a payment batch; remove it from a draft batch first") ErrRunNotApproved = errors.New("payroll: the payslip's run is not approved") ErrPayslipPaid = errors.New("payroll: the payslip is already paid") ErrNotInBatch = errors.New("payroll: the payslip is not on a live line of that approved batch") ErrNoSalary = errors.New("payroll: no salary is in force on an employed day") ErrDuplicateItemType = errors.New("payroll: another pay item type has this code"))
// FieldError: the input is wrong (422).type FieldError struct{ Field, Rule, Detail string }
func (e *FieldError) Error() string { return fmt.Sprintf("field=%s rule=%s: %s", e.Field, e.Rule, e.Detail) }
// RevisionMismatchError: the record moved since the caller read it (409).type RevisionMismatchError struct{ Current int }
func (e *RevisionMismatchError) Error() string { return fmt.Sprintf("payroll: the record is at revision %d", e.Current)}
// NegativeNetError: submission found payslips whose deductions exceed their gross (PD-16).type NegativeNetError struct{ References []string }
func (e *NegativeNetError) Error() string { return "payroll: deductions exceed gross pay on " + strings.Join(e.References, ", ")}
// Names is a pay item type's name per locale: en and ms required, zh optional.type Names map[string]string
var nameLocales = []string{"en", "ms", "zh"}
func orgOf(ctx context.Context) (uuid.UUID, error) { org, ok := tenancy.OrgFromContext(ctx) if !ok { return uuid.Nil, fmt.Errorf("payroll: %w", tenancy.ErrNoOrg) } return org, nil}
// dateOnly is t's calendar day as every DATE column stores it: midnight UTC.func dateOnly(t time.Time) time.Time { y, m, d := t.UTC().Date() return time.Date(y, m, d, 0, 0, 0, 0, time.UTC)}
// monthEnd is the last day of month (any day of it).func monthEnd(month time.Time) time.Time { y, m, _ := month.UTC().Date() return time.Date(y, m+1, 0, 0, 0, 0, 0, time.UTC)}
// checkText trims s, refuses it empty when required, longer than max runes, or outside the// record languages (spec 11A.3). nil in, nil out when not required.func checkText(field string, s *string, required bool, max int) (*string, error) { if s == nil || strings.TrimSpace(*s) == "" { if required { return nil, &FieldError{Field: field, Rule: "required", Detail: "this is required"} } return nil, nil } v := strings.TrimSpace(*s) if utf8.RuneCountInString(v) > max { return nil, &FieldError{Field: field, Rule: "length", Detail: fmt.Sprintf("at most %d characters", max)} } if err := recordtext.Check(field, v); err != nil { return nil, &FieldError{Field: field, Rule: "record_language", Detail: "English or Bahasa Malaysia"} } return &v, nil}
// checkAmount is a whole-sen amount; positive when asked.func checkAmount(field string, a money.Amount, positive bool) error { if a.Cmp(a.Cents()) != 0 { return &FieldError{Field: field, Rule: "sen", Detail: "an amount is in whole sen"} } if positive && !a.IsPositive() { return &FieldError{Field: field, Rule: "positive", Detail: "the amount is more than zero"} } return nil}
func checkNames(n Names) (Names, error) { out := Names{} for k, v := range n { if !slices.Contains(nameLocales, k) { return nil, &FieldError{Field: "names", Rule: "locale", Detail: "names are en, ms and zh"} } v = strings.TrimSpace(v) if v == "" { continue } if utf8.RuneCountInString(v) > 200 { return nil, &FieldError{Field: "names", Rule: "shape", Detail: "each name is 1 to 200 characters"} } out[k] = v } if out["en"] == "" || out["ms"] == "" { return nil, &FieldError{Field: "names", Rule: "required", Detail: "an English and a Bahasa Malaysia name are required"} } return out, nil}
// writeNames makes the translation rows of (kind, id) exactly n (hr's helper, copied: hr's is// package-internal).func writeNames(ctx context.Context, tx pgx.Tx, org uuid.UUID, kind string, id uuid.UUID, n Names) error { for _, loc := range nameLocales { v := n[loc] if v == "" { if _, err := tx.Exec(ctx, `DELETE FROM translation WHERE org_id = $1 AND entity_kind = $2 AND entity_id = $3 AND field = 'name' AND locale = $4`, org, kind, id, loc); err != nil { return fmt.Errorf("payroll: names: %w", err) } continue } if _, err := tx.Exec(ctx, `INSERT INTO translation (org_id, entity_kind, entity_id, field, locale, value) VALUES ($1, $2, $3, 'name', $4, $5) ON CONFLICT (org_id, entity_kind, entity_id, field, locale) WHERE org_id IS NOT NULL DO UPDATE SET value = EXCLUDED.value`, org, kind, id, loc, v); err != nil { return fmt.Errorf("payroll: names: %w", err) } } return nil}
// loadNames is every locale's name of each id.func loadNames(ctx context.Context, tx pgx.Tx, org uuid.UUID, kind string, ids []uuid.UUID) (map[uuid.UUID]Names, error) { out := make(map[uuid.UUID]Names, len(ids)) for _, id := range ids { out[id] = Names{} } rows, err := tx.Query(ctx, `SELECT entity_id, locale, value FROM translation WHERE org_id = $1 AND entity_kind = $2 AND entity_id = ANY($3) AND field = 'name'`, org, kind, ids) if err != nil { return nil, fmt.Errorf("payroll: load names: %w", err) } defer rows.Close() for rows.Next() { var id uuid.UUID var loc, v string if err := rows.Scan(&id, &loc, &v); err != nil { return nil, fmt.Errorf("payroll: load names: %w", err) } out[id][loc] = v } return out, rows.Err()}
func isConstraint(err error, name string) bool { var pe *pgconn.PgError return errors.As(err, &pe) && pe.ConstraintName == name}- Step 5: Write
setting.go
package payroll
import ( "context" "errors" "fmt"
"github.com/google/uuid" "github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/audit" "github.com/mcom/erp/internal/kernel/clock" "github.com/mcom/erp/internal/platform/accounting/chart" "github.com/mcom/erp/internal/platform/payout")
// Setting is the organization's payroll setting (PD-11, PD-17, PD-18, PD-20). Revision 0: no// row yet.type Setting struct { UnpaidLeaveDivisor *int // nil: the month's calendar days PayoutChannelID *uuid.UUID SalaryExpenseAccountID *uuid.UUID StatutoryPayableAccountID *uuid.UUID Revision int}
// Configured: a run can be created (ErrNotConfigured otherwise).func (s Setting) Configured() bool { return s.PayoutChannelID != nil && s.SalaryExpenseAccountID != nil && s.StatutoryPayableAccountID != nil}
const settingSelect = `SELECT unpaid_leave_divisor, payout_channel_id, salary_expense_account_id, statutory_payable_account_id, revision FROM payroll_setting WHERE org_id = $1`
func readSetting(ctx context.Context, tx pgx.Tx, suffix string) (Setting, error) { org, err := orgOf(ctx) if err != nil { return Setting{}, err } var s Setting err = tx.QueryRow(ctx, settingSelect+suffix, org).Scan(&s.UnpaidLeaveDivisor, &s.PayoutChannelID, &s.SalaryExpenseAccountID, &s.StatutoryPayableAccountID, &s.Revision) if errors.Is(err, pgx.ErrNoRows) { return Setting{}, nil } if err != nil { return Setting{}, fmt.Errorf("payroll: setting: %w", err) } return s, nil}
func GetSetting(ctx context.Context, tx pgx.Tx) (Setting, error) { return readSetting(ctx, tx, "") }
// PutSetting replaces the setting at expectedRevision (0: there is none yet), validated, audited.func PutSetting(ctx context.Context, tx pgx.Tx, c clock.Clock, s Setting, expectedRevision int, actor uuid.UUID) (Setting, error) { org, err := orgOf(ctx) if err != nil { return Setting{}, err } before, err := readSetting(ctx, tx, " FOR UPDATE") if err != nil { return Setting{}, err } if before.Revision != expectedRevision { return Setting{}, &RevisionMismatchError{Current: before.Revision} } if err := checkSetting(ctx, tx, s); err != nil { return Setting{}, err } if before.Revision == 0 { _, err = tx.Exec(ctx, `INSERT INTO payroll_setting (org_id, unpaid_leave_divisor, payout_channel_id, salary_expense_account_id, statutory_payable_account_id, updated_by, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7)`, org, s.UnpaidLeaveDivisor, s.PayoutChannelID, s.SalaryExpenseAccountID, s.StatutoryPayableAccountID, actor, c.Now()) if isConstraint(err, "payroll_setting_pkey") { return Setting{}, &RevisionMismatchError{Current: 1} } } else { _, err = tx.Exec(ctx, `UPDATE payroll_setting SET unpaid_leave_divisor = $2, payout_channel_id = $3, salary_expense_account_id = $4, statutory_payable_account_id = $5, updated_by = $6, updated_at = $7, revision = revision + 1 WHERE org_id = $1`, org, s.UnpaidLeaveDivisor, s.PayoutChannelID, s.SalaryExpenseAccountID, s.StatutoryPayableAccountID, actor, c.Now()) } if err != nil { return Setting{}, fmt.Errorf("payroll: put setting: %w", err) } after, err := GetSetting(ctx, tx) if err != nil { return Setting{}, err } return after, audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntitySetting, EntityID: org, Action: "update", Before: before, After: after})}
func checkSetting(ctx context.Context, tx pgx.Tx, s Setting) error { if d := s.UnpaidLeaveDivisor; d != nil && (*d < 1 || *d > 31) { return &FieldError{Field: "unpaid_leave_divisor", Rule: "range", Detail: "a divisor is 1 to 31 days; empty means the month's calendar days"} } if s.PayoutChannelID != nil { d, err := payout.GetDefinition(ctx, tx, *s.PayoutChannelID) if errors.Is(err, payout.ErrChannelNotFound) { return &FieldError{Field: "payout_channel_id", Rule: "unknown", Detail: "no such payout channel"} } if err != nil { return err } if !d.IsActive || d.SettlementBehaviour != payout.BehaviourExternal { return &FieldError{Field: "payout_channel_id", Rule: "channel_not_bank", Detail: "salaries are paid through an active bank transfer channel"} } } check := func(field string, id *uuid.UUID, typ string) error { if id == nil { return nil } a, err := chart.GetAccount(ctx, tx, *id) if errors.Is(err, chart.ErrNotFound) { return &FieldError{Field: field, Rule: "unknown", Detail: "no such account"} } if err != nil { return err } if !a.IsActive || !a.IsPostable || a.Type != typ || a.ControlKind != nil || a.OwnerModule != nil { return &FieldError{Field: field, Rule: "account_type", Detail: "an active postable " + typ + " account that no control kind or module owns"} } return nil } if err := check("salary_expense_account_id", s.SalaryExpenseAccountID, chart.TypeExpense); err != nil { return err } return check("statutory_payable_account_id", s.StatutoryPayableAccountID, chart.TypeLiability)}- Step 6: Write
salary.go
package payroll
import ( "context" "fmt" "time"
"github.com/google/uuid" "github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/audit" "github.com/mcom/erp/internal/kernel/clock" "github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/hr")
// Salary is one row of an employee's salary history (PD-9).type Salary struct { ID, EmployeeID uuid.UUID Amount money.Amount EffectiveFrom time.Time Reason *string CreatedBy uuid.UUID CreatedAt time.Time}
type SalaryInput struct { EmployeeID uuid.UUID Amount money.Amount EffectiveFrom time.Time Reason *string Actor uuid.UUID}
// AddSalary appends a salary row. The base currency is the organization's (as H2's bands).func AddSalary(ctx context.Context, tx pgx.Tx, c clock.Clock, in SalaryInput) (Salary, error) { org, err := orgOf(ctx) if err != nil { return Salary{}, err } if _, err := hr.Get(ctx, tx, in.EmployeeID); err != nil { return Salary{}, err } if err := checkAmount("amount", in.Amount, true); err != nil { return Salary{}, err } if in.EffectiveFrom.IsZero() { return Salary{}, &FieldError{Field: "effective_from", Rule: "required", Detail: "the day the salary takes effect"} } reason, err := checkText("reason", in.Reason, false, 500) if err != nil { return Salary{}, err } s := Salary{EmployeeID: in.EmployeeID, Amount: in.Amount, EffectiveFrom: dateOnly(in.EffectiveFrom), Reason: reason, CreatedBy: in.Actor, CreatedAt: c.Now()} if err := tx.QueryRow(ctx, `INSERT INTO salary (org_id, employee_id, amount, effective_from, reason, created_by, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`, org, s.EmployeeID, s.Amount.String(), s.EffectiveFrom, s.Reason, s.CreatedBy, s.CreatedAt).Scan(&s.ID); err != nil { return Salary{}, fmt.Errorf("payroll: add salary: %w", err) } return s, audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntitySalary, EntityID: s.ID, Action: "create", After: s})}
// salaries is each employee's rows in effective order (ties: created order), unaudited: the// calculation's own read.func salaries(ctx context.Context, tx pgx.Tx, employees []uuid.UUID) (map[uuid.UUID][]Salary, error) { org, err := orgOf(ctx) if err != nil { return nil, err } rows, err := tx.Query(ctx, `SELECT id, employee_id, amount, effective_from, reason, created_by, created_at FROM salary WHERE org_id = $1 AND employee_id = ANY($2) ORDER BY employee_id, effective_from, created_at, id`, org, employees) if err != nil { return nil, fmt.Errorf("payroll: salaries: %w", err) } defer rows.Close() out := map[uuid.UUID][]Salary{} for rows.Next() { var s Salary if err := rows.Scan(&s.ID, &s.EmployeeID, &s.Amount, &s.EffectiveFrom, &s.Reason, &s.CreatedBy, &s.CreatedAt); err != nil { return nil, err } s.EffectiveFrom = dateOnly(s.EffectiveFrom) out[s.EmployeeID] = append(out[s.EmployeeID], s) } return out, rows.Err()}
// SalaryHistory is employeeID's salary rows, newest first. The read is audited (PD-25): who// read whose salary, never an amount.func SalaryHistory(ctx context.Context, tx pgx.Tx, c clock.Clock, employeeID, actor uuid.UUID) ([]Salary, error) { if _, err := hr.Get(ctx, tx, employeeID); err != nil { return nil, err } all, err := salaries(ctx, tx, []uuid.UUID{employeeID}) if err != nil { return nil, err } rows := all[employeeID] out := make([]Salary, 0, len(rows)) for i := len(rows) - 1; i >= 0; i-- { out = append(out, rows[i]) } return out, audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntitySalary, EntityID: employeeID, Action: "read", ActorID: &actor, After: map[string]any{"employee_id": employeeID, "rows": len(out)}})}- Step 7: Write
items.go
package payroll
import ( "context" "errors" "fmt" "regexp" "time"
"github.com/google/uuid" "github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/audit" "github.com/mcom/erp/internal/kernel/clock" "github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/hr")
// ItemType is a pay item type (PD-12). PostingTag follows Kind.type ItemType struct { ID uuid.UUID Code string Names Names Kind, Recurrence string PostingTag string EPF, SOCSO, EIS, PCB bool Active bool Revision int CreatedAt, UpdatedAt time.Time}
type ItemTypeInput struct { Code string Names Names Kind, Recurrence string EPF, SOCSO, EIS, PCB bool Actor uuid.UUID}
type ItemTypeUpdate struct { Names Names EPF, SOCSO, EIS, PCB, Active bool Actor uuid.UUID}
var itemCode = regexp.MustCompile(`^[A-Z0-9][A-Z0-9_-]{0,19}$`)
func tagOf(kind string) string { if kind == KindDeduction { return TagStatutoryPayable } return TagSalaryExpense}
const itemTypeSelect = `SELECT id, code, kind, recurrence, posting_tag, epf, socso, eis, pcb, active, revision, created_at, updated_at FROM pay_item_type WHERE org_id = $1 AND `
func scanItemTypes(ctx context.Context, tx pgx.Tx, org uuid.UUID, rows pgx.Rows) ([]ItemType, error) { defer rows.Close() var out []ItemType var ids []uuid.UUID for rows.Next() { var t ItemType if err := rows.Scan(&t.ID, &t.Code, &t.Kind, &t.Recurrence, &t.PostingTag, &t.EPF, &t.SOCSO, &t.EIS, &t.PCB, &t.Active, &t.Revision, &t.CreatedAt, &t.UpdatedAt); err != nil { return nil, err } out = append(out, t) ids = append(ids, t.ID) } if err := rows.Err(); err != nil { return nil, err } rows.Close() names, err := loadNames(ctx, tx, org, EntityItemType, ids) if err != nil { return nil, err } for i := range out { out[i].Names = names[out[i].ID] } return out, nil}
func GetItemType(ctx context.Context, tx pgx.Tx, id uuid.UUID) (ItemType, error) { org, err := orgOf(ctx) if err != nil { return ItemType{}, err } rows, err := tx.Query(ctx, itemTypeSelect+`id = $2`, org, id) if err != nil { return ItemType{}, fmt.Errorf("payroll: item type: %w", err) } out, err := scanItemTypes(ctx, tx, org, rows) if err != nil { return ItemType{}, err } if len(out) == 0 { return ItemType{}, ErrNotFound } return out[0], nil}
func ListItemTypes(ctx context.Context, tx pgx.Tx, activeOnly bool) ([]ItemType, error) { org, err := orgOf(ctx) if err != nil { return nil, err } rows, err := tx.Query(ctx, itemTypeSelect+`(active OR NOT $2) ORDER BY code`, org, activeOnly) if err != nil { return nil, fmt.Errorf("payroll: item types: %w", err) } return scanItemTypes(ctx, tx, org, rows)}
func CreateItemType(ctx context.Context, tx pgx.Tx, c clock.Clock, in ItemTypeInput) (ItemType, error) { org, err := orgOf(ctx) if err != nil { return ItemType{}, err } switch { case !itemCode.MatchString(in.Code): return ItemType{}, &FieldError{Field: "code", Rule: "shape", Detail: "1 to 20 of A–Z, 0–9, _ and -"} case in.Kind != KindEarning && in.Kind != KindDeduction: return ItemType{}, &FieldError{Field: "kind", Rule: "known", Detail: "earning or deduction"} case in.Recurrence != RecurRecurring && in.Recurrence != RecurOneOff: return ItemType{}, &FieldError{Field: "recurrence", Rule: "known", Detail: "recurring or one_off"} } names, err := checkNames(in.Names) if err != nil { return ItemType{}, err } var id uuid.UUID now := c.Now() err = tx.QueryRow(ctx, `INSERT INTO pay_item_type (org_id, code, kind, recurrence, posting_tag, epf, socso, eis, pcb, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $10) RETURNING id`, org, in.Code, in.Kind, in.Recurrence, tagOf(in.Kind), in.EPF, in.SOCSO, in.EIS, in.PCB, now).Scan(&id) if isConstraint(err, "pay_item_type_code_unique") { return ItemType{}, ErrDuplicateItemType } if err != nil { return ItemType{}, fmt.Errorf("payroll: create item type: %w", err) } if err := writeNames(ctx, tx, org, EntityItemType, id, names); err != nil { return ItemType{}, err } t, err := GetItemType(ctx, tx, id) if err != nil { return ItemType{}, err } return t, audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntityItemType, EntityID: id, Action: "create", After: t})}
func UpdateItemType(ctx context.Context, tx pgx.Tx, c clock.Clock, id uuid.UUID, expectedRevision int, in ItemTypeUpdate) (ItemType, error) { org, err := orgOf(ctx) if err != nil { return ItemType{}, err } var rev int err = tx.QueryRow(ctx, `SELECT revision FROM pay_item_type WHERE org_id = $1 AND id = $2 FOR UPDATE`, org, id).Scan(&rev) if errors.Is(err, pgx.ErrNoRows) { return ItemType{}, ErrNotFound } if err != nil { return ItemType{}, fmt.Errorf("payroll: update item type: %w", err) } if rev != expectedRevision { return ItemType{}, &RevisionMismatchError{Current: rev} } before, err := GetItemType(ctx, tx, id) if err != nil { return ItemType{}, err } names, err := checkNames(in.Names) if err != nil { return ItemType{}, err } if _, err := tx.Exec(ctx, `UPDATE pay_item_type SET epf = $3, socso = $4, eis = $5, pcb = $6, active = $7, revision = revision + 1, updated_at = $8 WHERE org_id = $1 AND id = $2`, org, id, in.EPF, in.SOCSO, in.EIS, in.PCB, in.Active, c.Now()); err != nil { return ItemType{}, fmt.Errorf("payroll: update item type: %w", err) } if err := writeNames(ctx, tx, org, EntityItemType, id, names); err != nil { return ItemType{}, err } after, err := GetItemType(ctx, tx, id) if err != nil { return ItemType{}, err } return after, audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntityItemType, EntityID: id, Action: "update", Before: before, After: after})}
// EmployeeItem is one employee's recurring item (PD-12); EffectiveTo is inclusive.type EmployeeItem struct { ID, EmployeeID, TypeID uuid.UUID Amount money.Amount EffectiveFrom time.Time EffectiveTo *time.Time CreatedBy uuid.UUID CreatedAt time.Time EndedBy *uuid.UUID EndedAt *time.Time}
type EmployeeItemInput struct { EmployeeID, TypeID uuid.UUID Amount money.Amount EffectiveFrom time.Time EffectiveTo *time.Time Actor uuid.UUID}
const employeeItemSelect = `SELECT id, employee_id, pay_item_type_id, amount, effective_from, effective_to, created_by, created_at, ended_by, ended_at FROM employee_pay_item WHERE org_id = $1 AND `
func scanEmployeeItem(row pgx.Row) (EmployeeItem, error) { var x EmployeeItem err := row.Scan(&x.ID, &x.EmployeeID, &x.TypeID, &x.Amount, &x.EffectiveFrom, &x.EffectiveTo, &x.CreatedBy, &x.CreatedAt, &x.EndedBy, &x.EndedAt) if errors.Is(err, pgx.ErrNoRows) { return EmployeeItem{}, ErrNotFound } x.EffectiveFrom = dateOnly(x.EffectiveFrom) if x.EffectiveTo != nil { d := dateOnly(*x.EffectiveTo) x.EffectiveTo = &d } return x, err}
func AddEmployeeItem(ctx context.Context, tx pgx.Tx, c clock.Clock, in EmployeeItemInput) (EmployeeItem, error) { org, err := orgOf(ctx) if err != nil { return EmployeeItem{}, err } if _, err := hr.Get(ctx, tx, in.EmployeeID); err != nil { return EmployeeItem{}, err } t, err := GetItemType(ctx, tx, in.TypeID) if errors.Is(err, ErrNotFound) { return EmployeeItem{}, &FieldError{Field: "type_id", Rule: "unknown", Detail: "no such pay item type"} } if err != nil { return EmployeeItem{}, err } if t.Recurrence != RecurRecurring || !t.Active { return EmployeeItem{}, &FieldError{Field: "type_id", Rule: "recurring", Detail: "an employee's item is of an active recurring type"} } if err := checkAmount("amount", in.Amount, true); err != nil { return EmployeeItem{}, err } from := dateOnly(in.EffectiveFrom) var to *time.Time if in.EffectiveTo != nil { d := dateOnly(*in.EffectiveTo) if d.Before(from) { return EmployeeItem{}, &FieldError{Field: "effective_to", Rule: "order", Detail: "the last day is on or after the first"} } to = &d } var id uuid.UUID if err := tx.QueryRow(ctx, `INSERT INTO employee_pay_item (org_id, employee_id, pay_item_type_id, amount, effective_from, effective_to, created_by, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id`, org, in.EmployeeID, in.TypeID, in.Amount.String(), from, to, in.Actor, c.Now()).Scan(&id); err != nil { return EmployeeItem{}, fmt.Errorf("payroll: add employee item: %w", err) } x, err := scanEmployeeItem(tx.QueryRow(ctx, employeeItemSelect+`id = $2`, org, id)) if err != nil { return EmployeeItem{}, err } return x, audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntityEmployeeItem, EntityID: id, Action: "create", After: x})}
// EndEmployeeItem sets the item's last day, once (PD-12).func EndEmployeeItem(ctx context.Context, tx pgx.Tx, c clock.Clock, id uuid.UUID, lastDay time.Time, actor uuid.UUID) (EmployeeItem, error) { org, err := orgOf(ctx) if err != nil { return EmployeeItem{}, err } before, err := scanEmployeeItem(tx.QueryRow(ctx, employeeItemSelect+`id = $2 FOR UPDATE`, org, id)) if err != nil { return EmployeeItem{}, err } day := dateOnly(lastDay) switch { case before.EndedAt != nil: return EmployeeItem{}, &FieldError{Field: "last_day", Rule: "ended", Detail: "the item has already ended"} case day.Before(before.EffectiveFrom): return EmployeeItem{}, &FieldError{Field: "last_day", Rule: "order", Detail: "the last day is on or after the first"} case before.EffectiveTo != nil && day.After(*before.EffectiveTo): return EmployeeItem{}, &FieldError{Field: "last_day", Rule: "later", Detail: "an end is no later than the item's planned end"} } if _, err := tx.Exec(ctx, `UPDATE employee_pay_item SET effective_to = $3, ended_by = $4, ended_at = $5 WHERE org_id = $1 AND id = $2`, org, id, day, actor, c.Now()); err != nil { return EmployeeItem{}, fmt.Errorf("payroll: end employee item: %w", err) } after, err := scanEmployeeItem(tx.QueryRow(ctx, employeeItemSelect+`id = $2`, org, id)) if err != nil { return EmployeeItem{}, err } return after, audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntityEmployeeItem, EntityID: id, Action: "end", Before: before, After: after})}
// EmployeeItems is employeeID's items, oldest first.func EmployeeItems(ctx context.Context, tx pgx.Tx, employeeID uuid.UUID) ([]EmployeeItem, error) { org, err := orgOf(ctx) if err != nil { return nil, err } return employeeItems(ctx, tx, org, []uuid.UUID{employeeID})}
func employeeItems(ctx context.Context, tx pgx.Tx, org uuid.UUID, employees []uuid.UUID) ([]EmployeeItem, error) { rows, err := tx.Query(ctx, employeeItemSelect+`employee_id = ANY($2) ORDER BY employee_id, effective_from, created_at, id`, org, employees) if err != nil { return nil, fmt.Errorf("payroll: employee items: %w", err) } defer rows.Close() var out []EmployeeItem for rows.Next() { x, err := scanEmployeeItem(rows) if err != nil { return nil, err } out = append(out, x) } return out, rows.Err()}- Step 8: Run the tests
Run: go test ./internal/platform/payroll/... -count=1
Expected: PASS. (If hr.Get of an unknown id returns hr.ErrNotFound, it propagates unchanged; the module maps it.)
- Step 9: Commit
git add internal/platform/payrollgit commit -m "Payroll P1: settings, salaries, pay item types and employee items
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"Task 4: The calculation — who is employed, unpaid leave by date, proration
Section titled “Task 4: The calculation — who is employed, unpaid leave by date, proration”Files:
- Modify:
internal/platform/hr/employee.go(EmployedBetween),internal/platform/hr/leave_calendar.go(dayOff,UnpaidLeaveByDate) - Create:
internal/platform/payroll/calc.go - Test:
internal/platform/hr/payroll_reads_test.go,internal/platform/payroll/calc_test.go
Interfaces:
-
Consumes:
employeeSelect,scanEmployee,GetWorkCalendar,isoWeekday,public_holiday,leave_semantic(org, instance)(00841),money.Amount.MulDivCents(num, den money.Amount) (money.Amount, error). -
Produces:
hr.EmployedBetween,hr.UnpaidLeaveByDate(signatures above);payroll.Calculateand theCalc*types (signatures above). -
Step 1: Write the failing hr test
Create internal/platform/hr/payroll_reads_test.go:
package hr_test
import ( "context" "testing" "time"
"github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/hr" "github.com/mcom/erp/internal/platform/hr/hrtest")
func TestEmployedBetweenIsTheSpellOverlappingTheRange(t *testing.T) { e := hrtest.New(t) staff := e.Hired(t, e.Staff, "E-100") leaver := e.Hired(t, e.HR, "E-200") e.LastDay(t, leaver.ID, hrtest.Day(2026, 9, 21), hrtest.Day(2026, 10, 31), e.Owner) // notice today, leaving in October in := func(from, to time.Time) []string { var nos []string e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { got, err := hr.EmployedBetween(ctx, tx, from, to) for _, x := range got { nos = append(nos, x.EmployeeNo) } return err }) return nos } require.Equal(t, []string{"E-100", "E-200"}, in(hrtest.Day(2026, 10, 1), hrtest.Day(2026, 10, 31)), "PRESENCE: both in October") require.Equal(t, []string{"E-100"}, in(hrtest.Day(2026, 11, 1), hrtest.Day(2026, 11, 30)), "ABSENCE: the leaver is gone in November") _ = staff}
func TestUnpaidLeaveByDateCountsWorkingDaysAndHalves(t *testing.T) { e := hrtest.New(t) staff := e.Hired(t, e.Staff, "E-100") unpaid := e.LeaveType(t, hr.LeaveTypeInput{Code: "UL", Kind: hr.KindUnpaid, HalfDays: true}) annual := e.Annual(t, "AL", 14, 16, 20) e.Accrue(t, e.Clock, staff.ID) // Thu 30 Jul (half, afternoon) to Mon 3 Aug: Thu ½, Fri 1, (Sat, Sun off), Mon 1. r := e.Request(t, e.Staff, hr.RequestInput{TypeID: unpaid.ID, From: hrtest.Day(2026, 7, 30), To: hrtest.Day(2026, 8, 3), FromHalf: true}) e.LeaveMove(t, r.ID, e.Owner, "pending", "approved", "") // A paid day is not unpaid leave. p := e.Request(t, e.Staff, hr.RequestInput{TypeID: annual.ID, From: hrtest.Day(2026, 8, 5), To: hrtest.Day(2026, 8, 5)}) e.LeaveMove(t, p.ID, e.Owner, "pending", "approved", "") // A pending unpaid request is not approved. e.Request(t, e.Staff, hr.RequestInput{TypeID: unpaid.ID, From: hrtest.Day(2026, 8, 10), To: hrtest.Day(2026, 8, 10)})
var aug map[time.Time]hr.Days e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { var err error aug, err = hr.UnpaidLeaveByDate(ctx, tx, staff.ID, hrtest.Day(2026, 8, 1), hrtest.Day(2026, 8, 31)) return err }) require.Equal(t, map[time.Time]hr.Days{hrtest.Day(2026, 8, 3): hr.WholeDays(1)}, aug, "only the in-month working day") var jul map[time.Time]hr.Days e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { var err error jul, err = hr.UnpaidLeaveByDate(ctx, tx, staff.ID, hrtest.Day(2026, 7, 1), hrtest.Day(2026, 7, 31)) return err }) require.Equal(t, map[time.Time]hr.Days{hrtest.Day(2026, 7, 30): 1, hrtest.Day(2026, 7, 31): hr.WholeDays(1)}, jul)}((*hrtest.Env).Annual is H3’s annual type with rule bands from 2000-01-01; Accrue grants 2026’s entitlement as of the world’s clock, 2026-09-21. 2026-07-30 is a Thursday.)
- Step 2: Run it to verify it fails
Run: go test ./internal/platform/hr/ -run 'TestEmployedBetween|TestUnpaidLeaveByDate' -count=1
Expected: FAIL to compile — undefined: hr.EmployedBetween.
- Step 3: Implement the two hr reads
Append to internal/platform/hr/employee.go:
// EmployedBetween is every employee whose current spell overlaps [from, to]: hired on or// before to, and with no last day or one on or after from (payroll Ruling PD-8). The spell is// employee_spell's, the current one only. Ordered by employee number.func EmployedBetween(ctx context.Context, tx pgx.Tx, from, to time.Time) ([]Employee, error) { org, err := orgOf(ctx) if err != nil { return nil, err } rows, err := tx.Query(ctx, employeeSelect+`s.hire_date IS NOT NULL AND s.hire_date <= $2 AND (s.last_day IS NULL OR s.last_day >= $3) ORDER BY e.employee_no`, org, dateOnly(to), dateOnly(from)) if err != nil { return nil, fmt.Errorf("hr: employed between: %w", err) } defer rows.Close() var out []Employee for rows.Next() { e, err := scanEmployee(rows) if err != nil { return nil, err } out = append(out, e) } return out, rows.Err()}In internal/platform/hr/leave_calendar.go, extract the off-day rule out of WorkingDays and add UnpaidLeaveByDate:
// dayOff is the organization's "not a working day" over [from, to]: a weekday outside its// week, or a public holiday applying to it (Ruling H3-12).func dayOff(ctx context.Context, tx pgx.Tx, org uuid.UUID, from, to time.Time) (func(time.Time) bool, error) { cal, err := GetWorkCalendar(ctx, tx) if err != nil { return nil, err } rows, err := tx.Query(ctx, `SELECT holiday_date FROM public_holiday WHERE org_id = $1 AND active AND holiday_date BETWEEN $2 AND $3 AND (state IS NULL OR state = $4)`, org, from, to, cal.State) if err != nil { return nil, fmt.Errorf("hr: working days: %w", err) } dates, err := pgx.CollectRows(rows, pgx.RowTo[time.Time]) if err != nil { return nil, fmt.Errorf("hr: working days: %w", err) } holiday := map[time.Time]bool{} for _, d := range dates { holiday[dateOnly(d)] = true } return func(day time.Time) bool { return !slices.Contains(cal.Weekdays, isoWeekday(day)) || holiday[day] }, nil}In WorkingDays, replace the block from cal, err := GetWorkCalendar(ctx, tx) through the off := func(...) line with:
off, err := dayOff(ctx, tx, org, from, to) if err != nil { return 0, err }Append:
// UnpaidLeaveByDate is, for employeeID, the half-day units of approved unpaid leave on each// date in [from, to] (payroll Ruling PD-11): requests in the committed semantic of a type with// paid = false, counted as WorkingDays counts them — working days only, a half on a half-day// endpoint. Dates with none are absent.func UnpaidLeaveByDate(ctx context.Context, tx pgx.Tx, employeeID uuid.UUID, from, to time.Time) (map[time.Time]Days, error) { org, err := orgOf(ctx) if err != nil { return nil, err } from, to = dateOnly(from), dateOnly(to) rows, err := tx.Query(ctx, ` SELECT r.from_date, r.to_date, r.from_half, r.to_half FROM leave_request r JOIN leave_type t ON t.org_id = r.org_id AND t.id = r.leave_type_id WHERE r.org_id = $1 AND t.org_id = $1 AND r.employee_id = $2 AND NOT t.paid AND r.to_date >= $3 AND r.from_date <= $4 AND leave_semantic(r.org_id, r.instance_id) = 'committed'`, org, employeeID, from, to) if err != nil { return nil, fmt.Errorf("hr: unpaid leave: %w", err) } type span struct { from, to time.Time fromHalf, toHalf bool } var spans []span for rows.Next() { var s span if err := rows.Scan(&s.from, &s.to, &s.fromHalf, &s.toHalf); err != nil { rows.Close() return nil, err } s.from, s.to = dateOnly(s.from), dateOnly(s.to) spans = append(spans, s) } rows.Close() if err := rows.Err(); err != nil { return nil, err } out := map[time.Time]Days{} if len(spans) == 0 { return out, nil } off, err := dayOff(ctx, tx, org, from, to) if err != nil { return nil, err } for _, s := range spans { for day := s.from; !day.After(s.to); day = day.AddDate(0, 0, 1) { if day.Before(from) || day.After(to) || off(day) { continue } switch { case s.fromHalf && day.Equal(s.from), s.toHalf && day.Equal(s.to): out[day]++ default: out[day] += 2 } } } return out, nil}Run: go test ./internal/platform/hr/ -count=1
Expected: PASS (the WorkingDays tests guard the extraction).
- Step 4: Write the failing calculation test
Create internal/platform/payroll/calc_test.go:
package payroll_test
import ( "testing" "time"
"github.com/google/uuid" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/hr" "github.com/mcom/erp/internal/platform/payroll")
func d(y int, m time.Month, day int) time.Time { return time.Date(y, m, day, 0, 0, 0, 0, time.UTC) }
func salary(amount string, from time.Time) payroll.CalcSalary { return payroll.CalcSalary{ID: uuid.New(), Amount: money.MustParse(amount), From: from}}
func amounts(r payroll.CalcResult) (lines []string) { for _, l := range r.Lines { lines = append(lines, l.Kind+" "+l.Amount.String()) } return lines}
func TestAFullThirtyOneDayMonthPaysTheSalary(t *testing.T) { r, err := payroll.Calculate(payroll.CalcInput{Month: d(2026, 8, 1), First: d(2026, 8, 1), Last: d(2026, 8, 31), Salaries: []payroll.CalcSalary{salary("3100.00", d(2026, 1, 1))}}) require.NoError(t, err) require.Equal(t, []string{"basic 3100.0000"}, amounts(r)) require.Equal(t, 31, r.DaysInMonth) require.Equal(t, 31, r.DaysEmployed) require.Equal(t, 31, r.Divisor) require.Equal(t, "3100.0000", r.Net.String())}
func TestAStarterAndALeaverAreProratedByCalendarDays(t *testing.T) { s := []payroll.CalcSalary{salary("3100.00", d(2026, 1, 1))} starter, err := payroll.Calculate(payroll.CalcInput{Month: d(2026, 8, 1), First: d(2026, 8, 10), Last: d(2026, 8, 31), Salaries: s}) require.NoError(t, err) require.Equal(t, []string{"basic 2200.0000"}, amounts(starter), "3100 × 22 / 31") leaver, err := payroll.Calculate(payroll.CalcInput{Month: d(2026, 8, 1), First: d(2026, 8, 1), Last: d(2026, 8, 20), Salaries: s}) require.NoError(t, err) require.Equal(t, []string{"basic 2000.0000"}, amounts(leaver), "3100 × 20 / 31") require.Equal(t, 20, leaver.DaysEmployed)}
func TestAMidMonthRaiseSplitsTheBasicAndRoundsEachLine(t *testing.T) { r, err := payroll.Calculate(payroll.CalcInput{Month: d(2026, 8, 1), First: d(2026, 8, 1), Last: d(2026, 8, 31), Salaries: []payroll.CalcSalary{salary("3000.00", d(2026, 1, 1)), salary("3100.00", d(2026, 8, 16))}}) require.NoError(t, err) require.Equal(t, []string{"basic 1451.6100", "basic 1600.0000"}, amounts(r), "3000 × 15/31 = 1451.6129…; 3100 × 16/31") require.Equal(t, "3051.6100", r.Gross.String())}
func TestRoundingIsHalfUpToTheSen(t *testing.T) { r, err := payroll.Calculate(payroll.CalcInput{Month: d(2026, 6, 1), First: d(2026, 6, 16), Last: d(2026, 6, 30), Salaries: []payroll.CalcSalary{salary("1000.01", d(2026, 1, 1))}}) require.NoError(t, err) require.Equal(t, []string{"basic 500.0100"}, amounts(r), "1000.01 × 15 / 30 = 500.005 → 500.01")}
func TestUnpaidLeaveDeductsPerHalfDayAtTheDivisor(t *testing.T) { s := []payroll.CalcSalary{salary("3100.00", d(2026, 1, 1))} unpaid := map[time.Time]hr.Days{d(2026, 8, 3): 2, d(2026, 8, 4): 1} // 1½ days r, err := payroll.Calculate(payroll.CalcInput{Month: d(2026, 8, 1), First: d(2026, 8, 1), Last: d(2026, 8, 31), Salaries: s, Unpaid: unpaid}) require.NoError(t, err) require.Equal(t, []string{"basic 3100.0000", "unpaid_leave -150.0000"}, amounts(r), "3100 × 3 / 62") require.Equal(t, "1.5", r.Lines[1].Days.String()) require.Equal(t, "2950.0000", r.Net.String())
r, err = payroll.Calculate(payroll.CalcInput{Month: d(2026, 8, 1), First: d(2026, 8, 1), Last: d(2026, 8, 31), Salaries: []payroll.CalcSalary{salary("2600.00", d(2026, 1, 1))}, Unpaid: map[time.Time]hr.Days{d(2026, 8, 3): 2}, Divisor: 26}) require.NoError(t, err) require.Equal(t, []string{"basic 2600.0000", "unpaid_leave -100.0000"}, amounts(r), "2600 × 2 / 52 (divisor 26)") require.Equal(t, 26, r.Divisor)}
func TestUnpaidLeaveNeverExceedsTheBasic(t *testing.T) { every := map[time.Time]hr.Days{} for day := d(2026, 8, 1); day.Month() == time.August; day = day.AddDate(0, 0, 1) { every[day] = 2 } r, err := payroll.Calculate(payroll.CalcInput{Month: d(2026, 8, 1), First: d(2026, 8, 1), Last: d(2026, 8, 31), Salaries: []payroll.CalcSalary{salary("2600.00", d(2026, 1, 1))}, Unpaid: every, Divisor: 26}) require.NoError(t, err) require.Equal(t, []string{"basic 2600.0000", "unpaid_leave -2600.0000"}, amounts(r), "2600 × 62/52 capped at the basic") require.True(t, r.Net.IsZero())}
func TestItemsAreProratedAndEntriesAreNot(t *testing.T) { trans, loan := uuid.New(), uuid.New() epf := payroll.StatEPF r, err := payroll.Calculate(payroll.CalcInput{Month: d(2026, 8, 1), First: d(2026, 8, 10), Last: d(2026, 8, 31), Salaries: []payroll.CalcSalary{salary("3100.00", d(2026, 1, 1))}, Items: []payroll.CalcItem{ {ID: uuid.New(), TypeID: trans, Description: "Transport", Amount: money.MustParse("310.00"), From: d(2026, 1, 1)}, {ID: uuid.New(), TypeID: loan, Description: "Staff loan", Deduction: true, Amount: money.MustParse("62.00"), From: d(2026, 1, 1), To: ptr(d(2026, 8, 20))}, {ID: uuid.New(), TypeID: trans, Description: "Ended", Amount: money.MustParse("100.00"), From: d(2026, 1, 1), To: ptr(d(2026, 7, 31))}, }, Entries: []payroll.CalcEntry{ {InputID: uuid.New(), Kind: payroll.InputStatutory, StatutoryCode: &epf, Description: "EPF", Deduction: true, Amount: money.MustParse("242.00")}, {InputID: uuid.New(), Kind: payroll.InputAdjustment, Description: "July overtime", Amount: money.MustParse("-15.00")}, }}) require.NoError(t, err) require.Equal(t, []string{"basic 2200.0000", "item 220.0000", "item 22.0000", "statutory 242.0000", "adjustment -15.0000"}, amounts(r), "310 × 22/31; 62 × 11/31 (10–20 Aug); the ended item is absent; entries as entered") require.Equal(t, "2405.0000", r.Gross.String()) require.Equal(t, "264.0000", r.Deductions.String()) require.Equal(t, "2141.0000", r.Net.String())}
func TestNoSalaryOnAnEmployedDayIsRefused(t *testing.T) { _, err := payroll.Calculate(payroll.CalcInput{Month: d(2026, 8, 1), First: d(2026, 8, 1), Last: d(2026, 8, 31), Salaries: []payroll.CalcSalary{salary("3100.00", d(2026, 8, 2))}}) require.ErrorIs(t, err, payroll.ErrNoSalary)}
func ptr[T any](v T) *T { return &v }- Step 5: Run it to verify it fails
Run: go test ./internal/platform/payroll/ -run 'Month|Prorated|Raise|Rounding|Unpaid|Items|NoSalary' -count=1
Expected: FAIL to compile — undefined: payroll.Calculate.
- Step 6: Implement
calc.go
package payroll
import ( "strconv" "time"
"github.com/google/uuid"
"github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/hr")
// CalcSalary is a salary row as the calculation sees it.type CalcSalary struct { ID uuid.UUID Amount money.Amount From time.Time}
// CalcItem is a recurring employee item; To is inclusive.type CalcItem struct { ID, TypeID uuid.UUID Description string Deduction bool Amount money.Amount From time.Time To *time.Time}
// CalcEntry is a run input, taken as entered (PD-13).type CalcEntry struct { InputID uuid.UUID Kind string // InputItem | InputStatutory | InputAdjustment TypeID *uuid.UUID StatutoryCode *string Description string Deduction bool Amount money.Amount}
// CalcInput is one employee's month. First and Last are the employed days inside it// (inclusive); Salaries are in effective order, a later row with the same From winning;// Unpaid is half-day units per date; Divisor 0 means the month's calendar days.type CalcInput struct { Month, First, Last time.Time Salaries []CalcSalary Items []CalcItem Entries []CalcEntry Unpaid map[time.Time]hr.Days Divisor int}
type CalcLine struct { Kind string SalaryID, TypeID, EmployeeItemID, InputID *uuid.UUID StatutoryCode *string Description string Days *hr.Days Deduction bool Amount money.Amount}
type CalcResult struct { Lines []CalcLine Gross, Deductions, Net money.Amount DaysInMonth, DaysEmployed, Divisor int}
func num(i int) money.Amount { return money.MustParse(strconv.Itoa(i)) }
// share is a × num ÷ den, rounded once to the sen (PD-10).func share(a money.Amount, n, den int) money.Amount { out, err := a.MulDivCents(num(n), num(den)) if err != nil { // den is a day count ≥ 1 panic(err) } return out}
func halfDays(halves int) *hr.Days { d := hr.Days(halves); return &d }
// Calculate is one payslip's lines and totals (PD-9–PD-11, PD-13). Pure: no database.func Calculate(in CalcInput) (CalcResult, error) { month := dateOnly(in.Month) end := monthEnd(month) dim := end.Day() div := in.Divisor if div == 0 { div = dim } first, last := dateOnly(in.First), dateOnly(in.Last) if first.Before(month) { first = month } if last.After(end) { last = end } res := CalcResult{DaysInMonth: dim, Divisor: div} if last.Before(first) { return res, nil } res.DaysEmployed = int(last.Sub(first).Hours()/24) + 1
// Basic and unpaid leave, one group per salary row in force (PD-9). type group struct { s CalcSalary days, halves int } var groups []*group for day := first; !day.After(last); day = day.AddDate(0, 0, 1) { var s *CalcSalary for i := range in.Salaries { if !dateOnly(in.Salaries[i].From).After(day) { s = &in.Salaries[i] } } if s == nil { return CalcResult{}, ErrNoSalary } if len(groups) == 0 || groups[len(groups)-1].s.ID != s.ID { groups = append(groups, &group{s: *s}) } g := groups[len(groups)-1] g.days++ g.halves += int(in.Unpaid[day]) } basic := money.Zero() for _, g := range groups { id := g.s.ID amt := share(g.s.Amount, g.days, dim) basic = basic.Add(amt) res.Lines = append(res.Lines, CalcLine{Kind: LineBasic, SalaryID: &id, Description: "Basic salary", Days: halfDays(2 * g.days), Amount: amt}) } unpaid, lastUnpaid := money.Zero(), -1 for _, g := range groups { if g.halves == 0 { continue } id := g.s.ID amt := share(g.s.Amount, g.halves, 2*div) unpaid = unpaid.Add(amt) res.Lines = append(res.Lines, CalcLine{Kind: LineUnpaidLeave, SalaryID: &id, Description: "Unpaid leave", Days: halfDays(g.halves), Amount: amt.Neg()}) lastUnpaid = len(res.Lines) - 1 } if over := unpaid.Sub(basic); over.IsPositive() { // PD-11: never more than the basic res.Lines[lastUnpaid].Amount = res.Lines[lastUnpaid].Amount.Add(over) }
// Recurring items, prorated by the employed days they are effective (PD-10). for _, it := range in.Items { from, to := dateOnly(it.From), last if from.Before(first) { from = first } if it.To != nil && dateOnly(*it.To).Before(to) { to = dateOnly(*it.To) } if to.Before(from) { continue } k := int(to.Sub(from).Hours()/24) + 1 item, typ := it.ID, it.TypeID res.Lines = append(res.Lines, CalcLine{Kind: LineItem, TypeID: &typ, EmployeeItemID: &item, Description: it.Description, Days: halfDays(2 * k), Deduction: it.Deduction, Amount: share(it.Amount, k, dim)}) }
// Run inputs, as entered (PD-13). for _, en := range in.Entries { input := en.InputID kind := map[string]string{InputItem: LineItem, InputStatutory: LineStatutory, InputAdjustment: LineAdjustment}[en.Kind] res.Lines = append(res.Lines, CalcLine{Kind: kind, TypeID: en.TypeID, InputID: &input, StatutoryCode: en.StatutoryCode, Description: en.Description, Deduction: en.Deduction, Amount: en.Amount}) }
res.Gross, res.Deductions = money.Zero(), money.Zero() for _, l := range res.Lines { if l.Deduction { res.Deductions = res.Deductions.Add(l.Amount) } else { res.Gross = res.Gross.Add(l.Amount) } } res.Net = res.Gross.Sub(res.Deductions) return res, nil}- Step 7: Run the tests
Run: go test ./internal/platform/payroll/ ./internal/platform/hr/ -count=1
Expected: PASS.
- Step 8: Commit
git add internal/platform/hr/employee.go internal/platform/hr/leave_calendar.go internal/platform/hr/payroll_reads_test.go \ internal/platform/payroll/calc.go internal/platform/payroll/calc_test.gogit commit -m "Payroll P1: proration, unpaid leave by date and the payslip calculation
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"Task 5: The run — graphs, preparation, generations, inputs, payslip reads and warnings
Section titled “Task 5: The run — graphs, preparation, generations, inputs, payslip reads and warnings”Files:
- Create:
internal/platform/payroll/graphs.go,run.go,warnings.go - Modify:
internal/platform/workflow/model.go(AccrualOnlyKindsgains"payroll_run") - Modify:
internal/platform/payroll/payrolltest/env.go(Starter,Run,RunErr) - Test:
internal/platform/payroll/graphs_test.go,run_test.go,warnings_test.go
Interfaces:
-
Consumes: Task 3’s
GetSetting,salaries,employeeItems,ListItemTypes,checkText,checkAmount; Task 4’sCalculate,hr.EmployedBetween,hr.UnpaidLeaveByDate;workflow.RegisterSeededGraph,workflow.AllowSoleOwnerException,workflow.Start,workflow.SetAmount,workflow.Graph/StateSpec/EdgeSpec/GrantSpec,workflow.GateReporter,workflow.GateTarget,workflow.GateAdvance,workflow.GateSource,workflow.GranteePosition,workflow.AccrualFirst,workflow.CashOnly,numbering.Allocate(ctx, tx, scope string, period int) (int64, error),numbering.Format(prefix string, period int, n int64) string,orgstruct.PrimaryPositionOf,orgstruct.ErrNoPrimaryPosition,orgstruct.GetPosition,hr.GetGrade,(hr.Grade).BandOn,payout.ProfileVerification,payout.ErrNoProfile,workflowtest.RequireSeededGraphValid(t, kind). -
Produces:
RunGraph,PayslipGraph(registered;payroll_runopted into the sole-owner exception); everything under// Task 5in “Interfaces this plan produces”; package-internallockRun(ctx, tx, id) (Run, error),calculate(ctx, tx, c, run Run, actor uuid.UUID) (Run, error),lockPayslips(ctx, tx, run Run) ([]Payslip, error); fixture(*payrolltest.Env).Starter(t, member uuid.UUID, no, salary string, hired time.Time) hr.Employee,(*Env).Run(t, month time.Time) payroll.Run(created bye.HR),(*Env).RunErr(month time.Time, by uuid.UUID) (payroll.Run, error). -
Step 1: Write the failing graph test
Create internal/platform/payroll/graphs_test.go:
package payroll_test
import ( "testing"
"github.com/google/uuid" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/payroll" "github.com/mcom/erp/internal/platform/posting" "github.com/mcom/erp/internal/platform/workflow" "github.com/mcom/erp/internal/platform/workflow/workflowtest")
func TestTheRunAndPayslipGraphsAreValidSeededKinds(t *testing.T) { workflowtest.RequireSeededGraphValid(t, payroll.RunKind) workflowtest.RequireSeededGraphValid(t, payroll.PayslipKind) require.Contains(t, workflow.AccrualOnlyKinds, payroll.RunKind) require.NotContains(t, workflow.CommitmentKinds, payroll.RunKind, "a run posts; commitment kinds post nothing")
g := payroll.RunGraph(uuid.New()) approved, ok := g.State("approved") require.True(t, ok) require.Equal(t, posting.Accrual, approved.Semantic) require.Equal(t, payroll.ControlKindPayrollPayable, approved.ControlKind) p := payroll.PayslipGraph(uuid.New()) require.Equal(t, workflow.CashOnly, p.PostingOrder) paid, _ := p.State("paid") require.Equal(t, posting.Payout, paid.Semantic)}- Step 2: Run it to verify it fails
Run: go test ./internal/platform/payroll/ -run TestTheRunAndPayslipGraphs -count=1
Expected: FAIL to compile — undefined: payroll.RunGraph.
- Step 3: Write
graphs.goand widenAccrualOnlyKinds
In internal/platform/workflow/model.go:
var AccrualOnlyKinds = []string{"purchase_invoice", "supplier_credit_note", "sales_invoice", "customer_credit_note", "payroll_run"} // payroll P1 (PD-15): one accrual; each payslip's own payout pays itCreate internal/platform/payroll/graphs.go:
package payroll
import ( "github.com/google/uuid"
"github.com/mcom/erp/internal/platform/posting" "github.com/mcom/erp/internal/platform/workflow")
func init() { workflow.RegisterSeededGraph(RunKind, RunGraph) workflow.RegisterSeededGraph(PayslipKind, PayslipGraph) // PD-15: a one-member organization's Owner approves and voids their own run, with a reason. workflow.AllowSoleOwnerException(RunKind, true)}
func labels(en, zh, ms string) workflow.Labels { return workflow.Labels{"en": en, "zh": zh, "ms": ms} }
func state(order int, key string, sem posting.Semantic, terminal bool, l workflow.Labels) workflow.StateSpec { s := workflow.StateSpec{Key: key, Semantic: sem, DisplayOrder: order, IsTerminal: terminal, Labels: l} if sem == posting.Accrual { s.ControlKind = ControlKindPayrollPayable } return s}
func edge(order int, from, to string, gate workflow.Gate, action string, l workflow.Labels) workflow.EdgeSpec { return workflow.EdgeSpec{From: from, To: to, Gate: gate, ActionKey: action, DisplayOrder: order, Labels: l}}
func withReason(e workflow.EdgeSpec) workflow.EdgeSpec { e.RequiresReason = true; return e }
// RunGraph is the payroll_run workflow (PD-15). The preparer is the reporter; the OWNER seat// approves (an administrator may add grants); approval posts the accrual on payroll_payable.func RunGraph(ownerSeat uuid.UUID) workflow.Graph { void := withReason(edge(6, "approved", "voided", workflow.GateSource, "void", labels("Void", "作废", "Batalkan"))) void.ReversesAccrual = true return workflow.Graph{ Kind: RunKind, PostingOrder: workflow.AccrualFirst, States: []workflow.StateSpec{ state(1, "draft", posting.Draft, false, labels("Draft", "草稿", "Draf")), state(2, "submitted", posting.Pending, false, labels("Awaiting approval", "待批准", "Menunggu kelulusan")), state(3, "approved", posting.Accrual, false, labels("Approved", "已批准", "Diluluskan")), state(4, "rejected", posting.Rejected, false, labels("Rejected", "已拒绝", "Ditolak")), state(5, "voided", posting.Cancelled, true, labels("Voided", "已作废", "Dibatalkan")), }, Edges: []workflow.EdgeSpec{ edge(1, "draft", "submitted", workflow.GateReporter, "submit", labels("Submit", "提交", "Hantar")), edge(2, "submitted", "approved", workflow.GateTarget, "approve", labels("Approve", "批准", "Luluskan")), withReason(edge(3, "submitted", "rejected", workflow.GateAdvance, "reject", labels("Reject", "拒绝", "Tolak"))), edge(4, "rejected", "draft", workflow.GateReporter, "revise", labels("Revise", "修改", "Semak semula")), withReason(edge(5, "draft", "voided", workflow.GateReporter, "withdraw", labels("Withdraw", "撤回", "Tarik balik"))), void, }, Grants: []workflow.GrantSpec{ {StateKey: "approved", GranteeKind: workflow.GranteePosition, PositionID: &ownerSeat}, }, }.Normalized()}
// PayslipGraph is one payslip's payment (PD-20): unpaid until a payment batch pays it. The// reporter is the payee; the OWNER seat may take the payout edge (the batch's marker).func PayslipGraph(ownerSeat uuid.UUID) workflow.Graph { return workflow.Graph{ Kind: PayslipKind, PostingOrder: workflow.CashOnly, States: []workflow.StateSpec{ state(1, "unpaid", posting.Draft, false, labels("Unpaid", "未付", "Belum dibayar")), state(2, "paid", posting.Payout, true, labels("Paid", "已付", "Dibayar")), }, Edges: []workflow.EdgeSpec{ edge(1, "unpaid", "paid", workflow.GateTarget, "pay", labels("Pay", "支付", "Bayar")), }, Grants: []workflow.GrantSpec{ {StateKey: "paid", GranteeKind: workflow.GranteePosition, PositionID: &ownerSeat}, }, }.Normalized()}Run: go test ./internal/platform/payroll/ -run TestTheRunAndPayslipGraphs -count=1 — Expected: PASS. If the validator reports a rule (the message names it, e.g. rule 2 no_exit), fix the graph, not the validator.
- Step 4: Extend the fixture
Append to internal/platform/payroll/payrolltest/env.go (imports gain "time" and "github.com/mcom/erp/internal/platform/payroll" is already there):
// Starter is a member hired on hired (by HR) with salary from that day.func (e *Env) Starter(t *testing.T, member uuid.UUID, no, salary string, hired time.Time) hr.Employee { t.Helper() var emp hr.Employee e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { created, err := hr.Create(ctx, tx, e.Clock, hr.CreateInput{MemberID: &member, EmployeeNo: no}) if err != nil { return err } if emp, _, err = hr.Hire(ctx, tx, e.Clock, hr.HireInput{EmployeeID: created.ID, EffectiveOn: hired, EmploymentType: hr.TypePermanent, Actor: e.HR}); err != nil { return err } _, err = payroll.AddSalary(ctx, tx, e.Clock, payroll.SalaryInput{EmployeeID: emp.ID, Amount: money.MustParse(salary), EffectiveFrom: hired, Actor: e.HR}) return err }) return emp}
// RunErr creates month's run as by.func (e *Env) RunErr(month time.Time, by uuid.UUID) (payroll.Run, error) { var r payroll.Run err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error { var err error r, err = payroll.CreateRun(ctx, tx, e.Clock, month, by) return err }) return r, err}
// Run creates month's run, prepared by HR.func (e *Env) Run(t *testing.T, month time.Time) payroll.Run { t.Helper() r, err := e.RunErr(month, e.HR) require.NoError(t, err) return r}
// Slips is the run's current payslips, with lines, by employee number.func (e *Env) Slips(t *testing.T, runID uuid.UUID) map[string]payroll.Payslip { t.Helper() out := map[string]payroll.Payslip{} e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { ps, err := payroll.Payslips(ctx, tx, runID) for _, p := range ps { full, err := payroll.GetPayslip(ctx, tx, p.ID) if err != nil { return err } out[p.EmployeeNo] = full } return err }) return out}- Step 5: Write the failing run tests
Create internal/platform/payroll/run_test.go:
package payroll_test
import ( "context" "testing"
"github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/hr" "github.com/mcom/erp/internal/platform/hr/hrtest" "github.com/mcom/erp/internal/platform/payroll" "github.com/mcom/erp/internal/platform/payroll/payrolltest")
func lineSummary(p payroll.Payslip) (out []string) { for _, l := range p.Lines { out = append(out, l.Kind+" "+l.Amount.String()) } return out}
func TestARunPaysEveryoneEmployedInTheMonthWithALogin(t *testing.T) { e := payrolltest.New(t) e.Employee(t, e.Staff, "E-100", "3100.00") newcomer := e.Newcomer(t, "Aminah binti Yusof") e.Starter(t, newcomer, "E-200", "3100.00", hrtest.Day(2026, 8, 10)) run := e.Run(t, hrtest.Day(2026, 8, 1)) require.Equal(t, payroll.RunDraft, run.Status) require.Equal(t, "PR-2026-0001", run.Reference) require.Equal(t, 1, run.CalcNo)
slips := e.Slips(t, run.ID) require.Len(t, slips, 2) require.Equal(t, "PR-2026-0001-001", slips["E-100"].Reference) require.Equal(t, []string{"basic 3100.0000"}, lineSummary(slips["E-100"]), "31-day month in full") require.Equal(t, []string{"basic 2200.0000"}, lineSummary(slips["E-200"]), "a starter on the 10th: 22 of 31 days") require.Equal(t, 22, slips["E-200"].DaysEmployed) require.Equal(t, "5300.0000", run.Net.String()) require.Equal(t, 2, run.PayslipCount)}
func TestALeaverIsProratedToTheLastDay(t *testing.T) { e := payrolltest.New(t) emp := e.Employee(t, e.Staff, "E-100", "3000.00") e.LastDay(t, emp.ID, hrtest.Day(2026, 9, 21), hrtest.Day(2026, 9, 25), e.Owner) run := e.Run(t, hrtest.Day(2026, 9, 1)) // the current month require.Equal(t, []string{"basic 2500.0000"}, lineSummary(e.Slips(t, run.ID)["E-100"]), "3000 × 25 / 30")}
func TestApprovedUnpaidLeaveIsDeducted(t *testing.T) { e := payrolltest.New(t) emp := e.Employee(t, e.Staff, "E-100", "3100.00") _ = emp ul := e.LeaveType(t, hr.LeaveTypeInput{Code: "UL", Kind: hr.KindUnpaid, HalfDays: true}) r := e.Request(t, e.Staff, hr.RequestInput{TypeID: ul.ID, From: hrtest.Day(2026, 8, 3), To: hrtest.Day(2026, 8, 4), ToHalf: true}) e.LeaveMove(t, r.ID, e.Owner, "pending", "approved", "") run := e.Run(t, hrtest.Day(2026, 8, 1)) slip := e.Slips(t, run.ID)["E-100"] require.Equal(t, []string{"basic 3100.0000", "unpaid_leave -150.0000"}, lineSummary(slip), "1½ days at 3100 ÷ 31") require.Equal(t, "2950.0000", slip.Net.String())}
func TestARecalculationIsANewGenerationAndOldRowsStay(t *testing.T) { e := payrolltest.New(t) emp := e.Employee(t, e.Staff, "E-100", "3000.00") run := e.Run(t, hrtest.Day(2026, 8, 1)) e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { _, err := payroll.AddSalary(ctx, tx, e.Clock, payroll.SalaryInput{EmployeeID: emp.ID, Amount: money.MustParse("3100.00"), EffectiveFrom: hrtest.Day(2026, 8, 1), Actor: e.HR}) return err }) e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { var err error run, err = payroll.Recalculate(ctx, tx, e.Clock, run.ID, e.HR, run.Revision) return err }) require.Equal(t, 2, run.CalcNo) require.Equal(t, []string{"basic 3100.0000"}, lineSummary(e.Slips(t, run.ID)["E-100"])) require.Equal(t, 2, e.Count(t, `SELECT count(*) FROM payslip WHERE org_id = $1 AND run_id = $2`, e.Org, run.ID), "generation 1 is kept, never deleted")}
func TestOneRunPerMonthAndNotTheFuture(t *testing.T) { e := payrolltest.New(t) e.Employee(t, e.Staff, "E-100", "3000.00") e.Run(t, hrtest.Day(2026, 8, 1)) _, err := e.RunErr(hrtest.Day(2026, 8, 1), e.HR) require.ErrorIs(t, err, payroll.ErrRunExists) _, err = e.RunErr(hrtest.Day(2026, 10, 1), e.HR) // the world's today is 2026-09-21 require.ErrorIs(t, err, payroll.ErrMonthFuture)}
func TestInputsAreEnteredOnADraftAndRecalculate(t *testing.T) { e := payrolltest.New(t) emp := e.Employee(t, e.Staff, "E-100", "3100.00") run := e.Run(t, hrtest.Day(2026, 8, 1)) epf := payroll.StatEPF var in payroll.Input e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { var err error run, in, err = payroll.AddInput(ctx, tx, e.Clock, payroll.InputInput{RunID: run.ID, EmployeeID: emp.ID, Kind: payroll.InputStatutory, StatutoryCode: &epf, Amount: money.MustParse("341.00"), Actor: e.HR, ExpectedRevision: run.Revision}) return err }) require.Equal(t, "EPF", in.Description) require.Equal(t, []string{"basic 3100.0000", "statutory 341.0000"}, lineSummary(e.Slips(t, run.ID)["E-100"])) require.Equal(t, "2759.0000", run.Net.String()) e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { var err error run, err = payroll.RemoveInput(ctx, tx, e.Clock, run.ID, in.ID, e.HR, run.Revision) return err }) require.Equal(t, "3100.0000", run.Net.String())}Create internal/platform/payroll/warnings_test.go:
package payroll_test
import ( "context" "testing"
"github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/hr" "github.com/mcom/erp/internal/platform/hr/hrtest" "github.com/mcom/erp/internal/platform/payroll" "github.com/mcom/erp/internal/platform/payroll/payrolltest")
func TestWarningsListWhoIsLeftOutOrUnpayable(t *testing.T) { e := payrolltest.New(t) e.Employee(t, e.Staff, "E-100", "3100.00") // no payout details; below its band e.Hired(t, e.HR, "E-200") // no salary e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { // no login x, err := hr.Create(ctx, tx, e.Clock, hr.CreateInput{LegalName: "Ali bin Abu", EmployeeNo: "E-300"}) if err != nil { return err } _, _, err = hr.Hire(ctx, tx, e.Clock, hr.HireInput{EmployeeID: x.ID, EffectiveOn: hrtest.Day(2026, 1, 1), EmploymentType: hr.TypePermanent, Actor: e.HR}) return err }) e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { // the Staff seat's grade pays 4000–6000 g, err := hr.CreateGrade(ctx, tx, e.Clock, hr.GradeInput{Code: "G2", Names: hr.Names{"en": "Grade 2", "ms": "Gred 2"}, Rank: 2}) if err != nil { return err } if _, err = hr.AddBand(ctx, tx, e.Clock, hr.BandInput{GradeID: g.ID, EffectiveFrom: hrtest.Day(2026, 1, 1), MinSalary: money.MustParse("4000.00"), MaxSalary: money.MustParse("6000.00")}); err != nil { return err } _, err = hr.SetPositionGrade(ctx, tx, e.Clock, e.StaffSeat, &g.ID) return err }) run := e.Run(t, hrtest.Day(2026, 8, 1)) var ws []payroll.Warning e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { var err error ws, err = payroll.RunWarnings(ctx, tx, run) return err }) got := map[string][]string{} for _, w := range ws { got[w.EmployeeNo] = append(got[w.EmployeeNo], w.Code) } require.Equal(t, map[string][]string{ "E-100": {payroll.WarnOutsideBand, payroll.WarnNoPayoutDetails}, "E-200": {payroll.WarnNoSalary}, "E-300": {payroll.WarnNoMember}, }, got)
// PRESENCE then ABSENCE: once Staff's details are verified, that warning goes. e.Pay.VerifiedPayee(t, e.Staff) e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { var err error ws, err = payroll.RunWarnings(ctx, tx, run) return err }) for _, w := range ws { require.NotEqual(t, payroll.WarnNoPayoutDetails, w.Code) }}- Step 6: Run them to verify they fail
Run: go test ./internal/platform/payroll/ -run 'TestARun|TestALeaver|TestApprovedUnpaid|TestARecalculation|TestOneRun|TestInputs|TestWarnings' -count=1
Expected: FAIL to compile — undefined: payroll.CreateRun.
- Step 7: Write
run.go
package payroll
import ( "context" "errors" "fmt" "strings" "time"
"github.com/google/uuid" "github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/audit" "github.com/mcom/erp/internal/kernel/clock" "github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/hr" "github.com/mcom/erp/internal/platform/numbering" "github.com/mcom/erp/internal/platform/workflow")
// Run is a payroll run with its current generation's totals. Status is the stored one,// or RunPaid when it is approved and every payslip with net pay is paid (PD-15).type Run struct { ID uuid.UUID Reference string Month time.Time Status string InstanceID uuid.UUID State string CalcNo, Revision int PreparedBy uuid.UUID ApprovedBy *uuid.UUID ApprovedSoleOwner bool Gross, Deductions, Net money.Amount PayslipCount int PaidCount int CalculatedAt *time.Time CreatedAt time.Time}
// Payslip is one employee's payslip of a run's generation. Lines is filled by GetPayslip.type Payslip struct { ID, RunID, EmployeeID, MemberID, PartyID, PayoutChannelID uuid.UUID EmployeeNo, EmployeeName, Reference string Seq, CalcNo int Gross, Deductions, Net money.Amount DaysInMonth, DaysEmployed, Divisor int InstanceID *uuid.UUID RunStatus string Month time.Time Paid bool PaidAt *time.Time BatchReference *string Lines []Line}
type Line struct { ID uuid.UUID LineNo int Kind string SalaryID, TypeID, EmployeeItemID, InputID *uuid.UUID StatutoryCode *string Description string Days *hr.Days Deduction bool Amount money.Amount}
// Input is one live run input (PD-13).type Input struct { ID, RunID, EmployeeID uuid.UUID Kind string TypeID *uuid.UUID StatutoryCode *string Description string Amount money.Amount CreatedBy uuid.UUID CreatedAt time.Time}
type InputInput struct { RunID, EmployeeID uuid.UUID Kind string TypeID *uuid.UUID StatutoryCode *string Description string Amount money.Amount Actor uuid.UUID ExpectedRevision int}
const runSelect = ` SELECT r.id, r.reference, r.period_month, r.status, r.instance_id, i.state_key, r.calc_no, r.revision, r.prepared_by, r.approved_by, r.approved_sole_owner, r.calculated_at, r.created_at, coalesce(t.gross, 0), coalesce(t.deductions, 0), coalesce(t.net, 0), coalesce(t.n, 0), coalesce(t.payable, 0), coalesce(t.paid, 0) FROM payroll_run r JOIN workflow_instance i ON i.org_id = r.org_id AND i.id = r.instance_id LEFT JOIN LATERAL ( SELECT sum(p.gross) AS gross, sum(p.deductions) AS deductions, sum(p.net) AS net, count(*) AS n, count(*) FILTER (WHERE p.net > 0) AS payable, count(*) FILTER (WHERE EXISTS (SELECT 1 FROM payslip_payment pp WHERE pp.org_id = $1 AND pp.payslip_id = p.id)) AS paid FROM payslip p WHERE p.org_id = $1 AND p.run_id = r.id AND p.calc_no = r.calc_no) t ON true WHERE r.org_id = $1 AND i.org_id = $1 AND `
func scanRun(row pgx.Row) (Run, error) { var r Run var payable int err := row.Scan(&r.ID, &r.Reference, &r.Month, &r.Status, &r.InstanceID, &r.State, &r.CalcNo, &r.Revision, &r.PreparedBy, &r.ApprovedBy, &r.ApprovedSoleOwner, &r.CalculatedAt, &r.CreatedAt, &r.Gross, &r.Deductions, &r.Net, &r.PayslipCount, &payable, &r.PaidCount) if errors.Is(err, pgx.ErrNoRows) { return Run{}, ErrNotFound } if err != nil { return Run{}, err } r.Month = dateOnly(r.Month) if r.Status == RunApproved && payable > 0 && r.PaidCount == payable { r.Status = RunPaid } return r, nil}
func GetRun(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Run, error) { org, err := orgOf(ctx) if err != nil { return Run{}, err } return scanRun(tx.QueryRow(ctx, runSelect+`r.id = $2`, org, id))}
// ListRuns is the organization's runs, newest month first.// ponytail: capped at 120 runs (ten years of months); add a cursor when an organization has more.func ListRuns(ctx context.Context, tx pgx.Tx) ([]Run, error) { org, err := orgOf(ctx) if err != nil { return nil, err } rows, err := tx.Query(ctx, runSelect+`true ORDER BY r.period_month DESC, r.created_at DESC LIMIT 120`, org) if err != nil { return nil, fmt.Errorf("payroll: runs: %w", err) } defer rows.Close() var out []Run for rows.Next() { r, err := scanRun(rows) if err != nil { return nil, err } out = append(out, r) } return out, rows.Err()}
func lockRun(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Run, error) { org, err := orgOf(ctx) if err != nil { return Run{}, err } var x uuid.UUID err = tx.QueryRow(ctx, `SELECT id FROM payroll_run WHERE org_id = $1 AND id = $2 FOR UPDATE`, org, id).Scan(&x) if errors.Is(err, pgx.ErrNoRows) { return Run{}, ErrNotFound } if err != nil { return Run{}, fmt.Errorf("payroll: lock run: %w", err) } return GetRun(ctx, tx, id)}
// CreateRun opens month's run and prepares it (PD-17). month is any day of it.func CreateRun(ctx context.Context, tx pgx.Tx, c clock.Clock, month time.Time, actor uuid.UUID) (Run, error) { org, err := orgOf(ctx) if err != nil { return Run{}, err } y, m, _ := dateOnly(month).Date() first := time.Date(y, m, 1, 0, 0, 0, 0, time.UTC) ty, tm, _ := clock.Today(ctx, c).Date() if first.After(time.Date(ty, tm, 1, 0, 0, 0, 0, time.UTC)) { return Run{}, ErrMonthFuture } set, err := GetSetting(ctx, tx) if err != nil { return Run{}, err } if !set.Configured() { return Run{}, ErrNotConfigured } var taken bool if err := tx.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM payroll_run WHERE org_id = $1 AND period_month = $2 AND status <> 'voided')`, org, first).Scan(&taken); err != nil { return Run{}, fmt.Errorf("payroll: create run: %w", err) } if taken { return Run{}, ErrRunExists } n, err := numbering.Allocate(ctx, tx, RunKind, y) if err != nil { return Run{}, err } id := uuid.New() inst, err := workflow.Start(ctx, tx, c, workflow.StartInput{Kind: RunKind, SourceID: id, Reporter: actor, Amount: money.Zero()}) if err != nil { return Run{}, err } now := c.Now() _, err = tx.Exec(ctx, `INSERT INTO payroll_run (id, org_id, reference, period_month, instance_id, prepared_by, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $7)`, id, org, numbering.Format("PR", y, n), first, inst.ID, actor, now) if isConstraint(err, "payroll_run_one_per_month") { return Run{}, ErrRunExists } if err != nil { return Run{}, fmt.Errorf("payroll: create run: %w", err) } run, err := GetRun(ctx, tx, id) if err != nil { return Run{}, err } if run, err = calculate(ctx, tx, c, run, actor); err != nil { return Run{}, err } return run, audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntityRun, EntityID: id, Action: "create", After: map[string]any{"reference": run.Reference, "month": first.Format("2006-01"), "payslips": run.PayslipCount}})}
// Recalculate writes a new generation of the draft run (PD-14).func Recalculate(ctx context.Context, tx pgx.Tx, c clock.Clock, runID, actor uuid.UUID, expectedRevision int) (Run, error) { run, err := lockDraft(ctx, tx, runID, expectedRevision) if err != nil { return Run{}, err } if run, err = calculate(ctx, tx, c, run, actor); err != nil { return Run{}, err } return run, audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntityRun, EntityID: run.ID, Action: "recalculate", After: map[string]any{"calc_no": run.CalcNo, "payslips": run.PayslipCount}})}
func lockDraft(ctx context.Context, tx pgx.Tx, runID uuid.UUID, expectedRevision int) (Run, error) { run, err := lockRun(ctx, tx, runID) if err != nil { return Run{}, err } if run.Revision != expectedRevision { return Run{}, &RevisionMismatchError{Current: run.Revision} } if run.Status != RunDraft { return Run{}, ErrRunNotDraft } return run, nil}
// employedDays is emp's employed days inside [month, end] (PD-8).func employedDays(emp hr.Employee, month, end time.Time) (time.Time, time.Time) { first, last := month, end if emp.HireDate != nil && emp.HireDate.After(first) { first = dateOnly(*emp.HireDate) } if emp.LastDay != nil && emp.LastDay.Before(last) { last = dateOnly(*emp.LastDay) } return first, last}
// calculate writes generation run.CalcNo+1 (the caller holds the run, a draft) and sets the// run instance's amount to the total net pay (the amount the approval ladder reads).func calculate(ctx context.Context, tx pgx.Tx, c clock.Clock, run Run, actor uuid.UUID) (Run, error) { org, err := orgOf(ctx) if err != nil { return Run{}, err } set, err := GetSetting(ctx, tx) if err != nil { return Run{}, err } if !set.Configured() { return Run{}, ErrNotConfigured } divisor := 0 if set.UnpaidLeaveDivisor != nil { divisor = *set.UnpaidLeaveDivisor } month, end := run.Month, monthEnd(run.Month) all, err := hr.EmployedBetween(ctx, tx, month, end) if err != nil { return Run{}, err } var emps []hr.Employee var ids []uuid.UUID for _, emp := range all { if emp.MemberID != nil { // PD-8: no login, no payslip (listed as no_member) emps = append(emps, emp) ids = append(ids, emp.ID) } } sal, err := salaries(ctx, tx, ids) if err != nil { return Run{}, err } items, err := employeeItems(ctx, tx, org, ids) if err != nil { return Run{}, err } itemsOf := map[uuid.UUID][]EmployeeItem{} for _, it := range items { itemsOf[it.EmployeeID] = append(itemsOf[it.EmployeeID], it) } types, err := ListItemTypes(ctx, tx, false) if err != nil { return Run{}, err } typeOf := map[uuid.UUID]ItemType{} for _, t := range types { typeOf[t.ID] = t } inputs, err := Inputs(ctx, tx, run.ID) if err != nil { return Run{}, err } inputsOf := map[uuid.UUID][]Input{} for _, x := range inputs { inputsOf[x.EmployeeID] = append(inputsOf[x.EmployeeID], x) }
calcNo, now := run.CalcNo+1, c.Now() if _, err := tx.Exec(ctx, `UPDATE payroll_run SET calc_no = $3, calculated_by = $4, calculated_at = $5, revision = revision + 1, updated_at = $5 WHERE org_id = $1 AND id = $2`, org, run.ID, calcNo, actor, now); err != nil { return Run{}, fmt.Errorf("payroll: calculate: %w", err) } seq, total := 0, money.Zero() for _, emp := range emps { first, last := employedDays(emp, month, end) unpaid, err := hr.UnpaidLeaveByDate(ctx, tx, emp.ID, first, last) if err != nil { return Run{}, err } in := CalcInput{Month: month, First: first, Last: last, Unpaid: unpaid, Divisor: divisor} for _, s := range sal[emp.ID] { in.Salaries = append(in.Salaries, CalcSalary{ID: s.ID, Amount: s.Amount, From: s.EffectiveFrom}) } for _, it := range itemsOf[emp.ID] { t := typeOf[it.TypeID] in.Items = append(in.Items, CalcItem{ID: it.ID, TypeID: it.TypeID, Description: t.Names["en"], Deduction: t.Kind == KindDeduction, Amount: it.Amount, From: it.EffectiveFrom, To: it.EffectiveTo}) } for _, x := range inputsOf[emp.ID] { deduction := x.Kind == InputStatutory || (x.Kind == InputItem && typeOf[*x.TypeID].Kind == KindDeduction) in.Entries = append(in.Entries, CalcEntry{InputID: x.ID, Kind: x.Kind, TypeID: x.TypeID, StatutoryCode: x.StatutoryCode, Description: x.Description, Deduction: deduction, Amount: x.Amount}) } res, err := Calculate(in) if errors.Is(err, ErrNoSalary) { continue // listed by RunWarnings (no_salary) } if err != nil { return Run{}, err } if res.DaysEmployed == 0 { continue } seq++ var pid uuid.UUID if err := tx.QueryRow(ctx, `INSERT INTO payslip (org_id, run_id, calc_no, seq, reference, employee_id, member_id, payout_channel_id, days_in_month, days_employed, divisor, gross, deductions, net, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING id`, org, run.ID, calcNo, seq, fmt.Sprintf("%s-%03d", run.Reference, seq), emp.ID, *emp.MemberID, *set.PayoutChannelID, res.DaysInMonth, res.DaysEmployed, res.Divisor, res.Gross.String(), res.Deductions.String(), res.Net.String(), now).Scan(&pid); err != nil { return Run{}, fmt.Errorf("payroll: payslip: %w", err) } for i, l := range res.Lines { var days *string if l.Days != nil { s := l.Days.String() days = &s } if _, err := tx.Exec(ctx, `INSERT INTO payslip_line (org_id, payslip_id, line_no, kind, deduction, salary_id, pay_item_type_id, employee_pay_item_id, run_input_id, statutory_code, description, days, amount, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12::numeric, $13, $14)`, org, pid, i+1, l.Kind, l.Deduction, l.SalaryID, l.TypeID, l.EmployeeItemID, l.InputID, l.StatutoryCode, l.Description, days, l.Amount.String(), now); err != nil { return Run{}, fmt.Errorf("payroll: payslip line: %w", err) } } total = total.Add(res.Net) } if total.IsNegative() { // submission refuses a negative net (PD-16); the ladder reads zero meanwhile total = money.Zero() } if err := workflow.SetAmount(ctx, tx, c, run.InstanceID, total); err != nil { return Run{}, err } return GetRun(ctx, tx, run.ID)}
const inputSelect = `SELECT id, run_id, employee_id, kind, pay_item_type_id, statutory_code, description, amount, created_by, created_at FROM payroll_run_input WHERE org_id = $1 AND removed_at IS NULL AND `
// Inputs is the run's live inputs, oldest first.func Inputs(ctx context.Context, tx pgx.Tx, runID uuid.UUID) ([]Input, error) { org, err := orgOf(ctx) if err != nil { return nil, err } rows, err := tx.Query(ctx, inputSelect+`run_id = $2 ORDER BY created_at, id`, org, runID) if err != nil { return nil, fmt.Errorf("payroll: inputs: %w", err) } defer rows.Close() var out []Input for rows.Next() { var x Input if err := rows.Scan(&x.ID, &x.RunID, &x.EmployeeID, &x.Kind, &x.TypeID, &x.StatutoryCode, &x.Description, &x.Amount, &x.CreatedBy, &x.CreatedAt); err != nil { return nil, err } out = append(out, x) } return out, rows.Err()}
// AddInput enters a one-off item, a statutory amount or an adjustment on a draft run and// recalculates (PD-13).func AddInput(ctx context.Context, tx pgx.Tx, c clock.Clock, in InputInput) (Run, Input, error) { org, err := orgOf(ctx) if err != nil { return Run{}, Input{}, err } run, err := lockDraft(ctx, tx, in.RunID, in.ExpectedRevision) if err != nil { return Run{}, Input{}, err } if _, err := hr.Get(ctx, tx, in.EmployeeID); err != nil { return Run{}, Input{}, err } desc := in.Description switch in.Kind { case InputItem: if in.TypeID == nil { return Run{}, Input{}, &FieldError{Field: "type_id", Rule: "required", Detail: "a one-off item names its type"} } t, err := GetItemType(ctx, tx, *in.TypeID) if errors.Is(err, ErrNotFound) || (err == nil && (t.Recurrence != RecurOneOff || !t.Active)) { return Run{}, Input{}, &FieldError{Field: "type_id", Rule: "one_off", Detail: "an item entered on a run is of an active one-off type"} } if err != nil { return Run{}, Input{}, err } if err := checkAmount("amount", in.Amount, true); err != nil { return Run{}, Input{}, err } if strings.TrimSpace(desc) == "" { desc = t.Names["en"] } in.StatutoryCode = nil case InputStatutory: if in.StatutoryCode == nil || !containsString(StatutoryCodes, *in.StatutoryCode) { return Run{}, Input{}, &FieldError{Field: "statutory_code", Rule: "known", Detail: "epf, socso, eis or pcb"} } if err := checkAmount("amount", in.Amount, true); err != nil { return Run{}, Input{}, err } if strings.TrimSpace(desc) == "" { desc = strings.ToUpper(*in.StatutoryCode) } in.TypeID = nil case InputAdjustment: if err := checkAmount("amount", in.Amount, false); err != nil { return Run{}, Input{}, err } if in.Amount.IsZero() { return Run{}, Input{}, &FieldError{Field: "amount", Rule: "nonzero", Detail: "an adjustment is not zero"} } in.TypeID, in.StatutoryCode = nil, nil default: return Run{}, Input{}, &FieldError{Field: "kind", Rule: "known", Detail: "item, statutory or adjustment"} } d, err := checkText("description", &desc, true, 200) if err != nil { return Run{}, Input{}, err } x := Input{RunID: run.ID, EmployeeID: in.EmployeeID, Kind: in.Kind, TypeID: in.TypeID, StatutoryCode: in.StatutoryCode, Description: *d, Amount: in.Amount, CreatedBy: in.Actor, CreatedAt: c.Now()} if err := tx.QueryRow(ctx, `INSERT INTO payroll_run_input (org_id, run_id, employee_id, kind, pay_item_type_id, statutory_code, description, amount, created_by, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id`, org, x.RunID, x.EmployeeID, x.Kind, x.TypeID, x.StatutoryCode, x.Description, x.Amount.String(), x.CreatedBy, x.CreatedAt).Scan(&x.ID); err != nil { return Run{}, Input{}, fmt.Errorf("payroll: add input: %w", err) } if run, err = calculate(ctx, tx, c, run, in.Actor); err != nil { return Run{}, Input{}, err } return run, x, audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntityRunInput, EntityID: x.ID, Action: "create", After: x})}
// RemoveInput removes a live input from a draft run and recalculates.func RemoveInput(ctx context.Context, tx pgx.Tx, c clock.Clock, runID, inputID, actor uuid.UUID, expectedRevision int) (Run, error) { org, err := orgOf(ctx) if err != nil { return Run{}, err } run, err := lockDraft(ctx, tx, runID, expectedRevision) if err != nil { return Run{}, err } tag, err := tx.Exec(ctx, `UPDATE payroll_run_input SET removed_by = $4, removed_at = $5 WHERE org_id = $1 AND run_id = $2 AND id = $3 AND removed_at IS NULL`, org, runID, inputID, actor, c.Now()) if err != nil { return Run{}, fmt.Errorf("payroll: remove input: %w", err) } if tag.RowsAffected() == 0 { return Run{}, ErrNotFound } if run, err = calculate(ctx, tx, c, run, actor); err != nil { return Run{}, err } return run, audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntityRunInput, EntityID: inputID, Action: "remove", After: map[string]any{"run_id": runID}})}
func containsString(list []string, s string) bool { for _, x := range list { if x == s { return true } } return false}
const payslipSelect = ` SELECT p.id, p.run_id, p.employee_id, p.member_id, e.party_id, p.payout_channel_id, e.employee_no, pa.legal_name, p.reference, p.seq, p.calc_no, p.gross, p.deductions, p.net, p.days_in_month, p.days_employed, p.divisor, p.instance_id, r.status, r.period_month, pp.paid_at, pp.batch_reference FROM payslip p JOIN payroll_run r ON r.org_id = p.org_id AND r.id = p.run_id JOIN employee e ON e.org_id = p.org_id AND e.id = p.employee_id JOIN party pa ON pa.org_id = e.org_id AND pa.id = e.party_id LEFT JOIN payslip_payment pp ON pp.org_id = p.org_id AND pp.payslip_id = p.id WHERE p.org_id = $1 AND r.org_id = $1 AND e.org_id = $1 AND `
func scanPayslip(row pgx.Row) (Payslip, error) { var p Payslip err := row.Scan(&p.ID, &p.RunID, &p.EmployeeID, &p.MemberID, &p.PartyID, &p.PayoutChannelID, &p.EmployeeNo, &p.EmployeeName, &p.Reference, &p.Seq, &p.CalcNo, &p.Gross, &p.Deductions, &p.Net, &p.DaysInMonth, &p.DaysEmployed, &p.Divisor, &p.InstanceID, &p.RunStatus, &p.Month, &p.PaidAt, &p.BatchReference) if errors.Is(err, pgx.ErrNoRows) { return Payslip{}, ErrNotFound } p.Paid = p.PaidAt != nil p.Month = dateOnly(p.Month) return p, err}
func queryPayslips(ctx context.Context, tx pgx.Tx, where string, args ...any) ([]Payslip, error) { rows, err := tx.Query(ctx, payslipSelect+where, args...) if err != nil { return nil, fmt.Errorf("payroll: payslips: %w", err) } defer rows.Close() var out []Payslip for rows.Next() { p, err := scanPayslip(rows) if err != nil { return nil, err } out = append(out, p) } return out, rows.Err()}
// Payslips is the run's current generation, by sequence, without lines.func Payslips(ctx context.Context, tx pgx.Tx, runID uuid.UUID) ([]Payslip, error) { org, err := orgOf(ctx) if err != nil { return nil, err } return queryPayslips(ctx, tx, `p.run_id = $2 AND p.calc_no = r.calc_no ORDER BY p.seq`, org, runID)}
// GetPayslip is one payslip with its lines.func GetPayslip(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Payslip, error) { org, err := orgOf(ctx) if err != nil { return Payslip{}, err } p, err := scanPayslip(tx.QueryRow(ctx, payslipSelect+`p.id = $2`, org, id)) if err != nil { return Payslip{}, err } rows, err := tx.Query(ctx, `SELECT id, line_no, kind, salary_id, pay_item_type_id, employee_pay_item_id, run_input_id, statutory_code, description, (days * 2)::int, deduction, amount FROM payslip_line WHERE org_id = $1 AND payslip_id = $2 ORDER BY line_no`, org, id) if err != nil { return Payslip{}, fmt.Errorf("payroll: payslip lines: %w", err) } defer rows.Close() for rows.Next() { var l Line var halves *int if err := rows.Scan(&l.ID, &l.LineNo, &l.Kind, &l.SalaryID, &l.TypeID, &l.EmployeeItemID, &l.InputID, &l.StatutoryCode, &l.Description, &halves, &l.Deduction, &l.Amount); err != nil { return Payslip{}, err } if halves != nil { d := hr.Days(*halves) l.Days = &d } p.Lines = append(p.Lines, l) } return p, rows.Err()}
// LockPayslip is the payslip FOR UPDATE (the batch's lock; PD-27), without lines.func LockPayslip(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Payslip, error) { org, err := orgOf(ctx) if err != nil { return Payslip{}, err } var x uuid.UUID err = tx.QueryRow(ctx, `SELECT id FROM payslip WHERE org_id = $1 AND id = $2 FOR UPDATE`, org, id).Scan(&x) if errors.Is(err, pgx.ErrNoRows) { return Payslip{}, ErrNotFound } if err != nil { return Payslip{}, fmt.Errorf("payroll: lock payslip: %w", err) } return scanPayslip(tx.QueryRow(ctx, payslipSelect+`p.id = $2`, org, id))}
// lockPayslips is the run's current generation FOR UPDATE in id order (PD-27).func lockPayslips(ctx context.Context, tx pgx.Tx, run Run) ([]Payslip, error) { org, err := orgOf(ctx) if err != nil { return nil, err } if _, err := tx.Exec(ctx, `SELECT 1 FROM payslip WHERE org_id = $1 AND run_id = $2 AND calc_no = $3 ORDER BY id FOR UPDATE`, org, run.ID, run.CalcNo); err != nil { return nil, fmt.Errorf("payroll: lock payslips: %w", err) } return Payslips(ctx, tx, run.ID)}- Step 8: Write
warnings.go
package payroll
import ( "context" "errors" "fmt"
"github.com/google/uuid" "github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/hr" "github.com/mcom/erp/internal/platform/orgstruct" "github.com/mcom/erp/internal/platform/payout")
// Warning is one thing the run screen lists (PD-30). Warnings never block.type Warning struct { EmployeeID uuid.UUID EmployeeNo, EmployeeName string Code string}
// RunWarnings is computed on read, in employee-number order (PD-30).// ponytail: a few queries per employee; fine at SME size, batch them when a run passes a thousand payslips.func RunWarnings(ctx context.Context, tx pgx.Tx, run Run) ([]Warning, error) { month, end := run.Month, monthEnd(run.Month) emps, err := hr.EmployedBetween(ctx, tx, month, end) if err != nil { return nil, err } slips, err := Payslips(ctx, tx, run.ID) if err != nil { return nil, err } slipOf := map[uuid.UUID]Payslip{} for _, p := range slips { slipOf[p.EmployeeID] = p } var out []Warning add := func(emp hr.Employee, code string) { out = append(out, Warning{EmployeeID: emp.ID, EmployeeNo: emp.EmployeeNo, EmployeeName: emp.LegalName, Code: code}) } for _, emp := range emps { if emp.MemberID == nil { add(emp, WarnNoMember) continue } p, ok := slipOf[emp.ID] if !ok { add(emp, WarnNoSalary) continue } _, last := employedDays(emp, month, end) outside, err := outsideBand(ctx, tx, p, *emp.MemberID, last) if err != nil { return nil, err } if outside { add(emp, WarnOutsideBand) } v, err := payout.ProfileVerification(ctx, tx, p.MemberID, p.PayoutChannelID, false) switch { case errors.Is(err, payout.ErrNoProfile): add(emp, WarnNoPayoutDetails) case err != nil: return nil, err case !v.Verified(): add(emp, WarnBankUnverified) } } return out, nil}
// outsideBand: a salary the payslip's basic lines used lies outside the band in force on day of// the grade of member's primary seat on day. No seat, grade or band: no warning.func outsideBand(ctx context.Context, tx pgx.Tx, p Payslip, member uuid.UUID, day time.Time) (bool, error) { seat, err := orgstruct.PrimaryPositionOf(ctx, tx, member, day) if errors.Is(err, orgstruct.ErrNoPrimaryPosition) { return false, nil } if err != nil { return false, err } pos, err := orgstruct.GetPosition(ctx, tx, seat) if err != nil || pos.GradeID == nil { return false, err } g, err := hr.GetGrade(ctx, tx, *pos.GradeID) if err != nil { return false, err } b := g.BandOn(day) if b == nil { return false, nil } org, err := orgOf(ctx) if err != nil { return false, err } rows, err := tx.Query(ctx, `SELECT DISTINCT s.amount FROM payslip_line l JOIN salary s ON s.org_id = l.org_id AND s.id = l.salary_id WHERE l.org_id = $1 AND s.org_id = $1 AND l.payslip_id = $2 AND l.kind = 'basic'`, org, p.ID) if err != nil { return false, fmt.Errorf("payroll: band check: %w", err) } amounts, err := pgx.CollectRows(rows, pgx.RowTo[money.Amount]) if err != nil { return false, err } for _, a := range amounts { if a.Cmp(b.MinSalary) < 0 || a.Cmp(b.MaxSalary) > 0 { return true, nil } } return false, nil}(Add "time" to warnings.go’s imports.)
- Step 9: Run the tests
Run: go test ./internal/platform/payroll/ -count=1
Expected: PASS. If TestALeaverIsProratedToTheLastDay’s LastDay is refused, read the error: H1 refuses a last day only for open advances or floats, which this world has none of.
- Step 10: Commit
git add internal/platform/workflow/model.go internal/platform/payrollgit commit -m "Payroll P1: runs, calculation generations, inputs, payslips and warnings
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"Task 6: Submission, approval, rejection and void — TransitionRun and the freeze drift test
Section titled “Task 6: Submission, approval, rejection and void — TransitionRun and the freeze drift test”Files:
- Create:
internal/platform/payroll/transition.go - Modify:
internal/platform/payroll/payrolltest/env.go(Move,MoveErr,ApprovedRun) - Test:
internal/platform/payroll/transition_test.go,internal/platform/payroll/freeze_test.go
Interfaces:
-
Consumes: Task 5’s
lockRun,calculate,lockPayslips,Payslips,GetRun;workflow.LockSoleOwner(ctx, tx) error,workflow.Get,workflow.Load,(workflow.Definition).State,workflow.Transition(ctx, tx, c, workflow.TransitionInput{InstanceID, From, To, Actor, Reason}) (workflow.Decision, error),workflow.Start,workflow.ViaSoleOwner,workflow.SoleOwnerAudit(state string, d workflow.Decision) map[string]any,workflow.ErrNoSuchEdge,workflow.ErrStaleState,*workflow.RefusedError,(*advancestest.Env).NopPoster(t). -
Produces:
TransitionInput,TransitionRun,RequiredPermission(signatures above);checkVoidable(ctx, tx, org uuid.UUID, run Run) error(extended in Task 8); fixture(*payrolltest.Env).Move(t, run payroll.Run, by uuid.UUID, to, reason string) payroll.Run,(*Env).MoveErr(run payroll.Run, by uuid.UUID, to, reason string) error,(*Env).ApprovedRun(t, month time.Time) payroll.Run(prepared and submitted by HR, approved by the Owner). -
Step 1: Extend the fixture
Append to payrolltest/env.go:
// MoveErr moves run from its current state to to, as by.func (e *Env) MoveErr(run payroll.Run, by uuid.UUID, to, reason string) error { return e.TxErr(func(ctx context.Context, tx pgx.Tx) error { cur, err := payroll.GetRun(ctx, tx, run.ID) if err != nil { return err } _, _, err = payroll.TransitionRun(ctx, tx, e.Clock, payroll.TransitionInput{RunID: run.ID, From: cur.State, To: to, Actor: by, Reason: reason, ExpectedRevision: cur.Revision}) return err })}
// Move is MoveErr that must succeed; it returns the run afterwards.func (e *Env) Move(t *testing.T, run payroll.Run, by uuid.UUID, to, reason string) payroll.Run { t.Helper() require.NoError(t, e.MoveErr(run, by, to, reason)) var out payroll.Run e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { var err error out, err = payroll.GetRun(ctx, tx, run.ID) return err }) return out}
// ApprovedRun is month's run prepared and submitted by HR and approved by the Owner. The// caller registers a Poster first (NopPoster, or the payroll rules).func (e *Env) ApprovedRun(t *testing.T, month time.Time) payroll.Run { t.Helper() run := e.Move(t, e.Run(t, month), e.HR, "submitted", "") return e.Move(t, run, e.Owner, "approved", "")}- Step 2: Write the failing transition tests
Create internal/platform/payroll/transition_test.go:
package payroll_test
import ( "context" "testing"
"github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/hr/hrtest" "github.com/mcom/erp/internal/platform/payroll" "github.com/mcom/erp/internal/platform/payroll/payrolltest" "github.com/mcom/erp/internal/platform/workflow")
func TestThePreparerIsNotTheApprover(t *testing.T) { e := payrolltest.New(t) e.NopPoster(t) e.Employee(t, e.Staff, "E-100", "3100.00")
// PRESENCE: HR prepares and submits; the Owner (the OWNER-seat grant) approves. aug := e.ApprovedRun(t, hrtest.Day(2026, 8, 1)) require.Equal(t, payroll.RunApproved, aug.Status) require.Equal(t, e.Owner, *aug.ApprovedBy) require.False(t, aug.ApprovedSoleOwner)
// ABSENCE: the Owner prepares September, and may not approve it. sep, err := e.RunErr(hrtest.Day(2026, 9, 1), e.Owner) require.NoError(t, err) sep = e.Move(t, sep, e.Owner, "submitted", "") var refused *workflow.RefusedError require.ErrorAs(t, e.MoveErr(sep, e.Owner, "approved", ""), &refused) require.Equal(t, "self_approval", refused.Refusal.Code)
// The database refuses it too, whatever the Go does. _, err = e.H.Admin.Exec(context.Background(), `UPDATE payroll_run SET approved_by = prepared_by, approved_at = now() WHERE org_id = $1 AND id = $2`, e.Org, sep.ID) _, constraint := pgCode(err) require.Equal(t, "payroll_run_approver_differs", constraint)}
func TestApprovalStartsAPayslipInstancePerNetPay(t *testing.T) { e := payrolltest.New(t) e.NopPoster(t) e.Employee(t, e.Staff, "E-100", "3100.00") run := e.ApprovedRun(t, hrtest.Day(2026, 8, 1)) slip := e.Slips(t, run.ID)["E-100"] require.NotNil(t, slip.InstanceID) var inst workflow.Instance e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { var err error inst, err = workflow.Get(ctx, tx, *slip.InstanceID) return err }) require.Equal(t, payroll.PayslipKind, inst.Kind) require.Equal(t, "unpaid", inst.StateKey) require.Equal(t, e.Staff, inst.Reporter, "the payee is the payslip's reporter") require.Equal(t, "3100.0000", inst.Amount.String())}
func TestSubmissionRecalculatesAndRefusesANegativeNet(t *testing.T) { e := payrolltest.New(t) e.NopPoster(t) emp := e.Employee(t, e.Staff, "E-100", "3100.00") run := e.Run(t, hrtest.Day(2026, 8, 1)) pcb := payroll.StatPCB e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { var err error run, _, err = payroll.AddInput(ctx, tx, e.Clock, payroll.InputInput{RunID: run.ID, EmployeeID: emp.ID, Kind: payroll.InputStatutory, StatutoryCode: &pcb, Amount: money.MustParse("5000.00"), Actor: e.HR, ExpectedRevision: run.Revision}) return err }) var neg *payroll.NegativeNetError require.ErrorAs(t, e.MoveErr(run, e.HR, "submitted", ""), &neg) require.Equal(t, []string{"PR-2026-0001-001"}, neg.References)}
func TestARejectedRunIsRevisedAndAVoidedMonthCanRunAgain(t *testing.T) { e := payrolltest.New(t) e.NopPoster(t) e.Employee(t, e.Staff, "E-100", "3100.00") run := e.Move(t, e.Run(t, hrtest.Day(2026, 8, 1)), e.HR, "submitted", "") run = e.Move(t, run, e.Owner, "rejected", "Wrong allowance") require.Equal(t, payroll.RunRejected, run.Status) run = e.Move(t, run, e.HR, "draft", "") run = e.Move(t, run, e.HR, "submitted", "") run = e.Move(t, run, e.Owner, "approved", "") run = e.Move(t, run, e.Owner, "voided", "Paid the wrong month") require.Equal(t, payroll.RunVoided, run.Status) again := e.Run(t, hrtest.Day(2026, 8, 1)) require.Equal(t, payroll.RunDraft, again.Status, "a voided run frees its month")}
func TestTheTransitionPermissionFollowsTheSemantic(t *testing.T) { e := payrolltest.New(t) e.NopPoster(t) e.Employee(t, e.Staff, "E-100", "3100.00") run := e.Run(t, hrtest.Day(2026, 8, 1)) need := func(to string) string { var p string e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { var err error p, err = payroll.RequiredPermission(ctx, tx, run.ID, to) return err }) return p } require.Equal(t, payroll.PermissionRunPrepare, need("submitted")) run = e.Move(t, run, e.HR, "submitted", "") require.Equal(t, payroll.PermissionRunApprove, need("approved")) require.Equal(t, payroll.PermissionRunApprove, need("rejected")) run = e.Move(t, run, e.Owner, "approved", "") require.Equal(t, payroll.PermissionRunApprove, need("voided"))}- Step 3: Write the freeze drift test
Create internal/platform/payroll/freeze_test.go:
package payroll_test
import ( "context" "testing"
"github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/hr/hrtest" "github.com/mcom/erp/internal/platform/payroll" "github.com/mcom/erp/internal/platform/payroll/payrolltest")
// The spec's frozen payslips: once a run is submitted its payslips and lines never change.func TestASubmittedRunsPayslipsAreFrozen(t *testing.T) { e := payrolltest.New(t) e.NopPoster(t) e.Employee(t, e.Staff, "E-100", "3100.00") run := e.Run(t, hrtest.Day(2026, 8, 1))
// A forged basic line on the current payslip, as the app role. forge := func(slipID uuid.UUID) error { return e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := tx.Exec(ctx, `INSERT INTO payslip_line (org_id, payslip_id, line_no, kind, deduction, salary_id, description, days, amount, created_at) SELECT $1, $2, 99, 'basic', false, l.salary_id, 'Forged', 1, 1, now() FROM payslip_line l WHERE l.org_id = $1 AND l.payslip_id = $2 AND l.kind = 'basic' LIMIT 1`, e.Org, slipID) return err }) }
// PRESENCE: while the run is a draft, a line is written into its current generation. require.NoError(t, forge(e.Slips(t, run.ID)["E-100"].ID), "fixture: a draft's line is writable; the absence below would be vacuous") // …but a payslip row is never edited in place, draft or not. err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := tx.Exec(ctx, `UPDATE payslip SET gross = gross + 1, net = net + 1 WHERE org_id = $1 AND run_id = $2`, e.Org, run.ID) return err }) _, constraint := pgCode(err) require.Equal(t, "payslip_frozen", constraint, "a payslip is never edited in place")
run = e.Move(t, run, e.HR, "submitted", "") // recalculates: a fresh generation, then frozen slip := e.Slips(t, run.ID)["E-100"]
// ABSENCE: no generation, no line, no payslip after submission. err = e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := payroll.Recalculate(ctx, tx, e.Clock, run.ID, e.HR, run.Revision) return err }) require.ErrorIs(t, err, payroll.ErrRunNotDraft) _, constraint = pgCode(forge(slip.ID)) require.Equal(t, "payslip_frozen", constraint) _, err = e.H.Admin.Exec(context.Background(), `INSERT INTO payslip (org_id, run_id, calc_no, seq, reference, employee_id, member_id, payout_channel_id, days_in_month, days_employed, divisor, gross, deductions, net, created_at) SELECT org_id, run_id, calc_no, 99, reference || '9', employee_id, member_id, payout_channel_id, days_in_month, days_employed, divisor, gross, deductions, net, now() FROM payslip WHERE org_id = $1 AND id = $2`, e.Org, slip.ID) _, constraint = pgCode(err) require.Equal(t, "payslip_frozen", constraint, "the admin role too: the trigger is the rule, not the grant")}(imports: "context", "testing", "github.com/google/uuid", "github.com/jackc/pgx/v5", "github.com/stretchr/testify/require", hrtest, payroll, payrolltest.)
- Step 4: Run them to verify they fail
Run: go test ./internal/platform/payroll/ -run 'TestThePreparer|TestApprovalStarts|TestSubmission|TestARejected|TestTheTransitionPermission|TestASubmittedRuns' -count=1
Expected: FAIL to compile — undefined: payroll.TransitionRun.
- Step 5: Write
transition.go
package payroll
import ( "context" "fmt"
"github.com/google/uuid" "github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/audit" "github.com/mcom/erp/internal/kernel/clock" "github.com/mcom/erp/internal/platform/posting" "github.com/mcom/erp/internal/platform/workflow")
type TransitionInput struct { RunID uuid.UUID From, To string Actor uuid.UUID Reason string ExpectedRevision int}
// statusOf is the stored status for an entered semantic (PD-15): never a state name.func statusOf(sem posting.Semantic) string { switch sem { case posting.Pending: return RunSubmitted case posting.Accrual: return RunApproved case posting.Rejected: return RunRejected case posting.Cancelled: return RunVoided } return RunDraft}
func edgeStates(ctx context.Context, tx pgx.Tx, run Run, to string) (workflow.StateSpec, workflow.StateSpec, error) { inst, err := workflow.Get(ctx, tx, run.InstanceID) if err != nil { return workflow.StateSpec{}, workflow.StateSpec{}, err } def, err := workflow.Load(ctx, tx, inst.DefinitionID) if err != nil { return workflow.StateSpec{}, workflow.StateSpec{}, err } from, _ := def.State(inst.StateKey) target, ok := def.State(to) if !ok { return workflow.StateSpec{}, workflow.StateSpec{}, workflow.ErrNoSuchEdge } return from, target, nil}
// approvalSide: entering the accrual or a rejection, or leaving the accrual (PD-23).func approvalSide(from, target workflow.StateSpec) bool { return target.Semantic == posting.Accrual || target.Semantic == posting.Rejected || from.Semantic == posting.Accrual}
// RequiredPermission is the key the HTTP layer requires for moving runID to to (PD-23).func RequiredPermission(ctx context.Context, tx pgx.Tx, runID uuid.UUID, to string) (string, error) { run, err := GetRun(ctx, tx, runID) if err != nil { return "", err } from, target, err := edgeStates(ctx, tx, run, to) if err != nil { return "", err } if approvalSide(from, target) { return PermissionRunApprove, nil } return PermissionRunPrepare, nil}
// TransitionRun moves a run through its workflow (PD-15, PD-16, PD-22). Lock order: PD-27.func TransitionRun(ctx context.Context, tx pgx.Tx, c clock.Clock, in TransitionInput) (Run, workflow.Decision, error) { org, err := orgOf(ctx) if err != nil { return Run{}, workflow.Decision{}, err } if err := workflow.LockSoleOwner(ctx, tx); err != nil { return Run{}, workflow.Decision{}, err } run, err := lockRun(ctx, tx, in.RunID) if err != nil { return Run{}, workflow.Decision{}, err } if run.Revision != in.ExpectedRevision { return Run{}, workflow.Decision{}, &RevisionMismatchError{Current: run.Revision} } from, target, err := edgeStates(ctx, tx, run, in.To) if err != nil { return Run{}, workflow.Decision{}, err } if from.Key != in.From { return Run{}, workflow.Decision{}, workflow.ErrStaleState } if from.Semantic == posting.Draft && target.Semantic == posting.Pending { // PD-16 if run, err = calculate(ctx, tx, c, run, in.Actor); err != nil { return Run{}, workflow.Decision{}, err } if err := checkSubmittable(ctx, tx, run); err != nil { return Run{}, workflow.Decision{}, err } } var slips []Payslip if target.Semantic == posting.Accrual || from.Semantic == posting.Accrual { if slips, err = lockPayslips(ctx, tx, run); err != nil { return Run{}, workflow.Decision{}, err } } if from.Semantic == posting.Accrual && target.Semantic == posting.Cancelled { // PD-22 if err := checkVoidable(ctx, tx, org, run); err != nil { return Run{}, workflow.Decision{}, err } } d, err := workflow.Transition(ctx, tx, c, workflow.TransitionInput{InstanceID: run.InstanceID, From: in.From, To: in.To, Actor: in.Actor, Reason: in.Reason}) if err != nil { return Run{}, workflow.Decision{}, err } if d.Pending { return run, d, nil } now := c.Now() if target.Semantic == posting.Accrual { // PD-20: one payment instance per payslip with net pay; every row was locked above. for _, p := range slips { if !p.Net.IsPositive() { continue } inst, err := workflow.Start(ctx, tx, c, workflow.StartInput{Kind: PayslipKind, SourceID: p.ID, Reporter: p.MemberID, Amount: p.Net}) if err != nil { return Run{}, workflow.Decision{}, err } if _, err := tx.Exec(ctx, `UPDATE payslip SET instance_id = $3 WHERE org_id = $1 AND id = $2`, org, p.ID, inst.ID); err != nil { return Run{}, workflow.Decision{}, fmt.Errorf("payroll: payslip instance: %w", err) } } _, err = tx.Exec(ctx, `UPDATE payroll_run SET status = $3, approved_by = $4, approved_at = $5, approved_sole_owner = $6, revision = revision + 1, updated_at = $5 WHERE org_id = $1 AND id = $2`, org, run.ID, RunApproved, in.Actor, now, d.Via == workflow.ViaSoleOwner) } else { _, err = tx.Exec(ctx, `UPDATE payroll_run SET status = $3, revision = revision + 1, updated_at = $4 WHERE org_id = $1 AND id = $2`, org, run.ID, statusOf(target.Semantic), now) } if err != nil { return Run{}, workflow.Decision{}, fmt.Errorf("payroll: run status: %w", err) } after, err := GetRun(ctx, tx, run.ID) if err != nil { return Run{}, workflow.Decision{}, err } return after, d, audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntityRun, EntityID: run.ID, Action: "transition", After: workflow.SoleOwnerAudit(in.To, d)})}
func checkSubmittable(ctx context.Context, tx pgx.Tx, run Run) error { slips, err := Payslips(ctx, tx, run.ID) if err != nil { return err } if len(slips) == 0 || !run.Gross.IsPositive() { return ErrEmptyRun } var neg []string for _, p := range slips { if p.Net.IsNegative() { neg = append(neg, p.Reference) } } if len(neg) > 0 { return &NegativeNetError{References: neg} } return nil}
// checkVoidable refuses a void while a payslip is paid (PD-22). Task 8 adds "or on a live// batch line".func checkVoidable(ctx context.Context, tx pgx.Tx, org uuid.UUID, run Run) error { var held bool if err := tx.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM payslip p WHERE p.org_id = $1 AND p.run_id = $2 AND p.calc_no = $3 AND EXISTS (SELECT 1 FROM payslip_payment pp WHERE pp.org_id = $1 AND pp.payslip_id = p.id))`, org, run.ID, run.CalcNo).Scan(&held); err != nil { return fmt.Errorf("payroll: void check: %w", err) } if held { return ErrRunInPayment } return nil}- Step 6: Run the tests
Run: go test ./internal/platform/payroll/ -count=1
Expected: PASS.
- Step 7: Watch the freeze guard fail once
Sabotage: in db/migrations/00900_payroll.sql, comment out CREATE TRIGGER payslip_line_guard BEFORE INSERT ON payslip_line … (prefix -- ). Run go test ./internal/platform/payroll/ -run TestASubmittedRunsPayslipsAreFrozen -count=1.
Expected: FAIL at the post-submission forge — expected: "payslip_frozen" actual: "" (the forged line went in). Read it, restore the line, re-run: PASS. Repeat with CREATE TRIGGER payslip_guard … commented out: FAIL at “a payslip is never edited in place”; restore; PASS.
- Step 8: Commit
git add internal/platform/payrollgit commit -m "Payroll P1: submit, approve, reject, revise and void a run; payslip instances; frozen payslips
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"Task 7: Posting the run — the accrual rule by semantic tags
Section titled “Task 7: Posting the run — the accrual rule by semantic tags”Files:
- Create:
internal/platform/payroll/posting.go - Modify:
internal/platform/payroll/payrolltest/env.go(UsePayrollPoster,EntryLines) - Test:
internal/platform/payroll/posting_test.go
Interfaces:
-
Consumes:
accounting.PostingRule,accounting.Source{Event, BaseCurrency, Control},accounting.Draft{JournalID, JournalType, Currency, Description, Lines, EntryDate},ledger.Line{AccountID, Debit, Credit, PartyID, Memo},ledger.Unreversed,ledger.Get,chart.JournalMisc, Task 5’sGetRun,Payslips,monthEnd, Task 3’sGetSetting;(*expensestest.Env).UsePoster(t, rules ...accounting.PostingRule). -
Produces:
type AccrualRule struct{}(Kind() = RunKind,Semantic() = posting.Accrual);func Rules() []accounting.PostingRule(Task 8 addsPayoutRule{}); memo prefixesmemoGross = "Gross pay ",memoDeductions = "Deductions ",memoNet = "Net pay "; fixture(*Env).UsePayrollPoster(t),(*Env).EntryLines(t, instanceID uuid.UUID, semantic string) []ledger.PostedLine. -
Step 1: Extend the fixture
Append to payrolltest/env.go (imports gain "github.com/mcom/erp/internal/platform/accounting/ledger"):
// UsePayrollPoster registers the Poster over payroll's rules.func (e *Env) UsePayrollPoster(t *testing.T) { t.Helper(); e.UsePoster(t, payroll.Rules()...) }
// EntryLines is the lines of instanceID's one unreversed entry of semantic (empty when none).func (e *Env) EntryLines(t *testing.T, instanceID uuid.UUID, semantic string) []ledger.PostedLine { t.Helper() var out []ledger.PostedLine e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { open, err := ledger.Unreversed(ctx, tx, instanceID) if err != nil { return err } for _, x := range open { if x.Semantic != semantic { continue } entry, err := ledger.Get(ctx, tx, x.ID) if err != nil { return err } out = entry.Lines } return nil }) return out}- Step 2: Write the failing test
Create internal/platform/payroll/posting_test.go:
package payroll_test
import ( "context" "testing"
"github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/accounting/chart" "github.com/mcom/erp/internal/platform/accounting/ledger" "github.com/mcom/erp/internal/platform/hr/hrtest" "github.com/mcom/erp/internal/platform/payroll" "github.com/mcom/erp/internal/platform/payroll/payrolltest")
func TestApprovalPostsGrossDeductionsAndNetPerPayslip(t *testing.T) { e := payrolltest.New(t) e.UsePayrollPoster(t) emp := e.Employee(t, e.Staff, "E-100", "3100.00") run := e.Run(t, hrtest.Day(2026, 8, 1)) epf := payroll.StatEPF e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { var err error run, _, err = payroll.AddInput(ctx, tx, e.Clock, payroll.InputInput{RunID: run.ID, EmployeeID: emp.ID, Kind: payroll.InputStatutory, StatutoryCode: &epf, Amount: money.MustParse("341.00"), Actor: e.HR, ExpectedRevision: run.Revision}) return err }) run = e.Move(t, run, e.HR, "submitted", "") run = e.Move(t, run, e.Owner, "approved", "")
code := map[string]string{} e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { for _, c := range []string{"6210", "2190", "2180"} { a, err := chart.GetAccountByCode(ctx, tx, c) if err != nil { return err } code[a.ID.String()] = c } return nil }) got := map[string]string{} debit, credit := money.Zero(), money.Zero() for _, l := range e.EntryLines(t, run.InstanceID, ledger.SemanticAccrual) { require.NotNil(t, l.PartyID, "every line names the employee") require.Equal(t, emp.PartyID, *l.PartyID) side := "Dr " + l.Debit.String() if l.Credit.IsPositive() { side = "Cr " + l.Credit.String() } got[code[l.AccountID.String()]] = side + " " + l.Memo debit, credit = debit.Add(l.Debit), credit.Add(l.Credit) } require.Equal(t, map[string]string{ "6210": "Dr 3100.0000 Gross pay PR-2026-0001-001", "2190": "Cr 341.0000 Deductions PR-2026-0001-001", "2180": "Cr 2759.0000 Net pay PR-2026-0001-001", }, got) require.Zero(t, debit.Cmp(credit), "balanced")
// A void reverses it (the engine's mirror): nothing stays unreversed. e.Move(t, run, e.Owner, "voided", "Paid the wrong month") require.Empty(t, e.EntryLines(t, run.InstanceID, ledger.SemanticAccrual))}- Step 3: Run it to verify it fails
Run: go test ./internal/platform/payroll/ -run TestApprovalPostsGross -count=1
Expected: FAIL to compile — undefined: payroll.Rules.
- Step 4: Write
posting.go
package payroll
import ( "context" "errors" "fmt"
"github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/platform/accounting" "github.com/mcom/erp/internal/platform/accounting/chart" "github.com/mcom/erp/internal/platform/accounting/ledger" "github.com/mcom/erp/internal/platform/posting")
// The memos that tie a journal line to its payslip (PD-18, PD-21).const ( memoGross = "Gross pay " memoDeductions = "Deductions " memoNet = "Net pay ")
var errInstanceMismatch = errors.New("payroll: the event's instance is not this document's")
// Rules is every posting rule payroll registers (internal/modules/payroll/boot.go).func Rules() []accounting.PostingRule { return []accounting.PostingRule{AccrualRule{}} }
// AccrualRule posts an approved run (PD-18): per payslip, the gross to salary expense// (salary_expense), the deductions to the deductions payable (statutory_payable) and the net to// the run state's control, payroll_payable (net_pay_payable).type AccrualRule struct{}
var _ accounting.PostingRule = AccrualRule{}
func (AccrualRule) Kind() string { return RunKind }func (AccrualRule) Semantic() posting.Semantic { return posting.Accrual }
func (AccrualRule) Build(ctx context.Context, tx pgx.Tx, src accounting.Source) (accounting.Draft, error) { run, err := GetRun(ctx, tx, src.Event.SourceID) if err != nil { return accounting.Draft{}, fmt.Errorf("payroll: accrual: %w", err) } if run.InstanceID != src.Event.InstanceID { return accounting.Draft{}, errInstanceMismatch } set, err := GetSetting(ctx, tx) if err != nil { return accounting.Draft{}, err } if set.SalaryExpenseAccountID == nil || set.StatutoryPayableAccountID == nil { return accounting.Draft{}, ErrNotConfigured } slips, err := Payslips(ctx, tx, run.ID) if err != nil { return accounting.Draft{}, err } var lines []ledger.Line for _, p := range slips { party := p.PartyID if p.Gross.IsPositive() { lines = append(lines, ledger.Line{AccountID: *set.SalaryExpenseAccountID, Debit: p.Gross, PartyID: &party, Memo: memoGross + p.Reference}) } if p.Deductions.IsPositive() { lines = append(lines, ledger.Line{AccountID: *set.StatutoryPayableAccountID, Credit: p.Deductions, PartyID: &party, Memo: memoDeductions + p.Reference}) } if p.Net.IsPositive() { lines = append(lines, ledger.Line{AccountID: src.Control.ID, Credit: p.Net, PartyID: &party, Memo: memoNet + p.Reference}) } } date := monthEnd(run.Month) if bd := src.Event.BusinessDate; bd.Before(date) { date = bd } return accounting.Draft{JournalType: chart.JournalMisc, Currency: src.BaseCurrency, EntryDate: date, Lines: lines, Description: fmt.Sprintf("Payroll %s for %s", run.Reference, run.Month.Format("2006-01"))}, nil}- Step 5: Run the test
Run: go test ./internal/platform/payroll/ -count=1
Expected: PASS.
- Step 6: Commit
git add internal/platform/payrollgit commit -m "Payroll P1: the run's accrual posts gross, deductions and net by semantic tag
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"Task 8: Payslips in payment batches (00902), paid marking and the payout posting
Section titled “Task 8: Payslips in payment batches (00902), paid marking and the payout posting”Files:
- Create:
db/migrations/00902_payslip_batch_line.sql - Create:
internal/platform/payroll/pay.go - Modify:
internal/platform/payroll/posting.go(PayoutRule,Rules),internal/platform/payroll/transition.go(checkVoidable) - Modify:
internal/platform/payables/batch_payout.go,internal/platform/payables/batch.go - Modify:
internal/modules/payables/batch_handlers.go,api/openapi.yaml(payables batch schemas), thenmake generate - Modify:
internal/platform/payroll/payrolltest/env.go(PayslipBatch,PayslipBatchErr,ApproveBatch,MarkPaid),internal/platform/payables/payablestest/env.go(VerifiedPayeeBy) - Test:
internal/platform/payroll/pay_test.go,internal/platform/payables/batch_payslip_test.go
Interfaces:
-
Consumes: C4’s
payoutSource,loadPayoutSource,payoutEligible,movePayout,BatchPayoutCandidates,writeBatchLines,splitLines,BatchLines,CreateBatch,ApproveBatch(ctx, tx, c, id, actor uuid.UUID, expectedRevision int, reason string) (Batch, BatchFile, error),MarkBatchPaid(ctx, tx, c, id, actor uuid.UUID, expectedRevision int, failed []FailedPayment, reason string) (Batch, error),(*payablestest.Env).VerifiedPayee,(*payablestest.Env).Account,(*payablestest.Env).As; Task 5’sLockPayslip,GetPayslip; Task 6’scheckVoidable; Task 7’smemoNet. -
Produces: column
payment_batch_line.payslip_id, indexpayment_batch_line_one_live_payslip, constraintpayment_batch_line_payslip_run_approved;payables.SourcePayslip = payroll.PayslipKind;payables.BatchInput.PayslipIDs;payables.BatchLine.PayslipID *uuid.UUID;payroll.PayInput,payroll.PayPayslip(signatures above);type payroll.PayoutRule struct{}; fixture(*payrolltest.Env).PayslipBatch(t, ids ...uuid.UUID) payables.BatchandPayslipBatchErr(ids ...uuid.UUID) (payables.Batch, error)(prepared by HR from 1520),(*payablestest.Env).VerifiedPayeeBy(t, member, verifier uuid.UUID) uuid.UUID,ApproveBatch(t, b payables.Batch, by uuid.UUID) payables.Batch,MarkPaid(t, b payables.Batch, by uuid.UUID) payables.Batch; JSONpayslip_idson the batch bodies; enum valuepaysliponPaymentBatchLine.source_kindandPaymentBatchPayoutCandidate.kind. -
Step 1: Extend the fixture
Append to payrolltest/env.go (imports gain "github.com/mcom/erp/internal/platform/payables" and "github.com/mcom/erp/internal/platform/expenses/expensestest"):
// PayslipBatch is a draft batch from 1520, prepared by HR, holding the payslips.func (e *Env) PayslipBatch(t *testing.T, ids ...uuid.UUID) payables.Batch { t.Helper() b, err := e.PayslipBatchErr(ids...) require.NoError(t, err) return b}
func (e *Env) PayslipBatchErr(ids ...uuid.UUID) (payables.Batch, error) { var b payables.Batch err := e.DB.InTx(e.Pay.As(e.HR), func(ctx context.Context, tx pgx.Tx) error { bank, err := chartID(ctx, tx, "1520") if err != nil { return err } b, err = payables.CreateBatch(ctx, tx, e.Clock, e.HR, payables.BatchInput{BankAccountID: bank, ValueDate: expensestest.Today, PayslipIDs: ids}) return err }) return b, err}
// ApproveBatch approves b as by (not the preparer, not a payee).func (e *Env) ApproveBatch(t *testing.T, b payables.Batch, by uuid.UUID) payables.Batch { t.Helper() var out payables.Batch require.NoError(t, e.DB.InTx(e.Pay.As(by), func(ctx context.Context, tx pgx.Tx) error { var err error out, _, err = payables.ApproveBatch(ctx, tx, e.Clock, b.ID, by, b.Revision, "") return err })) return out}
// MarkPaid marks b paid in full as by.func (e *Env) MarkPaid(t *testing.T, b payables.Batch, by uuid.UUID) payables.Batch { t.Helper() var out payables.Batch require.NoError(t, e.DB.InTx(e.Pay.As(by), func(ctx context.Context, tx pgx.Tx) error { var err error out, err = payables.MarkBatchPaid(ctx, tx, e.Clock, b.ID, by, b.Revision, nil, "") return err })) return out}
func chartID(ctx context.Context, tx pgx.Tx, code string) (uuid.UUID, error) { a, err := chart.GetAccountByCode(ctx, tx, code) return a.ID, err}(add "github.com/mcom/erp/internal/platform/accounting/chart" to the imports).
- Step 2: Write the failing tests
Create internal/platform/payroll/pay_test.go:
package payroll_test
import ( "context" "strings" "testing"
"github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/accounting/ledger" "github.com/mcom/erp/internal/platform/expenses/expensestest" "github.com/mcom/erp/internal/platform/hr/hrtest" "github.com/mcom/erp/internal/platform/payables" "github.com/mcom/erp/internal/platform/payroll" "github.com/mcom/erp/internal/platform/payroll/payrolltest")
func candidates(t *testing.T, e *payrolltest.Env) map[string]payables.PayoutCandidate { t.Helper() out := map[string]payables.PayoutCandidate{} e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { bank, err := chartIDOf(ctx, tx, "1520") if err != nil { return err } cs, _, err := payables.BatchPayoutCandidates(ctx, tx, bank) for _, c := range cs { if c.Kind == payables.SourcePayslip { out[c.Reference] = c } } return err }) return out}
func TestABatchPaysAPayslipAndClearsThePayrollPayable(t *testing.T) { e := payrolltest.New(t) e.UsePayrollPoster(t) e.Employee(t, e.Staff, "E-100", "3100.00") e.Pay.VerifiedPayee(t, e.Staff) run := e.ApprovedRun(t, hrtest.Day(2026, 8, 1)) slip := e.Slips(t, run.ID)["E-100"]
c := candidates(t, e)[slip.Reference] require.True(t, c.Eligible, "rule %q", c.Rule) require.Equal(t, "3100.0000", c.Amount.String())
b := e.PayslipBatch(t, slip.ID) b = e.ApproveBatch(t, b, e.Owner) e.MarkPaid(t, b, e.Owner)
slip = e.Slips(t, run.ID)["E-100"] require.True(t, slip.Paid) require.Equal(t, b.Reference, *slip.BatchReference) e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { r, err := payroll.GetRun(ctx, tx, run.ID) require.Equal(t, payroll.RunPaid, r.Status, "paid is derived: every payslip with net pay is paid") return err }) lines := e.EntryLines(t, *slip.InstanceID, ledger.SemanticPayout) require.Len(t, lines, 2) require.Equal(t, "3100.0000", lines[0].Debit.String()) require.Equal(t, "3100.0000", lines[1].Credit.String()) require.True(t, strings.Contains(lines[1].Memo, b.Reference), "the bank line names the batch (bank reconciliation)") require.Equal(t, 1, e.Count(t, `SELECT count(*) FROM journal_entry j WHERE j.org_id = $1 AND j.instance_id = $2 AND j.entry_date = $3`, e.Org, *slip.InstanceID, expensestest.Today), "dated the batch's value date") // The payroll payable nets to zero for this payslip. require.Equal(t, 0, e.Count(t, `SELECT count(*) FROM (SELECT sum(l.base_credit) - sum(l.base_debit) AS open FROM journal_line l JOIN account a ON a.org_id = l.org_id AND a.id = l.account_id WHERE l.org_id = $1 AND a.code = '2180' HAVING sum(l.base_credit) - sum(l.base_debit) <> 0) x`, e.Org))}
func TestAnUnverifiedPayeeIsNotBatchable(t *testing.T) { e := payrolltest.New(t) e.UsePayrollPoster(t) e.Employee(t, e.Staff, "E-100", "3100.00") run := e.ApprovedRun(t, hrtest.Day(2026, 8, 1)) slip := e.Slips(t, run.ID)["E-100"] c := candidates(t, e)[slip.Reference] require.False(t, c.Eligible) require.Equal(t, "no_payout_details", c.Rule) _, err := e.PayslipBatchErr(slip.ID) var ie *payables.IneligibleError require.ErrorAs(t, err, &ie) require.Equal(t, "no_payout_details", ie.Rule)}
func TestAVoidWaitsForTheBatchAndAVoidedPayslipIsNeverPayable(t *testing.T) { e := payrolltest.New(t) e.UsePayrollPoster(t) e.Employee(t, e.Staff, "E-100", "3100.00") e.Pay.VerifiedPayee(t, e.Staff) run := e.ApprovedRun(t, hrtest.Day(2026, 8, 1)) slip := e.Slips(t, run.ID)["E-100"] b := e.PayslipBatch(t, slip.ID)
// ABSENCE: on a live (draft) batch line, the run cannot be voided. require.ErrorIs(t, e.MoveErr(run, e.Owner, "voided", "Wrong month"), payroll.ErrRunInPayment) // PRESENCE: off the batch, it can. require.NoError(t, e.DB.InTx(e.Pay.As(e.HR), func(ctx context.Context, tx pgx.Tx) error { _, err := payables.CancelBatch(ctx, tx, e.Clock, b.ID, e.HR, b.Revision, "Payroll voided", false) return err })) run = e.Move(t, run, e.Owner, "voided", "Wrong month")
_, listed := candidates(t, e)[slip.Reference] require.False(t, listed, "a voided run's payslip is not offered") _, err := e.PayslipBatchErr(slip.ID) var ie *payables.IneligibleError require.ErrorAs(t, err, &ie) require.Equal(t, "not_payable_now", ie.Rule)}Add to pay_test.go:
func chartIDOf(ctx context.Context, tx pgx.Tx, code string) (uuid.UUID, error) { a, err := chart.GetAccountByCode(ctx, tx, code) return a.ID, err}(with "github.com/google/uuid" and "github.com/mcom/erp/internal/platform/accounting/chart" imported).
- Step 3: Run them to verify they fail
Run: go test ./internal/platform/payroll/ -run 'TestABatchPays|TestAnUnverified|TestAVoidWaits' -count=1
Expected: FAIL to compile — undefined: payables.SourcePayslip, unknown field PayslipIDs.
- Step 4: Write
00902
Before writing it, confirm 00451 still holds the latest payment_batch_line_guard and payment_batch_line_one_source: grep -l 'FUNCTION payment_batch_line_guard\|payment_batch_line_one_source' db/migrations/*.sql | sort | tail -1 must print 00451_payment_batch_payouts.sql; if not, copy that later file’s body instead and add payslip_id the same way.
Create db/migrations/00902_payslip_batch_line.sql:
-- +goose Up-- Phase 5 payroll P1 (Rulings PD-20–PD-22): a payment batch line may pay a payslip, beside-- supplier payments, claims and advances (C4, 00451). Exactly one source per line; a payslip-- on at most one live line; the source columns frozen like the others; and a payslip joins a-- batch only while its run is approved, with its net pay as the line's amount.ALTER TABLE payment_batch_line ADD COLUMN payslip_id UUID, DROP CONSTRAINT payment_batch_line_one_source, ADD CONSTRAINT payment_batch_line_one_source CHECK (num_nonnulls(payment_id, expense_claim_id, advance_id, payslip_id) = 1), ADD CONSTRAINT payment_batch_line_payslip_fk FOREIGN KEY (org_id, payslip_id) REFERENCES payslip (org_id, id);CREATE UNIQUE INDEX payment_batch_line_one_live_payslip ON payment_batch_line (org_id, payslip_id) WHERE payslip_id IS NOT NULL AND outcome IS NULL;
-- +goose StatementBegin-- 00451's guard with payslip_id among the frozen source columns; otherwise unchanged.CREATE OR REPLACE FUNCTION payment_batch_line_guard() RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$DECLARE s TEXT;BEGIN SELECT b.status INTO s FROM public.payment_batch b WHERE b.org_id = NEW.org_id AND b.id = NEW.batch_id; IF TG_OP = 'INSERT' THEN IF s IS DISTINCT FROM 'draft' OR NEW.outcome IS NOT NULL OR NEW.account_no IS NOT NULL OR NEW.payee_snapshot IS NOT NULL THEN RAISE EXCEPTION 'payment batch %: lines join only a draft, empty', NEW.batch_id USING ERRCODE = '23514', CONSTRAINT = 'payment_batch_line_frozen'; END IF; RETURN NEW; END IF; IF (NEW.id, NEW.org_id, NEW.batch_id, NEW.payment_id, NEW.expense_claim_id, NEW.advance_id, NEW.payslip_id, NEW.line_no, NEW.amount, NEW.supplier_party_id, NEW.payee_member_id, NEW.payout_channel_id, NEW.created_at) IS DISTINCT FROM (OLD.id, OLD.org_id, OLD.batch_id, OLD.payment_id, OLD.expense_claim_id, OLD.advance_id, OLD.payslip_id, OLD.line_no, OLD.amount, OLD.supplier_party_id, OLD.payee_member_id, OLD.payout_channel_id, OLD.created_at) OR (OLD.outcome IS NOT NULL AND (NEW.outcome, NEW.outcome_reason, NEW.outcome_by, NEW.outcome_at) IS DISTINCT FROM (OLD.outcome, OLD.outcome_reason, OLD.outcome_by, OLD.outcome_at)) OR ((OLD.account_no IS NOT NULL OR OLD.payee_snapshot IS NOT NULL) AND ((NEW.payee_name, NEW.bank_entry_id, NEW.bank_key, NEW.bank_name, NEW.account_no, NEW.beneficiary_id, NEW.beneficiary_email, NEW.bank_verified_by, NEW.bank_verified_at) IS DISTINCT FROM (OLD.payee_name, OLD.bank_entry_id, OLD.bank_key, OLD.bank_name, OLD.account_no, OLD.beneficiary_id, OLD.beneficiary_email, OLD.bank_verified_by, OLD.bank_verified_at) OR (NEW.payee_snapshot IS NULL) <> (OLD.payee_snapshot IS NULL) OR public.expense_settlement_unsealed(NEW.payee_snapshot) IS DISTINCT FROM public.expense_settlement_unsealed(OLD.payee_snapshot))) OR (OLD.account_no IS NULL AND OLD.payee_snapshot IS NULL AND (NEW.account_no IS NOT NULL OR NEW.payee_snapshot IS NOT NULL) AND (s <> 'draft' OR OLD.outcome IS NOT NULL)) THEN RAISE EXCEPTION 'payment batch line %: frozen', OLD.id USING ERRCODE = '23514', CONSTRAINT = 'payment_batch_line_frozen'; END IF; IF NEW.outcome IS DISTINCT FROM OLD.outcome AND NOT ( (NEW.outcome = 'removed' AND s = 'draft') OR (NEW.outcome = 'cancelled' AND s IN ('draft','approved')) OR (NEW.outcome IN ('paid','failed') AND s = 'approved')) THEN RAISE EXCEPTION 'payment batch line %: % while the batch is %', OLD.id, NEW.outcome, s USING ERRCODE = '23514', CONSTRAINT = 'payment_batch_line_outcome_flow'; END IF; RETURN NEW;END $$;-- +goose StatementEnd
-- +goose StatementBeginCREATE FUNCTION payment_batch_line_payslip_guard() RETURNS trigger LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$DECLARE ok boolean;BEGIN SELECT r.status = 'approved' AND p.calc_no = r.calc_no AND p.instance_id IS NOT NULL AND p.net = NEW.amount AND p.member_id = NEW.payee_member_id INTO ok FROM public.payslip p JOIN public.payroll_run r ON r.org_id = p.org_id AND r.id = p.run_id WHERE p.org_id = NEW.org_id AND p.id = NEW.payslip_id; IF ok IS NOT TRUE THEN RAISE EXCEPTION 'payment batch line: payslip % is paid only while its run is approved, for its net pay, to its employee', NEW.payslip_id USING ERRCODE = '23514', CONSTRAINT = 'payment_batch_line_payslip_run_approved'; END IF; RETURN NEW;END $$;-- +goose StatementEndCREATE TRIGGER payment_batch_line_payslip_guard BEFORE INSERT ON payment_batch_line FOR EACH ROW WHEN (NEW.payslip_id IS NOT NULL) EXECUTE FUNCTION payment_batch_line_payslip_guard();
-- +goose Down-- Refuses (one_source) while a payslip line exists: those records stay.DROP TRIGGER payment_batch_line_payslip_guard ON payment_batch_line;DROP FUNCTION payment_batch_line_payslip_guard();-- 00451's guard, restored verbatim: copy its Up function body here when writing this file.DROP INDEX payment_batch_line_one_live_payslip;ALTER TABLE payment_batch_line DROP CONSTRAINT payment_batch_line_payslip_fk, DROP CONSTRAINT payment_batch_line_one_source, ADD CONSTRAINT payment_batch_line_one_source CHECK (num_nonnulls(payment_id, expense_claim_id, advance_id) = 1), DROP COLUMN payslip_id;In the Down section, replace the comment line -- 00451's guard, restored verbatim: … with 00451’s CREATE OR REPLACE FUNCTION payment_batch_line_guard() block (its Up version, lines -- +goose StatementBegin … -- +goose StatementEnd, copied exactly) — it must come before DROP COLUMN payslip_id, because the current function names that column.
- Step 5: Teach
payablesthe payslip source
In internal/platform/payables/batch_payout.go:
import "github.com/mcom/erp/internal/platform/payroll" // add
const ( SourcePayment = KindPayment SourceClaim = expenses.Kind SourceAdvance = advances.Kind SourcePayslip = payroll.PayslipKind // payroll P1 (PD-20))
var sourceColumn = map[string]string{SourceClaim: "expense_claim_id", SourceAdvance: "advance_id", SourcePayslip: "payslip_id"}payoutSource gains a field:
// Hold is a rule refusing the source whatever its channel: a payslip whose run is not // approved (voided) is never payable (PD-22). Hold stringloadPayoutSource gains a case before its final return:
case SourcePayslip: get := payroll.GetPayslip if lock { get = payroll.LockPayslip } p, err := get(ctx, tx, id) if err != nil { return payoutSource{}, err } profile, err := company.Get(ctx, tx) if err != nil { return payoutSource{}, err } src := payoutSource{Kind: kind, ID: p.ID, Member: p.MemberID, ChannelID: &p.PayoutChannelID, Reference: p.Reference, Currency: profile.BaseCurrency, Amount: p.Net} if p.InstanceID == nil || p.RunStatus != payroll.RunApproved { src.Hold = "not_payable_now" return src, nil } src.InstanceID = *p.InstanceID return src, nilpayoutEligible begins:
if src.Hold != "" { return src.Hold, nil }In BatchPayoutCandidates, add a third entry to the kinds slice after the advance’s:
// A payslip of an approved run, current generation, not yet paid (PD-20); its payout // is its net pay in the base currency. {SourcePayslip, ` SELECT x.id, x.member_id, x.payout_channel_id, x.reference, $4::text, x.net, i.definition_id, i.state_key, p.id IS NOT NULL, p.verified_by IS NOT NULL FROM payslip x JOIN payroll_run r ON r.org_id = x.org_id AND r.id = x.run_id AND r.calc_no = x.calc_no JOIN workflow_instance i ON i.org_id = x.org_id AND i.id = x.instance_id JOIN payout_channel ch ON ch.org_id = x.org_id AND ch.id = x.payout_channel_id LEFT JOIN member_payout_profile p ON p.org_id = x.org_id AND p.member_id = x.member_id AND p.channel_id = x.payout_channel_id WHERE x.org_id = $1 AND r.org_id = $1 AND i.org_id = $1 AND ch.org_id = $1 AND ch.credit_account_id = $2 AND r.status = 'approved' AND NOT i.payout_open AND NOT EXISTS (SELECT 1 FROM payment_batch_line l WHERE l.org_id = $1 AND l.payslip_id = x.id AND l.outcome IS NULL) ORDER BY x.created_at, x.id LIMIT $3`},and change the base-currency argument condition to if k.kind == SourceAdvance || k.kind == SourcePayslip {.
In movePayout’s switch kind, add:
case SourcePayslip: _, d, err = payroll.PayPayslip(ctx, tx, c, payroll.PayInput{PayslipID: id, From: from, To: to, Actor: actor, BatchLineID: l.ID, BatchReference: batchRef, Reason: "Paid in batch " + batchRef})In internal/platform/payables/batch.go:
BatchLine:ClaimID, AdvanceID, PayslipID *uuid.UUID;Source()gainscase l.PayslipID != nil: return SourcePayslip, *l.PayslipIDafter the advance case.BatchInputgainsPayslipIDs []uuid.UUID // approved payslips' net pay (payroll P1, PD-20).sources(): the map literal gainsSourcePayslip: in.PayslipIDs.checkBatchInput:len(in.PaymentIDs) + len(in.ClaimIDs) + len(in.AdvanceIDs) + len(in.PayslipIDs)andslices.Concat(in.PaymentIDs, in.ClaimIDs, in.AdvanceIDs, in.PayslipIDs).CreateBatch’s default branch picking the currency source becomes:
default: kind, id := SourceClaim, uuid.Nil switch { case len(in.ClaimIDs) > 0: id = in.ClaimIDs[0] case len(in.AdvanceIDs) > 0: kind, id = SourceAdvance, in.AdvanceIDs[0] default: kind, id = SourcePayslip, in.PayslipIDs[0] }writeBatchLines:for _, kind := range []string{SourceClaim, SourceAdvance, SourcePayslip};all := slices.Concat(src[SourcePayment], src[SourceClaim], src[SourceAdvance], src[SourcePayslip]);coalesce(payment_id, expense_claim_id, advance_id, payslip_id) <> ALL($3); the unique-violation mapping adds|| isConstraint(err, "payment_batch_line_one_live_payslip").BatchLines: selectl.payslip_idafterl.advance_id,coalesce(p.reference, c.reference, a.reference, s.reference), addLEFT JOIN payslip s ON s.org_id = l.org_id AND s.id = l.payslip_id, scan&l.PayslipIDafter&l.AdvanceID.splitLines: collectSourcePaysliplines intoslipsandreturn pays, append(append(claims, advs...), slips...)(update its comment: “claims, then advances, then payslips, each in id order: the lock order”).MarkBatchPaid’s failed-outcome update:coalesce(payment_id, expense_claim_id, advance_id, payslip_id) = $3.
In internal/modules/payables/batch_handlers.go: batchBody gains PayslipIDs []uuid.UUID \json:“payslip_ids”`, input()passesPayslipIDs: b.PayslipIDs`; the update body struct gains the same field and passes it on.
In api/openapi.yaml: PaymentBatchLine.source_kind enum [supplier_payment, expense_claim, advance, payslip]; PaymentBatchInput and PaymentBatchUpdate gain payslip_ids: { type: array, maxItems: 500, items: { type: string, format: uuid }, description: Approved payslips to pay their net pay. }; PaymentBatchPayoutCandidate.kind enum [expense_claim, advance, payslip]. Run make generate.
- Step 6: Write
pay.go, the payout rule, and widen the void check
Create internal/platform/payroll/pay.go:
package payroll
import ( "context" "errors" "fmt"
"github.com/google/uuid" "github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/audit" "github.com/mcom/erp/internal/kernel/clock" "github.com/mcom/erp/internal/platform/workflow")
// PayInput is a payment batch's mark-paid move of one payslip (PD-21).type PayInput struct { PayslipID uuid.UUID From, To string Actor uuid.UUID BatchLineID uuid.UUID BatchReference string Reason string}
// PayPayslip records the payslip's payment and moves its instance through the payout edge// (payables.movePayout calls it; the batch already holds the payslip FOR UPDATE).func PayPayslip(ctx context.Context, tx pgx.Tx, c clock.Clock, in PayInput) (Payslip, workflow.Decision, error) { org, err := orgOf(ctx) if err != nil { return Payslip{}, workflow.Decision{}, err } p, err := LockPayslip(ctx, tx, in.PayslipID) if err != nil { return Payslip{}, workflow.Decision{}, err } switch { case p.RunStatus != RunApproved || p.InstanceID == nil: return Payslip{}, workflow.Decision{}, ErrRunNotApproved case p.Paid: return Payslip{}, workflow.Decision{}, ErrPayslipPaid } // The C4 "held" check: a live line of that approved batch, with the sealed payee. var bank uuid.UUID err = tx.QueryRow(ctx, `SELECT b.bank_account_id FROM payment_batch_line l JOIN payment_batch b ON b.org_id = l.org_id AND b.id = l.batch_id WHERE l.org_id = $1 AND b.org_id = $1 AND l.id = $2 AND l.payslip_id = $3 AND l.outcome IS NULL AND l.payee_snapshot IS NOT NULL AND b.status = 'approved' AND b.reference = $4`, org, in.BatchLineID, p.ID, in.BatchReference).Scan(&bank) if errors.Is(err, pgx.ErrNoRows) { return Payslip{}, workflow.Decision{}, ErrNotInBatch } if err != nil { return Payslip{}, workflow.Decision{}, fmt.Errorf("payroll: pay payslip: %w", err) } if _, err := tx.Exec(ctx, `INSERT INTO payslip_payment (org_id, payslip_id, batch_line_id, batch_reference, credit_account_id, amount, paid_by, paid_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, org, p.ID, in.BatchLineID, in.BatchReference, bank, p.Net.String(), in.Actor, c.Now()); err != nil { return Payslip{}, workflow.Decision{}, fmt.Errorf("payroll: pay payslip: %w", err) } d, err := workflow.Transition(ctx, tx, c, workflow.TransitionInput{InstanceID: *p.InstanceID, From: in.From, To: in.To, Actor: in.Actor, Reason: in.Reason}) if err != nil { return Payslip{}, workflow.Decision{}, err } after, err := GetPayslip(ctx, tx, p.ID) if err != nil { return Payslip{}, workflow.Decision{}, err } return after, d, audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntityPayslip, EntityID: p.ID, Action: "paid", After: map[string]any{"reference": p.Reference, "batch_reference": in.BatchReference}})}In posting.go, change Rules and add the payout rule:
func Rules() []accounting.PostingRule { return []accounting.PostingRule{AccrualRule{}, PayoutRule{}} }
var errNotAccrued = errors.New("payroll: the payslip's run has no unreversed accrual naming it")
// PayoutRule clears one payslip's net pay from the payroll payable against the bank the batch// paid from, on the batch's value date, the bank line naming the batch (PD-21).type PayoutRule struct{}
var _ accounting.PostingRule = PayoutRule{}
func (PayoutRule) Kind() string { return PayslipKind }func (PayoutRule) Semantic() posting.Semantic { return posting.Payout }
func (PayoutRule) Build(ctx context.Context, tx pgx.Tx, src accounting.Source) (accounting.Draft, error) { org, err := orgOf(ctx) if err != nil { return accounting.Draft{}, err } p, err := GetPayslip(ctx, tx, src.Event.SourceID) if err != nil { return accounting.Draft{}, fmt.Errorf("payroll: payout: %w", err) } if p.InstanceID == nil || *p.InstanceID != src.Event.InstanceID { return accounting.Draft{}, errInstanceMismatch } var credit uuid.UUID var ref string var value time.Time if err := tx.QueryRow(ctx, `SELECT pp.credit_account_id, pp.batch_reference, b.value_date FROM payslip_payment pp JOIN payment_batch b ON b.org_id = pp.org_id AND b.reference = pp.batch_reference WHERE pp.org_id = $1 AND b.org_id = $1 AND pp.payslip_id = $2`, org, p.ID).Scan(&credit, &ref, &value); err != nil { return accounting.Draft{}, fmt.Errorf("payroll: payout of %s: %w", p.Reference, err) } run, err := GetRun(ctx, tx, p.RunID) if err != nil { return accounting.Draft{}, err } control, err := netPayAccount(ctx, tx, run.InstanceID, p) if err != nil { return accounting.Draft{}, err } party := p.PartyID draft := accounting.Draft{EntryDate: value, Currency: src.BaseCurrency, Description: "Net pay " + p.Reference, Lines: []ledger.Line{ {AccountID: control, Debit: p.Net, PartyID: &party}, {AccountID: credit, Credit: p.Net, PartyID: &party, Memo: p.Reference + " " + ref}, }} journals, err := chart.ListJournals(ctx, tx) if err != nil { return accounting.Draft{}, err } for _, j := range journals { if j.IsActive && j.DefaultAccountID != nil && *j.DefaultAccountID == credit { id := j.ID draft.JournalID = &id break } } if draft.JournalID == nil { draft.JournalType = chart.JournalBank } return draft, nil}
// netPayAccount is the account the run's accrual credited with p's net pay (read off the// accrual, never resolved afresh: expenses.PayoutRule's reasoning).func netPayAccount(ctx context.Context, tx pgx.Tx, runInstance uuid.UUID, p Payslip) (uuid.UUID, error) { open, err := ledger.Unreversed(ctx, tx, runInstance) if err != nil { return uuid.Nil, err } for _, x := range open { if x.Semantic != ledger.SemanticAccrual { continue } entry, err := ledger.Get(ctx, tx, x.ID) if err != nil { return uuid.Nil, err } for _, l := range entry.Lines { if l.Memo == memoNet+p.Reference && l.Credit.Cmp(p.Net) == 0 { return l.AccountID, nil } } } return uuid.Nil, fmt.Errorf("%w: %s", errNotAccrued, p.Reference)}(posting.go imports gain "time" and "github.com/google/uuid".)
In transition.go, checkVoidable’s query becomes:
if err := tx.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM payslip p WHERE p.org_id = $1 AND p.run_id = $2 AND p.calc_no = $3 AND (EXISTS (SELECT 1 FROM payslip_payment pp WHERE pp.org_id = $1 AND pp.payslip_id = p.id) OR EXISTS (SELECT 1 FROM payment_batch_line l WHERE l.org_id = $1 AND l.payslip_id = p.id AND l.outcome IS NULL)))`, org, run.ID, run.CalcNo).Scan(&held); err != nil {and its comment: “refuses a void while a payslip is paid or on a live line of any batch, draft or approved (PD-22)”.
- Step 7: A payables-side regression test
Create internal/platform/payables/batch_payslip_test.go:
package payables_test
import ( "context" "testing"
"github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/hr/hrtest" "github.com/mcom/erp/internal/platform/payables" "github.com/mcom/erp/internal/platform/payroll/payrolltest")
// A payslip line is a payout line like a claim's: its payee may not approve its batch.func TestAPayslipsPayeeDoesNotApproveItsBatch(t *testing.T) { e := payrolltest.New(t) e.UsePayrollPoster(t) e.Employee(t, e.Owner, "E-001", "8000.00") // the Owner is on the payroll too e.Pay.VerifiedPayeeBy(t, e.Owner, e.HR) run := e.ApprovedRun(t, hrtest.Day(2026, 8, 1)) // HR prepares; approving a run is not paying it slip := e.Slips(t, run.ID)["E-001"] b := e.PayslipBatch(t, slip.ID)
lines := func() []payables.BatchLine { var out []payables.BatchLine e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { var err error out, err = payables.BatchLines(ctx, tx, b.ID) return err }) return out }() require.Len(t, lines, 1) kind, id := lines[0].Source() require.Equal(t, payables.SourcePayslip, kind) require.Equal(t, slip.ID, id) require.Equal(t, slip.Reference, lines[0].Reference)
err := e.DB.InTx(e.Pay.As(e.Owner), func(ctx context.Context, tx pgx.Tx) error { _, _, err := payables.ApproveBatch(ctx, tx, e.Clock, b.ID, e.Owner, b.Revision, "") return err }) require.ErrorIs(t, err, payables.ErrBatchApproverIsPayee)}It needs one fixture helper in internal/platform/payables/payablestest/env.go, beside VerifiedPayee:
// VerifiedPayeeBy is VerifiedPayee verified by verifier (VerifiedPayee's verifier is the// Owner, who cannot verify their own details).func (e *Env) VerifiedPayeeBy(t *testing.T, member, verifier uuid.UUID) uuid.UUID { t.Helper() ch := e.Channel(t, "bank_transfer") e.PayoutProfile(t, member, ch, map[string]string{ "payee_name": "Ahmad bin Ismail", "bank": "maybank", "account_no": "114012345678"}) e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { v, err := payout.ProfileVerification(ctx, tx, member, ch, false) if err != nil { return err } _, err = payout.VerifyProfile(ctx, tx, e.Clock, member, ch, verifier, v.ProfileID, v.Revision, "") return err }) return ch}- Step 8: Run the tests
Run: go test ./internal/platform/payroll/... ./internal/platform/payables/... ./internal/modules/payables/... -count=1
Expected: PASS (the payables packages run their whole suites: the existing C4 tests are the regression check for the edits).
- Step 9: Commit
git add db/migrations/00902_payslip_batch_line.sql internal/platform/payroll internal/platform/payables internal/modules/payables \ api/openapi.yaml internal/gen web/src/lib/api/schema.d.tsgit commit -m "Payroll P1: payslips in payment batches, paid marking and the net pay payout (00902)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"Task 9: Own payslips and audited payslip reads
Section titled “Task 9: Own payslips and audited payslip reads”Files:
- Create:
internal/platform/payroll/me.go - Test:
internal/platform/payroll/me_test.go
Interfaces:
-
Consumes: Task 5’s
queryPayslips,GetPayslip;audit.WritewithActorID. -
Produces:
func MyPayslips(ctx, tx, member uuid.UUID) ([]Payslip, error);func OwnPayslip(ctx context.Context, tx pgx.Tx, id, member uuid.UUID) (Payslip, error)(ErrNotFoundfor anyone else’s, or a run not approved or voided);func ReadPayslip(ctx, tx, c, id, actor uuid.UUID) (Payslip, error)(audited unless the actor is the payee). -
Step 1: Write the failing test
Create internal/platform/payroll/me_test.go:
package payroll_test
import ( "context" "testing"
"github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/hr/hrtest" "github.com/mcom/erp/internal/platform/payroll" "github.com/mcom/erp/internal/platform/payroll/payrolltest")
func TestAMemberSeesOnlyTheirOwnApprovedPayslips(t *testing.T) { e := payrolltest.New(t) e.NopPoster(t) e.Employee(t, e.Staff, "E-100", "3100.00") e.Employee(t, e.HR, "E-200", "2800.00") aug := e.ApprovedRun(t, hrtest.Day(2026, 8, 1)) e.Run(t, hrtest.Day(2026, 9, 1)) // a draft: not shown staffSlip := e.Slips(t, aug.ID)["E-100"]
mine := func(member uuid.UUID) []payroll.Payslip { var out []payroll.Payslip e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { var err error out, err = payroll.MyPayslips(ctx, tx, member) return err }) return out } // PRESENCE: Staff sees their August payslip. got := mine(e.Staff) require.Len(t, got, 1) require.Equal(t, staffSlip.ID, got[0].ID) // ABSENCE: not HR's, and not September's draft. for _, p := range mine(e.HR) { require.NotEqual(t, staffSlip.ID, p.ID) } err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error { _, err := payroll.OwnPayslip(ctx, tx, staffSlip.ID, e.HR) return err }) require.ErrorIs(t, err, payroll.ErrNotFound, "someone else's payslip does not exist for you")
reads := func() int { return e.Count(t, `SELECT count(*) FROM audit_log WHERE org_id = $1 AND entity_kind = 'payslip' AND action = 'read'`, e.Org) } before := reads() e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { _, err := payroll.ReadPayslip(ctx, tx, e.Clock, staffSlip.ID, e.Staff) return err }) require.Equal(t, before, reads(), "reading your own payslip is not audited") e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { _, err := payroll.ReadPayslip(ctx, tx, e.Clock, staffSlip.ID, e.Owner) return err }) require.Equal(t, before+1, reads(), "reading another's is (PD-25)")}- Step 2: Run it to verify it fails
Run: go test ./internal/platform/payroll/ -run TestAMemberSeesOnlyTheirOwn -count=1
Expected: FAIL to compile — undefined: payroll.MyPayslips.
- Step 3: Write
me.go
package payroll
import ( "context"
"github.com/google/uuid" "github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/audit" "github.com/mcom/erp/internal/kernel/clock")
// MyPayslips is member's own payslips of approved or voided runs (current generation), newest// month first (PD-24).func MyPayslips(ctx context.Context, tx pgx.Tx, member uuid.UUID) ([]Payslip, error) { org, err := orgOf(ctx) if err != nil { return nil, err } return queryPayslips(ctx, tx, `p.member_id = $2 AND p.calc_no = r.calc_no AND r.status IN ('approved', 'voided') ORDER BY r.period_month DESC, p.seq`, org, member)}
// OwnPayslip is one of member's own payslips as MyPayslips lists them, with lines; anything// else is ErrNotFound (PD-24: someone else's payslip does not exist for you).func OwnPayslip(ctx context.Context, tx pgx.Tx, id, member uuid.UUID) (Payslip, error) { p, err := GetPayslip(ctx, tx, id) if err != nil { return Payslip{}, err } var current bool org, err := orgOf(ctx) if err != nil { return Payslip{}, err } if err := tx.QueryRow(ctx, `SELECT r.calc_no = $3 FROM payroll_run r WHERE r.org_id = $1 AND r.id = $2`, org, p.RunID, p.CalcNo).Scan(¤t); err != nil { return Payslip{}, err } if p.MemberID != member || !current || (p.RunStatus != RunApproved && p.RunStatus != RunVoided) { return Payslip{}, ErrNotFound } return p, nil}
// ReadPayslip is a payslip for a payroll.run.read holder; reading someone else's is audited// (PD-25: ids only).func ReadPayslip(ctx context.Context, tx pgx.Tx, c clock.Clock, id, actor uuid.UUID) (Payslip, error) { p, err := GetPayslip(ctx, tx, id) if err != nil || p.MemberID == actor { return p, err } return p, audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntityPayslip, EntityID: id, Action: "read", ActorID: &actor, After: map[string]any{"payslip_id": id, "employee_id": p.EmployeeID}})}- Step 4: Run the test
Run: go test ./internal/platform/payroll/ -count=1
Expected: PASS.
- Step 5: Commit
git add internal/platform/payroll/me.go internal/platform/payroll/me_test.gogit commit -m "Payroll P1: own payslips and audited payslip reads
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"Task 10: The payroll module — permissions, grants (00903), the module switch, the contract and the HTTP surface
Section titled “Task 10: The payroll module — permissions, grants (00903), the module switch, the contract and the HTTP surface”Files:
- Modify:
constants.yaml(permissions),make generate - Create:
db/migrations/00903_payroll_grants.sql - Modify:
internal/platform/moduleinstall/moduleinstall.go(Catalog) - Modify:
internal/platform/workflow/validate.go(ownedControlKinds),web/src/features/accounting/controlKinds.ts(OWNED_CONTROL_KINDS) - Create:
internal/modules/payroll/permissions.go,boot.go,routes.go,http.go,errors.go,dto.go,setup_handlers.go,run_handlers.go,me_handlers.go - Modify:
api/openapi.yaml(payroll paths and schemas,OrgModule.key),api/public_routes.txt,make generate - Modify:
internal/app/app.go(Deps.Payroll,Router),cmd/api/main.go,cmd/migrate/main.go,cmd/orgadmin/main.go,cmd/worker/main.go(blank import),cmd/migrate/wiring_test.go,cmd/orgadmin/wiring_test.go,internal/app/routes_test.go(composedModules) - Modify:
web/src/lib/i18n/locales/{en,ms,zh}/team.json(perm.*,modules.payroll),web/src/lib/i18n/locales/{en,ms,zh}/modules.json(names.payroll,descriptions.payroll) - Test:
internal/modules/payroll/routes_test.go,grants_test.go,env_test.go,handlers_test.go;internal/platform/payroll/migration00903_test.go
Interfaces:
- Consumes: every platform function above;
member.RegisterPermission(key, description string),member.Can,member.Permissions,httpx.Protected,httpx.PermissionLoader,audit.ActorFromContext,apperr.New,accounting.RegisterPostingRule,workflow.AvailableTransitions. - Produces:
package payroll(module) withconst PermissionSalaryRead … PermissionRunApprovere-exported,type Deps struct { DB *db.DB; Clock clock.Clock },func Register(g *gin.RouterGroup, deps Deps); routes below; module keypayroll(requireshr,payables).
Routes (all under /v1; Protected with the key shown, except the two me routes):
| Method and path | Key |
|---|---|
GET /payroll/settings, PUT /payroll/settings |
payroll.item.manage |
GET /payroll/item-types, POST /payroll/item-types, PUT /payroll/item-types/:id |
payroll.item.manage |
GET /payroll/employees/:id/salaries (audited) |
payroll.salary.read |
POST /payroll/employees/:id/salaries |
payroll.salary.write |
GET /payroll/employees/:id/items |
payroll.salary.read |
POST /payroll/employees/:id/items, POST /payroll/employee-items/:id/end |
payroll.item.manage |
GET /payroll/runs, GET /payroll/runs/:id, GET /payroll/payslips/:id (audited) |
payroll.run.read |
POST /payroll/runs, POST /payroll/runs/:id/recalculate, POST /payroll/runs/:id/inputs, POST /payroll/runs/:id/inputs/:input_id/remove |
payroll.run.prepare |
POST /payroll/runs/:id/transitions |
payroll.run.read, then the handler requires RequiredPermission’s key |
GET /payroll/me/payslips, GET /payroll/me/payslips/:id |
[no-permission]: the caller’s own only |
- Step 1: Permissions, the grants migration, the catalogue — tests first
Create internal/platform/payroll/migration00903_test.go:
package payroll_test
import ( "context" "database/sql" "testing"
"github.com/google/uuid" "github.com/jackc/pgx/v5" _ "github.com/jackc/pgx/v5/stdlib" "github.com/pressly/goose/v3" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/db/testdb")
// 00903 (PD-23, PD-26): an existing organization's Owner gains the six payroll keys, no other// role does; payroll joins the module CHECK; the Down takes both back.func TestMigration00903GrantsTheOwnerThePayrollKeys(t *testing.T) { h := testdb.StartAt(t, 902) ctx := context.Background() org := uuid.New() _, err := h.Admin.Exec(ctx, `INSERT INTO organization (id, org_id, name) VALUES ($1, $1, 'Alpha')`, org) require.NoError(t, err) role := func(name string) uuid.UUID { var id uuid.UUID require.NoError(t, h.Admin.QueryRow(ctx, `INSERT INTO role (org_id, name, system) VALUES ($1, $2, true) RETURNING id`, org, name).Scan(&id)) return id } owner, finance := role("Owner"), role("Finance") keys := func(id uuid.UUID) []string { rows, err := h.Admin.Query(ctx, `SELECT permission_key FROM role_permission WHERE role_id = $1 AND permission_key LIKE 'payroll.%' ORDER BY 1`, id) require.NoError(t, err) out, err := pgx.CollectRows(rows, pgx.RowTo[string]) require.NoError(t, err) return out } require.Empty(t, keys(owner), "ABSENCE before the migration")
conn, err := sql.Open("pgx", h.AdminDSN) require.NoError(t, err) t.Cleanup(func() { _ = conn.Close() }) require.NoError(t, goose.SetDialect("postgres")) require.NoError(t, goose.UpTo(conn, testdb.MigrationsDir(t), 903, goose.WithAllowMissing()))
require.Equal(t, []string{"payroll.item.manage", "payroll.run.approve", "payroll.run.prepare", "payroll.run.read", "payroll.salary.read", "payroll.salary.write"}, keys(owner), "PRESENCE: the Owner holds all six") require.Empty(t, keys(finance), "PD-5: no other default role") var def string require.NoError(t, h.Admin.QueryRow(ctx, `SELECT pg_get_constraintdef(oid) FROM pg_constraint WHERE conname = 'module_install_key_known'`).Scan(&def)) for _, k := range []string{"'payroll'", "'leave'", "'assets'", "'procurement'"} { require.Contains(t, def, k, "the list is the latest one plus payroll") }
require.NoError(t, goose.Down(conn, testdb.MigrationsDir(t))) require.Empty(t, keys(owner), "the Down removes them") require.NoError(t, h.Admin.QueryRow(ctx, `SELECT pg_get_constraintdef(oid) FROM pg_constraint WHERE conname = 'module_install_key_known'`).Scan(&def)) require.NotContains(t, def, "'payroll'") require.Contains(t, def, "'leave'")}Create internal/modules/payroll/grants_test.go:
package payroll_test
import ( "context" "testing"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/modules/payroll" "github.com/mcom/erp/internal/platform/hr/hrtest" "github.com/mcom/erp/internal/platform/identity/member")
var allKeys = []string{payroll.PermissionSalaryRead, payroll.PermissionSalaryWrite, payroll.PermissionItemManage, payroll.PermissionRunRead, payroll.PermissionRunPrepare, payroll.PermissionRunApprove}
// PD-5: payroll.* is held by no default role but the Owner (who is seeded with every// registered key, so the registry is asserted directly).func TestOnlyTheOwnerHoldsPayrollKeys(t *testing.T) { keys := member.RegisteredPermissionSet().Keys() for _, k := range allKeys { require.Contains(t, keys, k, "PRESENCE: registered, so a new Owner holds it") } e := hrtest.New(t) for _, role := range []string{"Employee", "Approver", "Finance", "Admin"} { rows, err := e.H.Admin.Query(context.Background(), `SELECT rp.permission_key FROM role_permission rp JOIN role r ON r.org_id = rp.org_id AND r.id = rp.role_id WHERE r.org_id = $1 AND r.system AND r.name = $2 AND rp.permission_key LIKE 'payroll.%'`, e.Org, role) require.NoError(t, err) require.False(t, rows.Next(), "ABSENCE: %s holds no payroll key", role) rows.Close() }}Run: go test ./internal/platform/payroll/ -run TestMigration00903 -count=1 — Expected: FAIL (goose: no migration 903). go test ./internal/modules/payroll/ -count=1 — Expected: FAIL to compile (no package).
- Step 2: Write the permissions, the grants and the catalogue
In constants.yaml, append to permissions: after - hr.leave.medical.read (the HR block):
- payroll.salary.read - payroll.salary.write - payroll.item.manage - payroll.run.read - payroll.run.prepare - payroll.run.approveCreate internal/modules/payroll/permissions.go:
package payroll
import ( p "github.com/mcom/erp/internal/platform/payroll" "github.com/mcom/erp/internal/platform/identity/member")
// The six keys (Ruling PD-23). The Owner holds every registered key; no other seeded role is// given any (spec PD-5), so there is no RegisterRolePermissions call.const ( PermissionSalaryRead = p.PermissionSalaryRead PermissionSalaryWrite = p.PermissionSalaryWrite PermissionItemManage = p.PermissionItemManage PermissionRunRead = p.PermissionRunRead PermissionRunPrepare = p.PermissionRunPrepare PermissionRunApprove = p.PermissionRunApprove)
func init() { member.RegisterPermission(PermissionSalaryRead, "Read employees' salaries and recurring pay items (each read is audited)") member.RegisterPermission(PermissionSalaryWrite, "Record salary changes") member.RegisterPermission(PermissionItemManage, "Manage pay item types, employees' recurring pay items and the payroll settings") member.RegisterPermission(PermissionRunRead, "Read payroll runs and every payslip in them (reading someone else's payslip is audited)") member.RegisterPermission(PermissionRunPrepare, "Prepare payroll runs: create, recalculate, enter one-off and statutory amounts, submit") member.RegisterPermission(PermissionRunApprove, "Approve, reject or void payroll runs prepared by someone else")}Create internal/modules/payroll/boot.go:
package payroll
import ( "github.com/mcom/erp/internal/platform/accounting" p "github.com/mcom/erp/internal/platform/payroll")
// Payroll's posting rules (PD-18, PD-21) join the process's registry when the module links.func init() { for _, r := range p.Rules() { accounting.RegisterPostingRule(r) }}Create db/migrations/00903_payroll_grants.sql — first run grep -l module_install_key_known db/migrations/*.sql | sort | tail -1 and copy that file’s Up list (2026-10-01: 00842); the list below is that list plus 'payroll':
-- +goose Up-- Phase 5 payroll P1 (Rulings PD-23, PD-26): existing organizations' Owners get the six-- payroll keys, as provisioning gives a new organization's Owner every registered key. No-- other seeded role is given any (spec PD-5). Runs as the migration role, which bypasses RLS.INSERT INTO role_permission (org_id, role_id, permission_key)SELECT r.org_id, r.id, k.key FROM role r CROSS JOIN (VALUES ('payroll.salary.read'), ('payroll.salary.write'), ('payroll.item.manage'), ('payroll.run.read'), ('payroll.run.prepare'), ('payroll.run.approve')) AS k(key) WHERE r.system AND r.name = 'Owner'ON CONFLICT (role_id, permission_key) DO NOTHING;
-- member.BumpPermVersion's contract (see 00170).UPDATE organization o SET perm_version = o.perm_version + 1 WHERE EXISTS (SELECT 1 FROM role r WHERE r.org_id = o.id AND r.system AND r.name = 'Owner');
-- payroll is an optional module. This list is the LATEST one (00842's) plus payroll: a list-- copied from an older migration would drop modules added since.ALTER TABLE module_install DROP CONSTRAINT module_install_key_known;ALTER TABLE module_install ADD CONSTRAINT module_install_key_known CHECK (module_key IN ( 'expenses', 'advances', 'pettycash', 'payables', 'receivables', 'banking', 'einvoice', 'taxreturns', 'inventory', 'procurement', 'sales', 'assets', 'hr', 'leave', 'payroll'));
-- +goose Down-- Deletes the payroll module_install rows to re-add 00842's CHECK, so an organization that had-- switched payroll off finds it on again after a rollback.DELETE FROM module_install WHERE module_key = 'payroll';ALTER TABLE module_install DROP CONSTRAINT module_install_key_known;ALTER TABLE module_install ADD CONSTRAINT module_install_key_known CHECK (module_key IN ( 'expenses', 'advances', 'pettycash', 'payables', 'receivables', 'banking', 'einvoice', 'taxreturns', 'inventory', 'procurement', 'sales', 'assets', 'hr', 'leave'));DELETE FROM role_permission rp USING role r WHERE r.org_id = rp.org_id AND r.id = rp.role_id AND r.system AND rp.permission_key LIKE 'payroll.%';UPDATE organization o SET perm_version = o.perm_version + 1 WHERE EXISTS (SELECT 1 FROM role r WHERE r.org_id = o.id AND r.system AND r.name = 'Owner');In internal/platform/moduleinstall/moduleinstall.go, Catalog, after {Key: "leave", …}:
{Key: "payroll", Requires: []string{"hr", "payables"}}, // Phase 5 payroll P1 (PD-26): payslips are paid through payment batchesIn internal/platform/workflow/validate.go, ownedControlKinds gains "payroll_payable": {"payroll_run"}, // payroll P1 (PD-18); in web/src/features/accounting/controlKinds.ts, OWNED_CONTROL_KINDS gains payroll_payable: ['payroll_run'],.
Run: make generate, then go test ./internal/platform/payroll/ -run TestMigration00903 -count=1 && go test ./internal/platform/moduleinstall/ -count=1
Expected: PASS (TestCatalogMatchesTheModuleInstallCheck included).
- Step 3: Watch the module-catalogue guard fail once
Sabotage: remove 'payroll' from 00903’s Up list. Run go test ./internal/platform/moduleinstall/ -run TestCatalogMatchesTheModuleInstallCheck -count=1. Expected: FAIL — the element lists differ by payroll. Restore; PASS.
- Step 4: Write the contract
In api/openapi.yaml: add payroll to the OrgModule.key enum (line ~16458). Add the components (under components.responses, components.headers, components.schemas) and paths below. Keep the file’s existing order conventions (paths grouped by module; insert the payroll block after the /leave/... paths, the schemas after the Leave* schemas).
# --- paths (after /leave/routing-moves) --- /payroll/settings: get: operationId: getPayrollSettings tags: [Payroll] summary: The payroll setting description: Revision 0 when none was saved. Requires payroll.item.manage. responses: '200': description: The setting headers: { ETag: { $ref: '#/components/headers/PayrollRevision' } } content: { application/json: { schema: { $ref: '#/components/schemas/PayrollSettings' } } } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/PayrollForbidden' } put: operationId: putPayrollSettings tags: [Payroll] summary: Save the payroll setting description: The unpaid-leave divisor (null is the month's calendar days), the payout channel, the salary expense and deductions payable accounts. 409 revision_mismatch. Requires payroll.item.manage. parameters: - { name: If-Match, in: header, required: true, schema: { type: string }, description: The ETag read ("0" when none was saved). } requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/PayrollSettingsInput' } } } } responses: '200': description: The setting headers: { ETag: { $ref: '#/components/headers/PayrollRevision' } } content: { application/json: { schema: { $ref: '#/components/schemas/PayrollSettings' } } } '400': { $ref: '#/components/responses/BadRequestBody' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/PayrollForbidden' } '409': { $ref: '#/components/responses/PayrollConflict' } '422': { $ref: '#/components/responses/PayrollInvalid' } /payroll/item-types: get: operationId: listPayItemTypes tags: [Payroll] summary: Pay item types description: Active ones, or every one with all=true. Requires payroll.item.manage. parameters: - { name: all, in: query, required: false, schema: { type: boolean } } responses: '200': description: The types content: application/json: schema: { type: object, required: [types], properties: { types: { type: array, items: { $ref: '#/components/schemas/PayItemType' } } } } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/PayrollForbidden' } post: operationId: createPayItemType tags: [Payroll] summary: Create a pay item type description: 409 duplicate_item_type. Requires payroll.item.manage. requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/PayItemTypeInput' } } } } responses: '201': { description: The type, content: { application/json: { schema: { $ref: '#/components/schemas/PayItemType' } } } } '400': { $ref: '#/components/responses/BadRequestBody' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/PayrollForbidden' } '409': { $ref: '#/components/responses/PayrollConflict' } '422': { $ref: '#/components/responses/PayrollInvalid' } /payroll/item-types/{id}: put: operationId: updatePayItemType tags: [Payroll] summary: Rename, reflag or retire a pay item type description: Code, kind and recurrence never change. Requires payroll.item.manage. parameters: - { name: id, in: path, required: true, schema: { type: string, format: uuid } } - { name: If-Match, in: header, required: true, schema: { type: string } } requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/PayItemTypeUpdate' } } } } responses: '200': description: The type headers: { ETag: { $ref: '#/components/headers/PayrollRevision' } } content: { application/json: { schema: { $ref: '#/components/schemas/PayItemType' } } } '400': { $ref: '#/components/responses/BadRequestBody' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/PayrollForbidden' } '404': { $ref: '#/components/responses/PayrollNotFound' } '409': { $ref: '#/components/responses/PayrollConflict' } '422': { $ref: '#/components/responses/PayrollInvalid' } /payroll/employees/{id}/salaries: get: operationId: listSalaries tags: [Payroll] summary: An employee's salary history, newest first description: Each read writes an audit row (ids only). Requires payroll.salary.read. parameters: - { name: id, in: path, required: true, schema: { type: string, format: uuid }, description: The employee. } responses: '200': description: The history content: application/json: schema: { type: object, required: [salaries], properties: { salaries: { type: array, items: { $ref: '#/components/schemas/Salary' } } } } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/PayrollForbidden' } '404': { $ref: '#/components/responses/PayrollNotFound' } post: operationId: addSalary tags: [Payroll] summary: Record a salary change description: A new row from effective_from; none is ever edited. Whole sen. Requires payroll.salary.write. parameters: - { name: id, in: path, required: true, schema: { type: string, format: uuid } } requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/SalaryInput' } } } } responses: '201': { description: The row, content: { application/json: { schema: { $ref: '#/components/schemas/Salary' } } } } '400': { $ref: '#/components/responses/BadRequestBody' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/PayrollForbidden' } '404': { $ref: '#/components/responses/PayrollNotFound' } '422': { $ref: '#/components/responses/PayrollInvalid' } /payroll/employees/{id}/items: get: operationId: listEmployeePayItems tags: [Payroll] summary: An employee's recurring pay items description: Requires payroll.salary.read. parameters: - { name: id, in: path, required: true, schema: { type: string, format: uuid } } responses: '200': description: The items content: application/json: schema: { type: object, required: [items], properties: { items: { type: array, items: { $ref: '#/components/schemas/EmployeePayItem' } } } } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/PayrollForbidden' } '404': { $ref: '#/components/responses/PayrollNotFound' } post: operationId: addEmployeePayItem tags: [Payroll] summary: Give an employee a recurring pay item description: A recurring type only. Requires payroll.item.manage. parameters: - { name: id, in: path, required: true, schema: { type: string, format: uuid } } requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/EmployeePayItemInput' } } } } responses: '201': { description: The item, content: { application/json: { schema: { $ref: '#/components/schemas/EmployeePayItem' } } } } '400': { $ref: '#/components/responses/BadRequestBody' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/PayrollForbidden' } '404': { $ref: '#/components/responses/PayrollNotFound' } '422': { $ref: '#/components/responses/PayrollInvalid' } /payroll/employee-items/{id}/end: post: operationId: endEmployeePayItem tags: [Payroll] summary: End a recurring pay item description: Its last day, once. Requires payroll.item.manage. parameters: - { name: id, in: path, required: true, schema: { type: string, format: uuid } } requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/EmployeePayItemEnd' } } } } responses: '200': { description: The item, content: { application/json: { schema: { $ref: '#/components/schemas/EmployeePayItem' } } } } '400': { $ref: '#/components/responses/BadRequestBody' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/PayrollForbidden' } '404': { $ref: '#/components/responses/PayrollNotFound' } '422': { $ref: '#/components/responses/PayrollInvalid' } /payroll/runs: get: operationId: listPayrollRuns tags: [Payroll] summary: Payroll runs, newest month first description: Requires payroll.run.read. responses: '200': description: The runs content: application/json: schema: { type: object, required: [runs], properties: { runs: { type: array, items: { $ref: '#/components/schemas/PayrollRun' } } } } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/PayrollForbidden' } post: operationId: createPayrollRun tags: [Payroll] summary: Open and prepare a month's run description: The current month or an earlier one; one run per month that is not voided. 409 run_exists, payroll_not_configured; 422 month_future. Requires payroll.run.prepare. requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/PayrollRunCreate' } } } } responses: '201': description: The run headers: { ETag: { $ref: '#/components/headers/PayrollRevision' } } content: { application/json: { schema: { $ref: '#/components/schemas/PayrollRunDetail' } } } '400': { $ref: '#/components/responses/BadRequestBody' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/PayrollForbidden' } '409': { $ref: '#/components/responses/PayrollConflict' } '422': { $ref: '#/components/responses/PayrollInvalid' } /payroll/runs/{id}: get: operationId: getPayrollRun tags: [Payroll] summary: A run with its payslips, inputs, warnings and the moves open to the caller description: Requires payroll.run.read. parameters: - { name: id, in: path, required: true, schema: { type: string, format: uuid } } responses: '200': description: The run headers: { ETag: { $ref: '#/components/headers/PayrollRevision' } } content: { application/json: { schema: { $ref: '#/components/schemas/PayrollRunDetail' } } } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/PayrollForbidden' } '404': { $ref: '#/components/responses/PayrollNotFound' } /payroll/runs/{id}/recalculate: post: operationId: recalculatePayrollRun tags: [Payroll] summary: Recalculate a draft run description: A new calculation generation; earlier ones are kept. 409 run_not_draft, revision_mismatch. Requires payroll.run.prepare. parameters: - { name: id, in: path, required: true, schema: { type: string, format: uuid } } - { name: If-Match, in: header, required: true, schema: { type: string } } responses: '200': description: The run headers: { ETag: { $ref: '#/components/headers/PayrollRevision' } } content: { application/json: { schema: { $ref: '#/components/schemas/PayrollRunDetail' } } } '400': { $ref: '#/components/responses/BadRequestBody' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/PayrollForbidden' } '404': { $ref: '#/components/responses/PayrollNotFound' } '409': { $ref: '#/components/responses/PayrollConflict' } /payroll/runs/{id}/inputs: post: operationId: addPayrollRunInput tags: [Payroll] summary: Enter a one-off item, a statutory amount or an adjustment on a draft run description: Recalculates. Statutory amounts are entered by hand in P1. Requires payroll.run.prepare. parameters: - { name: id, in: path, required: true, schema: { type: string, format: uuid } } - { name: If-Match, in: header, required: true, schema: { type: string } } requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/PayrollRunInputCreate' } } } } responses: '201': description: The run headers: { ETag: { $ref: '#/components/headers/PayrollRevision' } } content: { application/json: { schema: { $ref: '#/components/schemas/PayrollRunDetail' } } } '400': { $ref: '#/components/responses/BadRequestBody' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/PayrollForbidden' } '404': { $ref: '#/components/responses/PayrollNotFound' } '409': { $ref: '#/components/responses/PayrollConflict' } '422': { $ref: '#/components/responses/PayrollInvalid' } /payroll/runs/{id}/inputs/{input_id}/remove: post: operationId: removePayrollRunInput tags: [Payroll] summary: Remove an input from a draft run description: Recalculates. Requires payroll.run.prepare. parameters: - { name: id, in: path, required: true, schema: { type: string, format: uuid } } - { name: input_id, in: path, required: true, schema: { type: string, format: uuid } } - { name: If-Match, in: header, required: true, schema: { type: string } } responses: '200': description: The run headers: { ETag: { $ref: '#/components/headers/PayrollRevision' } } content: { application/json: { schema: { $ref: '#/components/schemas/PayrollRunDetail' } } } '400': { $ref: '#/components/responses/BadRequestBody' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/PayrollForbidden' } '404': { $ref: '#/components/responses/PayrollNotFound' } '409': { $ref: '#/components/responses/PayrollConflict' } /payroll/runs/{id}/transitions: post: operationId: transitionPayrollRun tags: [Payroll] summary: Submit, approve, reject, revise, withdraw or void a run description: > Requires payroll.run.read, and payroll.run.approve for a move into approval or rejection or out of approval (a void), payroll.run.prepare for the rest. The preparer never approves (self_approval) except under the sole-owner exception. Submission recalculates; 409 negative_net lists the payslips, empty_run; a void is refused while a payslip is paid or in a payment batch (run_in_payment). parameters: - { name: id, in: path, required: true, schema: { type: string, format: uuid } } - { name: If-Match, in: header, required: true, schema: { type: string } } requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/PayrollTransition' } } } } responses: '200': description: The run headers: { ETag: { $ref: '#/components/headers/PayrollRevision' } } content: { application/json: { schema: { $ref: '#/components/schemas/PayrollRunDetail' } } } '400': { $ref: '#/components/responses/BadRequestBody' } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/PayrollForbidden' } '404': { $ref: '#/components/responses/PayrollNotFound' } '409': { $ref: '#/components/responses/PayrollConflict' } '422': { $ref: '#/components/responses/PayrollInvalid' } /payroll/payslips/{id}: get: operationId: getPayslip tags: [Payroll] summary: A payslip with its lines description: Reading someone else's writes an audit row. Requires payroll.run.read. parameters: - { name: id, in: path, required: true, schema: { type: string, format: uuid } } responses: '200': { description: The payslip, content: { application/json: { schema: { $ref: '#/components/schemas/Payslip' } } } } '401': { $ref: '#/components/responses/Unauthenticated' } '403': { $ref: '#/components/responses/PayrollForbidden' } '404': { $ref: '#/components/responses/PayrollNotFound' } /payroll/me/payslips: get: operationId: listMyPayslips tags: [Payroll] summary: Your own payslips description: Of approved (or since voided) runs only. Every member; no permission (api/public_routes.txt). responses: '200': description: Your payslips content: application/json: schema: { type: object, required: [payslips], properties: { payslips: { type: array, items: { $ref: '#/components/schemas/Payslip' } } } } '401': { $ref: '#/components/responses/Unauthenticated' } /payroll/me/payslips/{id}: get: operationId: getMyPayslip tags: [Payroll] summary: One of your own payslips description: Anyone else's answers 404. parameters: - { name: id, in: path, required: true, schema: { type: string, format: uuid } } responses: '200': { description: The payslip, content: { application/json: { schema: { $ref: '#/components/schemas/Payslip' } } } } '401': { $ref: '#/components/responses/Unauthenticated' } '404': { $ref: '#/components/responses/PayrollNotFound' }
# --- components.headers --- PayrollRevision: description: The record's revision, for If-Match. schema: { type: string }
# --- components.responses --- PayrollForbidden: description: The caller lacks the permission the operation or the move requires (permission_required), or the workflow's grants name someone else. content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } PayrollNotFound: description: No such employee, item type, item, run, input or payslip in this organization — or none the caller may see. content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } PayrollConflict: description: > The records are in a state that refuses this: revision_mismatch, run_exists, run_not_draft, payroll_not_configured, empty_run, negative_net, run_in_payment, duplicate_item_type. content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } } PayrollInvalid: description: The request is wrong (field and rule name the problem), month_future, or a workflow reason is missing. content: { application/problem+json: { schema: { $ref: '#/components/schemas/Problem' } } }
# --- components.schemas --- PayrollSettings: type: object required: [unpaid_leave_divisor, payout_channel_id, salary_expense_account_id, statutory_payable_account_id, revision] properties: unpaid_leave_divisor: { type: integer, nullable: true, minimum: 1, maximum: 31, description: null is the month's calendar days. } payout_channel_id: { type: string, format: uuid, nullable: true } salary_expense_account_id: { type: string, format: uuid, nullable: true } statutory_payable_account_id: { type: string, format: uuid, nullable: true } revision: { type: integer } PayrollSettingsInput: type: object additionalProperties: false required: [unpaid_leave_divisor, payout_channel_id, salary_expense_account_id, statutory_payable_account_id] properties: unpaid_leave_divisor: { type: integer, nullable: true, minimum: 1, maximum: 31 } payout_channel_id: { type: string, format: uuid, nullable: true } salary_expense_account_id: { type: string, format: uuid, nullable: true } statutory_payable_account_id: { type: string, format: uuid, nullable: true } PayItemType: type: object required: [id, code, names, kind, recurrence, posting_tag, epf, socso, eis, pcb, active, revision] properties: id: { type: string, format: uuid } code: { type: string } names: { type: object, additionalProperties: { type: string } } kind: { type: string, enum: [earning, deduction] } recurrence: { type: string, enum: [recurring, one_off] } posting_tag: { type: string, enum: [salary_expense, statutory_payable] } epf: { type: boolean } socso: { type: boolean } eis: { type: boolean } pcb: { type: boolean } active: { type: boolean } revision: { type: integer } PayItemTypeInput: type: object additionalProperties: false required: [code, names, kind, recurrence] properties: code: { type: string, pattern: '^[A-Z0-9][A-Z0-9_-]{0,19}$' } names: { type: object, additionalProperties: { type: string } } kind: { type: string, enum: [earning, deduction] } recurrence: { type: string, enum: [recurring, one_off] } epf: { type: boolean } socso: { type: boolean } eis: { type: boolean } pcb: { type: boolean } PayItemTypeUpdate: type: object additionalProperties: false required: [names, epf, socso, eis, pcb, active] properties: names: { type: object, additionalProperties: { type: string } } epf: { type: boolean } socso: { type: boolean } eis: { type: boolean } pcb: { type: boolean } active: { type: boolean } Salary: type: object required: [id, employee_id, amount, effective_from, reason, created_by, created_at] properties: id: { type: string, format: uuid } employee_id: { type: string, format: uuid } amount: { $ref: '#/components/schemas/Money' } effective_from: { type: string, format: date } reason: { type: string, nullable: true } created_by: { type: string, format: uuid } created_at: { type: string, format: date-time } SalaryInput: type: object additionalProperties: false required: [amount, effective_from] properties: amount: { $ref: '#/components/schemas/Money' } effective_from: { type: string, format: date } reason: { type: string, maxLength: 500 } EmployeePayItem: type: object required: [id, employee_id, type_id, amount, effective_from, effective_to, ended_at] properties: id: { type: string, format: uuid } employee_id: { type: string, format: uuid } type_id: { type: string, format: uuid } amount: { $ref: '#/components/schemas/Money' } effective_from: { type: string, format: date } effective_to: { type: string, format: date, nullable: true } ended_at: { type: string, format: date-time, nullable: true } EmployeePayItemInput: type: object additionalProperties: false required: [type_id, amount, effective_from] properties: type_id: { type: string, format: uuid } amount: { $ref: '#/components/schemas/Money' } effective_from: { type: string, format: date } effective_to: { type: string, format: date, nullable: true } EmployeePayItemEnd: type: object additionalProperties: false required: [last_day] properties: last_day: { type: string, format: date } PayrollRun: type: object required: [id, reference, month, status, state, calc_no, revision, prepared_by, approved_by, approved_sole_owner, gross, deductions, net, payslip_count, paid_count, calculated_at, created_at] properties: id: { type: string, format: uuid } reference: { type: string } month: { type: string, pattern: '^[0-9]{4}-[0-9]{2}$' } status: { type: string, enum: [draft, submitted, approved, paid, rejected, voided] } state: { type: string, description: The workflow state key (display only; the client acts on available). } calc_no: { type: integer } revision: { type: integer } prepared_by: { type: string, format: uuid } approved_by: { type: string, format: uuid, nullable: true } approved_sole_owner: { type: boolean } gross: { $ref: '#/components/schemas/Money' } deductions: { $ref: '#/components/schemas/Money' } net: { $ref: '#/components/schemas/Money' } payslip_count: { type: integer } paid_count: { type: integer } calculated_at: { type: string, format: date-time, nullable: true } created_at: { type: string, format: date-time } PayrollRunCreate: type: object additionalProperties: false required: [month] properties: month: { type: string, pattern: '^[0-9]{4}-[0-9]{2}$' } PayrollRunDetail: type: object required: [run, payslips, inputs, warnings, available] properties: run: { $ref: '#/components/schemas/PayrollRun' } payslips: { type: array, items: { $ref: '#/components/schemas/Payslip' } } inputs: { type: array, items: { $ref: '#/components/schemas/PayrollRunInput' } } warnings: { type: array, items: { $ref: '#/components/schemas/PayrollWarning' } } available: { type: array, items: { $ref: '#/components/schemas/WorkflowAvailableTransition' } } PayrollRunInput: type: object required: [id, employee_id, kind, type_id, statutory_code, description, amount] properties: id: { type: string, format: uuid } employee_id: { type: string, format: uuid } kind: { type: string, enum: [item, statutory, adjustment] } type_id: { type: string, format: uuid, nullable: true } statutory_code: { type: string, enum: [epf, socso, eis, pcb], nullable: true } description: { type: string } amount: { $ref: '#/components/schemas/Money' } PayrollRunInputCreate: type: object additionalProperties: false required: [employee_id, kind, amount] properties: employee_id: { type: string, format: uuid } kind: { type: string, enum: [item, statutory, adjustment] } type_id: { type: string, format: uuid } statutory_code: { type: string, enum: [epf, socso, eis, pcb] } description: { type: string, maxLength: 200 } amount: { $ref: '#/components/schemas/Money' } PayrollTransition: type: object additionalProperties: false required: [from, to] properties: from: { type: string } to: { type: string } reason: { type: string, maxLength: 500 } Payslip: type: object required: [id, run_id, reference, employee_id, employee_no, employee_name, month, run_status, gross, deductions, net, days_in_month, days_employed, divisor, paid, paid_at, batch_reference] properties: id: { type: string, format: uuid } run_id: { type: string, format: uuid } reference: { type: string } employee_id: { type: string, format: uuid } employee_no: { type: string } employee_name: { type: string } month: { type: string, pattern: '^[0-9]{4}-[0-9]{2}$' } run_status: { type: string, enum: [draft, submitted, approved, rejected, voided] } gross: { $ref: '#/components/schemas/Money' } deductions: { $ref: '#/components/schemas/Money' } net: { $ref: '#/components/schemas/Money' } days_in_month: { type: integer } days_employed: { type: integer } divisor: { type: integer } paid: { type: boolean } paid_at: { type: string, format: date-time, nullable: true } batch_reference: { type: string, nullable: true } lines: { type: array, items: { $ref: '#/components/schemas/PayslipLine' }, description: On a single payslip only. } PayslipLine: type: object required: [line_no, kind, type_id, statutory_code, description, days, deduction, amount] properties: line_no: { type: integer } kind: { type: string, enum: [basic, item, unpaid_leave, statutory, adjustment] } type_id: { type: string, format: uuid, nullable: true } statutory_code: { type: string, enum: [epf, socso, eis, pcb], nullable: true } description: { type: string } days: { type: string, nullable: true, pattern: '^[0-9]{1,2}(\.[05])?$' } deduction: { type: boolean } amount: { $ref: '#/components/schemas/Money' } PayrollWarning: type: object required: [employee_id, employee_no, employee_name, code] properties: employee_id: { type: string, format: uuid } employee_no: { type: string } employee_name: { type: string } code: { type: string, enum: [no_member, no_salary, outside_band, no_payout_details, bank_unverified] }In api/public_routes.txt, in [no-permission] after the /payout/me/* block:
# --- payroll P1: your own payslips (Ruling PD-24) --------------------------# Behind Authenticate, Tenant and the payroll module switch. Every member reads# their own payslips and only theirs (spec PD-5); payroll.* is Owner-only, so a# permission here would hide everyone's own pay from them. The handler returns# only payslips whose payee is the caller; anyone else's id answers 404.GET /v1/payroll/me/payslipsGET /v1/payroll/me/payslips/:idRun: make generate.
- Step 5: Write the module’s HTTP code
Create internal/modules/payroll/http.go — copy internal/modules/leave/http.go’s decodeStrict, parseID, parseDate, mustActor, ifMatch, setETag and inTx verbatim (a module never imports another module), with the package doc:
// Package payroll is the HTTP surface of Phase 5 payroll P1: internal/platform/payroll owns// every rule and writes every audit row; this package parses requests, calls it inside one// transaction, and maps its refusals onto HTTP statuses.package payroll(drop leave’s year helper and optionalDate; add "github.com/mcom/erp/internal/kernel/db" only where routes.go needs it).
Create internal/modules/payroll/routes.go:
package payroll
import ( "context" "fmt"
"github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/clock" "github.com/mcom/erp/internal/kernel/db" apperr "github.com/mcom/erp/internal/kernel/errors" "github.com/mcom/erp/internal/kernel/httpx" "github.com/mcom/erp/internal/platform/identity/member")
type Deps struct { DB *db.DB Clock clock.Clock}
// Register hangs the payroll routes off the module's group (app.Router gives it the payroll// install switch). Every route is behind httpx.Protected except the caller's own payslips// (api/public_routes.txt, Ruling PD-24).func Register(g *gin.RouterGroup, deps Deps) { h := handlers{deps: deps} pl := permissionLoader{db: deps.DB} route := func(method, path, permission string, fn gin.HandlerFunc) { httpx.Protected(g, pl, method, path, permission, fn) } route("GET", "/payroll/settings", PermissionItemManage, h.getSettings) route("PUT", "/payroll/settings", PermissionItemManage, h.putSettings) route("GET", "/payroll/item-types", PermissionItemManage, h.listTypes) route("POST", "/payroll/item-types", PermissionItemManage, h.createType) route("PUT", "/payroll/item-types/:id", PermissionItemManage, h.updateType) route("GET", "/payroll/employees/:id/salaries", PermissionSalaryRead, h.listSalaries) route("POST", "/payroll/employees/:id/salaries", PermissionSalaryWrite, h.addSalary) route("GET", "/payroll/employees/:id/items", PermissionSalaryRead, h.listItems) route("POST", "/payroll/employees/:id/items", PermissionItemManage, h.addItem) route("POST", "/payroll/employee-items/:id/end", PermissionItemManage, h.endItem) route("GET", "/payroll/runs", PermissionRunRead, h.listRuns) route("POST", "/payroll/runs", PermissionRunPrepare, h.createRun) route("GET", "/payroll/runs/:id", PermissionRunRead, h.getRun) route("POST", "/payroll/runs/:id/recalculate", PermissionRunPrepare, h.recalculate) route("POST", "/payroll/runs/:id/inputs", PermissionRunPrepare, h.addInput) route("POST", "/payroll/runs/:id/inputs/:input_id/remove", PermissionRunPrepare, h.removeInput) route("POST", "/payroll/runs/:id/transitions", PermissionRunRead, h.transition) route("GET", "/payroll/payslips/:id", PermissionRunRead, h.getPayslip) g.GET("/payroll/me/payslips", h.myPayslips) g.GET("/payroll/me/payslips/:id", h.myPayslip)}
type handlers struct{ deps Deps }
// permissionLoader adapts member.Permissions to httpx.PermissionLoader (the other modules'// few lines, copied).type permissionLoader struct{ db *db.DB }
var _ httpx.PermissionLoader = permissionLoader{}
func (p permissionLoader) Permissions(ctx context.Context, memberID uuid.UUID) (map[string]struct{}, error) { if p.db == nil { return nil, apperr.New(apperr.KindInternal, "authorization is not configured") } var out map[string]struct{} err := p.db.InTx(ctx, func(ctx context.Context, tx pgx.Tx) error { var err error out, err = member.Permissions(ctx, tx, memberID) return err }) if err != nil { return nil, fmt.Errorf("payroll: load permissions: %w", err) } return out, nil}(Permissions is internal/modules/leave/routes.go’s, copied.)
Create internal/modules/payroll/errors.go:
package payroll
import ( "errors" "fmt" "strings"
apperr "github.com/mcom/erp/internal/kernel/errors" "github.com/mcom/erp/internal/platform/hr" p "github.com/mcom/erp/internal/platform/payroll" "github.com/mcom/erp/internal/platform/workflow")
func coded(kind apperr.Kind, code, detail string) *apperr.Problem { return apperr.New(kind, detail).With("code", code)}
// mapErr: 409 the same request would succeed against records in another state; 422 the// request itself is wrong; 403 the grants or the move's permission name someone else.func mapErr(err error) error { var ( field *p.FieldError rev *p.RevisionMismatchError neg *p.NegativeNetError refused *workflow.RefusedError ) switch { case errors.Is(err, p.ErrNotFound): return apperr.New(apperr.KindNotFound, "no such payroll record in this organization") case errors.Is(err, hr.ErrNotFound): return apperr.New(apperr.KindNotFound, "no such employee in this organization") case errors.As(err, &field): return apperr.New(apperr.KindValidation, field.Error()).With("field", field.Field).With("rule", field.Rule) case errors.As(err, &rev): return coded(apperr.KindConflict, "revision_mismatch", fmt.Sprintf("the record is at revision %d; reload and retry", rev.Current)) case errors.As(err, &neg): return coded(apperr.KindConflict, "negative_net", "deductions exceed gross pay on "+strings.Join(neg.References, ", ")). With("references", neg.References) case errors.Is(err, p.ErrRunExists): return coded(apperr.KindConflict, "run_exists", "this month already has a payroll run that is not voided") case errors.Is(err, p.ErrMonthFuture): return coded(apperr.KindValidation, "month_future", "a run is for the current month or an earlier one").With("field", "month") case errors.Is(err, p.ErrNotConfigured): return coded(apperr.KindConflict, "payroll_not_configured", err.Error()) case errors.Is(err, p.ErrRunNotDraft): return coded(apperr.KindConflict, "run_not_draft", "the run is no longer a draft") case errors.Is(err, p.ErrEmptyRun): return coded(apperr.KindConflict, "empty_run", "the run pays nobody") case errors.Is(err, p.ErrRunInPayment): return coded(apperr.KindConflict, "run_in_payment", err.Error()) case errors.Is(err, p.ErrDuplicateItemType): return coded(apperr.KindConflict, "duplicate_item_type", "another pay item type has this code").With("field", "code") case errors.Is(err, workflow.ErrStaleState): return apperr.New(apperr.KindConflict, "the run is no longer in the state you sent as from; reload and retry") case errors.Is(err, workflow.ErrNoSuchEdge): return apperr.New(apperr.KindValidation, "to names no transition out of the current state") case errors.As(err, new(*workflow.ReasonError)): return apperr.New(apperr.KindValidation, err.Error()).With("field", "reason") case errors.Is(err, workflow.ErrSoleOwnerReasonRequired): return coded(apperr.KindValidation, "sole_owner_reason_required", "field=reason rule=required: as the organization's only member you may do this yourself, with a reason").With("field", "reason") case errors.Is(err, workflow.ErrReasonRequired): return apperr.New(apperr.KindValidation, "field=reason rule=required: this transition needs a reason").With("field", "reason") case errors.As(err, &refused): return apperr.New(apperr.KindForbidden, "refused: "+refused.Refusal.Code+": "+refused.Refusal.Detail). With("code", refused.Refusal.Code) } return err}Create internal/modules/payroll/dto.go:
package payroll
import ( "time"
"github.com/google/uuid"
"github.com/mcom/erp/internal/kernel/money" p "github.com/mcom/erp/internal/platform/payroll" "github.com/mcom/erp/internal/platform/workflow")
const dateLayout = "2006-01-02"
func dateStr(t *time.Time) *string { if t == nil { return nil } s := t.Format(dateLayout) return &s}
type settingsDTO struct { UnpaidLeaveDivisor *int `json:"unpaid_leave_divisor"` PayoutChannelID *uuid.UUID `json:"payout_channel_id"` SalaryExpenseAccountID *uuid.UUID `json:"salary_expense_account_id"` StatutoryPayableAccountID *uuid.UUID `json:"statutory_payable_account_id"` Revision int `json:"revision"`}
func toSettings(s p.Setting) settingsDTO { return settingsDTO{s.UnpaidLeaveDivisor, s.PayoutChannelID, s.SalaryExpenseAccountID, s.StatutoryPayableAccountID, s.Revision}}
type itemTypeDTO struct { ID uuid.UUID `json:"id"` Code string `json:"code"` Names p.Names `json:"names"` Kind string `json:"kind"` Recurrence string `json:"recurrence"` PostingTag string `json:"posting_tag"` EPF bool `json:"epf"` SOCSO bool `json:"socso"` EIS bool `json:"eis"` PCB bool `json:"pcb"` Active bool `json:"active"` Revision int `json:"revision"`}
func toItemType(t p.ItemType) itemTypeDTO { return itemTypeDTO{t.ID, t.Code, t.Names, t.Kind, t.Recurrence, t.PostingTag, t.EPF, t.SOCSO, t.EIS, t.PCB, t.Active, t.Revision}}
type salaryDTO struct { ID uuid.UUID `json:"id"` EmployeeID uuid.UUID `json:"employee_id"` Amount money.Amount `json:"amount"` EffectiveFrom string `json:"effective_from"` Reason *string `json:"reason"` CreatedBy uuid.UUID `json:"created_by"` CreatedAt time.Time `json:"created_at"`}
func toSalary(s p.Salary) salaryDTO { return salaryDTO{s.ID, s.EmployeeID, s.Amount, s.EffectiveFrom.Format(dateLayout), s.Reason, s.CreatedBy, s.CreatedAt}}
type employeeItemDTO struct { ID uuid.UUID `json:"id"` EmployeeID uuid.UUID `json:"employee_id"` TypeID uuid.UUID `json:"type_id"` Amount money.Amount `json:"amount"` EffectiveFrom string `json:"effective_from"` EffectiveTo *string `json:"effective_to"` EndedAt *time.Time `json:"ended_at"`}
func toEmployeeItem(x p.EmployeeItem) employeeItemDTO { return employeeItemDTO{x.ID, x.EmployeeID, x.TypeID, x.Amount, x.EffectiveFrom.Format(dateLayout), dateStr(x.EffectiveTo), x.EndedAt}}
type runDTO struct { ID uuid.UUID `json:"id"` Reference string `json:"reference"` Month string `json:"month"` Status string `json:"status"` State string `json:"state"` CalcNo int `json:"calc_no"` Revision int `json:"revision"` PreparedBy uuid.UUID `json:"prepared_by"` ApprovedBy *uuid.UUID `json:"approved_by"` ApprovedSoleOwner bool `json:"approved_sole_owner"` Gross money.Amount `json:"gross"` Deductions money.Amount `json:"deductions"` Net money.Amount `json:"net"` PayslipCount int `json:"payslip_count"` PaidCount int `json:"paid_count"` CalculatedAt *time.Time `json:"calculated_at"` CreatedAt time.Time `json:"created_at"`}
func toRun(r p.Run) runDTO { return runDTO{r.ID, r.Reference, r.Month.Format("2006-01"), r.Status, r.State, r.CalcNo, r.Revision, r.PreparedBy, r.ApprovedBy, r.ApprovedSoleOwner, r.Gross, r.Deductions, r.Net, r.PayslipCount, r.PaidCount, r.CalculatedAt, r.CreatedAt}}
type lineDTO struct { LineNo int `json:"line_no"` Kind string `json:"kind"` TypeID *uuid.UUID `json:"type_id"` StatutoryCode *string `json:"statutory_code"` Description string `json:"description"` Days *string `json:"days"` Deduction bool `json:"deduction"` Amount money.Amount `json:"amount"`}
type payslipDTO struct { ID uuid.UUID `json:"id"` RunID uuid.UUID `json:"run_id"` Reference string `json:"reference"` EmployeeID uuid.UUID `json:"employee_id"` EmployeeNo string `json:"employee_no"` EmployeeName string `json:"employee_name"` Month string `json:"month"` RunStatus string `json:"run_status"` Gross money.Amount `json:"gross"` Deductions money.Amount `json:"deductions"` Net money.Amount `json:"net"` DaysInMonth int `json:"days_in_month"` DaysEmployed int `json:"days_employed"` Divisor int `json:"divisor"` Paid bool `json:"paid"` PaidAt *time.Time `json:"paid_at"` BatchReference *string `json:"batch_reference"` Lines []lineDTO `json:"lines,omitempty"`}
func toPayslip(s p.Payslip) payslipDTO { out := payslipDTO{s.ID, s.RunID, s.Reference, s.EmployeeID, s.EmployeeNo, s.EmployeeName, s.Month.Format("2006-01"), s.RunStatus, s.Gross, s.Deductions, s.Net, s.DaysInMonth, s.DaysEmployed, s.Divisor, s.Paid, s.PaidAt, s.BatchReference, nil} for _, l := range s.Lines { var days *string if l.Days != nil { d := l.Days.String() days = &d } out.Lines = append(out.Lines, lineDTO{l.LineNo, l.Kind, l.TypeID, l.StatutoryCode, l.Description, days, l.Deduction, l.Amount}) } return out}
type inputDTO struct { ID uuid.UUID `json:"id"` EmployeeID uuid.UUID `json:"employee_id"` Kind string `json:"kind"` TypeID *uuid.UUID `json:"type_id"` StatutoryCode *string `json:"statutory_code"` Description string `json:"description"` Amount money.Amount `json:"amount"`}
type warningDTO struct { EmployeeID uuid.UUID `json:"employee_id"` EmployeeNo string `json:"employee_no"` EmployeeName string `json:"employee_name"` Code string `json:"code"`}
type runDetailDTO struct { Run runDTO `json:"run"` Payslips []payslipDTO `json:"payslips"` Inputs []inputDTO `json:"inputs"` Warnings []warningDTO `json:"warnings"` Available []workflow.Available `json:"available"`}Create internal/modules/payroll/setup_handlers.go:
package payroll
import ( "context" "net/http" "time"
"github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/money" p "github.com/mcom/erp/internal/platform/payroll")
func (h handlers) getSettings(c *gin.Context) { var out settingsDTO if !h.inTx(c, func(ctx context.Context, tx pgx.Tx, _ uuid.UUID) error { s, err := p.GetSetting(ctx, tx) out = toSettings(s) return err }) { return } setETag(c, out.Revision) c.JSON(http.StatusOK, out)}
func (h handlers) putSettings(c *gin.Context) { rev, err := ifMatch(c) var body settingsDTO if err == nil { err = decodeStrict(c, &body) } if err != nil { _ = c.Error(err) return } var out settingsDTO if !h.inTx(c, func(ctx context.Context, tx pgx.Tx, who uuid.UUID) error { s, err := p.PutSetting(ctx, tx, h.deps.Clock, p.Setting{UnpaidLeaveDivisor: body.UnpaidLeaveDivisor, PayoutChannelID: body.PayoutChannelID, SalaryExpenseAccountID: body.SalaryExpenseAccountID, StatutoryPayableAccountID: body.StatutoryPayableAccountID}, rev, who) out = toSettings(s) return err }) { return } setETag(c, out.Revision) c.JSON(http.StatusOK, out)}
func (h handlers) listTypes(c *gin.Context) { out := []itemTypeDTO{} if !h.inTx(c, func(ctx context.Context, tx pgx.Tx, _ uuid.UUID) error { ts, err := p.ListItemTypes(ctx, tx, c.Query("all") != "true") for _, t := range ts { out = append(out, toItemType(t)) } return err }) { return } c.JSON(http.StatusOK, gin.H{"types": out})}
func (h handlers) createType(c *gin.Context) { var body struct { Code string `json:"code"` Names p.Names `json:"names"` Kind string `json:"kind"` Recurrence string `json:"recurrence"` EPF bool `json:"epf"` SOCSO bool `json:"socso"` EIS bool `json:"eis"` PCB bool `json:"pcb"` } if err := decodeStrict(c, &body); err != nil { _ = c.Error(err) return } var out itemTypeDTO if !h.inTx(c, func(ctx context.Context, tx pgx.Tx, who uuid.UUID) error { t, err := p.CreateItemType(ctx, tx, h.deps.Clock, p.ItemTypeInput{Code: body.Code, Names: body.Names, Kind: body.Kind, Recurrence: body.Recurrence, EPF: body.EPF, SOCSO: body.SOCSO, EIS: body.EIS, PCB: body.PCB, Actor: who}) out = toItemType(t) return err }) { return } setETag(c, out.Revision) c.JSON(http.StatusCreated, out)}
func (h handlers) updateType(c *gin.Context) { id, err := parseID(c, "id") rev, err2 := ifMatch(c) var body struct { Names p.Names `json:"names"` EPF bool `json:"epf"` SOCSO bool `json:"socso"` EIS bool `json:"eis"` PCB bool `json:"pcb"` Active bool `json:"active"` } if err == nil { err = err2 } if err == nil { err = decodeStrict(c, &body) } if err != nil { _ = c.Error(err) return } var out itemTypeDTO if !h.inTx(c, func(ctx context.Context, tx pgx.Tx, who uuid.UUID) error { t, err := p.UpdateItemType(ctx, tx, h.deps.Clock, id, rev, p.ItemTypeUpdate{Names: body.Names, EPF: body.EPF, SOCSO: body.SOCSO, EIS: body.EIS, PCB: body.PCB, Active: body.Active, Actor: who}) out = toItemType(t) return err }) { return } setETag(c, out.Revision) c.JSON(http.StatusOK, out)}
func (h handlers) listSalaries(c *gin.Context) { id, err := parseID(c, "id") if err != nil { _ = c.Error(err) return } out := []salaryDTO{} if !h.inTx(c, func(ctx context.Context, tx pgx.Tx, who uuid.UUID) error { rows, err := p.SalaryHistory(ctx, tx, h.deps.Clock, id, who) for _, s := range rows { out = append(out, toSalary(s)) } return err }) { return } c.JSON(http.StatusOK, gin.H{"salaries": out})}
func (h handlers) addSalary(c *gin.Context) { id, err := parseID(c, "id") var body struct { Amount money.Amount `json:"amount"` EffectiveFrom string `json:"effective_from"` Reason *string `json:"reason"` } if err == nil { err = decodeStrict(c, &body) } var from time.Time if err == nil { from, err = parseDate("effective_from", body.EffectiveFrom) } if err != nil { _ = c.Error(err) return } var out salaryDTO if !h.inTx(c, func(ctx context.Context, tx pgx.Tx, who uuid.UUID) error { s, err := p.AddSalary(ctx, tx, h.deps.Clock, p.SalaryInput{EmployeeID: id, Amount: body.Amount, EffectiveFrom: from, Reason: body.Reason, Actor: who}) out = toSalary(s) return err }) { return } c.JSON(http.StatusCreated, out)}
func (h handlers) listItems(c *gin.Context) { id, err := parseID(c, "id") if err != nil { _ = c.Error(err) return } out := []employeeItemDTO{} if !h.inTx(c, func(ctx context.Context, tx pgx.Tx, _ uuid.UUID) error { xs, err := p.EmployeeItems(ctx, tx, id) for _, x := range xs { out = append(out, toEmployeeItem(x)) } return err }) { return } c.JSON(http.StatusOK, gin.H{"items": out})}
func (h handlers) addItem(c *gin.Context) { id, err := parseID(c, "id") var body struct { TypeID uuid.UUID `json:"type_id"` Amount money.Amount `json:"amount"` EffectiveFrom string `json:"effective_from"` EffectiveTo *string `json:"effective_to"` } if err == nil { err = decodeStrict(c, &body) } var from time.Time var to *time.Time if err == nil { from, err = parseDate("effective_from", body.EffectiveFrom) } if err == nil && body.EffectiveTo != nil { var d time.Time d, err = parseDate("effective_to", *body.EffectiveTo) to = &d } if err != nil { _ = c.Error(err) return } var out employeeItemDTO if !h.inTx(c, func(ctx context.Context, tx pgx.Tx, who uuid.UUID) error { x, err := p.AddEmployeeItem(ctx, tx, h.deps.Clock, p.EmployeeItemInput{EmployeeID: id, TypeID: body.TypeID, Amount: body.Amount, EffectiveFrom: from, EffectiveTo: to, Actor: who}) out = toEmployeeItem(x) return err }) { return } c.JSON(http.StatusCreated, out)}
func (h handlers) endItem(c *gin.Context) { id, err := parseID(c, "id") var body struct { LastDay string `json:"last_day"` } if err == nil { err = decodeStrict(c, &body) } var day time.Time if err == nil { day, err = parseDate("last_day", body.LastDay) } if err != nil { _ = c.Error(err) return } var out employeeItemDTO if !h.inTx(c, func(ctx context.Context, tx pgx.Tx, who uuid.UUID) error { x, err := p.EndEmployeeItem(ctx, tx, h.deps.Clock, id, day, who) out = toEmployeeItem(x) return err }) { return } c.JSON(http.StatusOK, out)}Create internal/modules/payroll/run_handlers.go:
package payroll
import ( "context" "net/http" "time"
"github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/jackc/pgx/v5"
apperr "github.com/mcom/erp/internal/kernel/errors" "github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/identity/member" p "github.com/mcom/erp/internal/platform/payroll" "github.com/mcom/erp/internal/platform/workflow")
// detail is a run as the run screen shows it, with what who may do to it now.func (h handlers) detail(ctx context.Context, tx pgx.Tx, id, who uuid.UUID) (runDetailDTO, error) { r, err := p.GetRun(ctx, tx, id) if err != nil { return runDetailDTO{}, err } out := runDetailDTO{Run: toRun(r), Payslips: []payslipDTO{}, Inputs: []inputDTO{}, Warnings: []warningDTO{}} slips, err := p.Payslips(ctx, tx, id) if err != nil { return runDetailDTO{}, err } for _, s := range slips { out.Payslips = append(out.Payslips, toPayslip(s)) } ins, err := p.Inputs(ctx, tx, id) if err != nil { return runDetailDTO{}, err } for _, x := range ins { out.Inputs = append(out.Inputs, inputDTO{x.ID, x.EmployeeID, x.Kind, x.TypeID, x.StatutoryCode, x.Description, x.Amount}) } ws, err := p.RunWarnings(ctx, tx, r) if err != nil { return runDetailDTO{}, err } for _, w := range ws { out.Warnings = append(out.Warnings, warningDTO{w.EmployeeID, w.EmployeeNo, w.EmployeeName, w.Code}) } if out.Available, err = workflow.AvailableTransitions(ctx, tx, h.deps.Clock, r.InstanceID, who); err != nil { return runDetailDTO{}, err } return out, nil}
func (h handlers) listRuns(c *gin.Context) { out := []runDTO{} if !h.inTx(c, func(ctx context.Context, tx pgx.Tx, _ uuid.UUID) error { rs, err := p.ListRuns(ctx, tx) for _, r := range rs { out = append(out, toRun(r)) } return err }) { return } c.JSON(http.StatusOK, gin.H{"runs": out})}
func (h handlers) createRun(c *gin.Context) { var body struct { Month string `json:"month"` } if err := decodeStrict(c, &body); err != nil { _ = c.Error(err) return } month, err := time.Parse("2006-01", body.Month) if err != nil { _ = c.Error(apperr.New(apperr.KindValidation, "field=month rule=month: a month is YYYY-MM").With("field", "month")) return } var out runDetailDTO if !h.inTx(c, func(ctx context.Context, tx pgx.Tx, who uuid.UUID) error { r, err := p.CreateRun(ctx, tx, h.deps.Clock, month, who) if err != nil { return err } out, err = h.detail(ctx, tx, r.ID, who) return err }) { return } setETag(c, out.Run.Revision) c.JSON(http.StatusCreated, out)}
func (h handlers) getRun(c *gin.Context) { id, err := parseID(c, "id") if err != nil { _ = c.Error(err) return } var out runDetailDTO if !h.inTx(c, func(ctx context.Context, tx pgx.Tx, who uuid.UUID) error { var err error out, err = h.detail(ctx, tx, id, who) return err }) { return } setETag(c, out.Run.Revision) c.JSON(http.StatusOK, out)}
// runWrite is the shape of every revision-checked write to one run: parse, act, answer the detail.func (h handlers) runWrite(c *gin.Context, status int, body any, act func(ctx context.Context, tx pgx.Tx, id, who uuid.UUID, rev int) error) { id, err := parseID(c, "id") rev, err2 := ifMatch(c) if err == nil { err = err2 } if err == nil && body != nil { err = decodeStrict(c, body) } if err != nil { _ = c.Error(err) return } var out runDetailDTO if !h.inTx(c, func(ctx context.Context, tx pgx.Tx, who uuid.UUID) error { if err := act(ctx, tx, id, who, rev); err != nil { return err } var err error out, err = h.detail(ctx, tx, id, who) return err }) { return } setETag(c, out.Run.Revision) c.JSON(status, out)}
func (h handlers) recalculate(c *gin.Context) { h.runWrite(c, http.StatusOK, nil, func(ctx context.Context, tx pgx.Tx, id, who uuid.UUID, rev int) error { _, err := p.Recalculate(ctx, tx, h.deps.Clock, id, who, rev) return err })}
func (h handlers) addInput(c *gin.Context) { var body struct { EmployeeID uuid.UUID `json:"employee_id"` Kind string `json:"kind"` TypeID *uuid.UUID `json:"type_id"` StatutoryCode *string `json:"statutory_code"` Description string `json:"description"` Amount money.Amount `json:"amount"` } h.runWrite(c, http.StatusCreated, &body, func(ctx context.Context, tx pgx.Tx, id, who uuid.UUID, rev int) error { _, _, err := p.AddInput(ctx, tx, h.deps.Clock, p.InputInput{RunID: id, EmployeeID: body.EmployeeID, Kind: body.Kind, TypeID: body.TypeID, StatutoryCode: body.StatutoryCode, Description: body.Description, Amount: body.Amount, Actor: who, ExpectedRevision: rev}) return err })}
func (h handlers) removeInput(c *gin.Context) { input, err := parseID(c, "input_id") if err != nil { _ = c.Error(err) return } h.runWrite(c, http.StatusOK, nil, func(ctx context.Context, tx pgx.Tx, id, who uuid.UUID, rev int) error { _, err := p.RemoveInput(ctx, tx, h.deps.Clock, id, input, who, rev) return err })}
func (h handlers) transition(c *gin.Context) { var body struct { From string `json:"from"` To string `json:"to"` Reason string `json:"reason"` } h.runWrite(c, http.StatusOK, &body, func(ctx context.Context, tx pgx.Tx, id, who uuid.UUID, rev int) error { need, err := p.RequiredPermission(ctx, tx, id, body.To) if err != nil { return err } ok, err := member.Can(ctx, tx, who, need) if err != nil { return err } if !ok { return coded(apperr.KindForbidden, "permission_required", "this move needs "+need) } _, _, err = p.TransitionRun(ctx, tx, h.deps.Clock, p.TransitionInput{RunID: id, From: body.From, To: body.To, Actor: who, Reason: body.Reason, ExpectedRevision: rev}) return err })}
func (h handlers) getPayslip(c *gin.Context) { id, err := parseID(c, "id") if err != nil { _ = c.Error(err) return } var out payslipDTO if !h.inTx(c, func(ctx context.Context, tx pgx.Tx, who uuid.UUID) error { s, err := p.ReadPayslip(ctx, tx, h.deps.Clock, id, who) out = toPayslip(s) return err }) { return } c.JSON(http.StatusOK, out)}Create internal/modules/payroll/me_handlers.go:
package payroll
import ( "context" "net/http"
"github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/jackc/pgx/v5"
p "github.com/mcom/erp/internal/platform/payroll")
// myPayslips and myPayslip carry no permission (api/public_routes.txt, PD-24): the caller's own// payslips, by member, and nobody else's.func (h handlers) myPayslips(c *gin.Context) { out := []payslipDTO{} if !h.inTx(c, func(ctx context.Context, tx pgx.Tx, who uuid.UUID) error { ps, err := p.MyPayslips(ctx, tx, who) for _, s := range ps { out = append(out, toPayslip(s)) } return err }) { return } c.JSON(http.StatusOK, gin.H{"payslips": out})}
func (h handlers) myPayslip(c *gin.Context) { id, err := parseID(c, "id") if err != nil { _ = c.Error(err) return } var out payslipDTO if !h.inTx(c, func(ctx context.Context, tx pgx.Tx, who uuid.UUID) error { s, err := p.OwnPayslip(ctx, tx, id, who) out = toPayslip(s) return err }) { return } c.JSON(http.StatusOK, out)}-
Step 6: Wire the module into the binaries
-
internal/app/app.go: import"github.com/mcom/erp/internal/modules/payroll";DepsgainsPayroll payroll.DepsbesideLeave;Routergainspayroll.Register(optional("payroll"), deps.Payroll)after the leave line. -
cmd/api/main.go: besideLeave: leave.Deps{…}addPayroll: payroll.Deps{DB: database, Clock: clock.System()},and the import. -
cmd/migrate/main.go,cmd/orgadmin/main.go,cmd/worker/main.go: add_ "github.com/mcom/erp/internal/modules/payroll"to the blank-import block. -
cmd/migrate/wiring_test.goandcmd/orgadmin/wiring_test.go:wantModulesgains"github.com/mcom/erp/internal/modules/payroll",;wantPermissionsgains"payroll.salary.read", // modules/payrolland"payroll.run.approve", // modules/payroll. -
internal/app/routes_test.gocomposedModulesgains"payroll": {payroll.PermissionSalaryRead, payroll.PermissionSalaryWrite, payroll.PermissionItemManage, payroll.PermissionRunRead, payroll.PermissionRunPrepare, payroll.PermissionRunApprove},with the import. -
Step 7: The team labels — watch their guard fail once
Run: go test ./internal/app/ -run TestEveryRegisteredPermissionAndModuleHasATeamLabelInEveryLocale -count=1
Expected: FAIL naming perm.payroll_salary_read (and the other five) and modules.payroll as missing in en, ms and zh. Read it.
Add to each web/src/lib/i18n/locales/<loc>/team.json, inside "perm":
- en:
"payroll_salary_read": "Read salaries and recurring pay (audited)", "payroll_salary_write": "Record salary changes", "payroll_item_manage": "Manage pay item types, recurring pay and payroll settings", "payroll_run_read": "Read payroll runs and payslips", "payroll_run_prepare": "Prepare and submit payroll runs", "payroll_run_approve": "Approve, reject or void payroll runs" - ms:
"payroll_salary_read": "Baca gaji dan bayaran berulang (diaudit)", "payroll_salary_write": "Rekod perubahan gaji", "payroll_item_manage": "Urus jenis item gaji, bayaran berulang dan tetapan gaji", "payroll_run_read": "Baca larian gaji dan slip gaji", "payroll_run_prepare": "Sediakan dan hantar larian gaji", "payroll_run_approve": "Luluskan, tolak atau batalkan larian gaji" - zh:
"payroll_salary_read": "查看工资及固定薪酬项目(留有审计记录)", "payroll_salary_write": "记录工资变动", "payroll_item_manage": "管理薪酬项目类型、固定薪酬项目和工资设置", "payroll_run_read": "查看工资核算及工资单", "payroll_run_prepare": "准备并提交工资核算", "payroll_run_approve": "批准、拒绝或作废工资核算"
and inside "modules": en "payroll": "Payroll", ms "payroll": "Gaji", zh "payroll": "工资". In each modules.json: names.payroll (en "Payroll", ms "Gaji", zh "工资") and descriptions.payroll (en "Salaries, pay items, monthly runs and payslips. Statutory contributions are entered by hand until P2.", ms "Gaji, item bayaran, larian bulanan dan slip gaji. Caruman berkanun dimasukkan secara manual sehingga P2.", zh "工资、薪酬项目、月度核算和工资单。在 P2 之前,法定缴款需手动输入。").
Re-run the test. Expected: PASS.
- Step 8: The module’s route and handler tests
Create internal/modules/payroll/routes_test.go:
package payroll_test
import ( "testing"
"github.com/gin-gonic/gin" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/httpx" "github.com/mcom/erp/internal/modules/payroll")
// Each route's exact permission (PD-23). The two /me routes are not Protected: they are in// api/public_routes.txt, which TestEveryRouteIsAuthorized reads.func TestEveryRouteRequiresItsExactPermission(t *testing.T) { gin.SetMode(gin.TestMode) httpx.ResetAuthorizedRoutes() _, api := httpx.NewRouter(httpx.Deps{}) payroll.Register(api, payroll.Deps{}) require.Equal(t, map[string]string{ "GET /v1/payroll/settings": payroll.PermissionItemManage, "PUT /v1/payroll/settings": payroll.PermissionItemManage, "GET /v1/payroll/item-types": payroll.PermissionItemManage, "POST /v1/payroll/item-types": payroll.PermissionItemManage, "PUT /v1/payroll/item-types/:id": payroll.PermissionItemManage, "GET /v1/payroll/employees/:id/salaries": payroll.PermissionSalaryRead, "POST /v1/payroll/employees/:id/salaries": payroll.PermissionSalaryWrite, "GET /v1/payroll/employees/:id/items": payroll.PermissionSalaryRead, "POST /v1/payroll/employees/:id/items": payroll.PermissionItemManage, "POST /v1/payroll/employee-items/:id/end": payroll.PermissionItemManage, "GET /v1/payroll/runs": payroll.PermissionRunRead, "POST /v1/payroll/runs": payroll.PermissionRunPrepare, "GET /v1/payroll/runs/:id": payroll.PermissionRunRead, "POST /v1/payroll/runs/:id/recalculate": payroll.PermissionRunPrepare, "POST /v1/payroll/runs/:id/inputs": payroll.PermissionRunPrepare, "POST /v1/payroll/runs/:id/inputs/:input_id/remove": payroll.PermissionRunPrepare, "POST /v1/payroll/runs/:id/transitions": payroll.PermissionRunRead, "GET /v1/payroll/payslips/:id": payroll.PermissionRunRead, }, httpx.AuthorizedRoutes())}Create internal/modules/payroll/env_test.go — internal/modules/leave/env_test.go copied with three changes: package payroll_test; e := payrolltest.New(t) (type *payrolltest.Env in world.e); payroll.Register(api, payroll.Deps{DB: e.DB, Clock: e.Clock}). Keep alwaysLive, token, do and field as they are (w.e.Grant is hrtest’s and is promoted).
Create internal/modules/payroll/handlers_test.go:
package payroll_test
import ( "net/http" "strconv" "testing"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/modules/payroll" "github.com/mcom/erp/internal/platform/hr/hrtest")
// PD-23: holding prepare is not holding approve — the move's own key is required.func TestApprovingNeedsTheApproveKeyNotJustRead(t *testing.T) { w := newWorld(t) w.e.NopPoster(t) w.e.Employee(t, w.e.Staff, "E-100", "3100.00") run := w.e.Move(t, w.e.Run(t, hrtest.Day(2026, 8, 1)), w.e.HR, "submitted", "") approver := w.token(t, w.e.Owner, payroll.PermissionRunRead, payroll.PermissionRunPrepare) body := `{"from":"submitted","to":"approved"}` rec := w.do(t, "POST", "/v1/payroll/runs/"+run.ID.String()+"/transitions", body, approver, `"`+itoa(run.Revision)+`"`) require.Equal(t, http.StatusForbidden, rec.Code, rec.Body.String()) require.Equal(t, "permission_required", field(t, rec, "code"))
approver = w.token(t, w.e.Owner, payroll.PermissionRunApprove) rec = w.do(t, "POST", "/v1/payroll/runs/"+run.ID.String()+"/transitions", body, approver, `"`+itoa(run.Revision)+`"`) require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())}
// PD-24: every member reads their own payslips without any payroll key, and nobody else's.func TestAMemberReadsOnlyTheirOwnPayslipWithoutAKey(t *testing.T) { w := newWorld(t) w.e.NopPoster(t) w.e.Employee(t, w.e.Staff, "E-100", "3100.00") run := w.e.ApprovedRun(t, hrtest.Day(2026, 8, 1)) slip := w.e.Slips(t, run.ID)["E-100"]
staff := w.token(t, w.e.Staff) rec := w.do(t, "GET", "/v1/payroll/me/payslips/"+slip.ID.String(), "", staff, "") require.Equal(t, http.StatusOK, rec.Code, "PRESENCE: "+rec.Body.String()) require.Equal(t, "3100.0000", field(t, rec, "net"))
hr := w.token(t, w.e.HR) rec = w.do(t, "GET", "/v1/payroll/me/payslips/"+slip.ID.String(), "", hr, "") require.Equal(t, http.StatusNotFound, rec.Code, "ABSENCE: someone else's payslip")}
func itoa(n int) string { return strconv.Itoa(n) }- Step 9: Run the module, app and binary suites
Run:
go test ./internal/modules/payroll/... ./internal/app/... ./cmd/... ./internal/platform/moduleinstall/... ./internal/platform/workflow/... -count=1pnpm -C web exec tsc --noEmit && pnpm -C web test -- --run src/drift src/features/accountingExpected: PASS — including TestEveryRouteIsAuthorized, TestEveryModuleContributesRoutesAndPermissions, TestTheOpenAPIDocumentSaysWhatTheRouterEnforces, TestEveryBinaryLinksTheSameModules, TestANestedPermissionBelongsToAModuleThatRequiresItsParent, TestTheOwnedControlKindsNameRealKinds, TestEverySeededGraphPassesTheValidator, TestEverySeededGraphShipsAllThreeLocales, internal/gen/drift_test.go.
- Step 10: Commit
git add constants.yaml internal/kernel/vocab packages/contracts/vocab.ts db/migrations/00903_payroll_grants.sql \ internal/platform/moduleinstall internal/platform/workflow/validate.go internal/platform/payroll/migration00903_test.go \ internal/modules/payroll api internal/gen web/src/lib/api/schema.d.ts internal/app cmd \ web/src/features/accounting/controlKinds.ts web/src/lib/i18n/localesgit commit -m "Payroll P1: the payroll module — permissions, Owner grants, module switch, contract and HTTP (00903)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"Task 11: Screens — payroll setup, salary and pay items on the employee, My payslips
Section titled “Task 11: Screens — payroll setup, salary and pay items on the employee, My payslips”Files:
- Create:
web/src/features/payroll/api.ts,routes.tsx,PayrollSetupPage.tsx,ItemTypeDialog.tsx,SalarySection.tsx,PayslipView.tsx,MyPayslipsPage.tsx - Create:
web/src/lib/i18n/locales/{en,ms,zh}/payroll.json - Modify:
web/src/app/routes.ts(spreadpayrollRoutes),web/src/app/shell/nav.ts(three items),web/src/features/hr/EmployeeDrawer.tsx(the salary section) - Test:
web/src/features/payroll/__tests__/payroll.test.tsx,__tests__/fixtures.ts
Interfaces:
-
Consumes: Task 10’s contract (
Schemas['PayrollSettings'],PayItemType,Salary,EmployeePayItem,Payslip,PayslipLine,PayrollRun,PayrollRunDetail,PayrollWarning);api,unwrap,problemMessage,ProblemError;useCan,RequirePermission;usePayoutChannels,useAccounts;formatMoney,isMoneyInput,normalizeMoneyInput;formatDate,todayISO;pickLabel,useLocale; UIButton,Dialog,Drawer,DataTable,EmptyState,PageHeader,Field,Input,Select,Checkbox,Tag,MonthPicker. -
Produces:
web/src/features/payroll/api.tsexports (types above and)payrollKeys,usePayrollSettings,useSavePayrollSettings,useItemTypes,useSaveItemType,useSalaries,useAddSalary,useEmployeeItems,useAddEmployeeItem,useEndEmployeeItem,useRuns,useRun,useCreateRun,useRecalculate,useAddInput,useRemoveInput,useTransitionRun,usePayslip,useMyPayslips,useMyPayslip,payrollMessage(e: unknown, t: TFunction): string; componentsSalarySection({ employeeId }),PayslipView({ payslip }),RunStatusTag({ status }); routes/payroll/setup,/payroll/me. -
Step 1: Write the failing screen test
Create web/src/features/payroll/__tests__/fixtures.ts (shared by both payroll test files; a test file is never imported by another, or its tests would run twice):
export const slip = { id: 's1', run_id: 'r1', reference: 'PR-2026-0001-001', employee_id: 'e1', employee_no: 'E-100', employee_name: 'Siti binti Ahmad', month: '2026-08', run_status: 'approved', gross: '3100.0000', deductions: '341.0000', net: '2759.0000', days_in_month: 31, days_employed: 31, divisor: 31, paid: false, paid_at: null, batch_reference: null, lines: [ { line_no: 1, kind: 'basic', type_id: null, statutory_code: null, description: 'Basic salary', days: '31', deduction: false, amount: '3100.0000' }, { line_no: 2, kind: 'statutory', type_id: null, statutory_code: 'epf', description: 'EPF', days: null, deduction: true, amount: '341.0000' }, ],};Create web/src/features/payroll/__tests__/payroll.test.tsx:
import { describe, expect, it } from 'vitest';import { screen } from '@testing-library/react';import i18n from 'i18next';import { http, HttpResponse } from 'msw';import { server } from '@/test/msw/server';import { renderWithApp } from '@/test/renderWithApp';import { navItems } from '@/app/shell/nav';import { hasPermission } from '@/features/auth/session';import { slip } from './fixtures';
describe('My payslips', () => { it('lists the member’s own payslips with no payroll key (presence first)', async () => { server.use( http.get('*/v1/payroll/me/payslips', () => HttpResponse.json({ payslips: [slip] })), http.get('*/v1/payroll/me/payslips/s1', () => HttpResponse.json(slip)), ); await renderWithApp({ path: '/payroll/me', permissions: [] }); expect(await screen.findByText('PR-2026-0001-001')).toBeInTheDocument(); expect(screen.getByText('RM 2,759.00')).toBeInTheDocument(); const item = navItems.find((i) => i.to === '/payroll/me'); expect(item?.permission).toBeUndefined(); });
it('hides payroll setup without payroll.item.manage', async () => { const item = navItems.find((i) => i.to === '/payroll/setup'); expect(hasPermission(['payroll.item.manage'], item!.permission)).toBe(true); expect(hasPermission([], item!.permission)).toBe(false); await renderWithApp({ path: '/payroll/setup', permissions: [] }); expect(await screen.findByText(i18n.t('auth:forbidden.title'))).toBeInTheDocument(); });});
describe('Payroll setup', () => { it('shows the item types and the settings', async () => { server.use( http.get('*/v1/payroll/item-types', () => HttpResponse.json({ types: [{ id: 't1', code: 'TRANS', names: { en: 'Transport allowance', ms: 'Elaun pengangkutan' }, kind: 'earning', recurrence: 'recurring', posting_tag: 'salary_expense', epf: true, socso: false, eis: false, pcb: true, active: true, revision: 1 }], }), ), http.get('*/v1/payroll/settings', () => HttpResponse.json({ unpaid_leave_divisor: null, payout_channel_id: null, salary_expense_account_id: null, statutory_payable_account_id: null, revision: 1 }), ), http.get('*/v1/payout/channels', () => HttpResponse.json({ channels: [] })), http.get('*/v1/accounting/accounts', () => HttpResponse.json({ accounts: [] })), ); await renderWithApp({ path: '/payroll/setup', permissions: ['payroll.item.manage'] }); expect(await screen.findByText('Transport allowance')).toBeInTheDocument(); expect(screen.getByText(i18n.t('payroll:settings.divisorHint'))).toBeInTheDocument(); });});Run: pnpm -C web test -- --run src/features/payroll
Expected: FAIL — /payroll/me renders the not-found page (no route).
(LinkItem.permission is optional in web/src/app/shell/nav.ts: an item without one is shown to every member.)
- Step 2: Write
api.ts
// Phase 5 payroll P1: settings, item types, salaries, employee items, runs and payslips.import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';import type { TFunction } from 'i18next';import { api } from '@/lib/api/client';import { ProblemError, problemMessage, unwrap } from '@/lib/api/problem';import type { Schemas } from '@/lib/api/types';
export type PayrollSettings = Schemas['PayrollSettings'];export type PayItemType = Schemas['PayItemType'];export type Salary = Schemas['Salary'];export type EmployeePayItem = Schemas['EmployeePayItem'];export type PayrollRun = Schemas['PayrollRun'];export type PayrollRunDetail = Schemas['PayrollRunDetail'];export type Payslip = Schemas['Payslip'];export type PayslipLine = Schemas['PayslipLine'];export type PayrollWarning = Schemas['PayrollWarning'];export type RunStatus = PayrollRun['status'];
const etag = (rev: number) => `"${rev}"`;
export const payrollKeys = { all: ['payroll'] as const, settings: ['payroll', 'settings'] as const, types: ['payroll', 'types'] as const, salaries: (e: string) => ['payroll', 'salaries', e] as const, items: (e: string) => ['payroll', 'items', e] as const, runs: ['payroll', 'runs'] as const, run: (id: string) => ['payroll', 'run', id] as const, payslip: (id: string) => ['payroll', 'payslip', id] as const, mine: ['payroll', 'mine'] as const, myPayslip: (id: string) => ['payroll', 'mine', id] as const,};
function useInvalidate() { const qc = useQueryClient(); return () => void qc.invalidateQueries({ queryKey: payrollKeys.all });}
/** A refusal's code as this screen's sentence, else the server's own message. */export function payrollMessage(e: unknown, t: TFunction): string { if (e instanceof ProblemError && e.problem.code) { return t(`payroll:errors.${e.problem.code}`, { defaultValue: problemMessage(e) }); } return problemMessage(e);}
export function usePayrollSettings() { return useQuery({ queryKey: payrollKeys.settings, queryFn: async ({ signal }) => unwrap(await api.GET('/payroll/settings', { signal })), });}
export function useSavePayrollSettings() { const done = useInvalidate(); return useMutation({ mutationFn: async (v: { revision: number; body: Schemas['PayrollSettingsInput'] }) => unwrap(await api.PUT('/payroll/settings', { params: { header: { 'If-Match': etag(v.revision) } }, body: v.body })), onSuccess: done, });}
export function useItemTypes() { return useQuery({ queryKey: payrollKeys.types, queryFn: async ({ signal }) => unwrap(await api.GET('/payroll/item-types', { params: { query: { all: true } }, signal })).types, });}
export function useSaveItemType() { const done = useInvalidate(); return useMutation({ mutationFn: async (v: { id?: string; revision?: number; create?: Schemas['PayItemTypeInput']; update?: Schemas['PayItemTypeUpdate'] }) => v.id ? unwrap(await api.PUT('/payroll/item-types/{id}', { params: { path: { id: v.id }, header: { 'If-Match': etag(v.revision ?? 0) } }, body: v.update!, })) : unwrap(await api.POST('/payroll/item-types', { body: v.create! })), onSuccess: done, });}
export function useSalaries(employeeId: string, enabled: boolean) { return useQuery({ queryKey: payrollKeys.salaries(employeeId), enabled, queryFn: async ({ signal }) => unwrap(await api.GET('/payroll/employees/{id}/salaries', { params: { path: { id: employeeId } }, signal })).salaries, });}
export function useAddSalary(employeeId: string) { const done = useInvalidate(); return useMutation({ mutationFn: async (body: Schemas['SalaryInput']) => unwrap(await api.POST('/payroll/employees/{id}/salaries', { params: { path: { id: employeeId } }, body })), onSuccess: done, });}
export function useEmployeeItems(employeeId: string, enabled: boolean) { return useQuery({ queryKey: payrollKeys.items(employeeId), enabled, queryFn: async ({ signal }) => unwrap(await api.GET('/payroll/employees/{id}/items', { params: { path: { id: employeeId } }, signal })).items, });}
export function useAddEmployeeItem(employeeId: string) { const done = useInvalidate(); return useMutation({ mutationFn: async (body: Schemas['EmployeePayItemInput']) => unwrap(await api.POST('/payroll/employees/{id}/items', { params: { path: { id: employeeId } }, body })), onSuccess: done, });}
export function useEndEmployeeItem() { const done = useInvalidate(); return useMutation({ mutationFn: async (v: { id: string; lastDay: string }) => unwrap(await api.POST('/payroll/employee-items/{id}/end', { params: { path: { id: v.id } }, body: { last_day: v.lastDay } })), onSuccess: done, });}
export function useRuns() { return useQuery({ queryKey: payrollKeys.runs, queryFn: async ({ signal }) => unwrap(await api.GET('/payroll/runs', { signal })).runs, });}
export function useRun(id: string) { return useQuery({ queryKey: payrollKeys.run(id), queryFn: async ({ signal }) => unwrap(await api.GET('/payroll/runs/{id}', { params: { path: { id } }, signal })), });}
export function useCreateRun() { const done = useInvalidate(); return useMutation({ mutationFn: async (month: string) => unwrap(await api.POST('/payroll/runs', { body: { month } })), onSuccess: done, });}
export function useRecalculate(id: string) { const done = useInvalidate(); return useMutation({ mutationFn: async (revision: number) => unwrap(await api.POST('/payroll/runs/{id}/recalculate', { params: { path: { id }, header: { 'If-Match': etag(revision) } } })), onSuccess: done, });}
export function useAddInput(id: string) { const done = useInvalidate(); return useMutation({ mutationFn: async (v: { revision: number; body: Schemas['PayrollRunInputCreate'] }) => unwrap(await api.POST('/payroll/runs/{id}/inputs', { params: { path: { id }, header: { 'If-Match': etag(v.revision) } }, body: v.body, })), onSuccess: done, });}
export function useRemoveInput(id: string) { const done = useInvalidate(); return useMutation({ mutationFn: async (v: { revision: number; inputId: string }) => unwrap(await api.POST('/payroll/runs/{id}/inputs/{input_id}/remove', { params: { path: { id, input_id: v.inputId }, header: { 'If-Match': etag(v.revision) } }, })), onSuccess: done, });}
export function useTransitionRun(id: string) { const done = useInvalidate(); return useMutation({ mutationFn: async (v: { revision: number; from: string; to: string; reason?: string }) => unwrap(await api.POST('/payroll/runs/{id}/transitions', { params: { path: { id }, header: { 'If-Match': etag(v.revision) } }, body: { from: v.from, to: v.to, reason: v.reason }, })), onSuccess: done, });}
export function usePayslip(id: string | undefined) { return useQuery({ queryKey: payrollKeys.payslip(id ?? ''), enabled: !!id, queryFn: async ({ signal }) => unwrap(await api.GET('/payroll/payslips/{id}', { params: { path: { id: id! } }, signal })), });}
export function useMyPayslips() { return useQuery({ queryKey: payrollKeys.mine, queryFn: async ({ signal }) => unwrap(await api.GET('/payroll/me/payslips', { signal })).payslips, });}
export function useMyPayslip(id: string | undefined) { return useQuery({ queryKey: payrollKeys.myPayslip(id ?? ''), enabled: !!id, queryFn: async ({ signal }) => unwrap(await api.GET('/payroll/me/payslips/{id}', { params: { path: { id: id! } }, signal })), });}(If ProblemError’s problem body exposes the code under another name, read web/src/lib/api/problem.ts and use it; leave/api.ts’s leaveMessage is the precedent.)
- Step 3: Write the shared payslip view, My payslips and the setup page
Create web/src/features/payroll/PayslipView.tsx:
import { useTranslation } from 'react-i18next';import { Printer } from 'lucide-react';import { Button } from '@/components/ui/Button';import { Tag, type TagTone } from '@/components/ui/Tag';import { formatMoney } from '@/lib/money';import { useLocale } from '@/lib/i18n';import type { Payslip, RunStatus } from './api';
const TONE: Record<RunStatus, TagTone> = { draft: 'neutral', submitted: 'amber', approved: 'green', paid: 'green', rejected: 'red', voided: 'muted',};
export function RunStatusTag({ status }: { status: RunStatus }) { const { t } = useTranslation('payroll'); return <Tag tone={TONE[status]}>{t(`status.${status}`)}</Tag>;}
/** One payslip, printable through the browser's print path (spec "Out of P1": no PDF styling). */export function PayslipView({ payslip, currency = 'MYR' }: { payslip: Payslip; currency?: string }) { const { t } = useTranslation('payroll'); const [locale] = useLocale(); const m = (a: string) => formatMoney(a, currency, locale); const label = (l: NonNullable<Payslip['lines']>[number]) => l.kind === 'statutory' && l.statutory_code ? t(`statutory.${l.statutory_code}`) : l.kind === 'item' ? l.description : t(`lineKind.${l.kind}`); return ( <article className="flex flex-col gap-4"> <header className="flex items-start justify-between gap-4"> <div> <h2 className="text-title">{payslip.employee_name}</h2> <p className="text-caption text-fg-muted"> {payslip.employee_no} · {payslip.reference} · {payslip.month} </p> {payslip.run_status === 'voided' && <Tag tone="muted">{t('status.voided')}</Tag>} </div> <Button variant="outline" onClick={() => window.print()} className="print:hidden"> <Printer size={16} aria-hidden /> {t('payslip.print')} </Button> </header> <table className="w-full text-body"> <tbody> {(payslip.lines ?? []).map((l) => ( <tr key={l.line_no}> <td>{label(l)}{l.days ? ` (${t('payslip.days', { days: l.days })})` : ''}</td> <td className="text-right tabular-nums">{l.deduction ? `−${m(l.amount)}` : m(l.amount)}</td> </tr> ))} </tbody> <tfoot> <tr><th className="text-left">{t('fields.gross')}</th><td className="text-right tabular-nums">{m(payslip.gross)}</td></tr> <tr><th className="text-left">{t('fields.deductions')}</th><td className="text-right tabular-nums">{m(payslip.deductions)}</td></tr> <tr><th className="text-left">{t('fields.net')}</th><td className="text-right font-bold tabular-nums">{m(payslip.net)}</td></tr> </tfoot> </table> <p className="text-caption text-fg-muted"> {t('payslip.basis', { employed: payslip.days_employed, month: payslip.days_in_month, divisor: payslip.divisor })} </p> <p className="text-caption"> {payslip.paid ? t('payslip.paidIn', { batch: payslip.batch_reference }) : t('payslip.unpaid')} </p> </article> );}Create web/src/features/payroll/MyPayslipsPage.tsx:
import { useMemo } from 'react';import { useTranslation } from 'react-i18next';import { DataTable, type ColumnDef } from '@/components/ui/DataTable';import { Drawer } from '@/components/ui/Drawer';import { EmptyState } from '@/components/ui/EmptyState';import { PageHeader } from '@/components/ui/PageHeader';import { formatMoney } from '@/lib/money';import { useLocale } from '@/lib/i18n';import { PayslipView } from './PayslipView';import { useMyPayslip, useMyPayslips, type Payslip } from './api';
export function MyPayslipsPage({ open, onOpen }: { open?: string; onOpen(id: string | undefined): void }) { const { t } = useTranslation('payroll'); const [locale] = useLocale(); const mine = useMyPayslips(); const one = useMyPayslip(open); const columns = useMemo<ColumnDef<Payslip>[]>( () => [ { id: 'month', meta: { required: true }, header: t('fields.month'), cell: ({ row }) => row.original.month }, { id: 'reference', header: t('fields.reference'), cell: ({ row }) => row.original.reference }, { id: 'net', header: t('fields.net'), cell: ({ row }) => formatMoney(row.original.net, 'MYR', locale) }, { id: 'paid', header: t('fields.status'), cell: ({ row }) => row.original.run_status === 'voided' ? t('status.voided') : row.original.paid ? t('payslip.paid') : t('payslip.unpaidShort'), }, ], [t, locale], ); return ( <div className="flex flex-col gap-6"> <PageHeader title={t('mine.title')} subtitle={t('mine.subtitle')} /> <DataTable tableId="payroll.payslips.mine" columns={columns} data={mine.data ?? []} loading={mine.isLoading} getRowId={(p: Payslip) => p.id} onRowClick={(p: Payslip) => onOpen(p.id)} empty={<EmptyState title={t('mine.none')} />} /> {open && ( <Drawer open onOpenChange={(o) => !o && onOpen(undefined)} title={t('payslip.title')}> {one.data && <PayslipView payslip={one.data} />} </Drawer> )} </div> );}Create web/src/features/payroll/ItemTypeDialog.tsx:
import { useState } from 'react';import { useTranslation } from 'react-i18next';import { Button } from '@/components/ui/Button';import { Checkbox } from '@/components/ui/Checkbox';import { Dialog } from '@/components/ui/Dialog';import { Field } from '@/components/ui/Field';import { Input } from '@/components/ui/Input';import { Select } from '@/components/ui/Select';import { payrollMessage, useSaveItemType, type PayItemType } from './api';
const FLAGS = ['epf', 'socso', 'eis', 'pcb'] as const;
export function ItemTypeDialog({ type, onClose }: { type?: PayItemType; onClose(): void }) { const { t } = useTranslation('payroll'); const save = useSaveItemType(); const [code, setCode] = useState(type?.code ?? ''); const [names, setNames] = useState<Record<string, string>>(type?.names ?? {}); const [kind, setKind] = useState<PayItemType['kind']>(type?.kind ?? 'earning'); const [recurrence, setRecurrence] = useState<PayItemType['recurrence']>(type?.recurrence ?? 'recurring'); const [flags, setFlags] = useState({ epf: type?.epf ?? false, socso: type?.socso ?? false, eis: type?.eis ?? false, pcb: type?.pcb ?? false }); const [active, setActive] = useState(type?.active ?? true); const submit = () => save.mutate( type ? { id: type.id, revision: type.revision, update: { names, ...flags, active } } : { create: { code, names, kind, recurrence, ...flags } }, { onSuccess: onClose }, ); return ( <Dialog open onOpenChange={(o) => !o && onClose()} title={type ? t('setup.editType') : t('setup.newType')} footer={ <> <Button variant="outline" onClick={onClose}>{t('cancel')}</Button> <Button onClick={submit} loading={save.isPending}>{t('save')}</Button> </> } > <div className="flex flex-col gap-4"> <Field label={t('fields.code')}> <Input value={code} disabled={!!type} onChange={(e) => setCode(e.target.value.toUpperCase())} maxLength={20} /> </Field> {(['en', 'ms', 'zh'] as const).map((loc) => ( <Field key={loc} label={t(`fields.name_${loc}`)}> <Input value={names[loc] ?? ''} onChange={(e) => setNames({ ...names, [loc]: e.target.value })} maxLength={200} /> </Field> ))} <Field label={t('fields.kind')}> <Select value={kind} disabled={!!type} onValueChange={(v) => setKind(v as PayItemType['kind'])} options={[{ value: 'earning', label: t('kind.earning') }, { value: 'deduction', label: t('kind.deduction') }]} /> </Field> <Field label={t('fields.recurrence')}> <Select value={recurrence} disabled={!!type} onValueChange={(v) => setRecurrence(v as PayItemType['recurrence'])} options={[{ value: 'recurring', label: t('recurrence.recurring') }, { value: 'one_off', label: t('recurrence.one_off') }]} /> </Field> <Field label={t('fields.flags')} hint={t('setup.flagsHint')}> <div className="flex flex-wrap gap-4"> {FLAGS.map((f) => ( <Checkbox key={f} label={t(`statutory.${f}`)} checked={flags[f]} onCheckedChange={(v) => setFlags({ ...flags, [f]: !!v })} /> ))} </div> </Field> {type && <Checkbox label={t('fields.active')} checked={active} onCheckedChange={(v) => setActive(!!v)} />} {save.error && <p role="alert" className="text-caption text-tone-red-fg">{payrollMessage(save.error, t)}</p>} </div> </Dialog> );}(Before writing it, open web/src/components/ui/Checkbox.tsx and Field.tsx; if Checkbox takes children rather than label, or onChange rather than onCheckedChange, use that file’s props — the leave TypeDialog.tsx is the precedent for both.)
Create web/src/features/payroll/PayrollSetupPage.tsx:
import { useEffect, useMemo, useState } from 'react';import { useTranslation } from 'react-i18next';import { Plus } from 'lucide-react';import { Button } from '@/components/ui/Button';import { DataTable, type ColumnDef } from '@/components/ui/DataTable';import { EmptyState } from '@/components/ui/EmptyState';import { Field } from '@/components/ui/Field';import { Input } from '@/components/ui/Input';import { PageHeader } from '@/components/ui/PageHeader';import { Select } from '@/components/ui/Select';import { useAccounts } from '@/features/accounting/queries';import { usePayoutChannels } from '@/features/payout/api';import { useLocale } from '@/lib/i18n';import { pickLabel } from '@/lib/i18n/pickLabel';import { ItemTypeDialog } from './ItemTypeDialog';import { payrollMessage, useItemTypes, usePayrollSettings, useSavePayrollSettings, type PayItemType } from './api';
const NONE = '';
export function PayrollSetupPage() { const { t } = useTranslation('payroll'); const [locale] = useLocale(); const types = useItemTypes(); const [editing, setEditing] = useState<PayItemType | 'new' | null>(null); const columns = useMemo<ColumnDef<PayItemType>[]>( () => [ { id: 'code', meta: { required: true }, header: t('fields.code'), cell: ({ row }) => row.original.code }, { id: 'name', header: t('fields.name'), cell: ({ row }) => pickLabel(row.original.names, locale) }, { id: 'kind', header: t('fields.kind'), cell: ({ row }) => t(`kind.${row.original.kind}`) }, { id: 'recurrence', header: t('fields.recurrence'), cell: ({ row }) => t(`recurrence.${row.original.recurrence}`) }, { id: 'active', header: t('fields.active'), cell: ({ row }) => (row.original.active ? t('yes') : t('no')) }, ], [t, locale], ); return ( <div className="flex flex-col gap-6"> <PageHeader title={t('setup.title')} actions={ <Button onClick={() => setEditing('new')}> <Plus size={16} aria-hidden /> {t('setup.newType')} </Button> } /> <h2 className="text-title">{t('setup.types')}</h2> <DataTable tableId="payroll.item-types" columns={columns} data={types.data ?? []} loading={types.isLoading} getRowId={(x: PayItemType) => x.id} onRowClick={(x: PayItemType) => setEditing(x)} empty={<EmptyState title={t('setup.noTypes')} />} /> <SettingsForm /> {editing && <ItemTypeDialog type={editing === 'new' ? undefined : editing} onClose={() => setEditing(null)} />} </div> );}
function SettingsForm() { const { t } = useTranslation('payroll'); const settings = usePayrollSettings(); const channels = usePayoutChannels(); const accounts = useAccounts(); const save = useSavePayrollSettings(); const [divisor, setDivisor] = useState(''); const [channel, setChannel] = useState(NONE); const [expense, setExpense] = useState(NONE); const [payable, setPayable] = useState(NONE); useEffect(() => { const s = settings.data; if (!s) return; setDivisor(s.unpaid_leave_divisor === null ? '' : String(s.unpaid_leave_divisor)); setChannel(s.payout_channel_id ?? NONE); setExpense(s.salary_expense_account_id ?? NONE); setPayable(s.statutory_payable_account_id ?? NONE); }, [settings.data]); const plain = (type: string) => (accounts.data ?? []) .filter((a) => a.active && a.postable && a.type === type && a.control_kind === null && a.owner_module === null) .map((a) => ({ value: a.id, label: `${a.code} ${a.label}` })); const bankChannels = (channels.data ?? []) .filter((c) => c.active && c.settlement_behaviour === 'external') .map((c) => ({ value: c.id, label: c.label })); const n = divisor.trim() === '' ? null : Number.parseInt(divisor, 10); return ( <section className="flex max-w-xl flex-col gap-4"> <h2 className="text-title">{t('setup.settings')}</h2> <Field label={t('fields.divisor')} hint={t('settings.divisorHint')}> <Input inputMode="numeric" value={divisor} onChange={(e) => setDivisor(e.target.value.replace(/[^0-9]/g, ''))} maxLength={2} /> </Field> <Field label={t('fields.channel')}> <Select value={channel || undefined} onValueChange={setChannel} options={bankChannels} placeholder={t('choose')} /> </Field> <Field label={t('fields.salaryExpense')}> <Select value={expense || undefined} onValueChange={setExpense} options={plain('expense')} placeholder={t('choose')} /> </Field> <Field label={t('fields.deductionsPayable')}> <Select value={payable || undefined} onValueChange={setPayable} options={plain('liability')} placeholder={t('choose')} /> </Field> {save.error && <p role="alert" className="text-caption text-tone-red-fg">{payrollMessage(save.error, t)}</p>} <div> <Button loading={save.isPending} disabled={!settings.data} onClick={() => save.mutate({ revision: settings.data!.revision, body: { unpaid_leave_divisor: n, payout_channel_id: channel || null, salary_expense_account_id: expense || null, statutory_payable_account_id: payable || null, }, }) } > {t('save')} </Button> </div> </section> );}Number.parseInt on a two-digit string is a day count, not money (the nofloat drift test reads money paths; a day count is an integer).
- Step 4: The salary section on the employee drawer
Create web/src/features/payroll/SalarySection.tsx:
import { useState } from 'react';import { useTranslation } from 'react-i18next';import { Button } from '@/components/ui/Button';import { Field } from '@/components/ui/Field';import { Input } from '@/components/ui/Input';import { Select } from '@/components/ui/Select';import { useCan } from '@/features/auth/session';import { formatDate, todayISO } from '@/lib/date';import { useLocale } from '@/lib/i18n';import { pickLabel } from '@/lib/i18n/pickLabel';import { formatMoney, isMoneyInput, normalizeMoneyInput } from '@/lib/money';import { payrollMessage, useAddEmployeeItem, useAddSalary, useEmployeeItems, useEndEmployeeItem, useItemTypes, useSalaries,} from './api';
/** Salary and recurring pay on the employee drawer (spec "Screens"); shown to payroll.salary.read. */export function SalarySection({ employeeId }: { employeeId: string }) { const { t } = useTranslation('payroll'); const [locale] = useLocale(); const canRead = useCan('payroll.salary.read'); const canWrite = useCan('payroll.salary.write'); const canItems = useCan('payroll.item.manage'); const salaries = useSalaries(employeeId, canRead); const items = useEmployeeItems(employeeId, canRead); const types = useItemTypes(); const addSalary = useAddSalary(employeeId); const addItem = useAddEmployeeItem(employeeId); const endItem = useEndEmployeeItem(); const [amount, setAmount] = useState(''); const [from, setFrom] = useState(todayISO()); const [typeId, setTypeId] = useState<string | undefined>(); const [itemAmount, setItemAmount] = useState(''); if (!canRead) return null; const m = (a: string) => formatMoney(a, 'MYR', locale); const typeName = (id: string) => { const x = types.data?.find((y) => y.id === id); return x ? pickLabel(x.names, locale) : '—'; }; const recurring = (types.data ?? []).filter((x) => x.active && x.recurrence === 'recurring'); return ( <section className="flex flex-col gap-3"> <h3 className="text-body font-bold">{t('salary.title')}</h3> {(salaries.data ?? []).length === 0 ? ( <p className="text-body text-fg-muted">{t('salary.none')}</p> ) : ( <ul className="text-body"> {salaries.data!.map((s) => ( <li key={s.id}>{t('salary.row', { amount: m(s.amount), from: formatDate(s.effective_from, locale) })}</li> ))} </ul> )} {canWrite && ( <div className="flex flex-wrap items-end gap-2"> <Field label={t('fields.amount')}> <Input inputMode="decimal" value={amount} onChange={(e) => setAmount(e.target.value)} /> </Field> <Field label={t('fields.effectiveFrom')}> <Input type="date" value={from} onChange={(e) => setFrom(e.target.value)} /> </Field> <Button disabled={!isMoneyInput(amount)} loading={addSalary.isPending} onClick={() => addSalary.mutate({ amount: normalizeMoneyInput(amount), effective_from: from }, { onSuccess: () => setAmount('') })} > {t('salary.add')} </Button> </div> )} {addSalary.error && <p role="alert" className="text-caption text-tone-red-fg">{payrollMessage(addSalary.error, t)}</p>} <h3 className="text-body font-bold">{t('salary.items')}</h3> {(items.data ?? []).length === 0 ? ( <p className="text-body text-fg-muted">{t('salary.noItems')}</p> ) : ( <ul className="text-body"> {items.data!.map((x) => ( <li key={x.id} className="flex items-center justify-between gap-2"> <span> {typeName(x.type_id)} · {m(x.amount)} · {formatDate(x.effective_from, locale)} {x.effective_to ? ` – ${formatDate(x.effective_to, locale)}` : ''} </span> {canItems && !x.ended_at && ( <Button variant="outline" size="sm" onClick={() => endItem.mutate({ id: x.id, lastDay: todayISO() })}> {t('salary.endItem')} </Button> )} </li> ))} </ul> )} {canItems && ( <div className="flex flex-wrap items-end gap-2"> <Field label={t('fields.type')}> <Select value={typeId} onValueChange={setTypeId} placeholder={t('choose')} options={recurring.map((x) => ({ value: x.id, label: pickLabel(x.names, locale) }))} /> </Field> <Field label={t('fields.amount')}> <Input inputMode="decimal" value={itemAmount} onChange={(e) => setItemAmount(e.target.value)} /> </Field> <Button disabled={!typeId || !isMoneyInput(itemAmount)} loading={addItem.isPending} onClick={() => addItem.mutate( { type_id: typeId!, amount: normalizeMoneyInput(itemAmount), effective_from: todayISO() }, { onSuccess: () => setItemAmount('') }, ) } > {t('salary.addItem')} </Button> </div> )} {(addItem.error || endItem.error) && ( <p role="alert" className="text-caption text-tone-red-fg">{payrollMessage(addItem.error ?? endItem.error, t)}</p> )} </section> );}(useItemTypes requires payroll.item.manage; a salary.read-only holder sees the type column as — — acceptable, recorded in the ROADMAP follow-ups in Task 13. If Button has no size prop, drop it.)
In web/src/features/hr/EmployeeDrawer.tsx, import { SalarySection } from '@/features/payroll/SalarySection' and render <SalarySection employeeId={id} /> between the obligations section and the history section.
- Step 5: Routes, nav and the namespace
Create web/src/features/payroll/routes.tsx:
import { createRoute } from '@tanstack/react-router';import { z } from 'zod';import { appRoute } from '@/app/layouts';import { RequirePermission } from '@/features/auth/session';import { MyPayslipsPage } from './MyPayslipsPage';import { PayrollSetupPage } from './PayrollSetupPage';
const mineSearch = z.object({ payslip: z.string().optional().catch(undefined) });type MineSearch = z.infer<typeof mineSearch>;
const mineRoute = createRoute({ getParentRoute: () => appRoute, path: 'payroll/me', validateSearch: (s: Record<string, unknown>) => mineSearch.parse(s), component: function R() { const s = mineRoute.useSearch(); const navigate = mineRoute.useNavigate(); return ( <MyPayslipsPage open={s.payslip} onOpen={(id) => void navigate({ search: (p: MineSearch) => ({ ...p, payslip: id }) })} /> ); },});
const setupRoute = createRoute({ getParentRoute: () => appRoute, path: 'payroll/setup', component: () => ( <RequirePermission permission="payroll.item.manage"> <PayrollSetupPage /> </RequirePermission> ),});
export const routes = [mineRoute, setupRoute] as const;(Task 12 appends the run routes to this array.)
In web/src/app/routes.ts: import { routes as payrollRoutes } from '@/features/payroll/routes'; and ...payrollRoutes, in appRoutes. In web/src/app/shell/nav.ts append (imports ReceiptText and Settings already exist; add Wallet if not imported — it is):
{ group: 'requests', to: '/payroll/me', labelKey: 'payroll:nav.mine', icon: ReceiptText, }, { group: 'manage', to: '/payroll/setup', labelKey: 'payroll:nav.setup', icon: Settings, permission: 'payroll.item.manage', },(The “My payslips” item carries no permission: every member reads their own, PD-24. When the payroll module is off the route answers module_not_installed and the page shows the empty state; hiding the item for a switched-off module needs a module flag on /me, which the shell does not have — a ROADMAP follow-up, Task 13.)
Create web/src/lib/i18n/locales/en/payroll.json:
{ "nav": { "mine": "My payslips", "setup": "Payroll setup", "runs": "Payroll runs" }, "status": { "draft": "Draft", "submitted": "Awaiting approval", "approved": "Approved", "paid": "Paid", "rejected": "Rejected", "voided": "Voided" }, "kind": { "earning": "Earning", "deduction": "Deduction" }, "recurrence": { "recurring": "Recurring", "one_off": "One-off" }, "lineKind": { "basic": "Basic salary", "item": "Pay item", "unpaid_leave": "Unpaid leave", "statutory": "Statutory", "adjustment": "Adjustment" }, "statutory": { "epf": "EPF", "socso": "SOCSO", "eis": "EIS", "pcb": "PCB" }, "inputKind": { "item": "One-off item", "statutory": "Statutory amount", "adjustment": "Adjustment" }, "warning": { "no_member": "has no login, so no payslip: give them a member account", "no_salary": "has no salary in force, so no payslip", "outside_band": "is paid outside their grade's salary band", "no_payout_details": "has no bank details yet: not payable in a batch", "bank_unverified": "has bank details nobody has verified: not payable in a batch" }, "errors": { "run_exists": "This month already has a payroll run.", "month_future": "A run is for this month or an earlier one.", "payroll_not_configured": "Choose the payout channel and both accounts in Payroll setup first.", "run_not_draft": "The run is no longer a draft.", "empty_run": "The run pays nobody.", "negative_net": "Deductions exceed gross pay on some payslips.", "run_in_payment": "A payslip is paid or in a payment batch. Remove it from a draft batch first.", "duplicate_item_type": "Another pay item type has this code.", "permission_required": "You may not make this move.", "self_approval": "You prepared this run, so someone else approves it.", "revision_mismatch": "Someone changed this. Reload and try again." }, "fields": { "code": "Code", "name": "Name", "name_en": "Name (English)", "name_ms": "Name (Bahasa Malaysia)", "name_zh": "Name (Chinese)", "kind": "Kind", "recurrence": "Recurrence", "flags": "Statutory base", "active": "In use", "amount": "Amount", "effectiveFrom": "From", "month": "Month", "reference": "Reference", "employee": "Employee", "gross": "Gross pay", "deductions": "Deductions", "net": "Net pay", "status": "Status", "payslips": "Payslips", "type": "Type", "statutoryCode": "Contribution", "description": "Description", "divisor": "Unpaid leave divisor (days)", "channel": "Pay salaries through", "salaryExpense": "Salary expense account", "deductionsPayable": "Deductions payable account", "reason": "Reason" }, "settings": { "divisorHint": "Empty: the month's calendar days. Unpaid leave deducts the salary ÷ this, per day." }, "setup": { "title": "Payroll setup", "types": "Pay item types", "settings": "Settings", "newType": "New pay item type", "editType": "Edit pay item type", "noTypes": "No pay item types yet.", "flagsHint": "Which contributions this item will count towards once P2 calculates them." }, "salary": { "title": "Salary", "none": "No salary recorded.", "row": "{{amount}} from {{from}}", "add": "Record salary", "items": "Recurring pay items", "noItems": "No recurring pay items.", "addItem": "Add item", "endItem": "End today" }, "runs": { "title": "Payroll runs", "subtitle": "One run per month, approved by someone other than its preparer.", "new": "New run", "none": "No payroll runs yet.", "create": "Prepare", "statutoryNotice": "Statutory contributions (EPF, SOCSO, EIS, PCB) are not calculated yet. Enter them by hand; do not run real payroll on this alone." }, "run": { "warnings": "Needs attention", "noWarnings": "Nothing needs attention.", "inputs": "Entered on this run", "noInputs": "Nothing entered.", "addInput": "Enter an amount", "remove": "Remove", "recalculate": "Recalculate", "payslips": "Payslips", "employee": "Employee" }, "payslip": { "title": "Payslip", "print": "Print", "paid": "Paid", "unpaidShort": "Unpaid", "paidIn": "Paid in batch {{batch}}.", "unpaid": "Not paid yet.", "days": "{{days}} days", "basis": "{{employed}} of {{month}} days employed; unpaid leave per day is the salary ÷ {{divisor}}." }, "mine": { "title": "My payslips", "subtitle": "Your payslips, once a run is approved.", "none": "No payslips yet." }, "save": "Save", "cancel": "Cancel", "confirm": "Confirm", "choose": "Choose…", "yes": "Yes", "no": "No"}Create web/src/lib/i18n/locales/ms/payroll.json with the same keys:
{ "nav": { "mine": "Slip gaji saya", "setup": "Tetapan gaji", "runs": "Larian gaji" }, "status": { "draft": "Draf", "submitted": "Menunggu kelulusan", "approved": "Diluluskan", "paid": "Dibayar", "rejected": "Ditolak", "voided": "Dibatalkan" }, "kind": { "earning": "Pendapatan", "deduction": "Potongan" }, "recurrence": { "recurring": "Berulang", "one_off": "Sekali" }, "lineKind": { "basic": "Gaji pokok", "item": "Item bayaran", "unpaid_leave": "Cuti tanpa gaji", "statutory": "Berkanun", "adjustment": "Pelarasan" }, "statutory": { "epf": "KWSP", "socso": "PERKESO", "eis": "SIP", "pcb": "PCB" }, "inputKind": { "item": "Item sekali", "statutory": "Amaun berkanun", "adjustment": "Pelarasan" }, "warning": { "no_member": "tiada log masuk, jadi tiada slip gaji: berikan akaun ahli", "no_salary": "tiada gaji berkuat kuasa, jadi tiada slip gaji", "outside_band": "dibayar di luar julat gaji gred mereka", "no_payout_details": "belum ada butiran bank: tidak boleh dibayar dalam kelompok", "bank_unverified": "butiran bank belum disahkan: tidak boleh dibayar dalam kelompok" }, "errors": { "run_exists": "Bulan ini sudah ada larian gaji.", "month_future": "Larian adalah untuk bulan ini atau lebih awal.", "payroll_not_configured": "Pilih saluran pembayaran dan kedua-dua akaun dalam Tetapan gaji dahulu.", "run_not_draft": "Larian bukan lagi draf.", "empty_run": "Larian tidak membayar sesiapa.", "negative_net": "Potongan melebihi gaji kasar pada beberapa slip gaji.", "run_in_payment": "Satu slip gaji telah dibayar atau berada dalam kelompok pembayaran. Keluarkannya dari kelompok draf dahulu.", "duplicate_item_type": "Jenis item bayaran lain mempunyai kod ini.", "permission_required": "Anda tidak boleh membuat langkah ini.", "self_approval": "Anda menyediakan larian ini, jadi orang lain yang meluluskannya.", "revision_mismatch": "Seseorang telah mengubahnya. Muat semula dan cuba lagi." }, "fields": { "code": "Kod", "name": "Nama", "name_en": "Nama (Bahasa Inggeris)", "name_ms": "Nama (Bahasa Malaysia)", "name_zh": "Nama (Bahasa Cina)", "kind": "Jenis", "recurrence": "Kekerapan", "flags": "Asas berkanun", "active": "Digunakan", "amount": "Amaun", "effectiveFrom": "Dari", "month": "Bulan", "reference": "Rujukan", "employee": "Pekerja", "gross": "Gaji kasar", "deductions": "Potongan", "net": "Gaji bersih", "status": "Status", "payslips": "Slip gaji", "type": "Jenis", "statutoryCode": "Caruman", "description": "Keterangan", "divisor": "Pembahagi cuti tanpa gaji (hari)", "channel": "Bayar gaji melalui", "salaryExpense": "Akaun perbelanjaan gaji", "deductionsPayable": "Akaun potongan belum bayar", "reason": "Sebab" }, "settings": { "divisorHint": "Kosong: hari kalendar bulan itu. Cuti tanpa gaji memotong gaji ÷ nilai ini, sehari." }, "setup": { "title": "Tetapan gaji", "types": "Jenis item bayaran", "settings": "Tetapan", "newType": "Jenis item bayaran baharu", "editType": "Edit jenis item bayaran", "noTypes": "Belum ada jenis item bayaran.", "flagsHint": "Caruman yang akan dikira atas item ini apabila P2 mengiranya." }, "salary": { "title": "Gaji", "none": "Tiada gaji direkodkan.", "row": "{{amount}} dari {{from}}", "add": "Rekod gaji", "items": "Item bayaran berulang", "noItems": "Tiada item bayaran berulang.", "addItem": "Tambah item", "endItem": "Tamat hari ini" }, "runs": { "title": "Larian gaji", "subtitle": "Satu larian sebulan, diluluskan oleh orang selain penyedianya.", "new": "Larian baharu", "none": "Belum ada larian gaji.", "create": "Sediakan", "statutoryNotice": "Caruman berkanun (KWSP, PERKESO, SIP, PCB) belum dikira. Masukkannya secara manual; jangan jalankan gaji sebenar dengan ini sahaja." }, "run": { "warnings": "Perlu perhatian", "noWarnings": "Tiada yang perlu perhatian.", "inputs": "Dimasukkan pada larian ini", "noInputs": "Tiada yang dimasukkan.", "addInput": "Masukkan amaun", "remove": "Keluarkan", "recalculate": "Kira semula", "payslips": "Slip gaji", "employee": "Pekerja" }, "payslip": { "title": "Slip gaji", "print": "Cetak", "paid": "Dibayar", "unpaidShort": "Belum dibayar", "paidIn": "Dibayar dalam kelompok {{batch}}.", "unpaid": "Belum dibayar.", "days": "{{days}} hari", "basis": "Bekerja {{employed}} daripada {{month}} hari; cuti tanpa gaji sehari ialah gaji ÷ {{divisor}}." }, "mine": { "title": "Slip gaji saya", "subtitle": "Slip gaji anda, selepas larian diluluskan.", "none": "Belum ada slip gaji." }, "save": "Simpan", "cancel": "Batal", "confirm": "Sahkan", "choose": "Pilih…", "yes": "Ya", "no": "Tidak"}Create web/src/lib/i18n/locales/zh/payroll.json with the same keys:
{ "nav": { "mine": "我的工资单", "setup": "工资设置", "runs": "工资核算" }, "status": { "draft": "草稿", "submitted": "待批准", "approved": "已批准", "paid": "已支付", "rejected": "已拒绝", "voided": "已作废" }, "kind": { "earning": "收入项", "deduction": "扣除项" }, "recurrence": { "recurring": "固定", "one_off": "一次性" }, "lineKind": { "basic": "基本工资", "item": "薪酬项目", "unpaid_leave": "无薪假", "statutory": "法定扣款", "adjustment": "调整" }, "statutory": { "epf": "公积金 (EPF)", "socso": "社险 (SOCSO)", "eis": "就业保险 (EIS)", "pcb": "预扣税 (PCB)" }, "inputKind": { "item": "一次性项目", "statutory": "法定金额", "adjustment": "调整" }, "warning": { "no_member": "没有登录账号,因此没有工资单:请为其开设成员账号", "no_salary": "没有生效的工资,因此没有工资单", "outside_band": "工资超出其职级的工资范围", "no_payout_details": "尚无银行资料:无法在批量付款中支付", "bank_unverified": "银行资料尚未核实:无法在批量付款中支付" }, "errors": { "run_exists": "本月已有工资核算。", "month_future": "工资核算只能针对本月或更早的月份。", "payroll_not_configured": "请先在工资设置中选择付款渠道和两个科目。", "run_not_draft": "此工资核算已不是草稿。", "empty_run": "此工资核算没有支付任何人。", "negative_net": "部分工资单的扣款超过应发工资。", "run_in_payment": "有工资单已支付或在付款批次中。请先将其从草稿批次中移除。", "duplicate_item_type": "已有其他薪酬项目类型使用此代码。", "permission_required": "您无权执行此操作。", "self_approval": "此工资核算由您准备,须由他人批准。", "revision_mismatch": "已有人修改了此内容。请刷新后重试。" }, "fields": { "code": "代码", "name": "名称", "name_en": "名称(英文)", "name_ms": "名称(马来文)", "name_zh": "名称(中文)", "kind": "类别", "recurrence": "频率", "flags": "法定计算基数", "active": "使用中", "amount": "金额", "effectiveFrom": "生效日期", "month": "月份", "reference": "编号", "employee": "员工", "gross": "应发工资", "deductions": "扣款", "net": "实发工资", "status": "状态", "payslips": "工资单", "type": "类型", "statutoryCode": "缴款", "description": "说明", "divisor": "无薪假除数(天)", "channel": "工资付款渠道", "salaryExpense": "工资费用科目", "deductionsPayable": "应付扣款科目", "reason": "原因" }, "settings": { "divisorHint": "留空:当月日历天数。无薪假每天扣除 工资 ÷ 此数。" }, "setup": { "title": "工资设置", "types": "薪酬项目类型", "settings": "设置", "newType": "新建薪酬项目类型", "editType": "编辑薪酬项目类型", "noTypes": "尚无薪酬项目类型。", "flagsHint": "P2 计算法定缴款时,此项目计入哪些缴款。" }, "salary": { "title": "工资", "none": "尚未记录工资。", "row": "{{amount}},自 {{from}} 起", "add": "记录工资", "items": "固定薪酬项目", "noItems": "没有固定薪酬项目。", "addItem": "添加项目", "endItem": "今天结束" }, "runs": { "title": "工资核算", "subtitle": "每月一次,由准备人以外的人批准。", "new": "新建核算", "none": "尚无工资核算。", "create": "准备", "statutoryNotice": "法定缴款(EPF、SOCSO、EIS、PCB)尚未自动计算。请手动输入;切勿仅凭此运行正式工资。" }, "run": { "warnings": "需要处理", "noWarnings": "没有需要处理的事项。", "inputs": "本次核算的输入", "noInputs": "没有输入。", "addInput": "输入金额", "remove": "移除", "recalculate": "重新计算", "payslips": "工资单", "employee": "员工" }, "payslip": { "title": "工资单", "print": "打印", "paid": "已支付", "unpaidShort": "未支付", "paidIn": "已在批次 {{batch}} 中支付。", "unpaid": "尚未支付。", "days": "{{days}} 天", "basis": "在职 {{employed}} 天(共 {{month}} 天);无薪假每天按 工资 ÷ {{divisor}} 扣除。" }, "mine": { "title": "我的工资单", "subtitle": "工资核算批准后,您的工资单会显示在这里。", "none": "尚无工资单。" }, "save": "保存", "cancel": "取消", "confirm": "确认", "choose": "请选择…", "yes": "是", "no": "否"}- Step 6: Run the web checks
Run: pnpm -C web exec tsc --noEmit && pnpm -C web test -- --run src/features/payroll src/features/hr src/drift
Expected: PASS (i18nparity checks the three files have the same keys; nofloat passes).
- Step 7: Commit
git add web/src/features/payroll web/src/features/hr/EmployeeDrawer.tsx web/src/app/routes.ts web/src/app/shell/nav.ts web/src/lib/i18n/localesgit commit -m "Payroll P1 screens: setup, salary on the employee, My payslips (en/ms/zh)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"Task 12: Screens — the runs list, the run detail, payslips in the batch dialog
Section titled “Task 12: Screens — the runs list, the run detail, payslips in the batch dialog”Files:
- Create:
web/src/features/payroll/RunsPage.tsx,RunPage.tsx,InputDialog.tsx - Modify:
web/src/features/payroll/routes.tsx(two routes),web/src/app/shell/nav.ts(one item) - Modify:
web/src/features/payables/batches/BatchDialog.tsx(payslip_ids),web/src/lib/i18n/locales/{en,ms,zh}/payables.json(batches.kind.payslip) - Modify:
web/src/features/payout/ChannelEditor.tsx(CLAIM_TYPES),web/src/lib/i18n/locales/{en,ms,zh}/payoutAdmin.json(claimTypeOptions.payslip) - Test:
web/src/features/payroll/__tests__/runs.test.tsx
Interfaces:
-
Consumes: Task 11’s
api.tshooks,RunStatusTag,PayslipView,payrollMessage;leave/ReasonDialog.tsx’s shape (copied as an inline reason field; a feature does not import another’s private dialog);MonthPicker;useEmployeesfrom@/features/hr/api. -
Produces: routes
/payroll/runs,/payroll/runs/$id; batch dialog sendspayslip_ids; payout channel editor offerspayslip. -
Step 1: Write the failing test
Create web/src/features/payroll/__tests__/runs.test.tsx:
import { describe, expect, it } from 'vitest';import { screen } from '@testing-library/react';import userEvent from '@testing-library/user-event';import { http, HttpResponse } from 'msw';import { server } from '@/test/msw/server';import { renderWithApp } from '@/test/renderWithApp';import { slip } from './fixtures';
const run = { id: 'r1', reference: 'PR-2026-0001', month: '2026-08', status: 'submitted', state: 'submitted', calc_no: 1, revision: 3, prepared_by: 'm-hr', approved_by: null, approved_sole_owner: false, gross: '3100.0000', deductions: '341.0000', net: '2759.0000', payslip_count: 1, paid_count: 0, calculated_at: '2026-09-21T01:00:00Z', created_at: '2026-09-21T01:00:00Z',};const detail = { run, payslips: [{ ...slip, lines: undefined }], inputs: [], warnings: [{ employee_id: 'e1', employee_no: 'E-100', employee_name: 'Siti binti Ahmad', code: 'bank_unverified' }], available: [ { to: 'approved', action_key: 'approve', gate: 'target', requires_reason: false, allowed: true, labels: { en: 'Approve' } }, { to: 'rejected', action_key: 'reject', gate: 'advance', requires_reason: true, allowed: true, labels: { en: 'Reject' } }, ],};
describe('Payroll run', () => { it('shows the statutory notice, the warnings and the moves, and approves', async () => { let sent: unknown; server.use( http.get('*/v1/payroll/runs/r1', () => HttpResponse.json(detail)), http.post('*/v1/payroll/runs/r1/transitions', async ({ request }) => { sent = await request.json(); return HttpResponse.json({ ...detail, run: { ...run, status: 'approved', state: 'approved' }, available: [] }); }), ); await renderWithApp({ path: '/payroll/runs/r1', permissions: ['payroll.run.read', 'payroll.run.approve'] }); expect(await screen.findByText(/not calculated yet/)).toBeInTheDocument(); expect(screen.getByText(/nobody has verified/)).toBeInTheDocument(); await userEvent.click(screen.getByRole('button', { name: 'Approve' })); expect(sent).toEqual({ from: 'submitted', to: 'approved' }); });
it('lists runs with their status', async () => { server.use(http.get('*/v1/payroll/runs', () => HttpResponse.json({ runs: [run] }))); await renderWithApp({ path: '/payroll/runs', permissions: ['payroll.run.read'] }); expect(await screen.findByText('PR-2026-0001')).toBeInTheDocument(); expect(screen.getByText('Awaiting approval')).toBeInTheDocument(); });});Run: pnpm -C web test -- --run src/features/payroll/__tests__/runs.test.tsx
Expected: FAIL (no such route).
- Step 2: Write the pages
Create web/src/features/payroll/RunsPage.tsx:
import { useMemo, useState } from 'react';import { useTranslation } from 'react-i18next';import { Plus } from 'lucide-react';import { Button } from '@/components/ui/Button';import { DataTable, type ColumnDef } from '@/components/ui/DataTable';import { Dialog } from '@/components/ui/Dialog';import { EmptyState } from '@/components/ui/EmptyState';import { Field } from '@/components/ui/Field';import { MonthPicker } from '@/components/ui/MonthPicker';import { PageHeader } from '@/components/ui/PageHeader';import { useCan } from '@/features/auth/session';import { formatMoney } from '@/lib/money';import { useLocale } from '@/lib/i18n';import { RunStatusTag } from './PayslipView';import { payrollMessage, useCreateRun, useRuns, type PayrollRun } from './api';
export function RunsPage({ onOpen }: { onOpen(id: string): void }) { const { t } = useTranslation('payroll'); const [locale] = useLocale(); const runs = useRuns(); const canPrepare = useCan('payroll.run.prepare'); const create = useCreateRun(); const [month, setMonth] = useState<string | undefined>(); const [opening, setOpening] = useState(false); const columns = useMemo<ColumnDef<PayrollRun>[]>( () => [ { id: 'month', meta: { required: true }, header: t('fields.month'), cell: ({ row }) => row.original.month }, { id: 'reference', header: t('fields.reference'), cell: ({ row }) => row.original.reference }, { id: 'status', header: t('fields.status'), cell: ({ row }) => <RunStatusTag status={row.original.status} /> }, { id: 'payslips', header: t('fields.payslips'), cell: ({ row }) => row.original.payslip_count }, { id: 'net', header: t('fields.net'), cell: ({ row }) => formatMoney(row.original.net, 'MYR', locale) }, ], [t, locale], ); return ( <div className="flex flex-col gap-6"> <PageHeader title={t('runs.title')} subtitle={t('runs.subtitle')} actions={ canPrepare && ( <Button onClick={() => setOpening(true)}> <Plus size={16} aria-hidden /> {t('runs.new')} </Button> ) } /> <DataTable tableId="payroll.runs" columns={columns} data={runs.data ?? []} loading={runs.isLoading} getRowId={(r: PayrollRun) => r.id} onRowClick={(r: PayrollRun) => onOpen(r.id)} empty={<EmptyState title={t('runs.none')} />} /> {opening && ( <Dialog open onOpenChange={(o) => !o && setOpening(false)} title={t('runs.new')} footer={ <> <Button variant="outline" onClick={() => setOpening(false)}>{t('cancel')}</Button> <Button disabled={!month} loading={create.isPending} onClick={() => create.mutate(month!, { onSuccess: (d) => { setOpening(false); onOpen(d.run.id); } })} > {t('runs.create')} </Button> </> } > <Field label={t('fields.month')} error={create.error ? payrollMessage(create.error, t) : undefined}> <MonthPicker value={month} onChange={setMonth} months={6} /> </Field> </Dialog> )} </div> );}Create web/src/features/payroll/InputDialog.tsx:
import { useState } from 'react';import { useTranslation } from 'react-i18next';import { Button } from '@/components/ui/Button';import { Dialog } from '@/components/ui/Dialog';import { Field } from '@/components/ui/Field';import { Input } from '@/components/ui/Input';import { Select } from '@/components/ui/Select';import { useLocale } from '@/lib/i18n';import { pickLabel } from '@/lib/i18n/pickLabel';import { isMoneyInput, normalizeMoneyInput } from '@/lib/money';import { payrollMessage, useAddInput, useItemTypes, type PayrollRunDetail } from './api';
type Kind = 'item' | 'statutory' | 'adjustment';const CODES = ['epf', 'socso', 'eis', 'pcb'] as const;
/** One-off item, manual statutory amount (P1) or adjustment, on a draft run. */export function InputDialog({ detail, onClose }: { detail: PayrollRunDetail; onClose(): void }) { const { t } = useTranslation('payroll'); const [locale] = useLocale(); const types = useItemTypes(); const add = useAddInput(detail.run.id); const [employee, setEmployee] = useState<string | undefined>(); const [kind, setKind] = useState<Kind>('statutory'); const [typeId, setTypeId] = useState<string | undefined>(); const [code, setCode] = useState<(typeof CODES)[number]>('epf'); const [amount, setAmount] = useState(''); const [description, setDescription] = useState(''); const amountOk = kind === 'adjustment' ? /^-?\d{1,15}(\.\d{0,2})?$/.test(amount) : isMoneyInput(amount); const ready = !!employee && amountOk && (kind !== 'item' || !!typeId) && (kind !== 'adjustment' || description.trim() !== ''); return ( <Dialog open onOpenChange={(o) => !o && onClose()} title={t('run.addInput')} footer={ <> <Button variant="outline" onClick={onClose}>{t('cancel')}</Button> <Button disabled={!ready} loading={add.isPending} onClick={() => add.mutate( { revision: detail.run.revision, body: { employee_id: employee!, kind, amount: kind === 'adjustment' ? amount : normalizeMoneyInput(amount), ...(kind === 'item' ? { type_id: typeId } : {}), ...(kind === 'statutory' ? { statutory_code: code } : {}), ...(description.trim() ? { description: description.trim() } : {}), }, }, { onSuccess: onClose }, ) } > {t('save')} </Button> </> } > <div className="flex flex-col gap-4"> <Field label={t('fields.employee')}> <Select value={employee} onValueChange={setEmployee} placeholder={t('choose')} options={detail.payslips.map((p) => ({ value: p.employee_id, label: `${p.employee_no} ${p.employee_name}` }))} /> </Field> <Field label={t('fields.kind')}> <Select value={kind} onValueChange={(v) => setKind(v as Kind)} options={(['statutory', 'item', 'adjustment'] as const).map((k) => ({ value: k, label: t(`inputKind.${k}`) }))} /> </Field> {kind === 'item' && ( <Field label={t('fields.type')}> <Select value={typeId} onValueChange={setTypeId} placeholder={t('choose')} options={(types.data ?? []).filter((x) => x.active && x.recurrence === 'one_off') .map((x) => ({ value: x.id, label: pickLabel(x.names, locale) }))} /> </Field> )} {kind === 'statutory' && ( <Field label={t('fields.statutoryCode')}> <Select value={code} onValueChange={(v) => setCode(v as (typeof CODES)[number])} options={CODES.map((c) => ({ value: c, label: t(`statutory.${c}`) }))} /> </Field> )} <Field label={t('fields.amount')}> <Input inputMode="decimal" value={amount} onChange={(e) => setAmount(e.target.value)} /> </Field> <Field label={t('fields.description')}> <Input value={description} onChange={(e) => setDescription(e.target.value)} maxLength={200} /> </Field> {add.error && <p role="alert" className="text-caption text-tone-red-fg">{payrollMessage(add.error, t)}</p>} </div> </Dialog> );}The adjustment regex accepts a signed amount in whole sen; it validates a string and never parses it to a number.
Create web/src/features/payroll/RunPage.tsx:
import { useMemo, useState } from 'react';import { useTranslation } from 'react-i18next';import { Button } from '@/components/ui/Button';import { DataTable, type ColumnDef } from '@/components/ui/DataTable';import { Drawer } from '@/components/ui/Drawer';import { Field } from '@/components/ui/Field';import { PageHeader } from '@/components/ui/PageHeader';import { Textarea } from '@/components/ui/Textarea';import { useCan } from '@/features/auth/session';import { formatMoney } from '@/lib/money';import { useLocale } from '@/lib/i18n';import { pickLabel } from '@/lib/i18n/pickLabel';import { InputDialog } from './InputDialog';import { PayslipView, RunStatusTag } from './PayslipView';import { payrollMessage, usePayslip, useRecalculate, useRemoveInput, useRun, useTransitionRun, type Payslip } from './api';
export function RunPage({ id, payslip, onPayslip }: { id: string; payslip?: string; onPayslip(id: string | undefined): void }) { const { t } = useTranslation('payroll'); const [locale] = useLocale(); const run = useRun(id); const one = usePayslip(payslip); const canPrepare = useCan('payroll.run.prepare'); const recalc = useRecalculate(id); const remove = useRemoveInput(id); const move = useTransitionRun(id); const [entering, setEntering] = useState(false); const [asking, setAsking] = useState<{ to: string; label: string } | null>(null); const [reason, setReason] = useState(''); const m = (a: string) => formatMoney(a, 'MYR', locale); const columns = useMemo<ColumnDef<Payslip>[]>( () => [ { id: 'employee', meta: { required: true }, header: t('run.employee'), cell: ({ row }) => `${row.original.employee_no} ${row.original.employee_name}` }, { id: 'gross', header: t('fields.gross'), cell: ({ row }) => m(row.original.gross) }, { id: 'deductions', header: t('fields.deductions'), cell: ({ row }) => m(row.original.deductions) }, { id: 'net', header: t('fields.net'), cell: ({ row }) => m(row.original.net) }, { id: 'paid', header: t('fields.status'), cell: ({ row }) => (row.original.paid ? t('payslip.paid') : t('payslip.unpaidShort')) }, ], // eslint-disable-next-line react-hooks/exhaustive-deps [t, locale], ); const d = run.data; if (!d) return null; const draft = d.run.status === 'draft'; const go = (to: string, why?: string) => move.mutate( { revision: d.run.revision, from: d.run.state, to, ...(why ? { reason: why } : {}) }, { onSuccess: () => { setAsking(null); setReason(''); } }, ); return ( <div className="flex flex-col gap-6"> <PageHeader title={`${d.run.reference} · ${d.run.month}`} subtitle={<RunStatusTag status={d.run.status} />} actions={ <div className="flex flex-wrap gap-2"> {draft && canPrepare && ( <Button variant="outline" loading={recalc.isPending} onClick={() => recalc.mutate(d.run.revision)}> {t('run.recalculate')} </Button> )} {d.available.filter((a) => a.allowed).map((a) => ( <Button key={a.to} variant={a.requires_reason ? 'outline' : 'primary'} loading={move.isPending} onClick={() => (a.requires_reason ? setAsking({ to: a.to, label: pickLabel(a.labels, locale) }) : go(a.to))} > {pickLabel(a.labels, locale)} </Button> ))} </div> } /> <p className="rounded-md bg-tone-amber-soft px-4 py-3 text-caption text-tone-amber-fg">{t('runs.statutoryNotice')}</p> {(move.error || recalc.error || remove.error) && ( <p role="alert" className="text-caption text-tone-red-fg">{payrollMessage(move.error ?? recalc.error ?? remove.error, t)}</p> )} <section> <h2 className="text-title">{t('run.warnings')}</h2> {d.warnings.length === 0 ? ( <p className="text-body text-fg-muted">{t('run.noWarnings')}</p> ) : ( <ul className="text-body"> {d.warnings.map((w) => ( <li key={w.employee_id + w.code}>{w.employee_no} {w.employee_name} {t(`warning.${w.code}`)}</li> ))} </ul> )} </section> <section className="flex flex-col gap-2"> <div className="flex items-center justify-between"> <h2 className="text-title">{t('run.inputs')}</h2> {draft && canPrepare && <Button variant="outline" onClick={() => setEntering(true)}>{t('run.addInput')}</Button>} </div> {d.inputs.length === 0 ? ( <p className="text-body text-fg-muted">{t('run.noInputs')}</p> ) : ( <ul className="text-body"> {d.inputs.map((x) => ( <li key={x.id} className="flex items-center justify-between gap-2"> <span>{x.description} · {m(x.amount)}</span> {draft && canPrepare && ( <Button variant="outline" onClick={() => remove.mutate({ revision: d.run.revision, inputId: x.id })}> {t('run.remove')} </Button> )} </li> ))} </ul> )} </section> <h2 className="text-title">{t('run.payslips')}</h2> <DataTable tableId="payroll.run.payslips" columns={columns} data={d.payslips} getRowId={(p: Payslip) => p.id} onRowClick={(p: Payslip) => onPayslip(p.id)} /> {entering && <InputDialog detail={d} onClose={() => setEntering(false)} />} {asking && ( <Drawer open onOpenChange={(o) => !o && setAsking(null)} title={asking.label}> <Field label={t('fields.reason')}> <Textarea value={reason} onChange={(e) => setReason(e.target.value)} maxLength={500} /> </Field> <Button disabled={!reason.trim()} loading={move.isPending} onClick={() => go(asking.to, reason.trim())}> {t('confirm')} </Button> </Drawer> )} {payslip && ( <Drawer open onOpenChange={(o) => !o && onPayslip(undefined)} title={t('payslip.title')}> {one.data && <PayslipView payslip={one.data} />} </Drawer> )} </div> );}(If PageHeader.subtitle takes a string only, render the tag in the title row instead; if Button’s variants are named differently, use leave/RequestDrawer.tsx’s. The buttons come from the engine’s available list, never from state names.)
Append to web/src/features/payroll/routes.tsx:
import { RunPage } from './RunPage';import { RunsPage } from './RunsPage';
const runsRoute = createRoute({ getParentRoute: () => appRoute, path: 'payroll/runs', component: function R() { const navigate = runsRoute.useNavigate(); return ( <RequirePermission permission="payroll.run.read"> <RunsPage onOpen={(id) => void navigate({ to: '/payroll/runs/$id', params: { id } })} /> </RequirePermission> ); },});
const runSearch = z.object({ payslip: z.string().optional().catch(undefined) });type RunSearch = z.infer<typeof runSearch>;
const runRoute = createRoute({ getParentRoute: () => appRoute, path: 'payroll/runs/$id', validateSearch: (s: Record<string, unknown>) => runSearch.parse(s), component: function R() { const { id } = runRoute.useParams(); const s = runRoute.useSearch(); const navigate = runRoute.useNavigate(); return ( <RequirePermission permission="payroll.run.read"> <RunPage id={id} payslip={s.payslip} onPayslip={(p) => void navigate({ search: (x: RunSearch) => ({ ...x, payslip: p }) })} /> </RequirePermission> ); },});and change the export to export const routes = [mineRoute, setupRoute, runsRoute, runRoute] as const;. Append to nav.ts:
{ group: 'manage', to: '/payroll/runs', labelKey: 'payroll:nav.runs', icon: Wallet, permission: 'payroll.run.read', },- Step 3: Payslips in the batch dialog and the channel editor
In web/src/features/payables/batches/BatchDialog.tsx, where the body is built from idsOf(kind) (lines ~89–100), add payslip_ids: idsOf('payslip'), beside advance_ids. Add to each locale’s payables.json batches.kind: en "payslip": "Payslip", ms "payslip": "Slip gaji", zh "payslip": "工资单".
In web/src/features/payout/ChannelEditor.tsx: const CLAIM_TYPES = ['expense_claim', 'advance', 'advance_topup', 'payslip'] as const;. Add to each payoutAdmin.json claimTypeOptions: en "payslip": "Salaries (payslips)", ms "payslip": "Gaji (slip gaji)", zh "payslip": "工资(工资单)".
- Step 4: Run the web suite for the touched features
Run: pnpm -C web exec tsc --noEmit && pnpm -C web test -- --run src/features/payroll src/features/payables src/features/payout src/drift
Expected: PASS.
- Step 5: Commit
git add web/src/features/payroll web/src/features/payables/batches/BatchDialog.tsx web/src/features/payout/ChannelEditor.tsx \ web/src/app/shell/nav.ts web/src/lib/i18n/localesgit commit -m "Payroll P1 screens: runs and the run detail; payslips in payment batches (en/ms/zh)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"Task 13: End to end, documentation and the full suite
Section titled “Task 13: End to end, documentation and the full suite”Files:
- Test:
internal/platform/payroll/e2e_test.go - Modify:
docs/ROADMAP.md,docs/DEPLOY_NOTES.md,docs/LOCK_ORDER.md
Interfaces:
-
Consumes: everything above.
-
Produces: the spec’s end-to-end test; the docs.
-
Step 1: Write the end-to-end test
Create internal/platform/payroll/e2e_test.go:
package payroll_test
import ( "context" "testing"
"github.com/jackc/pgx/v5" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/accounting/ledger" "github.com/mcom/erp/internal/platform/hr" "github.com/mcom/erp/internal/platform/hr/hrtest" "github.com/mcom/erp/internal/platform/payroll" "github.com/mcom/erp/internal/platform/payroll/payrolltest" "github.com/mcom/erp/internal/platform/workflow")
// The spec's end-to-end: prepare (a starter, unpaid leave, a recurring allowance, a one-off// bonus, a manual EPF line) → the preparer refused as approver → approve (posts) → a batch →// paid; the journal balanced and the payroll payable cleared.func TestOnePayrollRunFromPrepareToPaid(t *testing.T) { e := payrolltest.New(t) e.UsePayrollPoster(t) staff := e.Employee(t, e.Staff, "E-100", "3100.00") newcomer := e.Newcomer(t, "Aminah binti Yusof") starter := e.Starter(t, newcomer, "E-200", "3100.00", hrtest.Day(2026, 8, 10)) e.Pay.VerifiedPayee(t, e.Staff) e.Pay.VerifiedPayee(t, newcomer)
trans := e.ItemType(t, "TRANS", payroll.KindEarning, payroll.RecurRecurring) bonus := e.ItemType(t, "BONUS", payroll.KindEarning, payroll.RecurOneOff) e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { _, err := payroll.AddEmployeeItem(ctx, tx, e.Clock, payroll.EmployeeItemInput{EmployeeID: starter.ID, TypeID: trans.ID, Amount: money.MustParse("310.00"), EffectiveFrom: hrtest.Day(2026, 8, 10), Actor: e.HR}) return err }) ul := e.LeaveType(t, hr.LeaveTypeInput{Code: "UL", Kind: hr.KindUnpaid, HalfDays: true}) r := e.Request(t, e.Staff, hr.RequestInput{TypeID: ul.ID, From: hrtest.Day(2026, 8, 3), To: hrtest.Day(2026, 8, 3)}) e.LeaveMove(t, r.ID, e.Owner, "pending", "approved", "")
run := e.Run(t, hrtest.Day(2026, 8, 1)) epf := payroll.StatEPF for _, in := range []payroll.InputInput{ {EmployeeID: staff.ID, Kind: payroll.InputStatutory, StatutoryCode: &epf, Amount: money.MustParse("330.00")}, {EmployeeID: starter.ID, Kind: payroll.InputItem, TypeID: &bonus.ID, Amount: money.MustParse("500.00")}, } { in.RunID, in.Actor, in.ExpectedRevision = run.ID, e.HR, run.Revision e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { var err error run, _, err = payroll.AddInput(ctx, tx, e.Clock, in) return err }) } run = e.Move(t, run, e.HR, "submitted", "") slips := e.Slips(t, run.ID) require.Equal(t, []string{"basic 3100.0000", "unpaid_leave -100.0000", "statutory 330.0000"}, lineSummary(slips["E-100"])) require.Equal(t, []string{"basic 2200.0000", "item 220.0000", "item 500.0000"}, lineSummary(slips["E-200"])) require.Equal(t, "2670.0000", slips["E-100"].Net.String()) require.Equal(t, "2920.0000", slips["E-200"].Net.String())
var refused *workflow.RefusedError require.ErrorAs(t, e.MoveErr(run, e.HR, "approved", ""), &refused, "the preparer does not approve") run = e.Move(t, run, e.Owner, "approved", "")
b := e.PayslipBatch(t, slips["E-100"].ID, slips["E-200"].ID) b = e.ApproveBatch(t, b, e.Owner) e.MarkPaid(t, b, e.Owner)
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error { r, err := payroll.GetRun(ctx, tx, run.ID) require.Equal(t, payroll.RunPaid, r.Status) return err }) // Every entry this run posted balances, and the payroll payable is back to zero. require.Zero(t, e.Count(t, `SELECT count(*) FROM (SELECT j.id FROM journal_entry j JOIN journal_line l ON l.org_id = j.org_id AND l.entry_id = j.id WHERE j.org_id = $1 GROUP BY j.id HAVING sum(l.base_debit) <> sum(l.base_credit)) x`, e.Org)) require.Zero(t, e.Count(t, `SELECT count(*) FROM (SELECT 1 FROM journal_line l JOIN account a ON a.org_id = l.org_id AND a.id = l.account_id WHERE l.org_id = $1 AND a.code = '2180' HAVING sum(l.base_credit) - sum(l.base_debit) <> 0) x`, e.Org)) require.Len(t, e.EntryLines(t, *e.Slips(t, run.ID)["E-200"].InstanceID, ledger.SemanticPayout), 2)}Expected arithmetic: Staff 3100 − 1 day unpaid (3100 ÷ 31 = 100.00) − EPF 330 = 2670.00; the starter 3100 × 22/31 = 2200.00, transport 310 × 22/31 = 220.00, bonus 500 → 2920.00.
Run: go test ./internal/platform/payroll/ -run TestOnePayrollRunFromPrepareToPaid -count=1
Expected: PASS.
- Step 2: The docs
docs/ROADMAP.md, Phase 5, under the payroll item, replace the P1 design bullet with:
- [x] P1 core (`platform/payroll`, `modules/payroll`, `00900`–`00903`; `00904`–`00919` reserved) Design: [`2026-10-01-payroll-p1-design.md`](superpowers/specs/2026-10-01-payroll-p1-design.md) (PD-1–PD-6) Plan: [`2026-10-01-phase-5-payroll-p1.md`](superpowers/plans/2026-10-01-phase-5-payroll-p1.md) (13 tasks, Rulings PD-7–PD-30) Deploy: [`DEPLOY_NOTES.md`](DEPLOY_NOTES.md) — re-provision for the two workflow kinds and the payroll accounts; settings before the first run Not yet exercised on a running stack: the manual en/ms/zh, phone-width and dialog-focus pass - Append-only salaries; pay item types (names as translation rows, tag fixed by kind); recurring items ended once - The monthly run: calendar-day proration per salary row, unpaid leave from approved H3 leave at a configurable divisor, one-off, manual statutory and adjustment inputs; every recalculation a new generation, nothing deleted - `payroll_run` workflow (accrual-only): preparer never approves (engine and database), sole-owner exception; void reverses - Posting by tag: salary expense / deductions payable / `payroll_payable` (new control kind); per payslip, party = employee - Each payslip a `payslip` workflow instance and a payment-batch source (C4): verified details sealed, one bank line per payslip - "My payslips" for every member; salary and others' payslip reads audited - Drift tests: frozen payslips (watched failing); preparer ≠ approver; tenant isolation; retention lists; module CHECK = catalogue - [ ] P1 follow-ups (decisions and gaps, none blocking): - Department split of salary expense: no org-unit → analytic mapping exists (PD-19) - Employees without a login are left out of a run (PD-8); a rehire inside one month pays the current spell only - A one-member organization cannot batch-pay the Owner's own payslip (PD-29) - Void is refused while a payslip sits in a draft batch, stricter than the spec (PD-22) - Holiday changes after a leave request can make a month's unpaid days differ from the request's stored days (PD-11) - Statutory lines are employee shares only; P2's employer contributions need a debit side the `statutory` line kind does not have - `GET /payroll/item-types` needs `payroll.item.manage`, so a `salary.read`-only holder sees item types unnamed - "My payslips" stays in the sidebar when the payroll module is off (the shell has no module flag) - Owner to confirm (spec): unpaid-leave divisor, proration basis, the retention periodand change the - [ ] Payroll: … line’s first sub-bullet (“Split into P1 core, P2 statutory, P3 filings; P1 design: …”) to - Split into P1 core, P2 statutory, P3 filings (the P1 entry above carries the links).
docs/DEPLOY_NOTES.md, a new section at the top (newest first):
## Phase 5 payroll P1 — salaries, pay items, the monthly run, payslips
Migrations `00900`–`00903` (`00904`–`00919` reserved). Permission keys `payroll.salary.read`, `payroll.salary.write`,`payroll.item.manage`, `payroll.run.read`, `payroll.run.prepare`, `payroll.run.approve` (Owner only: `00903` forexisting organizations, the registry for new ones). Optional module `payroll` (requires `hr` and `payables`), on bydefault. New control kind `payroll_payable`. New workflow kinds `payroll_run` and `payslip`. No environment variable, no job.
- **Re-run provisioning for existing organizations** (`POST /v1/org/provision` or `migrate provision`): step `workflow_definitions` publishes `payroll_run` and `payslip`; step `payroll_accounts` (order 84) creates `2180 Salaries payable` (`payroll_payable`), `2190 Statutory deductions payable`, `6210 Salaries and wages` and seeds the payroll setting with the last two. Until then creating a run answers 409 `workflow_missing` or `payroll_not_configured`.- **Before the first run** an administrator chooses the payout channel in Payroll setup (an active bank-transfer channel crediting the bank account batches pay from). A channel restricted by "Used for" must tick Salaries.- **Statutory contributions are not calculated in P1.** EPF, SOCSO, EIS and PCB are entered by hand on each run; the run screen says so. Do not run real payroll on P1 alone (owner question 3).- **Owner decisions still open:** the unpaid-leave divisor (default: the month's calendar days; a setting, not a migration), the proration basis (calendar days), and how long payroll records are kept (nothing in payroll can be erased).- **Generated files** (`api.gen.go`, `schema.d.ts`, `vocab.gen.go`, `vocab.ts`) conflict on merge: re-run `make generate`.- **The Down-migrations lose records once payroll is used:** `00900`'s drops every salary, run and payslip; `00902`'s refuses while a payslip is on a batch line. Roll the code back, not these migrations.docs/LOCK_ORDER.md, a row in “Per module” after “Asset disposals”:
| Payroll runs (Phase 5 payroll P1) | Create, recalculate, inputs: `payroll_run` `FOR UPDATE` → `numbering.Allocate` (create) → the run's own `workflow_instance` (`workflow.Start`, `workflow.SetAmount`) → payslip and line inserts → audit, last. Transition: in a one-member organization `organization` `FOR UPDATE` (`workflow.LockSoleOwner`) → `payroll_run` `FOR UPDATE` → the current generation's `payslip` rows `FOR UPDATE` in `id` order (submission recalculates first; approval and void lock them) → the void's unlocked read of `payslip_payment` and `payment_batch_line` → `workflow.Transition` (instance, Poster) → on approval one `workflow.Start` per payslip and the `payslip.instance_id` writes, after the transition's audit, on rows already held or created here (C4 M2's shape) → the run row (held) → audit, last. `PayPayslip` inside a batch's mark paid: its payslip (already held by the batch) → `payslip_payment` insert → `workflow.Transition` → audit. `paid` is derived, never stored, so mark paid never locks `payroll_run` | `payroll/run.go`, `payroll/transition.go` `TransitionRun`, `payroll/pay.go` `PayPayslip` |and in the “Payment batches” row, after → \expense_claim` rows, then `advance` rows, `FOR UPDATE` in `id` order (C4)insert→ `payslip` rows `FOR UPDATE` in `id` order (payroll P1), and after "each claim's and advance's own expenses/advances.Transition" add "and each payslip's payroll.PayPayslip". In "Orders that cross modules", the batch bullet becomes "**Batch → payments → claims → advances → payslips → suppliers and payout profiles (FOR SHARE`) → invoices** (C4, payroll P1). A payroll void takes the run, then its payslips; the batch never takes the run, so the two serialize on the payslip rows.”
- Step 3: The full suite, once
Run:
go test ./... -count=1pnpm -C web exec tsc --noEmit && pnpm -C web test -- --run && pnpm -C web run lintmake generate && git status --porcelainExpected: every Go and web test passes; git status --porcelain prints nothing after make generate (generated code matches its sources). If a test outside payroll fails, read it before touching anything: the payables, workflow, moduleinstall and app suites are the likely ones, and each failure names the list or enum it expects to grow.
- Step 4: The manual pass (record the outcome in the ROADMAP line)
On a running stack (make dev or the project’s documented local run): as the Owner, set the payroll settings, create two pay item types, record salaries on two employees, prepare the current month, add an EPF line, submit as the preparer (HR), approve as the Owner, verify one employee’s bank details, put both payslips in a batch, approve it, mark it paid; open “My payslips” as Staff and print. Repeat the run screen in ms and zh and at phone width. If it cannot be done here, leave “Not yet exercised on a running stack” in the ROADMAP entry, as H1–H3 did.
- Step 5: Commit
git add internal/platform/payroll/e2e_test.go docs/ROADMAP.md docs/DEPLOY_NOTES.md docs/LOCK_ORDER.mdgit commit -m "Payroll P1: end-to-end test, roadmap, deploy notes and lock order
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"Self-review
Section titled “Self-review”Spec coverage (each spec line → the task that builds it):
| Spec | Task |
|---|---|
PD-1 split; P1 leaves a statutory line kind |
1 (payslip_line.kind), 5 (manual statutory inputs) |
| PD-2 net pay through ordinary payment batches, no new cash path | 8 |
| PD-3 monthly, one run per org per month | 1 (payroll_run_one_per_month), 5 |
| PD-4 basic with effective dates, recurring items, one-offs, unpaid-leave deduction; no overtime | 3, 4, 5 |
PD-5 Owner-only keys; payroll_run workflow; preparer ≠ approver; sole-owner exception; own payslips only |
6, 9, 10 |
| PD-6 snapshot; approval freezes; corrections by void or adjustment | 1, 5, 6 (freeze drift test), 7 (void reversal) |
| Data: the six tables, RLS, NUMERIC(19,4), translations, append-only salary, frozen lines | 1 (+ payroll_setting, payroll_run_input, payslip_payment: Rulings PD-11, PD-13, PD-21) |
| Run states draft/submitted/approved/paid/rejected/voided | 5 (graph), 6, PD-15 (paid derived) |
| Prepare: proration, items, unpaid leave, statutory manual; recalc until submitted; half-up per line | 4, 5 |
| Warnings: out-of-band salary, no verified bank details (left out of the batch, listed) | 5 (RunWarnings), 8 (candidates) |
| Void reverses; refused once a payslip is in an approved batch | 6, 7, 8 (PD-22: stricter) |
| Posting by tags; net to payroll payable; department split | 7; split not built (PD-19, flagged) |
| One payable per payslip; batch controls; mark paid → paid, payable cleared, cash on value date | 8 |
| Permissions, own payslips, salary read audited, labels with drift test | 9, 10 |
| Screens: salary and items on the employee page, runs list, run detail with payslips and warnings, My payslips, en/ms/zh | 11, 12 |
| Tests: RLS and isolation (presence first), frozen trigger watched failing, proration and unpaid-leave arithmetic (31-day, leaver, half days), preparer refused, end to end with the journal balanced | 1, 6, 4 + 5, 6, 13 |
| Owner confirmations 1–4 | PD-10, PD-11 (a setting), PD-28 (notice), Task 1 retention; listed in DEPLOY_NOTES |
Placeholder scan: no “TBD”, “TODO” or “similar to Task N”. Three places tell the implementer to copy existing code verbatim rather than restating it: leave/http.go’s helpers (Task 10 step 5), leave/env_test.go (Task 10 step 8), and 00451’s guard function body into 00902’s Down (Task 8 step 4); each names the file and what to copy. Four places say “if the component’s props differ, use the precedent file’s” (Checkbox, Button size, PageHeader subtitle, ProblemError code) because those props were not read while planning; each names the precedent.
Type consistency: Payslip.MemberID is uuid.UUID (not a pointer) everywhere it is read (Tasks 5, 6, 8, 9); Payslip.PayoutChannelID is uuid.UUID, so payables takes its address; hr.Days is half-day units throughout, stored NUMERIC(5,1) and read (days * 2)::int; Run.Status holds RunPaid only as a derived value, never written; Rules() returns AccrualRule{} in Task 7 and both rules from Task 8; checkVoidable(ctx, tx, org, run) keeps its signature when Task 8 widens its query; the fixture’s ApprovedRun (HR prepares and submits, the Owner approves) is what every later task assumes.
Fixed inline during this review: payslip_payment moved into 00900 (Task 1) so that Task 5’s run query can derive paid without a later edit; the Task 8 payables regression test was simplified (no extra seat grant); createType decodes one body struct; me_test.go takes a uuid.UUID; the nav and journal_line.entry_id assumptions were checked against the code and their hedges removed.