Skip to content

Phase 5 H3 — Leave 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 every employee leave types with a database-enforced statutory floor, effective-dated entitlement rules, an append-only leave ledger whose sums are the balances, an idempotent yearly grant, leave requests approved by the system-resolved superior through a leave_request workflow that ends in committed, a working-day calendar, and the routing effect of leave on every approval in the system: an approver on approved leave is passed over by the existing on_vacancy: escalate walk, logged, and never the reason a step is refused (HD-2).

Architecture: The leave domain lives in platform/hr beside H1’s employee record (package hr, files leave_*.go), on migrations 00840–00842: global leave_statutory_floor (seeded, migration-only), leave_type (+ translation rows), append-only effective-dated leave_rule with a floor trigger, append-only leave_ledger (balance = sum, never stored), append-only leave_request driven by the seeded leave_request workflow, work_calendar, public_holiday, leave_setting (carry-forward) and the append-only leave_routing_move log. workflow gains a registered Unavailable seam that Resolve consults after it has resolved a grant; hr registers its “on approved leave that day” answer from init(), so workflow never imports hr. Two daily River jobs — hr_leave_grants and hr_leave_reroute — follow H1’s hr_apply_leavings pattern. modules/leave is the HTTP surface; web/src/features/leave the screens.

Tech Stack: Go 1.x, pgx v5, Postgres 16 (goose migrations, btree_gist), gin, River, oapi-codegen, React + TanStack Router/Query + i18next (web), vitest + msw.

Spec: docs/superpowers/specs/2026-09-30-hr-layer-design.md (§H3.1–H3.5, owner decisions HD-2, HD-3, HD-7, HD-8, the H3 drift-test bullets, “Roadmap and outside-authority additions”). Binding predecessor: docs/superpowers/plans/2026-09-30-phase-5-h1-employee-lifecycle.md (“Interfaces H2 and H3 consume”, Rulings H1-1–H1-17). Architecture: docs/superpowers/specs/2026-09-20-erp-architecture-design.md §4.7, §5.4, §19 Phase 5.

  • Migration range: H3 00840–00859 (declared by H1 Task 1 — do not re-add). This plan uses 00840 (droppable, Task 1), 00841, 00842.
  • H1 is merged first and its names are used exactly: hr.GetByMember, hr.Get, Employee.HireDate, Employee.LastDay, Employee.Status, hr.StatusActive, hr.StatusNotice, hr.ServiceMonths(hireDate, on time.Time) int, the SQL employee_spell(org, employee), the package-internal orgOf, lockEmployee, checkReason, *FieldError, *RevisionMismatchError, ErrWrongStatus, ErrNotFound, and the fixture hrtest.New, hrtest.Day, hrtest.At, (*hrtest.Env).Hired, (*hrtest.Env).DeactivateByAdmin. A member copied by 00801 has no hire date (status pending) and is granted no leave until HR records the hire.
  • A float never touches a day count or money. Days are hr.Days (half-day units, an int) in Go, NUMERIC(5,1)/NUMERIC(6,1) in Postgres, a decimal string on the wire (“1.5”).
  • Every business table has org_id, ENABLE and FORCE ROW LEVEL SECURITY, and a policy named org_isolation; every tenant foreign key is composite on org_id. The one global table, leave_statutory_floor, has no org_id and is declared in db/rls_exempt.txt (the fx_reference_rate shape: public reference data, identical for every tenant, no package under internal/modules names it).
  • Every constraint is named (CONSTRAINT <table>_<rule>); every function pins its search path (SET search_path = pg_catalog, public, pg_temp) and schema-qualifies what it reads. No SECURITY DEFINER.
  • Retention (spec H3.5): leave tables are retention-bearing — none grants DELETE. leave_rule, leave_ledger, leave_request, leave_routing_move and leave_statutory_floor are append-only (UPDATE, DELETE revoked); leave_type, work_calendar, public_holiday, leave_setting are amendable (DELETE revoked). TestOnlyOperationalTablesGrantDeleteToTheAppRole lists each.
  • The ledger is append-only. A correction is a reverse, an expire or an adjust, never an edit. A balance is the sum of entries, computed on read, never stored.
  • Vocabulary values are strings: leave kinds (annual, sick, hospitalisation, maternity, paternity, unpaid, other), entry kinds (grant, take, reverse, carry_forward, expire, adjust), expiry causes (year_end, carry_forward), warnings (no_public_holidays), routing notes (approver_on_leave, approver_on_leave_no_alternate).
  • Every user-facing name is a translation row: leave type and public holiday names (translation, entity_kind = leave_type / public_holiday, field name, en and ms required, zh optional).
  • Record text is English or Bahasa Malaysia (spec 11A.3): every reason (request, adjustment, cancellation) passes recordtext.Check and carries the CJK CHECK in the table.
  • accounting/ reacts to semantic tags, never to state names — and so does leave: approval is “entered a committed state” (post take), cancellation after approval is “left a committed state for a cancelled one” (post reverse). Nothing in leave posts to the general ledger.
  • A claimant never picks their own approver. The leave_request grants are the seat above the requester’s (levels_up: 1, their superior by the reporting line) and the OWNER seat, resolved by the engine; nobody names an approver on a request (spec H3 “Out”: a delegate named on the request). An OWNER-seat holder’s own leave follows Ruling H3-20 (owner decision 2026-09-30): another holder of the OWNER seat; in a one-member organization the sole-owner exception; otherwise the approver an administrator configures as a grant in the leave_request definition — and the requester never approves their own, which the database enforces (leave_take_by_requester).
  • Business dates come from clock.Today(ctx, c), never time.Now() or CURRENT_DATE.
  • Layering: platform/workflow never imports platform/hr (the Unavailable seam is registered by hr). platform/hr may import platform/workflow, platform/documents, platform/orgstruct, platform/identity/member, platform/posting. modules/leave imports platform/hr, never another module.
  • The contract is api/openapi.yaml. Endpoints are declared there first; make generate must leave the tree clean (internal/gen/drift_test.go, web/src/drift/schema.test.ts, internal/kernel/vocab/vocab_test.go).
  • Drift tests: presence before absence; each watched failing once (AGENTS.md), the sabotage and the expected message written into the task. While iterating, run only the touched packages; run go test ./... once, in Task 12.
  • Lock order (added to docs/LOCK_ORDER.md in Task 12): organization FOR UPDATE → employee FOR UPDATE (both through H1’s lockEmployee) → an attachment’s document FOR UPDATE → workflow_instance (inside workflow.Transition) → leave_ledger / leave_request inserts → audit.Write last.
  • Commits: one per task, the message ending with a blank line and Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>. Merging and pushing are the owner’s.
  • H3-1 Where the code lives. The leave domain is package hr (internal/platform/hr/leave_*.go): it shares H1’s employee lock and fixture, and it is hr that registers the Unavailable answer (spec H3.4). The HTTP surface is internal/modules/leave, under the optional module leave.
  • H3-2 Days are half-day units. hr.Days counts halves (2 = one day). Storage is NUMERIC(6,1) constrained to whole halves; SQL reads (days * 2)::int; the wire is a decimal string. Rules and grants are in days; takes may be half days.
  • H3-3 The leave year is the calendar year (spec “calendar year by default”; a configurable start is not built — H3 “Out” excludes half-year years and nothing asks for another start). A request lies inside one leave year (leave_request_one_year); a request across 31 December is two requests.
  • H3-4 Floor bands are half-open month ranges [0, 24), [24, 60), [60, ∞) of whole service months (“under 2 years”, “2–5 years”, “over 5 years”); hospitalisation, maternity and paternity have one band [0, ∞). The figures are UNVERIFIED (spec: recalled from the 2022 Employment Act amendments), marked so in 00841, DEPLOY_NOTES.md and the ROADMAP’s outside-authority list. The table is global and changes only by migration.
  • H3-5 A rule set is a type’s complete schedule from one effective_from: bands contiguous from month 0, the last open-ended, days ≥ 0 in whole halves. It is append-only; a change is a new set with a later date (HD-8), and one set per type and date (leave_rule_bands_disjoint). Unpaid types take no rules (nothing to grant). The floor is enforced by leave_rule_floor_guard against the highest floor overlapping each band (spec H3.1: a 0–36 band at 10 days breaks the 12-day floor from month 24).
  • H3-6 Grants. hr_leave_grants runs daily and on start; its first run in a year is the “leave year start” grant. It grants every active or notice employee with a hire date, for every active paid type whose rule set in force on the grant date has a band for the employee’s whole service months on that date. The grant date is 1 January, or the hire date in the hire year. The service band is read on the grant date and does not move mid-year (UNVERIFIED simplification). A first-year annual grant is prorated by whole months remaining (hr.ServiceMonths(hireDate, 1 January next)), rounded to whole days: a fraction under one half is dropped, one half or more is a day (Employment Act s.60E(1A), UNVERIFIED). Other kinds are granted in full (their statutory entitlements are not prorated — UNVERIFIED). Every grant records the floor for that year, prorated the same way (floor_days), which is what HD-7 checks. Idempotency is the database’s: leave_ledger_grant_once with ON CONFLICT DO NOTHING. A rehire in the same year gets no second grant.
  • H3-7 Year roll (annual kind only). On the first run of year Y, Y−1’s remaining annual balance lapses (expire, year_end) and up to leave_setting.carry_forward_max of it is carried into Y (carry_forward); after carry_forward_expiry_months, the carried days not yet used — min(carried, balance) — expire (expire, carry_forward). The default maximum is 0: unused annual leave lapses at year end (spec: “default none — the Act does not require it”). A request dated in a rolled year is decided against that year’s balance, which is then zero. Other kinds neither carry nor lapse: each year’s balance stands alone.
  • H3-8 Adjustment (HD-7). A non-zero number of half days, reason mandatory (record text), hr.leave.adjust, audited. A negative adjustment on a statutory kind is refused by leave_ledger_guard (leave_adjust_below_floor) when the year’s grant plus adjustments would fall below the grant’s floor_days, or when that year has no grant.
  • H3-9 Balance check. A paid type cannot go negative (the spec names paid annual leave; the rule is applied to every paid type, because a paid day beyond the grant has no entitlement behind it — an employee takes unpaid leave instead); an unpaid type is unlimited. Checked at submission (balance less the employee’s other pending requests of the type and year) and at approval (balance), under the organization and employee locks, and re-checked on the take insert by leave_ledger_guard (leave_balance_insufficient).
  • H3-10 Requests are immutable. A request’s dates, type, days, reason and attachment are fixed when it is created (append-only table); to change one, withdraw it and request again. Days are computed at creation from the calendar and holidays of that moment. Workflow leave_request: draft → pending → approved (committed), pending → rejected (a reason), draft|pending → cancelled (withdraw, a reason), approved → cancelled (the requester, a reason, only before the first day — ErrLeaveStarted). rejected and cancelled are terminal. Requests are the member’s own (self-service); HR recording leave on someone’s behalf is not in H3 (ROADMAP follow-up). A request lies between the hire date and, under notice, the last day.
  • H3-11 Approver grants. On approved: levels_up: 1 — the seat the requester’s primary seat reports to, escalating past a vacant seat (on_vacancy: escalate, bound 2) — and the OWNER seat. That is “the employee’s superior, resolved by the system” by the reporting line; the engine’s superior kind is not used because workflow.Route refuses any submission whose superior grant resolves to nobody (Ruling 13 (a)), which is always so for the root seat, so a one-member organization’s Owner could never reach the sole-owner exception the spec requires (H3.3). The difference, deliberate: leave does not follow organization_member.manager_override_id or the unit-head fallback. The OWNER-seat grant is what the exception stands on (it resolves to the claimant alone); the consequence, also deliberate, is that the Owner may approve anyone’s leave. Who approves an OWNER-seat holder’s own leave is Ruling H3-20.
  • H3-12 Working days. work_calendar.weekdays (ISO 1–7, default Monday–Friday when no row) and work_calendar.state (ISO 3166-2:MY, MY-01…MY-16, optional). A public holiday applies when its state is null (national) or equals the calendar’s. Maternity and paternity count consecutive calendar days (spec: “98 consecutive days”, “7 consecutive days”) and take no half days. A half day is from_half (the first day, afternoon only) or to_half (the last day, morning only), only on a type with half_days. A request of zero working days is refused. Holidays are retired, never deleted; no_public_holidays is returned when the request’s year (or a listed year) has no holiday that applies.
  • H3-13 Medical certificates. An attachment is a verified document uploaded by the requester, bound to one live request (documents.BoundElsewhere); sick and hospitalisation types require one (leave_type_attachment_for_medical). It is readable by the requester, a member who may decide the request now or has decided it, hr.leave.manage (HR) and hr.leave.medical.read — not by hr.leave.read_all. The leave type is visible to everyone who may read the request, the approver included; the team calendar withholds it from members without hr.leave.read_all.
  • H3-14 Unavailable (HD-2). workflow.UnavailableFunc(ctx, tx, member, day) (bool, error) — the spec’s shape plus an error, because the answer is a database read. After a grant resolves, members unavailable on the day are passed over: if some remain they decide; if none remain and the grant escalates, the walk goes up from the grant’s seat (the named seat, the unit head’s seat, the seat levels_up above the claimant’s, or for superior the claimant’s own seat) within max_escalation_levels to the first seat with an available holder who is not the claimant; otherwise the step stays with those resolved, flagged approver_on_leave_no_alternate — never refused. Resolved carries Away and Note, which the decision log’s resolution persists. A half day of leave is not unavailability. The passed-over approver cannot decide the step on those days (that is what “unavailable” means). Account-approver sign-off steps are not consulted (ROADMAP follow-up). No delegation is created.
  • H3-15 The reroute log. hr_leave_reroute (daily, on start) runs only on a day someone in the organization is on approved leave; it resolves every pending step for that day (workflow.ReroutedOn) and logs each one that passes over somebody once per instance, state, grant and day (leave_routing_move_once) with an audit row. Nothing is rewritten: the engine resolves approvers when a decision is attempted (Ruling H1-10), so the approvals queue already shows the new approver. No notification is sent (ROADMAP follow-up).
  • H3-16 Nothing is seeded per organization. Leave types are the organization’s (the floor binds whatever statutory-kind rules it writes); holidays are the Owner’s (spec). A starter set of types is a ROADMAP follow-up.
  • H3-17 Permissions and module. hr.leave.request (self-service: request, see one’s own balances and requests, decide what the engine routes to you), hr.leave.manage (types, rules, holidays, calendar, settings; reads medical certificates as HR), hr.leave.read_all, hr.leave.calendar, hr.leave.adjust, hr.leave.medical.read. Owner: all six (every registered key; 00842 for existing Owners). Employee, Approver, Finance, Admin: hr.leave.request; Approver also hr.leave.calendar. Module leave requires hr. Because the keys nest under hr., a switched-off module’s keys are those whose first or second segment is its key (member.Permissions, moduleinstall.OwnsPermission), and a drift test in internal/app proves no registered key’s second segment names an optional module other than one that requires the first.
  • H3-18 Jobs run whether or not the module is on (H1-17): an approved leave still makes its approver unavailable, and an organization that switches leave back on finds its entitlements current.
  • H3-19 committed. Phase 4 O1 Task 1 (00700) adds the semantic and O1 Task 5 Step 6b lets the sole-owner exception cover it; H2 Task 1 and this plan’s Task 1 carry both, droppably. Whichever of the three merges first wins; the others drop their task. leave_request joins workflow.CommitmentKinds in Task 6 (H2 adds vacancy the same way; the second to merge keeps both entries).
  • H3-20 An OWNER-seat holder’s own leave (owner decision 2026-09-30). The approver is still configured by an administrator and resolved by the system; the claimant never picks it.
    1. Two or more holders of the OWNER seat: the seeded OWNER-seat grant resolves to the holders other than the requester (the engine’s self case removes the claimant), so another holder approves. The requester can never approve their own: the engine refuses it (self_approval), and leave_ledger_guard refuses a take recorded by the request’s own requester unless that request’s approving decision is the sole-owner exception (leave_take_by_requester) — the database backstop, like the other preparer ≠ approver checks.
    2. A one-member organization: the sole-owner exception, as before — reason mandatory, authority_via = sole_owner_exception, audit row flagged.
    3. Several members, exactly one OWNER-seat holder: the administrator adds a grant to the leave_request definition through the existing workflow definition editor (draft, then publish: workflow.definition.manage / workflow.definition.publish) — grantee a named member or a position, on the approved state, with a floor of hr.OwnerLeaveFloor (MYR 1.00). No second configuration path exists. What confines that grant to OWNER-seat leave is the engine’s own floor ladder: RequestLeave starts an OWNER-seat holder’s request at amount OwnerLeaveFloor and everyone else’s at 0, so the grant is in no one else’s band, and on an OWNER-seat request it outranks the floor-0 grants (when configured it decides OWNER-seat leave in case 1 too — the administrator’s choice). Until it is configured, or while what it names is vacant, submission is refused with a named reason (*hr.OwnerLeaveApproverError: owner_leave_approver_missing — “add a grant at the OWNER-seat floor to leave_request’s approved step” — or owner_leave_approver_vacant), in orgstruct.NoSuperiorReason’s style; never silently left unapproved. The instance amount is a routing tier for leave, not money: nothing posts from it.

All in github.com/mcom/erp/internal/platform/.... Every task’s Interfaces block repeats the part it produces.

// package workflow (Task 2, Task 7)
const NoteApproverOnLeave = "approver_on_leave"
const NoteNoAlternate = "approver_on_leave_no_alternate"
type UnavailableFunc func(ctx context.Context, tx pgx.Tx, member uuid.UUID, day time.Time) (bool, error)
func RegisterUnavailable(f UnavailableFunc) // panics on nil or a second registration
func UnavailableRegistered() bool
func SetUnavailableForTest(t interface{ Cleanup(func()) }, f UnavailableFunc)
// Resolved gains: Away []uuid.UUID `json:"away,omitempty"`; Note string `json:"note,omitempty"`
type Reroute struct {
InstanceID uuid.UUID; Kind, State string; GrantID uuid.UUID
Away, Members []uuid.UUID; Note string
}
func ReroutedOn(ctx context.Context, tx pgx.Tx, day time.Time) ([]Reroute, error)
var CommitmentKinds // gains "leave_request" (Task 6)
// package orgstruct (Task 2)
type SeatHolders struct { PositionID uuid.UUID; Members []uuid.UUID }
func FilledSeatsAbove(ctx context.Context, tx pgx.Tx, positionID uuid.UUID, maxLevels int, on time.Time) ([]SeatHolders, error)
// package hr (Tasks 4–8)
const (
KindAnnual = "annual"; KindSick = "sick"; KindHospitalisation = "hospitalisation"
KindMaternity = "maternity"; KindPaternity = "paternity"; KindUnpaid = "unpaid"; KindOther = "other"
EntryGrant = "grant"; EntryTake = "take"; EntryReverse = "reverse"
EntryCarryForward = "carry_forward"; EntryExpire = "expire"; EntryAdjust = "adjust"
ExpiryYearEnd = "year_end"; ExpiryCarryForward = "carry_forward"
LeaveRequestKind = "leave_request" // the workflow kind and the document binder kind
EntityLeaveType = "leave_type"; EntityLeaveRule = "leave_rule"; EntityLeaveRequest = "leave_request"
EntityLeaveLedger = "leave_ledger"; EntityPublicHoliday = "public_holiday"
EntityWorkCalendar = "work_calendar"; EntityLeaveSetting = "leave_setting"
PermissionLeaveRequest = "hr.leave.request"; PermissionLeaveManage = "hr.leave.manage"
PermissionLeaveReadAll = "hr.leave.read_all"; PermissionLeaveCalendar = "hr.leave.calendar"
PermissionLeaveAdjust = "hr.leave.adjust"; PermissionLeaveMedicalRead = "hr.leave.medical.read"
WarningNoPublicHolidays = "no_public_holidays"
)
var LeaveKinds []string
func StatutoryKind(kind string) bool
type Days int // half-day units
func WholeDays(n int) Days
func ParseDays(s string) (Days, error)
func (d Days) String() string
type Names map[string]string
type LeaveType struct {
ID uuid.UUID; Code string; Names Names; Kind string
Paid, HalfDays, AttachmentRequired, Active bool; Revision int; CreatedAt, UpdatedAt time.Time
}
type LeaveTypeInput struct { Code string; Names Names; Kind string; Paid, HalfDays, AttachmentRequired bool; Actor uuid.UUID }
type LeaveTypeUpdate struct { Names Names; HalfDays, AttachmentRequired, Active bool; Actor uuid.UUID }
func CreateLeaveType(ctx context.Context, tx pgx.Tx, c clock.Clock, in LeaveTypeInput) (LeaveType, error)
func UpdateLeaveType(ctx context.Context, tx pgx.Tx, c clock.Clock, id uuid.UUID, expectedRevision int, in LeaveTypeUpdate) (LeaveType, error)
func GetLeaveType(ctx context.Context, tx pgx.Tx, id uuid.UUID) (LeaveType, error)
func ListLeaveTypes(ctx context.Context, tx pgx.Tx, activeOnly bool) ([]LeaveType, error)
type Band struct { FromMonth int; ToMonth *int; Days Days }
type LeaveRule struct { ID, TypeID uuid.UUID; EffectiveFrom time.Time; Band; CreatedBy uuid.UUID; CreatedAt time.Time }
type RuleSetInput struct { TypeID uuid.UUID; EffectiveFrom time.Time; Bands []Band; Actor uuid.UUID }
func SetLeaveRules(ctx context.Context, tx pgx.Tx, c clock.Clock, in RuleSetInput) ([]LeaveRule, error)
func LeaveRules(ctx context.Context, tx pgx.Tx, typeID uuid.UUID) ([]LeaveRule, error) // newest set first
type Floor struct { Kind string; FromMonth int; ToMonth *int; Days Days }
func StatutoryFloors(ctx context.Context, tx pgx.Tx) ([]Floor, error)
type WorkCalendar struct { Weekdays []int; State *string }
func GetWorkCalendar(ctx context.Context, tx pgx.Tx) (WorkCalendar, error)
func SetWorkCalendar(ctx context.Context, tx pgx.Tx, c clock.Clock, cal WorkCalendar, actor uuid.UUID) (WorkCalendar, error)
type LeaveSetting struct { CarryForwardMax Days; CarryForwardExpiryMonths *int }
func GetLeaveSetting(ctx context.Context, tx pgx.Tx) (LeaveSetting, error)
func SetLeaveSetting(ctx context.Context, tx pgx.Tx, c clock.Clock, s LeaveSetting, actor uuid.UUID) (LeaveSetting, error)
type Holiday struct { ID uuid.UUID; Date time.Time; State *string; Names Names; Active bool; CreatedAt time.Time }
type HolidayInput struct { Date time.Time; State *string; Names Names; Actor uuid.UUID }
func AddHoliday(ctx context.Context, tx pgx.Tx, c clock.Clock, in HolidayInput) (Holiday, error)
func RetireHoliday(ctx context.Context, tx pgx.Tx, c clock.Clock, id, actor uuid.UUID) (Holiday, error)
func Holidays(ctx context.Context, tx pgx.Tx, year int) ([]Holiday, error)
func LeaveYearWarnings(ctx context.Context, tx pgx.Tx, year int) ([]string, error)
func WorkingDays(ctx context.Context, tx pgx.Tx, kind string, from, to time.Time, fromHalf, toHalf bool) (Days, error)
type Entry struct {
ID uuid.UUID; Seq int64; EmployeeID, TypeID uuid.UUID; Year int; Kind string; Days Days
FloorDays *Days; RuleID, RequestID *uuid.UUID; ExpiryOf *string; Reason string
RecordedBy *uuid.UUID; RecordedAt time.Time
}
func Ledger(ctx context.Context, tx pgx.Tx, employeeID uuid.UUID, year int) ([]Entry, error)
func BalanceOf(ctx context.Context, tx pgx.Tx, employeeID, typeID uuid.UUID, year int) (Days, error)
type Balance struct {
EmployeeID uuid.UUID; EmployeeName, EmployeeNo string; TypeID uuid.UUID; Year int
Granted, CarriedForward, Taken, Reversed, Expired, Adjusted, Balance, Pending Days
}
func Balances(ctx context.Context, tx pgx.Tx, year int, employeeID *uuid.UUID) ([]Balance, error)
func AccrualDue(ctx context.Context, tx pgx.Tx) ([]uuid.UUID, error)
func AccrueYear(ctx context.Context, tx pgx.Tx, c clock.Clock, employeeID uuid.UUID) ([]Entry, error)
type AdjustInput struct { EmployeeID, TypeID uuid.UUID; Year int; Days Days; Reason string; Actor uuid.UUID }
func Adjust(ctx context.Context, tx pgx.Tx, c clock.Clock, in AdjustInput) (Entry, error)
// River: LeaveGrantsArgs (kind "hr_leave_grants"), LeaveGrantsPeriodic(), LeaveGrantsWorker{DB, Clock, Logger}
type LeaveRequest struct {
ID, EmployeeID uuid.UUID; EmployeeName string; TypeID uuid.UUID; TypeKind string
From, To time.Time; FromHalf, ToHalf bool; Days Days; Year int; Reason string
AttachmentID *uuid.UUID; InstanceID, RequestedBy uuid.UUID; CreatedAt time.Time
State string; Semantic posting.Semantic
}
type RequestInput struct {
TypeID uuid.UUID; From, To time.Time; FromHalf, ToHalf bool; Reason string
AttachmentID *uuid.UUID; Actor uuid.UUID
}
func RequestLeave(ctx context.Context, tx pgx.Tx, c clock.Clock, in RequestInput) (LeaveRequest, []string, error)
func GetLeaveRequest(ctx context.Context, tx pgx.Tx, id uuid.UUID) (LeaveRequest, error)
type RequestFilter struct { EmployeeID *uuid.UUID; Semantic string; From, To *time.Time }
func ListLeaveRequests(ctx context.Context, tx pgx.Tx, f RequestFilter) ([]LeaveRequest, error)
type LeaveTransitionInput struct { RequestID uuid.UUID; From, To string; Actor uuid.UUID; Reason string }
func TransitionLeave(ctx context.Context, tx pgx.Tx, c clock.Clock, in LeaveTransitionInput) (LeaveRequest, workflow.Decision, error)
func AwaitingLeave(ctx context.Context, tx pgx.Tx, c clock.Clock, member uuid.UUID) ([]LeaveRequest, error)
func CanReadLeave(ctx context.Context, tx pgx.Tx, c clock.Clock, r LeaveRequest, member uuid.UUID) (bool, error)
func MayReadMedical(ctx context.Context, tx pgx.Tx, c clock.Clock, r LeaveRequest, member uuid.UUID) (bool, error)
func LeaveRequestGraph(ownerSeat uuid.UUID) workflow.Graph
func OnLeave(ctx context.Context, tx pgx.Tx, member uuid.UUID, day time.Time) (bool, error) // registered as workflow.Unavailable
type RoutingMove struct {
ID, InstanceID uuid.UUID; Kind, State string; GrantID uuid.UUID; On time.Time
Away, MovedTo []uuid.UUID; NoAlternate bool; LoggedAt time.Time
}
func RecordRoutingMoves(ctx context.Context, tx pgx.Tx, c clock.Clock) ([]RoutingMove, error)
func RoutingMoves(ctx context.Context, tx pgx.Tx, from, to time.Time) ([]RoutingMove, error)
// River: LeaveRerouteArgs (kind "hr_leave_reroute"), LeaveReroutePeriodic(), LeaveRerouteWorker{DB, Clock, Logger}
type TakenRow struct { TypeID uuid.UUID; Requests int; Days Days }
func LeaveTaken(ctx context.Context, tx pgx.Tx, from, to time.Time) ([]TakenRow, error)
type CalendarEntry struct {
RequestID, EmployeeID uuid.UUID; EmployeeName string; TypeID uuid.UUID
From, To time.Time; FromHalf, ToHalf bool; Days Days
}
func TeamCalendar(ctx context.Context, tx pgx.Tx, from, to time.Time) ([]CalendarEntry, error)
var OwnerLeaveFloor money.Amount // MYR 1.00: an OWNER-seat holder's request is started at it (Ruling H3-20)
type OwnerLeaveReason string
const ReasonOwnerLeaveApproverMissing OwnerLeaveReason = "owner_leave_approver_missing"
const ReasonOwnerLeaveApproverVacant OwnerLeaveReason = "owner_leave_approver_vacant"
type OwnerLeaveApproverError struct{ Reason OwnerLeaveReason } // submission refused, naming the missing setting
// errors: ErrNotAnEmployee, ErrLeaveTypeNotFound, ErrDuplicateLeaveType, ErrRuleSetExists, ErrHolidayNotFound,
// ErrDuplicateHoliday, ErrLeaveRequestNotFound, ErrOverlappingLeave, ErrAttachmentBound, ErrLeaveStarted,
// ErrAdjustBelowFloor, ErrInsufficientBalance; *InsufficientBalanceError{Available, Requested Days}
// (errors.Is(…, ErrInsufficientBalance) holds); H1's *FieldError, *RevisionMismatchError, ErrWrongStatus.
// package hr/hrtest (Tasks 5–7; H1's Env)
func M(n int) *int
func (e *Env) Grant(t *testing.T, member uuid.UUID, keys ...string)
func (e *Env) LeaveType(t *testing.T, in hr.LeaveTypeInput) hr.LeaveType // Actor defaults to e.HR
func (e *Env) Rules(t *testing.T, typeID uuid.UUID, from time.Time, bands ...hr.Band) []hr.LeaveRule
func (e *Env) Annual(t *testing.T, code string, d0, d24, d60 int) hr.LeaveType // bands from 2000-01-01
func (e *Env) Accrue(t *testing.T, c clock.Clock, employeeID uuid.UUID) []hr.Entry
func (e *Env) Balance(t *testing.T, employeeID, typeID uuid.UUID, year int) hr.Days
func (e *Env) Request(t *testing.T, member uuid.UUID, in hr.RequestInput) hr.LeaveRequest
func (e *Env) LeaveMove(t *testing.T, id, by uuid.UUID, from, to, reason string) hr.LeaveRequest
func (e *Env) ApprovedLeave(t *testing.T, member, typeID uuid.UUID, from, to time.Time) hr.LeaveRequest
func (e *Env) CoOwner(t *testing.T, name string) uuid.UUID // a member who also holds the OWNER seat

Create

Path Responsibility
db/migrations/00840_committed_semantic.sql only if Task 1 is not dropped: committed joins the semantic CHECK
db/migrations/00841_leave.sql every leave table, the floor seed, leave_semantic, leave_balance, the floor and ledger guards
db/migrations/00842_leave_grants.sql existing roles get their leave keys
internal/platform/workflow/unavailable.go the Unavailable seam, passOver, ReroutedOn
internal/platform/workflow/unavailable_test.go HD-2 at the engine
internal/platform/hr/leave_days.go Days, proration
internal/platform/hr/leave_types.go kinds, names, leave types
internal/platform/hr/leave_rules.go rule sets, statutory floors
internal/platform/hr/leave_calendar.go work calendar, holidays, working days, settings
internal/platform/hr/leave_errors.go leave errors and constraint mapping
internal/platform/hr/leave_ledger.go entries, balances, accrual, adjustment
internal/platform/hr/leave_job.go hr_leave_grants and hr_leave_reroute
internal/platform/workflow/soleowner_committed_test.go only if Task 1 is not dropped
internal/platform/hr/leave_request.go the graph, requests, transitions, visibility, the binder
internal/platform/hr/leave_routing.go OnLeave (registered), the reroute log
internal/platform/hr/leave_reports.go leave taken, team calendar
internal/platform/hr/hrtest/leave.go leave fixtures
internal/platform/hr/{leave_schema,leave_setup,leave_ledger,leave_request,leave_routing,leave_reports,migration00842}_test.go tests
internal/modules/leave/{permissions,routes,http,errors,dto,setup_handlers,request_handlers,report_handlers}.go HTTP surface
internal/modules/leave/{routes,env,handlers,errors}_test.go, internal/app/modulekeys_test.go its tests; the nested-module-key drift test
web/src/features/leave/{api.ts,MyLeavePage.tsx,RequestDialog.tsx,RequestDrawer.tsx,ReasonDialog.tsx,TeamCalendarPage.tsx,LeaveAdminPage.tsx,TypeDialog.tsx,RulesDialog.tsx,AdjustDialog.tsx,routes.tsx} screens
web/src/features/leave/__tests__/leave.test.tsx screen tests
web/src/lib/i18n/locales/{en,ms,zh}/leave.json screen copy

Modify — internal/platform/workflow/{resolve,model}.go, internal/platform/orgstruct/superior.go, internal/platform/identity/member/permission.go, internal/platform/moduleinstall/{moduleinstall,moduleinstall_test}.go, internal/kernel/db/retention_test.go, db/rls_exempt.txt, constants.yaml (+ generated internal/kernel/vocab/vocab.gen.go, packages/contracts/vocab.ts), api/openapi.yaml (+ generated internal/gen/api/api.gen.go, web/src/lib/api/schema.d.ts), generated internal/gen/sqlc/models.go, internal/app/{app,routes_test}.go, cmd/api/{main,wiring_test}.go, cmd/worker/{main,exports,wiring_test}.go, cmd/{orgadmin,migrate}/{main,wiring_test}.go, web/src/app/{routes.ts,shell/nav.ts}, web/src/lib/i18n/locales/{en,ms,zh}/modules.json, docs/ROADMAP.md, docs/DEPLOY_NOTES.md, docs/LOCK_ORDER.md.


Task 1: The committed semantic — Phase 4 O1, carried over (drop if O1 or H2 landed it first)

Section titled “Task 1: The committed semantic — Phase 4 O1, carried over (drop if O1 or H2 landed it first)”

Phase 4 O1 is canonical; if O1’s Task 1 or H2’s Task 1 already landed, drop this task. Whichever of O1, H2 and H3 merges first wins; the other two drop theirs (Ruling H3-19). Check both halves:

  • git grep -n 'Committed Semantic = "committed"' internal/platform/posting/ — a hit means the semantic landed: skip Steps 1–2.
  • git grep -n 'posting.Committed' internal/platform/workflow/soleowner.go — a hit means the sole-owner half landed: skip Step 3.

Both hit ⇒ go straight to Task 2.

Files: exactly those of O1 Task 1 (renamed as below), plus internal/platform/workflow/soleowner.go and internal/platform/workflow/soleowner_committed_test.go.

Interfaces:

  • Consumes: nothing new.

  • Produces, identical to O1 Task 1 and relied on by Task 6: posting.Committed Semantic = "committed" (PostsToLedger() false); workflow.CommitmentKinds []string with O1’s four kinds; validator codes committed_count, posting_in_commitment, committed_not_commitment_kind; DB constraint workflow_state_semantic_known; OpenAPI Semantic/WorkflowSemantic include committed; semanticTone.ts has committed: 'green'. From O1 Task 5 Step 6b: soleOwnerEdge’s issue covers the edge into a committed state and its void the cancel out of one.

  • Step 1: Execute O1 Task 1 with these substitutions and no others

Execute docs/superpowers/plans/2026-09-30-phase-4-o1-chain-items.md → “Task 1: The committed semantic, end to end”, Steps 2–13 (every file, test, code block, sabotage and run command exactly as written there):

In O1 Task 1 In H3 Task 1
Step 1 (declare the Phase 4 migration ranges) Skip it: migrationranges_test.go belongs to Phase 4 and H1; H1 Task 1 already declared {840, 859, …}.
db/migrations/00700_committed_semantic.sql db/migrations/00840_committed_semantic.sql. The body is the same, with the header comment’s first line reading -- Phase 4 O1.1, carried by Phase 5 H3 Task 1 because H3 merged first (O1 Task 1 is canonical).
git add … db/migrations/00700_committed_semantic.sql internal/kernel/db/migrationranges_test.go … git add … db/migrations/00840_committed_semantic.sql … (no migrationranges_test.go)
commit subject Phase 4 O1: the committed semantic (approved to act on; nothing posts) H3 Task 1: the committed semantic (Phase 4 O1 Task 1, canonical) — the commit is made after Step 3 below
  • Step 2: Run O1 Task 1’s checks

Run: go test ./internal/platform/workflow/ ./internal/platform/posting/ ./internal/kernel/vocab/ ./internal/kernel/db/ -count=1 && pnpm -C web typecheck && pnpm -C web test -- semanticTone i18n schema Expected: PASS.

  • Step 3: Let the sole-owner exception cover committed (O1 Task 5 Step 6b, canonical)

Create internal/platform/workflow/soleowner_committed_test.go:

package workflow
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/posting"
)
// Phase 4 O1 Ruling O1-18, carried by Phase 5 H3 (Ruling H3-19): an opted-in
// kind's edge into a committed state is an issue the sole-owner exception
// covers, and its cancel out of one is a void; a kind that did not opt in
// gets neither.
func TestTheSoleOwnerExceptionCoversACommitment(t *testing.T) {
g := Graph{Kind: "zz_commitment_probe", States: []StateSpec{
{Key: "draft", Semantic: posting.Draft}, {Key: "pending", Semantic: posting.Pending},
{Key: "approved", Semantic: posting.Committed}, {Key: "cancelled", Semantic: posting.Cancelled, IsTerminal: true},
}}
def := Definition{Graph: g}
approve := EdgeSpec{From: "pending", To: "approved", Gate: GateTarget}
cancel := EdgeSpec{From: "approved", To: "cancelled", Gate: GateTarget, RequiresReason: true}
require.False(t, soleOwnerEdge(def, approve), "ABSENCE before the kind opts in")
AllowSoleOwnerException(g.Kind, true)
t.Cleanup(func() { soleOwnerMu.Lock(); delete(soleOwnerKinds, g.Kind); soleOwnerMu.Unlock() })
require.True(t, soleOwnerEdge(def, approve), "PRESENCE: the approval into committed is an issue")
require.True(t, soleOwnerEdge(def, cancel), "and the cancel out of it a void")
}

Run: go test ./internal/platform/workflow/ -run TestTheSoleOwnerExceptionCoversACommitment -count=1 Expected: FAIL — PRESENCE: the approval into committed is an issue: Should be true.

In internal/platform/workflow/soleowner.go, in soleOwnerEdge, replace

void := from.Semantic == posting.Accrual && to.Semantic == posting.Cancelled && e.ReversesAccrual
return (m.void && void) || (m.issue && to.Semantic == posting.Accrual) || (m.payout && to.Semantic == posting.Payout)

with

void := from.Semantic == posting.Accrual && to.Semantic == posting.Cancelled && e.ReversesAccrual
// Phase 4 O1 (Ruling O1-18): a commitment's approval and its cancel are
// a second person's decisions too; the cancel reverses nothing.
void = void || (from.Semantic == posting.Committed && to.Semantic == posting.Cancelled)
issue := to.Semantic == posting.Accrual || to.Semantic == posting.Committed
return (m.void && void) || (m.issue && issue) || (m.payout && to.Semantic == posting.Payout)

(Phase 4 O2 edits these same lines for effect; the merged form keeps every condition.)

Run: go test ./internal/platform/workflow/ -count=1 Expected: PASS.

  • Step 4: Commit
Terminal window
git add constants.yaml internal/kernel/vocab packages/contracts/vocab.ts internal/platform/posting \
internal/platform/workflow db/migrations/00840_committed_semantic.sql api/openapi.yaml internal/gen/api web/src
git commit -m "H3 Task 1: the committed semantic (Phase 4 O1 Task 1, canonical)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 2: The Unavailable seam — an approver on leave is passed over at resolution (HD-2)

Section titled “Task 2: The Unavailable seam — an approver on leave is passed over at resolution (HD-2)”

Files:

  • Create: internal/platform/workflow/unavailable.go, internal/platform/workflow/unavailable_test.go
  • Modify: internal/platform/workflow/resolve.go (Resolved, Resolve), internal/platform/orgstruct/superior.go (FilledSeatsAbove, NextFilledAbove)
  • Modify: api/openapi.yaml (WorkflowResolvedGrant), then make generate

Interfaces:

  • Consumes: orgstruct.GetPosition, orgstruct.GetUnit, orgstruct.PrimaryPositionOf (through primarySeat), the package’s activeOnly, orgOf.

  • Produces:

    • workflow.NoteApproverOnLeave = "approver_on_leave", workflow.NoteNoAlternate = "approver_on_leave_no_alternate"
    • type UnavailableFunc func(ctx context.Context, tx pgx.Tx, member uuid.UUID, day time.Time) (bool, error)
    • func RegisterUnavailable(f UnavailableFunc), func UnavailableRegistered() bool, func SetUnavailableForTest(t interface{ Cleanup(func()) }, f UnavailableFunc)
    • Resolved.Away []uuid.UUID (json:"away,omitempty"), Resolved.Note string (json:"note,omitempty") — persisted in workflow_decision.resolution
    • orgstruct.SeatHolders{PositionID uuid.UUID; Members []uuid.UUID}, orgstruct.FilledSeatsAbove(ctx context.Context, tx pgx.Tx, positionID uuid.UUID, maxLevels int, on time.Time) ([]SeatHolders, error)
  • Step 1: Write the failing engine tests

Create internal/platform/workflow/unavailable_test.go (package workflow_test; rsNewChart, rsResolve and rsGrant are resolve_test.go’s — CEO > HEAD > STAFF, HEAD heads the unit):

package workflow_test
import (
"context"
"slices"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/workflow"
)
// away answers "unavailable" for exactly these members, whatever the day.
func away(members ...uuid.UUID) workflow.UnavailableFunc {
return func(_ context.Context, _ pgx.Tx, m uuid.UUID, _ time.Time) (bool, error) {
return slices.Contains(members, m), nil
}
}
// Spec H3.4 / HD-2: a seat whose holder is unavailable on the day is passed
// over by the on_vacancy: escalate walk to the next filled seat above, and
// the resolution says so. No delegation is created.
func TestAnApproverOnLeaveIsPassedOverUpTheTree(t *testing.T) {
c := rsNewChart(t)
superior := rsGrant(workflow.GranteeSuperior)
seat := rsGrant(workflow.GranteePosition)
seat.PositionID = &c.headSeat
// PRESENCE: with nobody away, both grants resolve to the head.
r := rsResolve(t, c.w, c.staff, superior, seat)
require.Equal(t, []uuid.UUID{c.head}, r[0].Members)
require.Equal(t, []uuid.UUID{c.head}, r[1].Members)
require.Empty(t, r[0].Note)
require.False(t, workflow.UnavailableRegistered(), "this test binary links no hr")
workflow.SetUnavailableForTest(t, away(c.head))
require.True(t, workflow.UnavailableRegistered())
r = rsResolve(t, c.w, c.staff, superior, seat)
for i, got := range r {
require.Equal(t, []uuid.UUID{c.ceo}, got.Members, "grant %d: the next filled seat above", i)
require.True(t, got.Escalated, "grant %d", i)
require.Equal(t, []uuid.UUID{c.head}, got.Away, "grant %d", i)
require.Equal(t, workflow.NoteApproverOnLeave, got.Note, "grant %d", i)
require.Nil(t, got.Gap, "grant %d", i)
}
}
// A seat held by two people: the one who is not away decides, without
// escalating.
func TestTheOtherHolderOfTheSeatDecides(t *testing.T) {
c := rsNewChart(t)
deputy := c.w.Member("Deputy")
c.w.SitSecondary(deputy, c.headSeat)
seat := rsGrant(workflow.GranteePosition)
seat.PositionID = &c.headSeat
require.ElementsMatch(t, []uuid.UUID{c.head, deputy}, rsResolve(t, c.w, c.staff, seat)[0].Members, "PRESENCE: both hold it")
workflow.SetUnavailableForTest(t, away(c.head))
r := rsResolve(t, c.w, c.staff, seat)[0]
require.Equal(t, []uuid.UUID{deputy}, r.Members)
require.False(t, r.Escalated)
require.Equal(t, workflow.NoteApproverOnLeave, r.Note)
}
// Spec H3.4 "Nobody above is available": the step stays with the person on
// leave, flagged, and is never refused. The same for a grant that does not
// escalate and for a grant naming a person.
func TestWithNobodyAvailableAboveTheStepStaysAndIsFlagged(t *testing.T) {
c := rsNewChart(t)
superior := rsGrant(workflow.GranteeSuperior)
refuse := rsGrant(workflow.GranteeSuperior)
refuse.OnVacancy = workflow.VacancyRefuse
named := rsGrant(workflow.GranteeMember)
named.MemberID = &c.head
workflow.SetUnavailableForTest(t, away(c.head, c.ceo))
for i, r := range rsResolve(t, c.w, c.staff, superior, refuse, named) {
require.Equal(t, []uuid.UUID{c.head}, r.Members, "grant %d stays with the approver on leave", i)
require.Equal(t, workflow.NoteNoAlternate, r.Note, "grant %d is flagged", i)
require.Equal(t, []uuid.UUID{c.head}, r.Away, "grant %d", i)
require.Nil(t, r.Gap, "grant %d: a flag, never a gap", i)
}
}
  • Step 2: Run them to verify they fail

Run: go test ./internal/platform/workflow/ -run 'TestAnApproverOnLeave|TestTheOtherHolder|TestWithNobodyAvailable' -count=1 Expected: FAIL to compile — undefined: workflow.UnavailableFunc, workflow.SetUnavailableForTest, r[0].Note.

  • Step 3: Add the seat walk to orgstruct

In internal/platform/orgstruct/superior.go, replace the body of NextFilledAbove — from the line if maxLevels < 1 { through its closing return nil, nil and } — with:

seats, err := FilledSeatsAbove(ctx, tx, positionID, maxLevels, on)
if err != nil || len(seats) == 0 {
return nil, err
}
return seats[0].Members, nil
}
// SeatHolders is one seat above a starting seat and its active holders.
type SeatHolders struct {
PositionID uuid.UUID
Members []uuid.UUID
}
// FilledSeatsAbove is NextFilledAbove's walk, kept whole: every filled seat
// above positionID within maxLevels, nearest first, each with its active
// holders. NextFilledAbove answers the first; workflow's HD-2 walk (Phase 5
// H3) goes on past a seat whose holders are all on leave. The same rules
// hold: maxLevels < 1 is ErrNonPositiveEscalationBound; the bound counts
// levels of the tree, abolished seats included; the starting seat is never
// a candidate; ONE read for the whole ancestry and ONE for every holder.
func FilledSeatsAbove(ctx context.Context, tx pgx.Tx, positionID uuid.UUID, maxLevels int, on time.Time) ([]SeatHolders, error) {
if maxLevels < 1 {
return nil, ErrNonPositiveEscalationBound
}
seat, err := GetPosition(ctx, tx, positionID)
if err != nil {
return nil, err
}
candidates := ancestorsNearestFirst(seat.Path, seat.ID)
if len(candidates) > maxLevels {
candidates = candidates[:maxLevels]
}
if len(candidates) == 0 {
return nil, nil
}
holders, err := holdersOfLiveSeats(ctx, tx, candidates, on)
if err != nil {
return nil, err
}
var out []SeatHolders
for _, id := range candidates {
if h := holders[id]; len(h) > 0 {
out = append(out, SeatHolders{PositionID: id, Members: h})
}
}
return out, nil
}

(NextFilledAbove’s long doc comment stays as it is: it still describes the walk, now performed by FilledSeatsAbove.)

  • Step 4: Write the seam

Create internal/platform/workflow/unavailable.go:

package workflow
import (
"context"
"fmt"
"slices"
"sync"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/platform/orgstruct"
)
// The notes a Resolved carries when an approver was unavailable on the day
// it was resolved for (Phase 5 H3.4, HD-2). Resolve's output is what a
// decision persists in workflow_decision.resolution, so an approval that was
// routed around somebody's leave explains itself years later.
const (
// NoteApproverOnLeave: somebody resolved was passed over and somebody
// available decides instead.
NoteApproverOnLeave = "approver_on_leave"
// NoteNoAlternate: everybody resolved was unavailable and nobody above
// was available, so the step stays with them, flagged. Never refused: a
// claimant is not blocked by someone else's holiday.
NoteNoAlternate = "approver_on_leave_no_alternate"
)
// UnavailableFunc reports whether member cannot decide anything on day. HR
// answers "on approved leave that day" (Phase 5 H3.4). workflow may not
// import hr, so hr registers its answer from init() — the seam accounting's
// Poster uses (posting.Register). It returns an error because the answer
// is a database read.
type UnavailableFunc func(ctx context.Context, tx pgx.Tx, member uuid.UUID, day time.Time) (bool, error)
var (
unavailableMu sync.RWMutex
unavailable UnavailableFunc
)
// RegisterUnavailable installs the process's answer. It panics on nil and on
// a second registration: two answers in one binary is a wiring bug.
func RegisterUnavailable(f UnavailableFunc) {
if f == nil {
panic("workflow: RegisterUnavailable(nil)")
}
unavailableMu.Lock()
defer unavailableMu.Unlock()
if unavailable != nil {
panic("workflow: RegisterUnavailable called twice")
}
unavailable = f
}
// UnavailableRegistered reports whether this binary knows who is on leave
// (cmd/worker's wiring test asks it).
func UnavailableRegistered() bool {
unavailableMu.RLock()
defer unavailableMu.RUnlock()
return unavailable != nil
}
// SetUnavailableForTest replaces the registered answer until the test ends.
// Tests only.
func SetUnavailableForTest(t interface{ Cleanup(func()) }, f UnavailableFunc) {
unavailableMu.Lock()
prev := unavailable
unavailable = f
unavailableMu.Unlock()
t.Cleanup(func() {
unavailableMu.Lock()
unavailable = prev
unavailableMu.Unlock()
})
}
func registeredUnavailable() UnavailableFunc {
unavailableMu.RLock()
defer unavailableMu.RUnlock()
return unavailable
}
// passOver is HD-2 applied to one grant's resolved members on day. Members
// unavailable that day are passed over. When some remain, they decide. When
// none remain and the grant escalates, the walk goes up from the grant's
// seat — the existing on_vacancy: escalate walk, within the same bound — to
// the first seat with an active holder who is available and is not the
// claimant. Otherwise the step stays with those resolved, flagged
// NoteNoAlternate. No delegation is created and no authority moves.
func passOver(ctx context.Context, tx pgx.Tx, org uuid.UUID, g Grant, reporter uuid.UUID, on time.Time,
members []uuid.UUID) (out, away []uuid.UUID, note string, escalated bool, err error) {
f := registeredUnavailable()
if f == nil || len(members) == 0 {
return members, nil, "", false, nil
}
here, away, err := splitAvailable(ctx, tx, f, members, on)
if err != nil {
return nil, nil, "", false, err
}
switch {
case len(away) == 0:
return members, nil, "", false, nil
case len(here) > 0:
return here, away, NoteApproverOnLeave, false, nil
}
if g.GranteeKind != GranteeMember && g.OnVacancy == VacancyEscalate && g.MaxEscalationLevels > 0 {
start, ok, err := grantSeat(ctx, tx, g, reporter, on)
if err != nil {
return nil, nil, "", false, err
}
if ok {
seats, err := orgstruct.FilledSeatsAbove(ctx, tx, start, g.MaxEscalationLevels, on)
if err != nil {
return nil, nil, "", false, err
}
for _, s := range seats {
live, err := activeOnly(ctx, tx, org, s.Members)
if err != nil {
return nil, nil, "", false, err
}
live = slices.DeleteFunc(live, func(m uuid.UUID) bool { return m == reporter })
avail, _, err := splitAvailable(ctx, tx, f, live, on)
if err != nil {
return nil, nil, "", false, err
}
if len(avail) > 0 {
return avail, away, NoteApproverOnLeave, true, nil
}
}
}
}
return members, away, NoteNoAlternate, false, nil
}
func splitAvailable(ctx context.Context, tx pgx.Tx, f UnavailableFunc, members []uuid.UUID, on time.Time) (here, away []uuid.UUID, err error) {
for _, m := range members {
out, err := f(ctx, tx, m, on)
if err != nil {
return nil, nil, fmt.Errorf("workflow: unavailable: %w", err)
}
if out {
away = append(away, m)
} else {
here = append(here, m)
}
}
return here, away, nil
}
// grantSeat is the seat an escalating grant stands on, where the HD-2 walk
// starts: the named seat; the unit head's seat, or the seat levels_up above
// the claimant's; for superior, the claimant's own seat — its superior is
// the first seat above it, which is also where resolveOne's vacant-superior
// walk starts. false when there is no such seat (no primary seat, no unit
// head, the top of the tree).
func grantSeat(ctx context.Context, tx pgx.Tx, g Grant, reporter uuid.UUID, on time.Time) (uuid.UUID, bool, error) {
if g.GranteeKind == GranteePosition {
return *g.PositionID, true, nil
}
pos, gap, err := primarySeat(ctx, tx, reporter, on)
if gap != nil || err != nil {
return uuid.Nil, false, err
}
switch g.GranteeKind {
case GranteeSuperior:
return pos.ID, true, nil
case GranteeUnitHead:
unit, err := orgstruct.GetUnit(ctx, tx, pos.OrgUnitID)
if err != nil || unit.UnitHeadPositionID == nil {
return uuid.Nil, false, err
}
return *unit.UnitHeadPositionID, true, nil
case GranteeLevelsUp:
for i := 0; i < *g.LevelsUp; i++ {
if pos.ReportsToPositionID == nil {
return uuid.Nil, false, nil
}
if pos, err = orgstruct.GetPosition(ctx, tx, *pos.ReportsToPositionID); err != nil {
return uuid.Nil, false, err
}
}
return pos.ID, true, nil
}
return uuid.Nil, false, nil
}
  • Step 5: Call it from Resolve

In internal/platform/workflow/resolve.go, in type Resolved struct, replace

Escalated bool `json:"escalated"`
Gap *Gap `json:"gap,omitempty"`

with

Escalated bool `json:"escalated"`
Gap *Gap `json:"gap,omitempty"`
// Away is who this grant resolved to but was unavailable on the day
// (Phase 5 H3.4, HD-2: an approver on leave); Note says what routing
// did about it (NoteApproverOnLeave, NoteNoAlternate). Both empty when
// nobody was away.
Away []uuid.UUID `json:"away,omitempty"`
Note string `json:"note,omitempty"`

In Resolve, insert immediately above the line // Holders that all turned out to have left: name it, never return an:

// HD-2 (Phase 5 H3.4): an approver unavailable on the day is passed
// over, after the self case so a walk never lands on the claimant.
away, note, passed := []uuid.UUID(nil), "", false
if members, away, note, passed, err = passOver(ctx, tx, org, g, reporter, on, members); err != nil {
return nil, fmt.Errorf("workflow: resolve grant %s: %w", g.ID, err)
}
escalated = escalated || passed

and replace

r.Members, r.Escalated, r.Gap = slices.Compact(members), escalated, gap

with

r.Members, r.Escalated, r.Gap = slices.Compact(members), escalated, gap
r.Away, r.Note = away, note
  • Step 6: Document the two fields

In api/openapi.yaml, in WorkflowResolvedGrant’s properties, add after signoffs: { type: array, items: { type: string, format: uuid } }:

away:
type: array
items: { type: string, format: uuid }
description: Who this grant resolved to but was unavailable on the day (on approved leave, HD-2).
note:
type: string
enum: [approver_on_leave, approver_on_leave_no_alternate]
description: >
approver_on_leave — someone was passed over and someone available
decides; approver_on_leave_no_alternate — nobody above was
available, so the step stays with the person on leave. Absent when
nobody was away.

Run: make generate Expected: internal/gen/api/api.gen.go and web/src/lib/api/schema.d.ts gain the two optional fields.

  • Step 7: Run the tests

Run: go test ./internal/platform/workflow/... ./internal/platform/orgstruct/ ./internal/gen/ -count=1 Expected: PASS — the three new tests, and every existing resolve, escalation and NextFilledAbove test unchanged (nothing is registered in these binaries, so passOver returns its input).

  • Step 8: Watch each guard fail once

In passOver, replace return avail, away, NoteApproverOnLeave, true, nil with continue. Run go test ./internal/platform/workflow/ -run TestAnApproverOnLeaveIsPassedOverUpTheTree -count=1. Expected: FAIL — grant 0: the next filled seat above: expected [<ceo>], actual [<head>]. Restore.

In passOver, replace the last line return members, away, NoteNoAlternate, false, nil with return nil, away, NoteNoAlternate, false, nil. Run go test ./internal/platform/workflow/ -run TestWithNobodyAvailableAboveTheStepStaysAndIsFlagged -count=1. Expected: FAIL — grant 0 stays with the approver on leave: expected [<head>], actual []. Restore; PASS.

  • Step 9: Commit
Terminal window
git add internal/platform/workflow internal/platform/orgstruct/superior.go api/openapi.yaml internal/gen/api web/src/lib/api
git commit -m "Phase 5 H3: workflow's Unavailable seam — an approver on leave is passed over (HD-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 3: The leave schema (00841) and the statutory-floor drift test

Section titled “Task 3: The leave schema (00841) and the statutory-floor drift test”

Files:

  • Create: db/migrations/00841_leave.sql, internal/platform/hr/leave_schema_test.go
  • Modify: internal/kernel/db/retention_test.go, db/rls_exempt.txt
  • Regenerate: internal/gen/sqlc/models.go (make generate)

Interfaces:

  • Consumes: H1’s employee (employee_tenant_id), organization_member, workflow_instance, document, translation.

  • Produces: tables leave_statutory_floor (global, seeded), leave_type, leave_rule, work_calendar, leave_setting, public_holiday, leave_request, leave_ledger, leave_routing_move; SQL functions leave_semantic(p_org uuid, p_instance uuid) RETURNS text, leave_balance(p_org uuid, p_employee uuid, p_type uuid, p_year int) RETURNS numeric; the constraint names Go maps: leave_type_code_once, leave_type_fixed, leave_rule_below_floor, leave_rule_bands_disjoint, public_holiday_once, leave_request_instance_once, leave_ledger_grant_once, leave_ledger_carry_once, leave_ledger_expire_once, leave_ledger_take_once, leave_ledger_reverse_once, leave_ledger_take_matches, leave_ledger_reverse_matches, leave_balance_insufficient, leave_take_by_requester, leave_adjust_below_floor, leave_routing_move_once.

  • Step 1: Write the failing schema tests

Create internal/platform/hr/leave_schema_test.go:

package hr_test
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/expenses/expensestest"
)
func leaveTypeRow(t *testing.T, e *expensestest.Env, code, kind string, paid bool) uuid.UUID {
t.Helper()
var id uuid.UUID
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
return tx.QueryRow(ctx, `INSERT INTO leave_type (org_id, code, kind, paid, attachment_required, created_at, updated_at)
VALUES ($1, $2, $3, $4, $3 IN ('sick', 'hospitalisation'), now(), now()) RETURNING id`,
e.Org, code, kind, paid).Scan(&id)
})
return id
}
// Spec H3 drift bullet: "A rule below the statutory floor anywhere in its
// band is refused, including a band that spans two floor bands" (HD-3) — in
// the database, whatever the Go above it does.
func TestARuleBelowTheStatutoryFloorIsRefusedAnywhereInItsBand(t *testing.T) {
e := expensestest.New(t)
annual := leaveTypeRow(t, e, "AL", "annual", true)
study := leaveTypeRow(t, e, "STUDY", "other", true)
rule := func(typ uuid.UUID, effective string, from int, to *int, days string) error {
return e.TxErr(func(ctx context.Context, tx pgx.Tx) error {
_, err := tx.Exec(ctx, `INSERT INTO leave_rule (org_id, leave_type_id, effective_from, from_month, to_month,
days, created_by, created_at) VALUES ($1, $2, $3::date, $4, $5, $6::numeric, $7, now())`,
e.Org, typ, effective, from, to, days, e.Owner)
return err
})
}
m := func(n int) *int { return &n }
// PRESENCE: at the floor, each band is accepted (8 / 12 / 16).
require.NoError(t, rule(annual, "2026-01-01", 0, m(24), "8"))
require.NoError(t, rule(annual, "2026-01-01", 24, m(60), "12"))
require.NoError(t, rule(annual, "2026-01-01", 60, nil, "16"))
// ABSENCE: under the floor of its own band…
require.ErrorContains(t, rule(annual, "2027-01-01", 0, m(24), "7.5"), "leave_rule_below_floor")
require.ErrorContains(t, rule(annual, "2027-01-01", 60, nil, "15.5"), "leave_rule_below_floor")
// …and the spec's example: 0–36 months at 10 days breaks the 12-day floor
// from month 24, though it clears the first band's 8.
require.ErrorContains(t, rule(annual, "2027-01-01", 0, m(36), "10"), "leave_rule_below_floor")
// The same spanning band at the higher floor is accepted.
require.NoError(t, rule(annual, "2027-01-01", 0, m(36), "12"))
// A kind with no floor takes any days.
require.NoError(t, rule(study, "2026-01-01", 0, nil, "0"))
// One schedule per type and date: overlapping bands are refused.
require.ErrorContains(t, rule(annual, "2026-01-01", 10, m(30), "20"), "leave_rule_bands_disjoint")
}
// The floor is global and changed only by migration; the rules, the ledger,
// the requests and the routing log are append-only for the app role.
func TestTheLeaveRecordsAreAppendOnly(t *testing.T) {
e := expensestest.New(t)
var floors int
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
return tx.QueryRow(ctx, `SELECT count(*) FROM leave_statutory_floor`).Scan(&floors)
})
require.Equal(t, 9, floors, "PRESENCE: the seeded floors are readable by every tenant")
for _, stmt := range []string{
`INSERT INTO leave_statutory_floor (kind, from_month, days) VALUES ('annual', 120, 20)`,
`UPDATE leave_statutory_floor SET days = 1`,
`UPDATE leave_rule SET days = 1`,
`DELETE FROM leave_rule`,
`UPDATE leave_ledger SET days = 1`,
`DELETE FROM leave_ledger`,
`UPDATE leave_request SET days = 1`,
`DELETE FROM leave_request`,
`DELETE FROM leave_type`,
`DELETE FROM public_holiday`,
`DELETE FROM leave_routing_move`,
} {
err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error {
_, err := tx.Exec(ctx, stmt)
return err
})
require.ErrorContains(t, err, "permission denied", stmt)
}
}
// One grant per employee, type and year, whatever writes it (Ruling H3-6),
// and a statutory type's kind and pay never change once created.
func TestAGrantIsOncePerEmployeeTypeAndYear(t *testing.T) {
e := expensestest.New(t)
annual := leaveTypeRow(t, e, "AL", "annual", true)
var party, emp uuid.UUID
require.NoError(t, e.H.Admin.QueryRow(context.Background(),
`SELECT party_id FROM organization_member WHERE id = $1`, e.Staff).Scan(&party))
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
return tx.QueryRow(ctx, `INSERT INTO employee (org_id, party_id, employee_no, created_at, updated_at)
VALUES ($1, $2, 'E-1', now(), now()) RETURNING id`, e.Org, party).Scan(&emp)
})
grant := func(year int) error {
return e.TxErr(func(ctx context.Context, tx pgx.Tx) error {
_, err := tx.Exec(ctx, `INSERT INTO leave_ledger (org_id, employee_id, leave_type_id, leave_year, kind, days,
floor_days, recorded_at) VALUES ($1, $2, $3, $4, 'grant', 8, 8, now())`, e.Org, emp, annual, year)
return err
})
}
require.NoError(t, grant(2026), "PRESENCE: the first grant of the year")
require.ErrorContains(t, grant(2026), "leave_ledger_grant_once")
require.NoError(t, grant(2027), "another year is another grant")
err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error {
_, err := tx.Exec(ctx, `UPDATE leave_type SET kind = 'other' WHERE id = $1`, annual)
return err
})
require.ErrorContains(t, err, "leave_type_fixed")
}
  • Step 2: Run them to verify they fail

Run: go test ./internal/platform/hr/ -run 'TestARuleBelowTheStatutoryFloor|TestTheLeaveRecordsAreAppendOnly|TestAGrantIsOnce' -count=1 Expected: FAIL — relation "leave_type" does not exist.

  • Step 3: Write the migration

Create db/migrations/00841_leave.sql:

-- +goose Up
-- Phase 5 H3 (docs/superpowers/specs/2026-09-30-hr-layer-design.md §H3;
-- HD-2, HD-3, HD-7, HD-8): leave types over a statutory floor, effective-
-- dated entitlement rules, an append-only ledger whose sums are the
-- balances, requests decided through the leave_request workflow, the
-- working-day calendar, and the log of approvals routed around leave.
-- Every leave table is retention-bearing: none grants DELETE (spec H3.5).
-- The Employment Act minimums (HD-3). Global, identical for every tenant and
-- changed only by migration, so it has no org_id (db/rls_exempt.txt).
-- THE FIGURES ARE UNVERIFIED: recalled from the 2022 Employment Act 1955
-- amendments, not checked against the Act. An HR or legal adviser must
-- confirm them, and which employees they cover (some provisions apply only
-- below a wage threshold) — docs/ROADMAP.md, "Needs an outside authority".
-- Bands are half-open ranges of whole service months (Ruling H3-4).
CREATE TABLE leave_statutory_floor (
kind TEXT NOT NULL CONSTRAINT leave_statutory_floor_kind_known
CHECK (kind IN ('annual', 'sick', 'hospitalisation', 'maternity', 'paternity')),
from_month INT NOT NULL CONSTRAINT leave_statutory_floor_from_month_nonnegative CHECK (from_month >= 0),
to_month INT CONSTRAINT leave_statutory_floor_band_order CHECK (to_month IS NULL OR to_month > from_month),
days NUMERIC(5,1) NOT NULL CONSTRAINT leave_statutory_floor_days_positive CHECK (days > 0),
CONSTRAINT leave_statutory_floor_pkey PRIMARY KEY (kind, from_month)
);
REVOKE INSERT, UPDATE, DELETE ON leave_statutory_floor FROM erp_app;
INSERT INTO leave_statutory_floor (kind, from_month, to_month, days) VALUES
('annual', 0, 24, 8), ('annual', 24, 60, 12), ('annual', 60, NULL, 16),
('sick', 0, 24, 14), ('sick', 24, 60, 18), ('sick', 60, NULL, 22),
('hospitalisation', 0, NULL, 60),
('maternity', 0, NULL, 98), -- consecutive calendar days
('paternity', 0, NULL, 7); -- consecutive calendar days
-- A leave type (spec H3.1). Its name is translation rows (entity_kind
-- leave_type). Code, kind and pay are fixed once created (leave_type_fixed):
-- a grant and a take made under one reading must not change meaning. Sick
-- and hospitalisation leave need a medical certificate.
CREATE TABLE leave_type (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organization (id),
code TEXT NOT NULL CONSTRAINT leave_type_code_shape CHECK (code ~ '^[A-Z][A-Z0-9_]{0,19}$'),
kind TEXT NOT NULL CONSTRAINT leave_type_kind_known
CHECK (kind IN ('annual', 'sick', 'hospitalisation', 'maternity', 'paternity', 'unpaid', 'other')),
paid BOOLEAN NOT NULL,
half_days BOOLEAN NOT NULL DEFAULT false,
attachment_required BOOLEAN NOT NULL DEFAULT false,
active BOOLEAN NOT NULL DEFAULT true,
revision INT NOT NULL DEFAULT 1 CONSTRAINT leave_type_revision_positive CHECK (revision >= 1),
created_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
CONSTRAINT leave_type_tenant_id UNIQUE (org_id, id),
CONSTRAINT leave_type_code_once UNIQUE (org_id, code),
CONSTRAINT leave_type_pay_by_kind CHECK (
(kind <> 'unpaid' OR NOT paid)
AND (kind NOT IN ('annual', 'sick', 'hospitalisation', 'maternity', 'paternity') OR paid)),
CONSTRAINT leave_type_attachment_for_medical CHECK (kind NOT IN ('sick', 'hospitalisation') OR attachment_required),
CONSTRAINT leave_type_half_days_working CHECK (NOT half_days OR kind NOT IN ('maternity', 'paternity'))
);
ALTER TABLE leave_type ENABLE ROW LEVEL SECURITY;
ALTER TABLE leave_type FORCE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON leave_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 leave_type FROM erp_app;
-- +goose StatementBegin
CREATE FUNCTION leave_type_fixed() RETURNS trigger
LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$
BEGIN
IF NEW.code IS DISTINCT FROM OLD.code OR NEW.kind IS DISTINCT FROM OLD.kind OR NEW.paid IS DISTINCT FROM OLD.paid THEN
RAISE EXCEPTION 'leave type %: code, kind and pay never change', OLD.id
USING ERRCODE = '23514', CONSTRAINT = 'leave_type_fixed';
END IF;
RETURN NEW;
END $$;
-- +goose StatementEnd
CREATE TRIGGER leave_type_fixed BEFORE UPDATE ON leave_type FOR EACH ROW EXECUTE FUNCTION leave_type_fixed();
-- Entitlement (spec H3.1): days per year for a band of whole service months,
-- effective-dated (HD-8): a set applies to grants made on or after its date,
-- and a change is a later set, never an edit. Append-only.
CREATE TABLE leave_rule (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organization (id),
leave_type_id UUID NOT NULL,
effective_from DATE NOT NULL,
from_month INT NOT NULL CONSTRAINT leave_rule_from_month_nonnegative CHECK (from_month >= 0),
to_month INT CONSTRAINT leave_rule_band_order CHECK (to_month IS NULL OR to_month > from_month),
days NUMERIC(5,1) NOT NULL CONSTRAINT leave_rule_days_shape CHECK (days >= 0 AND days * 2 = trunc(days * 2)),
created_by UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
CONSTRAINT leave_rule_tenant_id UNIQUE (org_id, id),
CONSTRAINT leave_rule_type_fk FOREIGN KEY (org_id, leave_type_id) REFERENCES leave_type (org_id, id),
CONSTRAINT leave_rule_created_by_fk FOREIGN KEY (org_id, created_by) REFERENCES organization_member (org_id, id),
CONSTRAINT leave_rule_bands_disjoint EXCLUDE USING gist (
org_id WITH =, leave_type_id WITH =, effective_from WITH =, int4range(from_month, to_month) WITH &&)
);
CREATE INDEX leave_rule_by_type ON leave_rule (org_id, leave_type_id, effective_from);
ALTER TABLE leave_rule ENABLE ROW LEVEL SECURITY;
ALTER TABLE leave_rule FORCE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON leave_rule
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 leave_rule FROM erp_app;
-- HD-3: a rule for a statutory kind is refused if any service month in its
-- band has fewer days than the floor — checked against the HIGHEST floor
-- whose band overlaps the rule's (spec H3.1).
-- +goose StatementBegin
CREATE FUNCTION leave_rule_floor_guard() RETURNS trigger
LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$
DECLARE
k TEXT;
f NUMERIC;
BEGIN
SELECT t.kind INTO k FROM public.leave_type t WHERE t.org_id = NEW.org_id AND t.id = NEW.leave_type_id;
SELECT max(s.days) INTO f FROM public.leave_statutory_floor s
WHERE s.kind = k AND int4range(s.from_month, s.to_month) && int4range(NEW.from_month, NEW.to_month);
IF f IS NOT NULL AND NEW.days < f THEN
RAISE EXCEPTION 'months % to %: % days is below the statutory floor of % for % leave',
NEW.from_month, coalesce(NEW.to_month::text, 'onwards'), NEW.days, f, k
USING ERRCODE = '23514', CONSTRAINT = 'leave_rule_below_floor';
END IF;
RETURN NEW;
END $$;
-- +goose StatementEnd
CREATE TRIGGER leave_rule_floor_guard BEFORE INSERT ON leave_rule
FOR EACH ROW EXECUTE FUNCTION leave_rule_floor_guard();
-- The working week (spec H3.3), Monday to Friday when there is no row. state
-- is the organization's (ISO 3166-2:MY) for state public holidays.
CREATE TABLE work_calendar (
org_id UUID PRIMARY KEY REFERENCES organization (id),
weekdays SMALLINT[] NOT NULL CONSTRAINT work_calendar_weekdays_valid
CHECK (cardinality(weekdays) BETWEEN 1 AND 7 AND weekdays <@ ARRAY[1, 2, 3, 4, 5, 6, 7]::smallint[]),
state TEXT CONSTRAINT work_calendar_state_shape CHECK (state ~ '^MY-(0[1-9]|1[0-6])$'),
updated_by UUID NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
CONSTRAINT work_calendar_updated_by_fk FOREIGN KEY (org_id, updated_by) REFERENCES organization_member (org_id, id)
);
ALTER TABLE work_calendar ENABLE ROW LEVEL SECURITY;
ALTER TABLE work_calendar FORCE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON work_calendar
USING (org_id = current_setting('app.current_org', true)::uuid)
WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);
REVOKE DELETE ON work_calendar FROM erp_app;
-- Carry-forward (spec H3.2): an organization setting, default none (no row
-- or a zero maximum: unused annual leave lapses at year end).
CREATE TABLE leave_setting (
org_id UUID PRIMARY KEY REFERENCES organization (id),
carry_forward_max NUMERIC(5,1) NOT NULL DEFAULT 0 CONSTRAINT leave_setting_carry_forward_max_shape
CHECK (carry_forward_max >= 0 AND carry_forward_max * 2 = trunc(carry_forward_max * 2)),
carry_forward_expiry_months INT CONSTRAINT leave_setting_expiry_range CHECK (carry_forward_expiry_months BETWEEN 1 AND 12),
updated_by UUID NOT NULL,
updated_at TIMESTAMPTZ NOT NULL,
CONSTRAINT leave_setting_updated_by_fk FOREIGN KEY (org_id, updated_by) REFERENCES organization_member (org_id, id)
);
ALTER TABLE leave_setting ENABLE ROW LEVEL SECURITY;
ALTER TABLE leave_setting FORCE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON leave_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 leave_setting FROM erp_app;
-- Public holidays (spec H3.3): NOT seeded — gazetted dates change every year
-- and vary by state; the Owner enters them. The name is translation rows
-- (entity_kind public_holiday). state NULL is national. Retired, never
-- deleted.
CREATE TABLE public_holiday (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organization (id),
holiday_date DATE NOT NULL,
state TEXT CONSTRAINT public_holiday_state_shape CHECK (state ~ '^MY-(0[1-9]|1[0-6])$'),
active BOOLEAN NOT NULL DEFAULT true,
created_by UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
retired_by UUID,
retired_at TIMESTAMPTZ,
CONSTRAINT public_holiday_tenant_id UNIQUE (org_id, id),
CONSTRAINT public_holiday_retired_shape CHECK (active = (retired_at IS NULL) AND (retired_at IS NULL) = (retired_by IS NULL)),
CONSTRAINT public_holiday_created_by_fk FOREIGN KEY (org_id, created_by) REFERENCES organization_member (org_id, id),
CONSTRAINT public_holiday_retired_by_fk FOREIGN KEY (org_id, retired_by) REFERENCES organization_member (org_id, id)
);
CREATE UNIQUE INDEX public_holiday_once ON public_holiday (org_id, holiday_date, coalesce(state, '')) WHERE active;
ALTER TABLE public_holiday ENABLE ROW LEVEL SECURITY;
ALTER TABLE public_holiday FORCE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON public_holiday
USING (org_id = current_setting('app.current_org', true)::uuid)
WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);
REVOKE DELETE ON public_holiday FROM erp_app;
-- A leave request (spec H3.3), decided through its workflow instance. Its
-- terms are fixed when it is created: append-only (Ruling H3-10). days is
-- the working days at creation, in half days.
CREATE TABLE leave_request (
id UUID PRIMARY KEY,
org_id UUID NOT NULL REFERENCES organization (id),
employee_id UUID NOT NULL,
leave_type_id UUID NOT NULL,
from_date DATE NOT NULL,
to_date DATE NOT NULL,
from_half BOOLEAN NOT NULL DEFAULT false,
to_half BOOLEAN NOT NULL DEFAULT false,
days NUMERIC(6,1) NOT NULL CONSTRAINT leave_request_days_shape CHECK (days > 0 AND days * 2 = trunc(days * 2)),
leave_year INT NOT NULL,
reason TEXT NOT NULL DEFAULT '' CONSTRAINT leave_request_reason_shape
CHECK (reason = btrim(reason) AND length(reason) <= 500
AND reason !~ '[぀-ヿ㐀-䶿一-鿿가-힯]'),
attachment_document_id UUID,
instance_id UUID NOT NULL,
requested_by UUID NOT NULL,
created_at TIMESTAMPTZ NOT NULL,
CONSTRAINT leave_request_tenant_id UNIQUE (org_id, id),
CONSTRAINT leave_request_instance_once UNIQUE (org_id, instance_id),
CONSTRAINT leave_request_dates_order CHECK (to_date >= from_date),
CONSTRAINT leave_request_one_year CHECK (leave_year = extract(year FROM from_date) AND leave_year = extract(year FROM to_date)),
CONSTRAINT leave_request_halves CHECK (NOT (from_half AND to_half AND from_date = to_date)),
CONSTRAINT leave_request_employee_fk FOREIGN KEY (org_id, employee_id) REFERENCES employee (org_id, id),
CONSTRAINT leave_request_type_fk FOREIGN KEY (org_id, leave_type_id) REFERENCES leave_type (org_id, id),
CONSTRAINT leave_request_attachment_fk FOREIGN KEY (org_id, attachment_document_id) REFERENCES document (org_id, id),
CONSTRAINT leave_request_instance_fk FOREIGN KEY (org_id, instance_id) REFERENCES workflow_instance (org_id, id),
CONSTRAINT leave_request_requested_by_fk FOREIGN KEY (org_id, requested_by) REFERENCES organization_member (org_id, id)
);
CREATE INDEX leave_request_by_employee ON leave_request (org_id, employee_id, from_date);
CREATE INDEX leave_request_by_dates ON leave_request (org_id, from_date, to_date);
CREATE INDEX leave_request_by_attachment ON leave_request (org_id, attachment_document_id) WHERE attachment_document_id IS NOT NULL;
ALTER TABLE leave_request ENABLE ROW LEVEL SECURITY;
ALTER TABLE leave_request FORCE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON leave_request
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 leave_request FROM erp_app;
-- The state of a leave request's instance, by semantic (never by state name).
-- +goose StatementBegin
CREATE FUNCTION leave_semantic(p_org UUID, p_instance UUID) RETURNS TEXT
LANGUAGE sql STABLE SET search_path = pg_catalog, public, pg_temp AS $$
SELECT s.semantic
FROM public.workflow_instance i
JOIN public.workflow_state s ON s.org_id = i.org_id AND s.definition_id = i.definition_id AND s.state_key = i.state_key
WHERE i.org_id = p_org AND i.id = p_instance
$$;
-- +goose StatementEnd
-- The ledger (spec H3.2): append-only; a balance is the sum of entries, never
-- stored. Signs: grant, reverse and carry_forward add; take and expire take
-- away; adjust is either. A grant records the floor for its year (HD-7).
CREATE TABLE leave_ledger (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organization (id),
seq BIGINT NOT NULL GENERATED ALWAYS AS IDENTITY,
employee_id UUID NOT NULL,
leave_type_id UUID NOT NULL,
leave_year INT NOT NULL CONSTRAINT leave_ledger_year_range CHECK (leave_year BETWEEN 2000 AND 2999),
kind TEXT NOT NULL CONSTRAINT leave_ledger_kind_known
CHECK (kind IN ('grant', 'take', 'reverse', 'carry_forward', 'expire', 'adjust')),
days NUMERIC(6,1) NOT NULL CONSTRAINT leave_ledger_days_halves CHECK (days * 2 = trunc(days * 2)),
floor_days NUMERIC(6,1),
rule_id UUID,
request_id UUID,
expiry_of TEXT CONSTRAINT leave_ledger_expiry_of_known CHECK (expiry_of IN ('year_end', 'carry_forward')),
reason TEXT NOT NULL DEFAULT '' CONSTRAINT leave_ledger_reason_shape
CHECK (reason = btrim(reason) AND length(reason) <= 500 AND reason !~ '[぀-ヿ㐀-䶿一-鿿가-힯]'),
-- NULL on what the system writes (grants, the year roll).
recorded_by UUID,
recorded_at TIMESTAMPTZ NOT NULL,
CONSTRAINT leave_ledger_tenant_id UNIQUE (org_id, id),
CONSTRAINT leave_ledger_seq_once UNIQUE (seq),
CONSTRAINT leave_ledger_shape CHECK (
(kind = 'grant') = (floor_days IS NOT NULL)
AND (kind IN ('take', 'reverse')) = (request_id IS NOT NULL)
AND (kind = 'expire') = (expiry_of IS NOT NULL)
AND (rule_id IS NULL OR kind = 'grant')
AND (kind <> 'adjust' OR (reason <> '' AND recorded_by IS NOT NULL AND days <> 0))
AND (kind NOT IN ('grant', 'reverse', 'carry_forward') OR days >= 0)
AND (kind NOT IN ('take', 'expire') OR days < 0)
AND (floor_days IS NULL OR floor_days >= 0)),
CONSTRAINT leave_ledger_employee_fk FOREIGN KEY (org_id, employee_id) REFERENCES employee (org_id, id),
CONSTRAINT leave_ledger_type_fk FOREIGN KEY (org_id, leave_type_id) REFERENCES leave_type (org_id, id),
CONSTRAINT leave_ledger_rule_fk FOREIGN KEY (org_id, rule_id) REFERENCES leave_rule (org_id, id),
CONSTRAINT leave_ledger_request_fk FOREIGN KEY (org_id, request_id) REFERENCES leave_request (org_id, id),
CONSTRAINT leave_ledger_recorded_by_fk FOREIGN KEY (org_id, recorded_by) REFERENCES organization_member (org_id, id)
);
CREATE UNIQUE INDEX leave_ledger_grant_once ON leave_ledger (org_id, employee_id, leave_type_id, leave_year) WHERE kind = 'grant';
CREATE UNIQUE INDEX leave_ledger_carry_once ON leave_ledger (org_id, employee_id, leave_type_id, leave_year) WHERE kind = 'carry_forward';
CREATE UNIQUE INDEX leave_ledger_expire_once ON leave_ledger (org_id, employee_id, leave_type_id, leave_year, expiry_of) WHERE kind = 'expire';
CREATE UNIQUE INDEX leave_ledger_take_once ON leave_ledger (org_id, request_id) WHERE kind = 'take';
CREATE UNIQUE INDEX leave_ledger_reverse_once ON leave_ledger (org_id, request_id) WHERE kind = 'reverse';
CREATE INDEX leave_ledger_by_balance ON leave_ledger (org_id, employee_id, leave_type_id, leave_year);
ALTER TABLE leave_ledger ENABLE ROW LEVEL SECURITY;
ALTER TABLE leave_ledger FORCE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON leave_ledger
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 leave_ledger FROM erp_app;
GRANT USAGE ON SEQUENCE leave_ledger_seq_seq TO erp_app;
-- A balance: the sum of the entries, nothing else.
-- +goose StatementBegin
CREATE FUNCTION leave_balance(p_org UUID, p_employee UUID, p_type UUID, p_year INT) RETURNS NUMERIC
LANGUAGE sql STABLE SET search_path = pg_catalog, public, pg_temp AS $$
SELECT coalesce(sum(l.days), 0) FROM public.leave_ledger l
WHERE l.org_id = p_org AND l.employee_id = p_employee AND l.leave_type_id = p_type AND l.leave_year = p_year
$$;
-- +goose StatementEnd
-- What the database refuses whatever the Go above it does: a take is its
-- request's days, once, in its year, for its employee and type, and never
-- takes a paid balance below zero (Ruling H3-9); a reverse mirrors its take;
-- a negative adjustment never takes a statutory kind's year below the floor
-- its grant recorded, and needs a grant to adjust (HD-7).
-- +goose StatementBegin
CREATE FUNCTION leave_ledger_guard() RETURNS trigger
LANGUAGE plpgsql SET search_path = pg_catalog, public, pg_temp AS $$
DECLARE
r public.leave_request%ROWTYPE;
t public.leave_type%ROWTYPE;
fl NUMERIC;
total NUMERIC;
BEGIN
SELECT * INTO t FROM public.leave_type x WHERE x.org_id = NEW.org_id AND x.id = NEW.leave_type_id;
IF NEW.kind IN ('take', 'reverse') THEN
SELECT * INTO r FROM public.leave_request x WHERE x.org_id = NEW.org_id AND x.id = NEW.request_id;
IF r.employee_id <> NEW.employee_id OR r.leave_type_id <> NEW.leave_type_id OR r.leave_year <> NEW.leave_year
OR (NEW.kind = 'take' AND NEW.days <> -r.days) OR (NEW.kind = 'reverse' AND NEW.days <> r.days) THEN
RAISE EXCEPTION 'a % must be its request''s days, employee, type and year', NEW.kind
USING ERRCODE = '23514', CONSTRAINT = 'leave_ledger_take_matches';
END IF;
IF NEW.kind = 'reverse' AND NOT EXISTS (SELECT 1 FROM public.leave_ledger x
WHERE x.org_id = NEW.org_id AND x.request_id = NEW.request_id AND x.kind = 'take') THEN
RAISE EXCEPTION 'request % was never taken, so it cannot be reversed', NEW.request_id
USING ERRCODE = '23514', CONSTRAINT = 'leave_ledger_reverse_matches';
END IF;
IF NEW.kind = 'take' AND t.paid
AND public.leave_balance(NEW.org_id, NEW.employee_id, NEW.leave_type_id, NEW.leave_year) + NEW.days < 0 THEN
RAISE EXCEPTION 'this would take % leave below zero for %', t.code, NEW.leave_year
USING ERRCODE = '23514', CONSTRAINT = 'leave_balance_insufficient';
END IF;
-- Ruling H3-20 (owner decision 2026-09-30): the requester never approves
-- their own leave, except under the one-member sole-owner exception.
IF NEW.kind = 'take' AND NEW.recorded_by = r.requested_by AND NOT EXISTS (
SELECT 1 FROM public.workflow_decision d
WHERE d.org_id = NEW.org_id AND d.instance_id = r.instance_id AND d.actor_member_id = NEW.recorded_by
AND d.authority_via = 'sole_owner_exception') THEN
RAISE EXCEPTION 'leave request %: the requester cannot approve their own leave', r.id
USING ERRCODE = '23514', CONSTRAINT = 'leave_take_by_requester';
END IF;
END IF;
IF NEW.kind = 'adjust' AND NEW.days < 0 AND t.kind IN ('annual', 'sick', 'hospitalisation', 'maternity', 'paternity') THEN
SELECT g.floor_days INTO fl FROM public.leave_ledger g
WHERE g.org_id = NEW.org_id AND g.employee_id = NEW.employee_id AND g.leave_type_id = NEW.leave_type_id
AND g.leave_year = NEW.leave_year AND g.kind = 'grant';
SELECT coalesce(sum(x.days), 0) INTO total FROM public.leave_ledger x
WHERE x.org_id = NEW.org_id AND x.employee_id = NEW.employee_id AND x.leave_type_id = NEW.leave_type_id
AND x.leave_year = NEW.leave_year AND x.kind IN ('grant', 'adjust');
IF fl IS NULL OR total + NEW.days < fl THEN
RAISE EXCEPTION 'this adjustment takes % leave for % below its statutory floor of %', t.code, NEW.leave_year, coalesce(fl, 0)
USING ERRCODE = '23514', CONSTRAINT = 'leave_adjust_below_floor';
END IF;
END IF;
RETURN NEW;
END $$;
-- +goose StatementEnd
CREATE TRIGGER leave_ledger_guard BEFORE INSERT ON leave_ledger FOR EACH ROW EXECUTE FUNCTION leave_ledger_guard();
-- HD-2: each approval routed around an approver on leave, once per instance,
-- step, grant and day (Ruling H3-15). Append-only evidence.
CREATE TABLE leave_routing_move (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organization (id),
instance_id UUID NOT NULL,
kind TEXT NOT NULL,
state_key TEXT NOT NULL,
grant_id UUID NOT NULL,
on_day DATE NOT NULL,
away_member_ids UUID[] NOT NULL CONSTRAINT leave_routing_move_away_present CHECK (cardinality(away_member_ids) > 0),
moved_to_member_ids UUID[] NOT NULL,
no_alternate BOOLEAN NOT NULL,
logged_at TIMESTAMPTZ NOT NULL,
CONSTRAINT leave_routing_move_tenant_id UNIQUE (org_id, id),
CONSTRAINT leave_routing_move_once UNIQUE (org_id, instance_id, state_key, grant_id, on_day),
CONSTRAINT leave_routing_move_instance_fk FOREIGN KEY (org_id, instance_id) REFERENCES workflow_instance (org_id, id)
);
CREATE INDEX leave_routing_move_by_day ON leave_routing_move (org_id, on_day);
ALTER TABLE leave_routing_move ENABLE ROW LEVEL SECURITY;
ALTER TABLE leave_routing_move FORCE ROW LEVEL SECURITY;
CREATE POLICY org_isolation ON leave_routing_move
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 leave_routing_move FROM erp_app;
-- +goose Down
DROP TABLE leave_routing_move;
DROP TRIGGER leave_ledger_guard ON leave_ledger;
DROP FUNCTION leave_ledger_guard();
DROP FUNCTION leave_balance(UUID, UUID, UUID, INT);
DROP TABLE leave_ledger;
DROP FUNCTION leave_semantic(UUID, UUID);
DROP TABLE leave_request;
DROP TABLE public_holiday;
DROP TABLE leave_setting;
DROP TABLE work_calendar;
DROP TRIGGER leave_rule_floor_guard ON leave_rule;
DROP FUNCTION leave_rule_floor_guard();
DROP TABLE leave_rule;
DROP TRIGGER leave_type_fixed ON leave_type;
DROP FUNCTION leave_type_fixed();
DROP TABLE leave_type;
DROP TABLE leave_statutory_floor;
  • Step 4: Declare the global floor and the retention of every leave table

In db/rls_exempt.txt, append after the fx_reference_rate line:

# leave_statutory_floor (00841, Phase 5 H3): the Employment Act's minimum
# leave days per service band (HD-3). Public reference data, identical for
# every tenant, containing no tenant or personal data, and changed only by
# migration: erp_app holds SELECT alone (INSERT, UPDATE, DELETE revoked).
# Condition of the exemption, as for fx_reference_rate: no package under
# internal/modules names it; internal/platform/hr is its only reader.
leave_statutory_floor

In internal/kernel/db/retention_test.go, add to appendOnly after the H1 "lifecycle_override", line:

// Phase 5 H3 (00841): the statutory floor (migration-only), the
// effective-dated rules (HD-8), the leave ledger (a correction is a
// reverse, an expiry or an adjustment), the requests (fixed at
// creation, decided through their workflow) and the log of approvals
// routed around leave (HD-2). Leave records are retention-bearing
// (spec H3.5).
"leave_statutory_floor",
"leave_rule",
"leave_ledger",
"leave_request",
"leave_routing_move",

and to amendable after the H1 "employee", line:

// Phase 5 H3 (00841): leave types (renamed, retired), the working
// week, public holidays (retired, never deleted) and the
// carry-forward setting. None grants DELETE (spec H3.5).
"leave_type",
"work_calendar",
"public_holiday",
"leave_setting",
  • Step 5: Run the tests

Run: go test ./internal/platform/hr/ ./internal/kernel/db/ ./internal/kernel/layering/ -count=1 Expected: PASS (including TestEveryTableIsTenantIsolated, TestEveryTenantForeignKeyIsCompositeOnOrgID, TestEveryConstraintIsNamed, TestEveryFunctionPinsItsSearchPath, TestOnlyOperationalTablesGrantDeleteToTheAppRole, TestEveryMigrationIsNumberedInItsOwnersRange).

  • Step 6: Watch the floor test fail once

In leave_rule_floor_guard(), change SELECT max(s.days) to SELECT min(s.days) and run go test ./internal/platform/hr/ -run TestARuleBelowTheStatutoryFloor -count=1. Expected: FAIL — An error is expected but got nil at the 0–36-months-at-10-days assertion (the lowest overlapping floor, 8, lets it through). Restore; PASS.

Then change int4range(s.from_month, s.to_month) && int4range(NEW.from_month, NEW.to_month) to s.from_month = NEW.from_month. Expected: FAIL at the same assertion (only the band starting at 0 is compared). Restore; PASS.

Then comment out CREATE UNIQUE INDEX leave_ledger_grant_once … and run go test ./internal/platform/hr/ -run TestAGrantIsOnce -count=1. Expected: FAIL — An error is expected but got nil. Restore; PASS.

  • Step 7: Regenerate sqlc’s models

Run: make generate Expected: internal/gen/sqlc/models.go gains LeaveStatutoryFloor, LeaveType, LeaveRule, WorkCalendar, LeaveSetting, PublicHoliday, LeaveRequest, LeaveLedger, LeaveRoutingMove; nothing else changes. go test ./internal/gen/ -count=1 PASS.

  • Step 8: Commit
Terminal window
git add db/migrations/00841_leave.sql db/rls_exempt.txt internal/platform/hr/leave_schema_test.go \
internal/kernel/db/retention_test.go internal/gen/sqlc
git commit -m "Phase 5 H3: leave schema — statutory floor, types, rules, ledger, requests, calendar, routing log
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 4: Leave types, rule sets, floors, the working-day calendar, holidays and settings

Section titled “Task 4: Leave types, rule sets, floors, the working-day calendar, holidays and settings”

Files:

  • Create: internal/platform/hr/leave_days.go, internal/platform/hr/leave_errors.go, internal/platform/hr/leave_types.go, internal/platform/hr/leave_rules.go, internal/platform/hr/leave_calendar.go, internal/platform/hr/hrtest/leave.go
  • Test: internal/platform/hr/leave_days_test.go (package hr), internal/platform/hr/leave_setup_test.go

Interfaces:

  • Consumes: Task 3’s tables and constraint names; H1’s orgOf, *FieldError, *RevisionMismatchError, hrtest.Env; audit.Write, recordtext.Check.

  • Produces: the hr kind, entity, permission and warning constants; LeaveKinds, StatutoryKind; Days, WholeDays, ParseDays, (Days).String; package-internal prorate(d Days, months int) Days, dateOnly(t time.Time) time.Time, countsCalendarDays(kind string) bool, mapLeaveConstraint(err error) error, writeNames, loadNames, ruleFor(ctx, tx, typeID uuid.UUID, on time.Time, months int) (LeaveRule, bool, error), floorFor(ctx, tx, kind string, months int) (Days, error); Names, LeaveType, LeaveTypeInput, LeaveTypeUpdate, CreateLeaveType, UpdateLeaveType, GetLeaveType, ListLeaveTypes; Band, LeaveRule, RuleSetInput, SetLeaveRules, LeaveRules, Floor, StatutoryFloors; WorkCalendar, GetWorkCalendar, SetWorkCalendar; LeaveSetting, GetLeaveSetting, SetLeaveSetting; Holiday, HolidayInput, AddHoliday, RetireHoliday, Holidays, LeaveYearWarnings, WorkingDays; the leave errors listed in “Interfaces this plan produces”; hrtest.M, (*hrtest.Env).Grant, LeaveType, Rules, Annual.

  • Step 1: Write the fixture

Create internal/platform/hr/hrtest/leave.go:

package hrtest
import (
"context"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/hr"
)
// M is an optional month bound for a hr.Band.
func M(n int) *int { return &n }
// Grant gives member a fresh role holding exactly keys: hr's own checks read
// member.Can (medical certificates, visibility).
func (e *Env) Grant(t *testing.T, member uuid.UUID, keys ...string) {
t.Helper()
ctx, admin := context.Background(), e.H.Admin
var role uuid.UUID
require.NoError(t, admin.QueryRow(ctx, `INSERT INTO role (org_id, name, system) VALUES ($1, $2, false) RETURNING id`,
e.Org, "R-"+uuid.NewString()).Scan(&role))
for _, k := range keys {
_, err := admin.Exec(ctx, `INSERT INTO role_permission (org_id, role_id, permission_key) VALUES ($1, $2, $3)`, e.Org, role, k)
require.NoError(t, err)
}
_, err := admin.Exec(ctx, `INSERT INTO member_role (org_id, member_id, role_id) VALUES ($1, $2, $3)`, e.Org, member, role)
require.NoError(t, err)
}
// LeaveType creates a leave type. Actor defaults to e.HR, Names to an
// English and a Malay name.
func (e *Env) LeaveType(t *testing.T, in hr.LeaveTypeInput) hr.LeaveType {
t.Helper()
if in.Actor == uuid.Nil {
in.Actor = e.HR
}
if in.Names == nil {
in.Names = hr.Names{"en": in.Code + " leave", "ms": "Cuti " + in.Code}
}
var out hr.LeaveType
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
var err error
out, err = hr.CreateLeaveType(ctx, tx, e.Clock, in)
return err
})
return out
}
// Rules sets typeID's rule set from the day from, by HR.
func (e *Env) Rules(t *testing.T, typeID uuid.UUID, from time.Time, bands ...hr.Band) []hr.LeaveRule {
t.Helper()
var out []hr.LeaveRule
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
var err error
out, err = hr.SetLeaveRules(ctx, tx, e.Clock, hr.RuleSetInput{TypeID: typeID, EffectiveFrom: from, Bands: bands, Actor: e.HR})
return err
})
return out
}
// Annual is an annual leave type with d0, d24 and d60 whole days for the
// statutory bands (under 2 years, 2–5 years, over 5 years), from 2000-01-01.
func (e *Env) Annual(t *testing.T, code string, d0, d24, d60 int) hr.LeaveType {
t.Helper()
lt := e.LeaveType(t, hr.LeaveTypeInput{Code: code, Kind: hr.KindAnnual, HalfDays: true})
e.Rules(t, lt.ID, Day(2000, 1, 1),
hr.Band{FromMonth: 0, ToMonth: M(24), Days: hr.WholeDays(d0)},
hr.Band{FromMonth: 24, ToMonth: M(60), Days: hr.WholeDays(d24)},
hr.Band{FromMonth: 60, Days: hr.WholeDays(d60)})
return lt
}
  • Step 2: Write the failing tests

Create internal/platform/hr/leave_days_test.go:

package hr
import (
"testing"
"github.com/stretchr/testify/require"
)
// Ruling H3-2: days are half-day units; the wire form is a decimal string.
func TestDaysAreHalfDayUnits(t *testing.T) {
for s, want := range map[string]Days{"0": 0, "1": 2, "1.5": 3, "0.5": 1, "-2.5": -5, "98": 196, "1.0": 2} {
got, err := ParseDays(s)
require.NoError(t, err, s)
require.Equal(t, want, got, s)
}
for _, bad := range []string{"", "1.25", "abc", "1e3", "12345"} {
_, err := ParseDays(bad)
require.Error(t, err, bad)
}
require.Equal(t, "1.5", Days(3).String())
require.Equal(t, "-2", Days(-4).String())
require.Equal(t, "0", Days(0).String())
}
// Ruling H3-6: a first-year grant is days × months ÷ 12, rounded to whole
// days — under one half is dropped, one half or more is a day.
func TestProrationRoundsToWholeDays(t *testing.T) {
require.Equal(t, WholeDays(11), prorate(WholeDays(14), 9), "10.5 is a half or more")
require.Equal(t, WholeDays(6), prorate(WholeDays(8), 9), "exactly 6")
require.Equal(t, WholeDays(1), prorate(WholeDays(8), 1), "0.67 rounds up")
require.Equal(t, WholeDays(0), prorate(WholeDays(5), 1), "0.42 is dropped")
require.Equal(t, WholeDays(12), prorate(WholeDays(12), 12), "a whole year is the whole grant")
require.Equal(t, Days(0), prorate(WholeDays(12), 0))
}

Create internal/platform/hr/leave_setup_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"
)
// Spec H3.1: a leave type is named in translation rows, its kind decides its
// pay and whether it needs a medical certificate, and code, kind and pay
// never change.
func TestALeaveTypeIsNamedAndKeepsItsKind(t *testing.T) {
e := hrtest.New(t)
annual := e.LeaveType(t, hr.LeaveTypeInput{Code: "al", Kind: hr.KindAnnual,
Names: hr.Names{"en": "Annual leave", "ms": "Cuti tahunan", "zh": "年假"}})
require.Equal(t, "AL", annual.Code, "upper-cased")
require.True(t, annual.Paid, "a statutory kind is paid whatever was asked")
require.Equal(t, "年假", annual.Names["zh"])
sick := e.LeaveType(t, hr.LeaveTypeInput{Code: "MC", Kind: hr.KindSick})
require.True(t, sick.AttachmentRequired, "sick leave needs a medical certificate")
unpaid := e.LeaveType(t, hr.LeaveTypeInput{Code: "UL", Kind: hr.KindUnpaid, Paid: true})
require.False(t, unpaid.Paid)
create := func(in hr.LeaveTypeInput) error {
in.Actor = e.HR
return e.TxErr(func(ctx context.Context, tx pgx.Tx) error {
_, err := hr.CreateLeaveType(ctx, tx, e.Clock, in)
return err
})
}
require.ErrorIs(t, create(hr.LeaveTypeInput{Code: "AL", Kind: hr.KindOther,
Names: hr.Names{"en": "Again", "ms": "Lagi"}}), hr.ErrDuplicateLeaveType)
var fe *hr.FieldError
require.ErrorAs(t, create(hr.LeaveTypeInput{Code: "X1", Kind: hr.KindOther, Names: hr.Names{"en": "Only English"}}), &fe)
require.Equal(t, "names", fe.Field)
require.ErrorAs(t, create(hr.LeaveTypeInput{Code: "ML", Kind: hr.KindMaternity, HalfDays: true,
Names: hr.Names{"en": "Maternity", "ms": "Bersalin"}}), &fe)
require.Equal(t, "half_days", fe.Field)
var got hr.LeaveType
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
var err error
got, err = hr.UpdateLeaveType(ctx, tx, e.Clock, annual.ID, annual.Revision, hr.LeaveTypeUpdate{
Names: hr.Names{"en": "Annual", "ms": "Tahunan"}, HalfDays: true, Active: false, Actor: e.HR})
return err
})
require.False(t, got.Active)
require.Equal(t, "Annual", got.Names["en"])
require.NotContains(t, got.Names, "zh", "a name left out is removed")
err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error {
_, err := hr.UpdateLeaveType(ctx, tx, e.Clock, annual.ID, annual.Revision, hr.LeaveTypeUpdate{
Names: hr.Names{"en": "A", "ms": "B"}, Active: true, Actor: e.HR})
return err
})
var stale *hr.RevisionMismatchError
require.ErrorAs(t, err, &stale)
err = e.TxErr(func(ctx context.Context, tx pgx.Tx) error {
_, err := hr.UpdateLeaveType(ctx, tx, e.Clock, sick.ID, sick.Revision, hr.LeaveTypeUpdate{
Names: hr.Names{"en": "Sick", "ms": "Sakit"}, AttachmentRequired: false, Active: true, Actor: e.HR})
return err
})
require.ErrorAs(t, err, &fe)
require.Equal(t, "attachment_required", fe.Field)
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
all, err := hr.ListLeaveTypes(ctx, tx, false)
require.NoError(t, err)
require.Len(t, all, 3)
active, err := hr.ListLeaveTypes(ctx, tx, true)
require.NoError(t, err)
require.Len(t, active, 2, "the retired annual type is left out")
return nil
})
}
// Rulings H3-5, HD-3, HD-8: a rule set is contiguous from month 0, open at
// the end, never below the floor, and one per type and date.
func TestARuleSetIsContiguousAndNeverBelowTheFloor(t *testing.T) {
e := hrtest.New(t)
annual := e.Annual(t, "AL", 8, 12, 16) // PRESENCE: at the floor, accepted
set := func(typ hr.LeaveType, from string, bands ...hr.Band) error {
return e.TxErr(func(ctx context.Context, tx pgx.Tx) error {
_, err := hr.SetLeaveRules(ctx, tx, e.Clock, hr.RuleSetInput{TypeID: typ.ID,
EffectiveFrom: mustDay(from), Bands: bands, Actor: e.HR})
return err
})
}
var fe *hr.FieldError
require.ErrorAs(t, set(annual, "2027-01-01", hr.Band{FromMonth: 0, ToMonth: hrtest.M(24), Days: hr.WholeDays(10)},
hr.Band{FromMonth: 36, Days: hr.WholeDays(16)}), &fe)
require.Equal(t, "contiguous", fe.Rule)
require.ErrorAs(t, set(annual, "2027-01-01", hr.Band{FromMonth: 0, ToMonth: hrtest.M(36), Days: hr.WholeDays(10)},
hr.Band{FromMonth: 36, Days: hr.WholeDays(16)}), &fe)
require.Equal(t, "statutory_floor", fe.Rule, "0–36 at 10 breaks the 12-day floor from month 24")
require.ErrorIs(t, set(annual, "2000-01-01", hr.Band{FromMonth: 0, Days: hr.WholeDays(20)}), hr.ErrRuleSetExists)
unpaid := e.LeaveType(t, hr.LeaveTypeInput{Code: "UL", Kind: hr.KindUnpaid})
require.ErrorAs(t, set(unpaid, "2026-01-01", hr.Band{FromMonth: 0, Days: hr.WholeDays(5)}), &fe)
require.Equal(t, "paid", fe.Rule)
e.Rules(t, annual.ID, hrtest.Day(2026, 6, 1), hr.Band{FromMonth: 0, Days: hr.WholeDays(20)})
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
rules, err := hr.LeaveRules(ctx, tx, annual.ID)
require.NoError(t, err)
require.Len(t, rules, 4)
require.Equal(t, hrtest.Day(2026, 6, 1), rules[0].EffectiveFrom, "newest set first")
floors, err := hr.StatutoryFloors(ctx, tx)
require.NoError(t, err)
require.Len(t, floors, 9)
return nil
})
}
// Spec H3.3, Ruling H3-12: working days skip non-working weekdays and the
// public holidays that apply; half days count one half; maternity counts
// consecutive calendar days.
func TestWorkingDaysSkipWeekendsAndHolidaysAndCountHalves(t *testing.T) {
e := hrtest.New(t)
days := func(kind, from, to string, fromHalf, toHalf bool) hr.Days {
var d hr.Days
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
var err error
d, err = hr.WorkingDays(ctx, tx, kind, mustDay(from), mustDay(to), fromHalf, toHalf)
return err
})
return d
}
holiday := func(date string, state *string) {
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
_, err := hr.AddHoliday(ctx, tx, e.Clock, hr.HolidayInput{Date: mustDay(date), State: state,
Names: hr.Names{"en": "Holiday " + date, "ms": "Cuti umum " + date}, Actor: e.HR})
return err
})
}
selangor := "MY-10"
// 2026-10-05 is a Monday; the week runs to Sunday 2026-10-11.
require.Equal(t, hr.WholeDays(5), days(hr.KindAnnual, "2026-10-05", "2026-10-11", false, false), "PRESENCE: Mon–Fri")
holiday("2026-10-07", nil)
require.Equal(t, hr.WholeDays(4), days(hr.KindAnnual, "2026-10-05", "2026-10-11", false, false), "a national holiday")
holiday("2026-10-08", &selangor)
require.Equal(t, hr.WholeDays(4), days(hr.KindAnnual, "2026-10-05", "2026-10-11", false, false),
"a state holiday does not apply to an organization with no state")
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
_, err := hr.SetWorkCalendar(ctx, tx, e.Clock, hr.WorkCalendar{Weekdays: []int{1, 2, 3, 4, 5, 6}, State: &selangor}, e.HR)
return err
})
require.Equal(t, hr.WholeDays(4), days(hr.KindAnnual, "2026-10-05", "2026-10-11", false, false),
"Selangor's holiday applies now, and Saturday is a working day")
require.Equal(t, hr.Days(7), days(hr.KindAnnual, "2026-10-05", "2026-10-11", true, false), "a first half day")
require.Equal(t, hr.Days(1), days(hr.KindAnnual, "2026-10-05", "2026-10-05", true, false), "one half day")
require.Equal(t, hr.WholeDays(7), days(hr.KindMaternity, "2026-10-05", "2026-10-11", false, false),
"maternity counts consecutive calendar days")
err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error {
_, err := hr.WorkingDays(ctx, tx, hr.KindMaternity, mustDay("2026-10-05"), mustDay("2026-10-06"), true, false)
return err
})
var fe *hr.FieldError
require.ErrorAs(t, err, &fe)
require.Equal(t, "half_days", fe.Field)
err = e.TxErr(func(ctx context.Context, tx pgx.Tx) error {
_, err := hr.WorkingDays(ctx, tx, hr.KindAnnual, mustDay("2026-10-06"), mustDay("2026-10-05"), false, false)
return err
})
require.ErrorAs(t, err, &fe)
require.Equal(t, "to_date", fe.Field)
}
// Spec H3.3: holidays are not seeded; a leave year with none is warned.
func TestAYearWithNoPublicHolidaysIsWarned(t *testing.T) {
e := hrtest.New(t)
warnings := func() []string {
var w []string
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
var err error
w, err = hr.LeaveYearWarnings(ctx, tx, 2027)
return err
})
return w
}
require.Equal(t, []string{hr.WarningNoPublicHolidays}, warnings(), "PRESENCE: nothing seeded")
var h hr.Holiday
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
var err error
h, err = hr.AddHoliday(ctx, tx, e.Clock, hr.HolidayInput{Date: mustDay("2027-01-01"),
Names: hr.Names{"en": "New Year's Day", "ms": "Hari Tahun Baru"}, Actor: e.HR})
return err
})
require.Empty(t, warnings(), "ABSENCE once the year has one")
err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error {
_, err := hr.AddHoliday(ctx, tx, e.Clock, hr.HolidayInput{Date: mustDay("2027-01-01"),
Names: hr.Names{"en": "Again", "ms": "Lagi"}, Actor: e.HR})
return err
})
require.ErrorIs(t, err, hr.ErrDuplicateHoliday)
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
_, err := hr.RetireHoliday(ctx, tx, e.Clock, h.ID, e.HR)
return err
})
require.Equal(t, []string{hr.WarningNoPublicHolidays}, warnings(), "a retired holiday does not count")
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
hs, err := hr.Holidays(ctx, tx, 2027)
require.NoError(t, err)
require.Empty(t, hs)
return nil
})
}
func mustDay(s string) time.Time {
d, err := time.Parse("2006-01-02", s)
if err != nil {
panic(err)
}
return d
}
  • Step 3: Run them to verify they fail

Run: go test ./internal/platform/hr/... -run 'TestDays|TestProration|TestALeaveType|TestARuleSet|TestWorkingDays|TestAYearWith' -count=1 Expected: FAIL to compile — undefined: Days, hr.CreateLeaveType, hr.WorkingDays, ….

  • Step 4: Write days and errors

Create internal/platform/hr/leave_days.go:

package hr
import (
"fmt"
"regexp"
"strconv"
"strings"
"time"
)
// Days is a count of leave days in half-day units: 1 is half a day, 2 a
// whole day (Ruling H3-2). The database stores NUMERIC(6,1) constrained to
// whole halves and is read as (days * 2)::int; Go never holds a day count in
// a float. The wire form is a decimal string ("1.5").
type Days int
// WholeDays is n whole days.
func WholeDays(n int) Days { return Days(2 * n) }
func (d Days) String() string {
sign := ""
if d < 0 {
sign, d = "-", -d
}
if d%2 == 0 {
return fmt.Sprintf("%s%d", sign, d/2)
}
return fmt.Sprintf("%s%d.5", sign, d/2)
}
var daysShape = regexp.MustCompile(`^-?[0-9]{1,4}(\.[05])?$`)
// ParseDays reads a decimal number of days in whole halves ("1", "1.5",
// "-2"); anything else is a *FieldError on "days".
func ParseDays(s string) (Days, error) {
s = strings.TrimSpace(s)
if !daysShape.MatchString(s) {
return 0, &FieldError{Field: "days", Rule: "half_days", Detail: "a number of days in half days, as 1 or 1.5"}
}
neg := strings.HasPrefix(s, "-")
whole, frac, _ := strings.Cut(strings.TrimPrefix(s, "-"), ".")
n, _ := strconv.Atoi(whole)
d := WholeDays(n)
if frac == "5" {
d++
}
if neg {
d = -d
}
return d, nil
}
// prorate is d × months ÷ 12 rounded to whole days: a fraction under one
// half is dropped, one half or more is a day (Employment Act 1955
// s.60E(1A) — UNVERIFIED, Ruling H3-6). A whole year is d unchanged.
func prorate(d Days, months int) Days {
switch {
case months >= 12:
return d
case months <= 0:
return 0
}
x := int(d) * months // in twenty-fourths of a day
whole := x / 24
if (x%24)*2 >= 24 {
whole++
}
return WholeDays(whole)
}
// dateOnly is t's calendar date as every DATE column stores it: midnight UTC.
func dateOnly(t time.Time) time.Time {
y, m, d := t.Date()
return time.Date(y, m, d, 0, 0, 0, 0, time.UTC)
}

Create internal/platform/hr/leave_errors.go:

package hr
import (
"errors"
"fmt"
"github.com/jackc/pgx/v5/pgconn"
)
var (
ErrNotAnEmployee = errors.New("hr: the member has no employee record, so has no leave")
ErrLeaveTypeNotFound = errors.New("hr: no such leave type in this organization")
ErrDuplicateLeaveType = errors.New("hr: another leave type has this code")
ErrRuleSetExists = errors.New("hr: this leave type already has a rule set from that date")
ErrHolidayNotFound = errors.New("hr: no such public holiday in this organization")
ErrDuplicateHoliday = errors.New("hr: that day is already a public holiday here")
ErrLeaveRequestNotFound = errors.New("hr: no such leave request in this organization")
ErrOverlappingLeave = errors.New("hr: the employee already has leave on some of these days")
ErrAttachmentBound = errors.New("hr: the document already supports another record")
ErrLeaveStarted = errors.New("hr: the leave has started; it can no longer be cancelled")
ErrAdjustBelowFloor = errors.New("hr: the adjustment would take this leave below its statutory floor for the year")
ErrInsufficientBalance = errors.New("hr: not enough leave left")
)
// InsufficientBalanceError is the balance check's refusal (Ruling H3-9):
// what is left after the employee's other pending requests, and what was
// asked. errors.Is(err, ErrInsufficientBalance) holds.
type InsufficientBalanceError struct{ Available, Requested Days }
func (e *InsufficientBalanceError) Error() string {
return fmt.Sprintf("hr: %s days left, %s requested", e.Available, e.Requested)
}
func (e *InsufficientBalanceError) Is(target error) bool { return target == ErrInsufficientBalance }
// mapLeaveConstraint names the database's leave refusals; anything else
// passes through.
func mapLeaveConstraint(err error) error {
var pg *pgconn.PgError
if !errors.As(err, &pg) {
return err
}
switch pg.ConstraintName {
case "leave_type_code_once":
return ErrDuplicateLeaveType
case "leave_rule_bands_disjoint":
return ErrRuleSetExists
case "leave_rule_below_floor":
return &FieldError{Field: "bands", Rule: "statutory_floor", Detail: pg.Message}
case "public_holiday_once":
return ErrDuplicateHoliday
case "leave_balance_insufficient":
return ErrInsufficientBalance
case "leave_adjust_below_floor":
return ErrAdjustBelowFloor
}
return err
}
  • Step 5: Write leave types and names

Create internal/platform/hr/leave_types.go:

package hr
import (
"context"
"errors"
"fmt"
"regexp"
"slices"
"strings"
"time"
"unicode/utf8"
"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/recordtext"
)
const (
KindAnnual = "annual"
KindSick = "sick"
KindHospitalisation = "hospitalisation"
KindMaternity = "maternity"
KindPaternity = "paternity"
KindUnpaid = "unpaid"
KindOther = "other"
// Audit entity kinds.
EntityLeaveType = "leave_type"
EntityLeaveRule = "leave_rule"
EntityLeaveRequest = "leave_request"
EntityLeaveLedger = "leave_ledger"
EntityPublicHoliday = "public_holiday"
EntityWorkCalendar = "work_calendar"
EntityLeaveSetting = "leave_setting"
// The module registers these (internal/modules/leave, Ruling H3-17);
// they live here because hr's own visibility rules check them.
PermissionLeaveRequest = "hr.leave.request"
PermissionLeaveManage = "hr.leave.manage"
PermissionLeaveReadAll = "hr.leave.read_all"
PermissionLeaveCalendar = "hr.leave.calendar"
PermissionLeaveAdjust = "hr.leave.adjust"
PermissionLeaveMedicalRead = "hr.leave.medical.read"
// WarningNoPublicHolidays: the leave year has no public holiday that
// applies, so every weekday counts as a working day (spec H3.3).
WarningNoPublicHolidays = "no_public_holidays"
)
// LeaveKinds is leave_type.kind's vocabulary, in display order.
var LeaveKinds = []string{KindAnnual, KindSick, KindHospitalisation, KindMaternity, KindPaternity, KindUnpaid, KindOther}
// StatutoryKind reports whether kind has an Employment Act floor
// (leave_statutory_floor). A statutory kind is always paid.
func StatutoryKind(kind string) bool {
switch kind {
case KindAnnual, KindSick, KindHospitalisation, KindMaternity, KindPaternity:
return true
}
return false
}
// countsCalendarDays: maternity and paternity are consecutive calendar days
// (spec H3.1), with no half days (Ruling H3-12).
func countsCalendarDays(kind string) bool { return kind == KindMaternity || kind == KindPaternity }
// Names holds a name per locale: en and ms required (either may be the
// record language, spec 11A.3), zh optional (a UI translation only).
type Names map[string]string
var nameLocales = []string{"en", "ms", "zh"}
func checkNames(field string, n Names) error {
for k, v := range n {
if !slices.Contains(nameLocales, k) {
return &FieldError{Field: field, Rule: "locale", Detail: "names are en, ms and zh"}
}
v = strings.TrimSpace(v)
if v == "" || utf8.RuneCountInString(v) > 200 {
return &FieldError{Field: field, Rule: "shape", Detail: "each name is 1 to 200 characters"}
}
if k != "zh" && recordtext.Check(field, v) != nil {
return &FieldError{Field: field, Rule: recordtext.Rule, Detail: recordtext.Detail}
}
}
if strings.TrimSpace(n["en"]) == "" || strings.TrimSpace(n["ms"]) == "" {
return &FieldError{Field: field, Rule: "required", Detail: "an English and a Bahasa Malaysia name are required"}
}
return nil
}
// writeNames makes the translation rows of (kind, id) exactly n.
func writeNames(ctx context.Context, tx pgx.Tx, org uuid.UUID, kind string, id uuid.UUID, n Names) error {
for _, loc := range nameLocales {
v := strings.TrimSpace(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("hr: 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("hr: 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("hr: 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("hr: load names: %w", err)
}
out[id][loc] = v
}
return out, rows.Err()
}
// LeaveType is one kind of leave the organization grants (spec H3.1).
type LeaveType struct {
ID uuid.UUID
Code string
Names Names
Kind string
Paid bool
HalfDays bool
AttachmentRequired bool
Active bool
Revision int
CreatedAt time.Time
UpdatedAt time.Time
}
// LeaveTypeInput creates a type. Paid is forced by the kind where the kind
// decides it (Ruling H3-5): unpaid is never paid, a statutory kind always is.
type LeaveTypeInput struct {
Code string
Names Names
Kind string
Paid bool
HalfDays bool
AttachmentRequired bool
Actor uuid.UUID
}
// LeaveTypeUpdate is what may change: names, half days, the attachment rule
// (never off for sick or hospitalisation) and whether it is offered.
type LeaveTypeUpdate struct {
Names Names
HalfDays bool
AttachmentRequired bool
Active bool
Actor uuid.UUID
}
const leaveTypeColumns = `id, code, kind, paid, half_days, attachment_required, active, revision, created_at, updated_at`
func scanLeaveType(row pgx.Row) (LeaveType, error) {
var t LeaveType
err := row.Scan(&t.ID, &t.Code, &t.Kind, &t.Paid, &t.HalfDays, &t.AttachmentRequired, &t.Active, &t.Revision,
&t.CreatedAt, &t.UpdatedAt)
t.CreatedAt, t.UpdatedAt = t.CreatedAt.UTC(), t.UpdatedAt.UTC()
return t, err
}
var leaveCodeShape = regexp.MustCompile(`^[A-Z][A-Z0-9_]{0,19}$`)
func GetLeaveType(ctx context.Context, tx pgx.Tx, id uuid.UUID) (LeaveType, error) {
org, err := orgOf(ctx)
if err != nil {
return LeaveType{}, err
}
t, err := scanLeaveType(tx.QueryRow(ctx, `SELECT `+leaveTypeColumns+` FROM leave_type WHERE org_id = $1 AND id = $2`, org, id))
if errors.Is(err, pgx.ErrNoRows) {
return LeaveType{}, ErrLeaveTypeNotFound
}
if err != nil {
return LeaveType{}, fmt.Errorf("hr: leave type: %w", err)
}
names, err := loadNames(ctx, tx, org, EntityLeaveType, []uuid.UUID{id})
if err != nil {
return LeaveType{}, err
}
t.Names = names[id]
return t, nil
}
// ListLeaveTypes is every type by code; activeOnly leaves the retired out.
func ListLeaveTypes(ctx context.Context, tx pgx.Tx, activeOnly bool) ([]LeaveType, error) {
org, err := orgOf(ctx)
if err != nil {
return nil, err
}
rows, err := tx.Query(ctx, `SELECT `+leaveTypeColumns+` FROM leave_type
WHERE org_id = $1 AND (NOT $2 OR active) ORDER BY code`, org, activeOnly)
if err != nil {
return nil, fmt.Errorf("hr: leave types: %w", err)
}
out := []LeaveType{}
for rows.Next() {
t, err := scanLeaveType(rows)
if err != nil {
rows.Close()
return nil, fmt.Errorf("hr: leave types: %w", err)
}
out = append(out, t)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("hr: leave types: %w", err)
}
ids := make([]uuid.UUID, len(out))
for i, t := range out {
ids[i] = t.ID
}
names, err := loadNames(ctx, tx, org, EntityLeaveType, ids)
if err != nil {
return nil, err
}
for i := range out {
out[i].Names = names[out[i].ID]
}
return out, nil
}
func CreateLeaveType(ctx context.Context, tx pgx.Tx, c clock.Clock, in LeaveTypeInput) (LeaveType, error) {
org, err := orgOf(ctx)
if err != nil {
return LeaveType{}, err
}
code := strings.ToUpper(strings.TrimSpace(in.Code))
switch {
case !leaveCodeShape.MatchString(code):
return LeaveType{}, &FieldError{Field: "code", Rule: "shape", Detail: "a code is 1 to 20 letters, digits or _, starting with a letter"}
case !slices.Contains(LeaveKinds, in.Kind):
return LeaveType{}, &FieldError{Field: "kind", Rule: "known", Detail: "annual, sick, hospitalisation, maternity, paternity, unpaid or other"}
case in.HalfDays && countsCalendarDays(in.Kind):
return LeaveType{}, &FieldError{Field: "half_days", Rule: "calendar_days", Detail: "maternity and paternity leave are whole consecutive days"}
}
if err := checkNames("names", in.Names); err != nil {
return LeaveType{}, err
}
paid := (in.Paid || StatutoryKind(in.Kind)) && in.Kind != KindUnpaid
attach := in.AttachmentRequired || in.Kind == KindSick || in.Kind == KindHospitalisation
now := c.Now()
var id uuid.UUID
if err := tx.QueryRow(ctx, `INSERT INTO leave_type (org_id, code, kind, paid, half_days, attachment_required,
created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $7) RETURNING id`,
org, code, in.Kind, paid, in.HalfDays, attach, now).Scan(&id); err != nil {
return LeaveType{}, fmt.Errorf("hr: create leave type: %w", mapLeaveConstraint(err))
}
if err := writeNames(ctx, tx, org, EntityLeaveType, id, in.Names); err != nil {
return LeaveType{}, err
}
t, err := GetLeaveType(ctx, tx, id)
if err != nil {
return LeaveType{}, err
}
return t, audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntityLeaveType, EntityID: id, Action: "create", After: t})
}
func UpdateLeaveType(ctx context.Context, tx pgx.Tx, c clock.Clock, id uuid.UUID, expectedRevision int, in LeaveTypeUpdate) (LeaveType, error) {
org, err := orgOf(ctx)
if err != nil {
return LeaveType{}, err
}
var rev int
var kind string
err = tx.QueryRow(ctx, `SELECT revision, kind FROM leave_type WHERE org_id = $1 AND id = $2 FOR UPDATE`, org, id).Scan(&rev, &kind)
if errors.Is(err, pgx.ErrNoRows) {
return LeaveType{}, ErrLeaveTypeNotFound
}
if err != nil {
return LeaveType{}, fmt.Errorf("hr: update leave type: %w", err)
}
switch {
case rev != expectedRevision:
return LeaveType{}, &RevisionMismatchError{Current: rev}
case in.HalfDays && countsCalendarDays(kind):
return LeaveType{}, &FieldError{Field: "half_days", Rule: "calendar_days", Detail: "maternity and paternity leave are whole consecutive days"}
case !in.AttachmentRequired && (kind == KindSick || kind == KindHospitalisation):
return LeaveType{}, &FieldError{Field: "attachment_required", Rule: "medical", Detail: "sick and hospitalisation leave need a medical certificate"}
}
if err := checkNames("names", in.Names); err != nil {
return LeaveType{}, err
}
before, err := GetLeaveType(ctx, tx, id)
if err != nil {
return LeaveType{}, err
}
if _, err := tx.Exec(ctx, `UPDATE leave_type SET half_days = $3, attachment_required = $4, active = $5,
revision = revision + 1, updated_at = $6 WHERE org_id = $1 AND id = $2`,
org, id, in.HalfDays, in.AttachmentRequired, in.Active, c.Now()); err != nil {
return LeaveType{}, fmt.Errorf("hr: update leave type: %w", mapLeaveConstraint(err))
}
if err := writeNames(ctx, tx, org, EntityLeaveType, id, in.Names); err != nil {
return LeaveType{}, err
}
after, err := GetLeaveType(ctx, tx, id)
if err != nil {
return LeaveType{}, err
}
return after, audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntityLeaveType, EntityID: id, Action: "update",
Before: before, After: after})
}
  • Step 6: Write rule sets and floors

Create internal/platform/hr/leave_rules.go:

package hr
import (
"context"
"errors"
"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"
)
// Band is days per year for whole service months [FromMonth, ToMonth); a nil
// ToMonth is open-ended.
type Band struct {
FromMonth int
ToMonth *int
Days Days
}
// LeaveRule is one band of a type's rule set from EffectiveFrom (HD-8).
type LeaveRule struct {
ID, TypeID uuid.UUID
EffectiveFrom time.Time
Band
CreatedBy uuid.UUID
CreatedAt time.Time
}
// RuleSetInput is a type's whole schedule from one day (Ruling H3-5).
type RuleSetInput struct {
TypeID uuid.UUID
EffectiveFrom time.Time
Bands []Band
Actor uuid.UUID
}
// Floor is one statutory band of leave_statutory_floor (HD-3).
type Floor struct {
Kind string
FromMonth int
ToMonth *int
Days Days
}
const ruleColumns = `id, leave_type_id, effective_from, from_month, to_month, (days * 2)::int, created_by, created_at`
func scanRule(row pgx.Row) (LeaveRule, error) {
var r LeaveRule
err := row.Scan(&r.ID, &r.TypeID, &r.EffectiveFrom, &r.FromMonth, &r.ToMonth, &r.Days, &r.CreatedBy, &r.CreatedAt)
r.EffectiveFrom, r.CreatedAt = r.EffectiveFrom.UTC(), r.CreatedAt.UTC()
return r, err
}
// checkBands: from month 0, contiguous, in order, only the last open-ended,
// each 0 to 366 days in whole halves.
func checkBands(bands []Band) error {
if len(bands) == 0 {
return &FieldError{Field: "bands", Rule: "required", Detail: "a rule set has at least one band"}
}
next := 0
for i, b := range bands {
last := i == len(bands)-1
switch {
case b.FromMonth != next:
return &FieldError{Field: "bands", Rule: "contiguous", Detail: "bands run from month 0, in order, without gaps"}
case last != (b.ToMonth == nil):
return &FieldError{Field: "bands", Rule: "open_ended", Detail: "the last band, and only the last, is open-ended"}
case b.ToMonth != nil && *b.ToMonth <= b.FromMonth:
return &FieldError{Field: "bands", Rule: "order", Detail: "a band ends after it starts"}
case b.Days < 0 || b.Days > WholeDays(366):
return &FieldError{Field: "bands", Rule: "days", Detail: "a band grants 0 to 366 days"}
}
if b.ToMonth != nil {
next = *b.ToMonth
}
}
return nil
}
// SetLeaveRules records a type's rule set from EffectiveFrom. The database
// refuses a band below the statutory floor (HD-3, a *FieldError on "bands"
// with rule statutory_floor) and a second set on the same day
// (ErrRuleSetExists). Past grants are never touched (HD-8).
func SetLeaveRules(ctx context.Context, tx pgx.Tx, c clock.Clock, in RuleSetInput) ([]LeaveRule, error) {
org, err := orgOf(ctx)
if err != nil {
return nil, err
}
t, err := GetLeaveType(ctx, tx, in.TypeID)
if err != nil {
return nil, err
}
if !t.Paid {
return nil, &FieldError{Field: "type_id", Rule: "paid", Detail: "an unpaid leave type has no entitlement to grant"}
}
if err := checkBands(in.Bands); err != nil {
return nil, err
}
eff, now := dateOnly(in.EffectiveFrom), c.Now()
out := make([]LeaveRule, 0, len(in.Bands))
for _, b := range in.Bands {
r, err := scanRule(tx.QueryRow(ctx, `INSERT INTO leave_rule (org_id, leave_type_id, effective_from, from_month,
to_month, days, created_by, created_at) VALUES ($1, $2, $3, $4, $5, $6::numeric, $7, $8) RETURNING `+ruleColumns,
org, t.ID, eff, b.FromMonth, b.ToMonth, b.Days.String(), in.Actor, now))
if err != nil {
return nil, fmt.Errorf("hr: leave rules: %w", mapLeaveConstraint(err))
}
out = append(out, r)
}
return out, audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntityLeaveRule, EntityID: t.ID, Action: "set",
After: map[string]any{"effective_from": eff, "bands": out}})
}
// LeaveRules is every rule of a type, the newest set first.
func LeaveRules(ctx context.Context, tx pgx.Tx, typeID uuid.UUID) ([]LeaveRule, error) {
org, err := orgOf(ctx)
if err != nil {
return nil, err
}
rows, err := tx.Query(ctx, `SELECT `+ruleColumns+` FROM leave_rule WHERE org_id = $1 AND leave_type_id = $2
ORDER BY effective_from DESC, from_month`, org, typeID)
if err != nil {
return nil, fmt.Errorf("hr: leave rules: %w", err)
}
defer rows.Close()
out := []LeaveRule{}
for rows.Next() {
r, err := scanRule(rows)
if err != nil {
return nil, fmt.Errorf("hr: leave rules: %w", err)
}
out = append(out, r)
}
return out, rows.Err()
}
// ruleFor is the band of the set in force on day on (the latest
// effective_from not after it) that covers months of service.
func ruleFor(ctx context.Context, tx pgx.Tx, typeID uuid.UUID, on time.Time, months int) (LeaveRule, bool, error) {
org, err := orgOf(ctx)
if err != nil {
return LeaveRule{}, false, err
}
r, err := scanRule(tx.QueryRow(ctx, `SELECT `+ruleColumns+` FROM leave_rule r
WHERE r.org_id = $1 AND r.leave_type_id = $2
AND r.effective_from = (SELECT max(x.effective_from) FROM leave_rule x
WHERE x.org_id = $1 AND x.leave_type_id = $2 AND x.effective_from <= $3)
AND r.from_month <= $4 AND (r.to_month IS NULL OR r.to_month > $4)`, org, typeID, dateOnly(on), months))
if errors.Is(err, pgx.ErrNoRows) {
return LeaveRule{}, false, nil
}
if err != nil {
return LeaveRule{}, false, fmt.Errorf("hr: leave rule: %w", err)
}
return r, true, nil
}
// StatutoryFloors is the seeded floor table (global, migration-only).
func StatutoryFloors(ctx context.Context, tx pgx.Tx) ([]Floor, error) {
rows, err := tx.Query(ctx, `SELECT kind, from_month, to_month, (days * 2)::int FROM leave_statutory_floor
ORDER BY kind, from_month`)
if err != nil {
return nil, fmt.Errorf("hr: statutory floors: %w", err)
}
defer rows.Close()
out := []Floor{}
for rows.Next() {
var f Floor
if err := rows.Scan(&f.Kind, &f.FromMonth, &f.ToMonth, &f.Days); err != nil {
return nil, fmt.Errorf("hr: statutory floors: %w", err)
}
out = append(out, f)
}
return out, rows.Err()
}
// floorFor is kind's floor at months of service; 0 for a kind with none.
func floorFor(ctx context.Context, tx pgx.Tx, kind string, months int) (Days, error) {
var d Days
err := tx.QueryRow(ctx, `SELECT coalesce(max((days * 2)::int), 0) FROM leave_statutory_floor
WHERE kind = $1 AND from_month <= $2 AND (to_month IS NULL OR to_month > $2)`, kind, months).Scan(&d)
if err != nil {
return 0, fmt.Errorf("hr: statutory floor: %w", err)
}
return d, nil
}
  • Step 7: Write the calendar, holidays and settings

Create internal/platform/hr/leave_calendar.go:

package hr
import (
"context"
"errors"
"fmt"
"regexp"
"slices"
"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"
)
// WorkCalendar is the working week (ISO weekdays, 1 Monday … 7 Sunday) and
// the organization's state for state holidays (Ruling H3-12).
type WorkCalendar struct {
Weekdays []int
State *string
}
// LeaveSetting is carry-forward (spec H3.2): the most annual days carried
// into the next year, and after how many months the carried days expire.
// The zero value is the default: nothing carries.
type LeaveSetting struct {
CarryForwardMax Days
CarryForwardExpiryMonths *int
}
// Holiday is one public holiday; State nil is national.
type Holiday struct {
ID uuid.UUID
Date time.Time
State *string
Names Names
Active bool
CreatedAt time.Time
}
type HolidayInput struct {
Date time.Time
State *string
Names Names
Actor uuid.UUID
}
var (
defaultWeekdays = []int{1, 2, 3, 4, 5}
stateShape = regexp.MustCompile(`^MY-(0[1-9]|1[0-6])$`)
)
func isoWeekday(t time.Time) int {
if w := int(t.Weekday()); w != 0 {
return w
}
return 7
}
func checkState(s *string) (*string, error) {
if s == nil || strings.TrimSpace(*s) == "" {
return nil, nil
}
v := strings.ToUpper(strings.TrimSpace(*s))
if !stateShape.MatchString(v) {
return nil, &FieldError{Field: "state", Rule: "known", Detail: "a Malaysian state code, MY-01 to MY-16"}
}
return &v, nil
}
// GetWorkCalendar is the organization's working week: Monday to Friday and
// no state when it has not set one.
func GetWorkCalendar(ctx context.Context, tx pgx.Tx) (WorkCalendar, error) {
org, err := orgOf(ctx)
if err != nil {
return WorkCalendar{}, err
}
var days []int16
var state *string
err = tx.QueryRow(ctx, `SELECT weekdays, state FROM work_calendar WHERE org_id = $1`, org).Scan(&days, &state)
if errors.Is(err, pgx.ErrNoRows) {
return WorkCalendar{Weekdays: slices.Clone(defaultWeekdays)}, nil
}
if err != nil {
return WorkCalendar{}, fmt.Errorf("hr: work calendar: %w", err)
}
out := WorkCalendar{State: state}
for _, d := range days {
out.Weekdays = append(out.Weekdays, int(d))
}
slices.Sort(out.Weekdays)
return out, nil
}
func SetWorkCalendar(ctx context.Context, tx pgx.Tx, c clock.Clock, cal WorkCalendar, actor uuid.UUID) (WorkCalendar, error) {
org, err := orgOf(ctx)
if err != nil {
return WorkCalendar{}, err
}
days := slices.Clone(cal.Weekdays)
slices.Sort(days)
days = slices.Compact(days)
if len(days) == 0 || days[0] < 1 || days[len(days)-1] > 7 {
return WorkCalendar{}, &FieldError{Field: "weekdays", Rule: "range", Detail: "one to seven weekdays, 1 Monday to 7 Sunday"}
}
state, err := checkState(cal.State)
if err != nil {
return WorkCalendar{}, err
}
before, err := GetWorkCalendar(ctx, tx)
if err != nil {
return WorkCalendar{}, err
}
stored := make([]int16, len(days))
for i, d := range days {
stored[i] = int16(d)
}
if _, err := tx.Exec(ctx, `INSERT INTO work_calendar (org_id, weekdays, state, updated_by, updated_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (org_id) DO UPDATE SET weekdays = EXCLUDED.weekdays, state = EXCLUDED.state,
updated_by = EXCLUDED.updated_by, updated_at = EXCLUDED.updated_at`,
org, stored, state, actor, c.Now()); err != nil {
return WorkCalendar{}, fmt.Errorf("hr: set work calendar: %w", err)
}
after := WorkCalendar{Weekdays: days, State: state}
return after, audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntityWorkCalendar, EntityID: org, Action: "set",
Before: before, After: after})
}
func GetLeaveSetting(ctx context.Context, tx pgx.Tx) (LeaveSetting, error) {
org, err := orgOf(ctx)
if err != nil {
return LeaveSetting{}, err
}
var s LeaveSetting
err = tx.QueryRow(ctx, `SELECT (carry_forward_max * 2)::int, carry_forward_expiry_months FROM leave_setting
WHERE org_id = $1`, org).Scan(&s.CarryForwardMax, &s.CarryForwardExpiryMonths)
if errors.Is(err, pgx.ErrNoRows) {
return LeaveSetting{}, nil
}
if err != nil {
return LeaveSetting{}, fmt.Errorf("hr: leave setting: %w", err)
}
return s, nil
}
func SetLeaveSetting(ctx context.Context, tx pgx.Tx, c clock.Clock, s LeaveSetting, actor uuid.UUID) (LeaveSetting, error) {
org, err := orgOf(ctx)
if err != nil {
return LeaveSetting{}, err
}
switch {
case s.CarryForwardMax < 0 || s.CarryForwardMax > WholeDays(366):
return LeaveSetting{}, &FieldError{Field: "carry_forward_max", Rule: "range", Detail: "0 to 366 days"}
case s.CarryForwardExpiryMonths != nil && (*s.CarryForwardExpiryMonths < 1 || *s.CarryForwardExpiryMonths > 12):
return LeaveSetting{}, &FieldError{Field: "carry_forward_expiry_months", Rule: "range", Detail: "1 to 12 months, or none"}
}
before, err := GetLeaveSetting(ctx, tx)
if err != nil {
return LeaveSetting{}, err
}
if _, err := tx.Exec(ctx, `INSERT INTO leave_setting (org_id, carry_forward_max, carry_forward_expiry_months,
updated_by, updated_at) VALUES ($1, $2::numeric, $3, $4, $5)
ON CONFLICT (org_id) DO UPDATE SET carry_forward_max = EXCLUDED.carry_forward_max,
carry_forward_expiry_months = EXCLUDED.carry_forward_expiry_months,
updated_by = EXCLUDED.updated_by, updated_at = EXCLUDED.updated_at`,
org, s.CarryForwardMax.String(), s.CarryForwardExpiryMonths, actor, c.Now()); err != nil {
return LeaveSetting{}, fmt.Errorf("hr: set leave setting: %w", err)
}
return s, audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntityLeaveSetting, EntityID: org, Action: "set",
Before: before, After: s})
}
const holidayColumns = `id, holiday_date, state, active, created_at`
func getHoliday(ctx context.Context, tx pgx.Tx, org, id uuid.UUID) (Holiday, error) {
var h Holiday
err := tx.QueryRow(ctx, `SELECT `+holidayColumns+` FROM public_holiday WHERE org_id = $1 AND id = $2`, org, id).
Scan(&h.ID, &h.Date, &h.State, &h.Active, &h.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return Holiday{}, ErrHolidayNotFound
}
if err != nil {
return Holiday{}, fmt.Errorf("hr: holiday: %w", err)
}
h.Date, h.CreatedAt = h.Date.UTC(), h.CreatedAt.UTC()
names, err := loadNames(ctx, tx, org, EntityPublicHoliday, []uuid.UUID{id})
if err != nil {
return Holiday{}, err
}
h.Names = names[id]
return h, nil
}
// AddHoliday records one public holiday (spec H3.3: the Owner enters them).
func AddHoliday(ctx context.Context, tx pgx.Tx, c clock.Clock, in HolidayInput) (Holiday, error) {
org, err := orgOf(ctx)
if err != nil {
return Holiday{}, err
}
if in.Date.IsZero() {
return Holiday{}, &FieldError{Field: "date", Rule: "required", Detail: "a date is required"}
}
state, err := checkState(in.State)
if err != nil {
return Holiday{}, err
}
if err := checkNames("names", in.Names); err != nil {
return Holiday{}, err
}
var id uuid.UUID
if err := tx.QueryRow(ctx, `INSERT INTO public_holiday (org_id, holiday_date, state, created_by, created_at)
VALUES ($1, $2, $3, $4, $5) RETURNING id`, org, dateOnly(in.Date), state, in.Actor, c.Now()).Scan(&id); err != nil {
return Holiday{}, fmt.Errorf("hr: add holiday: %w", mapLeaveConstraint(err))
}
if err := writeNames(ctx, tx, org, EntityPublicHoliday, id, in.Names); err != nil {
return Holiday{}, err
}
h, err := getHoliday(ctx, tx, org, id)
if err != nil {
return Holiday{}, err
}
return h, audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntityPublicHoliday, EntityID: id, Action: "create", After: h})
}
// RetireHoliday takes a holiday out of every working-day count from now on;
// it is never deleted, and requests already made keep their days.
func RetireHoliday(ctx context.Context, tx pgx.Tx, c clock.Clock, id, actor uuid.UUID) (Holiday, error) {
org, err := orgOf(ctx)
if err != nil {
return Holiday{}, err
}
var active bool
err = tx.QueryRow(ctx, `SELECT active FROM public_holiday WHERE org_id = $1 AND id = $2 FOR UPDATE`, org, id).Scan(&active)
if errors.Is(err, pgx.ErrNoRows) {
return Holiday{}, ErrHolidayNotFound
}
if err != nil {
return Holiday{}, fmt.Errorf("hr: retire holiday: %w", err)
}
if active {
if _, err := tx.Exec(ctx, `UPDATE public_holiday SET active = false, retired_by = $3, retired_at = $4
WHERE org_id = $1 AND id = $2`, org, id, actor, c.Now()); err != nil {
return Holiday{}, fmt.Errorf("hr: retire holiday: %w", err)
}
}
h, err := getHoliday(ctx, tx, org, id)
if err != nil || !active {
return h, err
}
return h, audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntityPublicHoliday, EntityID: id, Action: "retire", After: h})
}
// Holidays is every active holiday in year, national first on a day.
func Holidays(ctx context.Context, tx pgx.Tx, year int) ([]Holiday, error) {
org, err := orgOf(ctx)
if err != nil {
return nil, err
}
rows, err := tx.Query(ctx, `SELECT `+holidayColumns+` FROM public_holiday
WHERE org_id = $1 AND active AND extract(year FROM holiday_date) = $2
ORDER BY holiday_date, state NULLS FIRST`, org, year)
if err != nil {
return nil, fmt.Errorf("hr: holidays: %w", err)
}
out := []Holiday{}
for rows.Next() {
var h Holiday
if err := rows.Scan(&h.ID, &h.Date, &h.State, &h.Active, &h.CreatedAt); err != nil {
rows.Close()
return nil, fmt.Errorf("hr: holidays: %w", err)
}
h.Date, h.CreatedAt = h.Date.UTC(), h.CreatedAt.UTC()
out = append(out, h)
}
rows.Close()
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("hr: holidays: %w", err)
}
ids := make([]uuid.UUID, len(out))
for i, h := range out {
ids[i] = h.ID
}
names, err := loadNames(ctx, tx, org, EntityPublicHoliday, ids)
if err != nil {
return nil, err
}
for i := range out {
out[i].Names = names[out[i].ID]
}
return out, nil
}
// LeaveYearWarnings is [no_public_holidays] when year has no active holiday
// that applies to the organization (national, or its state), else empty.
func LeaveYearWarnings(ctx context.Context, tx pgx.Tx, year int) ([]string, error) {
org, err := orgOf(ctx)
if err != nil {
return nil, err
}
cal, err := GetWorkCalendar(ctx, tx)
if err != nil {
return nil, err
}
var found bool
if err := tx.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM public_holiday
WHERE org_id = $1 AND active AND extract(year FROM holiday_date) = $2 AND (state IS NULL OR state = $3))`,
org, year, cal.State).Scan(&found); err != nil {
return nil, fmt.Errorf("hr: leave year warnings: %w", err)
}
if !found {
return []string{WarningNoPublicHolidays}, nil
}
return []string{}, nil
}
// WorkingDays counts from..to inclusive as kind counts them (Ruling H3-12):
// maternity and paternity every calendar day; every other kind the working
// weekdays that are not a public holiday applying to the organization, a
// half day as one half.
func WorkingDays(ctx context.Context, tx pgx.Tx, kind string, from, to time.Time, fromHalf, toHalf bool) (Days, error) {
org, err := orgOf(ctx)
if err != nil {
return 0, err
}
from, to = dateOnly(from), dateOnly(to)
switch {
case to.Before(from):
return 0, &FieldError{Field: "to_date", Rule: "order", Detail: "the last day is on or after the first"}
case to.Sub(from) > 366*24*time.Hour:
return 0, &FieldError{Field: "to_date", Rule: "range", Detail: "a request covers at most a year"}
case fromHalf && toHalf && from.Equal(to):
return 0, &FieldError{Field: "to_half", Rule: "one_half", Detail: "a single day is one half day, not two"}
case (fromHalf || toHalf) && countsCalendarDays(kind):
return 0, &FieldError{Field: "half_days", Rule: "calendar_days", Detail: "maternity and paternity leave are whole consecutive days"}
}
if countsCalendarDays(kind) {
return WholeDays(int(to.Sub(from).Hours()/24) + 1), nil
}
cal, err := GetWorkCalendar(ctx, tx)
if err != nil {
return 0, 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 0, fmt.Errorf("hr: working days: %w", err)
}
dates, err := pgx.CollectRows(rows, pgx.RowTo[time.Time])
if err != nil {
return 0, fmt.Errorf("hr: working days: %w", err)
}
holiday := map[time.Time]bool{}
for _, d := range dates {
holiday[dateOnly(d)] = true
}
var n Days
for day := from; !day.After(to); day = day.AddDate(0, 0, 1) {
if !slices.Contains(cal.Weekdays, isoWeekday(day)) || holiday[day] {
continue
}
switch {
case fromHalf && day.Equal(from), toHalf && day.Equal(to):
n++
default:
n += 2
}
}
return n, nil
}
  • Step 8: Run the tests

Run: go test ./internal/platform/hr/... ./internal/kernel/layering/ -count=1 Expected: PASS (TestEveryTenantQueryCarriesAnExplicitOrgPredicate included: every leave query names org_id = $1; leave_statutory_floor has no org_id column, so the guard does not count it as org-scoped).

  • Step 9: Watch the calendar fail once

In WorkingDays, change || holiday[day] to || false and run go test ./internal/platform/hr/ -run TestWorkingDaysSkipWeekendsAndHolidays -count=1. Expected: FAIL — a national holiday: expected 8, actual 10. Restore. Then in LeaveYearWarnings change AND active to AND true. Run go test ./internal/platform/hr/ -run TestAYearWithNoPublicHolidaysIsWarned -count=1. Expected: FAIL — a retired holiday does not count: expected [no_public_holidays], actual []. Restore; PASS.

  • Step 10: Commit
Terminal window
git add internal/platform/hr
git commit -m "Phase 5 H3: leave types, rule sets over the statutory floor, working-day calendar, holidays, settings
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 5: The ledger — grants, the year roll, adjustments, balances and the hr_leave_grants job

Section titled “Task 5: The ledger — grants, the year roll, adjustments, balances and the hr_leave_grants job”

Files:

  • Create: internal/platform/hr/leave_ledger.go, internal/platform/hr/leave_job.go
  • Modify: internal/platform/hr/hrtest/leave.go (append Accrue, Balance), cmd/worker/exports.go, cmd/worker/main.go
  • Test: internal/platform/hr/leave_ledger_test.go

Interfaces:

  • Consumes: Task 4’s ruleFor, floorFor, prorate, ListLeaveTypes, GetLeaveType, GetLeaveSetting, mapLeaveConstraint; H1’s lockEmployee, checkReason, ServiceMonths, employee_spell, ops.org_ids(); jobs.QueueMaintenance.

  • Produces:

    • constants EntryGrant, EntryTake, EntryReverse, EntryCarryForward, EntryExpire, EntryAdjust, ExpiryYearEnd, ExpiryCarryForward
    • type Entry struct { ID uuid.UUID; Seq int64; EmployeeID, TypeID uuid.UUID; Year int; Kind string; Days Days; FloorDays *Days; RuleID, RequestID *uuid.UUID; ExpiryOf *string; Reason string; RecordedBy *uuid.UUID; RecordedAt time.Time }
    • func Ledger(ctx context.Context, tx pgx.Tx, employeeID uuid.UUID, year int) ([]Entry, error)
    • func BalanceOf(ctx context.Context, tx pgx.Tx, employeeID, typeID uuid.UUID, year int) (Days, error)
    • type Balance struct { EmployeeID uuid.UUID; EmployeeName, EmployeeNo string; TypeID uuid.UUID; Year int; Granted, CarriedForward, Taken, Reversed, Expired, Adjusted, Balance, Pending Days } (Taken and Expired are negative, as in the ledger)
    • func Balances(ctx context.Context, tx pgx.Tx, year int, employeeID *uuid.UUID) ([]Balance, error)
    • func AccrualDue(ctx context.Context, tx pgx.Tx) ([]uuid.UUID, error), func AccrueYear(ctx context.Context, tx pgx.Tx, c clock.Clock, employeeID uuid.UUID) ([]Entry, error)
    • type AdjustInput struct { EmployeeID, TypeID uuid.UUID; Year int; Days Days; Reason string; Actor uuid.UUID }, func Adjust(ctx context.Context, tx pgx.Tx, c clock.Clock, in AdjustInput) (Entry, error)
    • package-internal insertEntry(ctx, tx, c, org uuid.UUID, e Entry, once string) (Entry, bool, error), orgIDs(ctx context.Context, d *db.DB) ([]uuid.UUID, error) (Task 6, Task 7)
    • River: LeaveGrantsArgs (kind hr_leave_grants, queue maintenance), LeaveGrantsPeriodic() *river.PeriodicJob (daily, on start), LeaveGrantsWorker{DB *db.DB; Clock clock.Clock; Logger *slog.Logger}
    • (*hrtest.Env).Accrue(t *testing.T, c clock.Clock, employeeID uuid.UUID) []hr.Entry, (*hrtest.Env).Balance(t *testing.T, employeeID, typeID uuid.UUID, year int) hr.Days
  • Step 1: Extend the fixture

Append to internal/platform/hr/hrtest/leave.go (add "github.com/mcom/erp/internal/kernel/clock" to its imports):

// Accrue runs one employee's grants and year roll as of c's day.
func (e *Env) Accrue(t *testing.T, c clock.Clock, employeeID uuid.UUID) []hr.Entry {
t.Helper()
var out []hr.Entry
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
var err error
out, err = hr.AccrueYear(ctx, tx, c, employeeID)
return err
})
return out
}
// Balance is the sum of the employee's ledger for the type and year.
func (e *Env) Balance(t *testing.T, employeeID, typeID uuid.UUID, year int) hr.Days {
t.Helper()
var d hr.Days
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
var err error
d, err = hr.BalanceOf(ctx, tx, employeeID, typeID, year)
return err
})
return d
}
  • Step 2: Write the failing tests

Create internal/platform/hr/leave_ledger_test.go:

package hr_test
import (
"context"
"io"
"log/slog"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/riverqueue/river"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/clock"
"github.com/mcom/erp/internal/platform/hr"
"github.com/mcom/erp/internal/platform/hr/hrtest"
)
func runGrants(t *testing.T, e *hrtest.Env, c clock.Clock) {
t.Helper()
w := &hr.LeaveGrantsWorker{DB: e.DB, Clock: c, Logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
require.NoError(t, w.Work(context.Background(), &river.Job[hr.LeaveGrantsArgs]{}))
}
// entriesOf is the employee's ledger for year, of one kind.
func entriesOf(t *testing.T, e *hrtest.Env, employee uuid.UUID, year int, kind string) []hr.Entry {
t.Helper()
var out []hr.Entry
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
all, err := hr.Ledger(ctx, tx, employee, year)
for _, x := range all {
if x.Kind == kind {
out = append(out, x)
}
}
return err
})
return out
}
// hireOn opens member's employee record and records a permanent hire on day.
func hireOn(t *testing.T, e *hrtest.Env, member uuid.UUID, no string, day time.Time) hr.Employee {
t.Helper()
var out 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
}
out, _, err = hr.Hire(ctx, tx, e.Clock, hr.HireInput{EmployeeID: created.ID, EffectiveOn: day,
EmploymentType: hr.TypePermanent, Actor: e.HR})
return err
})
return out
}
// Spec H3 drift bullet (first half): "The grant job run twice grants once."
func TestTheGrantJobRunTwiceGrantsOnce(t *testing.T) {
e := hrtest.New(t)
e.Annual(t, "AL", 10, 12, 16)
staff := e.Hired(t, e.Staff, "E-100") // hired 2026-01-01: a whole first year
var copied hr.Employee
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
var err error
copied, err = hr.Create(ctx, tx, e.Clock, hr.CreateInput{MemberID: &e.HR, EmployeeNo: "E-101"})
return err
})
runGrants(t, e, e.Clock)
grants := entriesOf(t, e, staff.ID, 2026, hr.EntryGrant)
require.Len(t, grants, 1, "PRESENCE: the year's grant")
require.Equal(t, hr.WholeDays(10), grants[0].Days)
require.Equal(t, hr.WholeDays(8), *grants[0].FloorDays, "the year's floor is recorded with the grant")
runGrants(t, e, e.Clock)
require.Len(t, entriesOf(t, e, staff.ID, 2026, hr.EntryGrant), 1, "ABSENCE of a second grant")
require.Empty(t, entriesOf(t, e, copied.ID, 2026, hr.EntryGrant),
"a pending record has no hire date, so no leave (Ruling H1-4)")
}
// Spec H3 drift bullet (second half) and HD-8: "a rule change does not alter
// a past grant"; a rule applies only to grants made on or after its date.
func TestARuleChangeDoesNotAlterAPastGrant(t *testing.T) {
e := hrtest.New(t)
al := e.Annual(t, "AL", 10, 12, 16)
staff := e.Hired(t, e.Staff, "E-100")
runGrants(t, e, e.Clock)
require.Equal(t, hr.WholeDays(10), entriesOf(t, e, staff.ID, 2026, hr.EntryGrant)[0].Days, "PRESENCE")
e.Rules(t, al.ID, hrtest.Day(2026, 6, 1),
hr.Band{FromMonth: 0, ToMonth: hrtest.M(24), Days: hr.WholeDays(14)},
hr.Band{FromMonth: 24, ToMonth: hrtest.M(60), Days: hr.WholeDays(18)},
hr.Band{FromMonth: 60, Days: hr.WholeDays(20)})
may, _ := e.Member(t, "May", &e.OwnerSeat)
july, _ := e.Member(t, "July", &e.OwnerSeat)
mayHire := hireOn(t, e, may, "E-200", hrtest.Day(2026, 5, 1))
julyHire := hireOn(t, e, july, "E-201", hrtest.Day(2026, 7, 1))
runGrants(t, e, e.Clock)
staffGrants := entriesOf(t, e, staff.ID, 2026, hr.EntryGrant)
require.Len(t, staffGrants, 1)
require.Equal(t, hr.WholeDays(10), staffGrants[0].Days, "the past grant stands as it was made")
require.Equal(t, hr.WholeDays(7), entriesOf(t, e, mayHire.ID, 2026, hr.EntryGrant)[0].Days,
"hired before the change: 10 × 8 ÷ 12 under the old rule")
require.Equal(t, hr.WholeDays(7), entriesOf(t, e, julyHire.ID, 2026, hr.EntryGrant)[0].Days,
"hired after it: 14 × 6 ÷ 12 under the new rule")
runGrants(t, e, hrtest.At(hrtest.Day(2027, 1, 4)))
require.Equal(t, hr.WholeDays(14), entriesOf(t, e, staff.ID, 2027, hr.EntryGrant)[0].Days, "next year: the new rule")
require.Equal(t, hr.WholeDays(10), entriesOf(t, e, staff.ID, 2026, hr.EntryGrant)[0].Days, "and 2026's is untouched")
}
// Ruling H3-6: a first-year annual grant is prorated by whole months
// remaining and rounded; other kinds are granted in full.
func TestAFirstYearHireIsProratedByWholeMonthsRemaining(t *testing.T) {
e := hrtest.New(t)
al := e.Annual(t, "AL", 14, 14, 16)
sick := e.LeaveType(t, hr.LeaveTypeInput{Code: "MC", Kind: hr.KindSick})
e.Rules(t, sick.ID, hrtest.Day(2000, 1, 1),
hr.Band{FromMonth: 0, ToMonth: hrtest.M(24), Days: hr.WholeDays(14)},
hr.Band{FromMonth: 24, ToMonth: hrtest.M(60), Days: hr.WholeDays(18)},
hr.Band{FromMonth: 60, Days: hr.WholeDays(22)})
emp := hireOn(t, e, e.Staff, "E-100", hrtest.Day(2026, 3, 15)) // nine whole months to 1 January
got := e.Accrue(t, e.Clock, emp.ID)
require.Len(t, got, 2)
annual := entriesOf(t, e, emp.ID, 2026, hr.EntryGrant)
byType := map[uuid.UUID]hr.Entry{}
for _, g := range annual {
byType[g.TypeID] = g
}
require.Equal(t, hr.WholeDays(11), byType[al.ID].Days, "14 × 9 ÷ 12 = 10.5, a half or more is a day")
require.Equal(t, hr.WholeDays(6), *byType[al.ID].FloorDays, "the floor prorates the same way: 8 × 9 ÷ 12")
require.Equal(t, hr.WholeDays(14), byType[sick.ID].Days, "sick leave is not prorated")
}
// Spec H3.2, Ruling H3-7: carry-forward is an organization setting, default
// none; carried days expire after the set months.
func TestUnusedAnnualLeaveLapsesUnlessCarriedForward(t *testing.T) {
e := hrtest.New(t)
al := e.Annual(t, "AL", 12, 12, 16)
a := e.Hired(t, e.Staff, "E-100")
b := e.Hired(t, e.HR, "E-101")
runGrants(t, e, e.Clock)
january := hrtest.At(hrtest.Day(2027, 1, 4))
e.Accrue(t, january, a.ID)
require.Equal(t, hr.Days(0), e.Balance(t, a.ID, al.ID, 2026), "PRESENCE of the year-end lapse")
require.Equal(t, hr.WholeDays(-12), entriesOf(t, e, a.ID, 2026, hr.EntryExpire)[0].Days)
require.Empty(t, entriesOf(t, e, a.ID, 2027, hr.EntryCarryForward), "by default nothing carries")
three := 3
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
_, err := hr.SetLeaveSetting(ctx, tx, e.Clock, hr.LeaveSetting{CarryForwardMax: hr.WholeDays(5),
CarryForwardExpiryMonths: &three}, e.Owner)
return err
})
e.Accrue(t, january, b.ID)
require.Equal(t, hr.WholeDays(5), entriesOf(t, e, b.ID, 2027, hr.EntryCarryForward)[0].Days)
require.Equal(t, hr.WholeDays(17), e.Balance(t, b.ID, al.ID, 2027), "12 granted and 5 carried")
e.Accrue(t, january, b.ID)
require.Len(t, entriesOf(t, e, b.ID, 2027, hr.EntryCarryForward), 1, "once")
e.Accrue(t, hrtest.At(hrtest.Day(2027, 3, 31)), b.ID)
require.Empty(t, entriesOf(t, e, b.ID, 2027, hr.EntryExpire), "not before the three months are up")
e.Accrue(t, hrtest.At(hrtest.Day(2027, 4, 1)), b.ID)
expired := entriesOf(t, e, b.ID, 2027, hr.EntryExpire)
require.Len(t, expired, 1)
require.Equal(t, hr.WholeDays(-5), expired[0].Days)
require.Equal(t, hr.ExpiryCarryForward, *expired[0].ExpiryOf)
require.Equal(t, hr.WholeDays(12), e.Balance(t, b.ID, al.ID, 2027))
}
// HD-7: a manual adjustment cannot take a statutory type below the floor for
// that leave year; a reason is mandatory; it is audited.
func TestAnAdjustmentCannotTakeAStatutoryTypeBelowTheFloor(t *testing.T) {
e := hrtest.New(t)
al := e.Annual(t, "AL", 12, 12, 16) // floor 8 under two years' service
study := e.LeaveType(t, hr.LeaveTypeInput{Code: "STUDY", Kind: hr.KindOther, Paid: true})
e.Rules(t, study.ID, hrtest.Day(2000, 1, 1), hr.Band{FromMonth: 0, Days: hr.WholeDays(3)})
staff := e.Hired(t, e.Staff, "E-100")
e.Accrue(t, e.Clock, staff.ID)
adjust := func(typ uuid.UUID, year int, days, reason string) error {
d, err := hr.ParseDays(days)
require.NoError(t, err)
return e.TxErr(func(ctx context.Context, tx pgx.Tx) error {
_, err := hr.Adjust(ctx, tx, e.Clock, hr.AdjustInput{EmployeeID: staff.ID, TypeID: typ, Year: year,
Days: d, Reason: reason, Actor: e.HR})
return err
})
}
require.NoError(t, adjust(al.ID, 2026, "-4", "Leave advanced in 2025 recovered"), "PRESENCE: down to the floor")
require.ErrorIs(t, adjust(al.ID, 2026, "-0.5", "One more half day"), hr.ErrAdjustBelowFloor, "ABSENCE below it")
require.NoError(t, adjust(al.ID, 2026, "2", "Long service award"))
require.NoError(t, adjust(al.ID, 2026, "-2", "Award withdrawn"), "back to the floor, not below")
require.ErrorIs(t, adjust(al.ID, 2027, "-1", "No grant yet"), hr.ErrAdjustBelowFloor, "a year with no grant")
require.NoError(t, adjust(study.ID, 2026, "-5", "Study leave has no floor"), "a kind with no floor")
var fe *hr.FieldError
require.ErrorAs(t, adjust(al.ID, 2026, "1", " "), &fe)
require.Equal(t, "reason", fe.Field)
require.ErrorAs(t, adjust(al.ID, 2026, "0", "Nothing"), &fe)
require.Equal(t, "days", fe.Field)
require.Equal(t, hr.WholeDays(8), e.Balance(t, staff.ID, al.ID, 2026))
require.Equal(t, 4, e.Count(t, `SELECT count(*) FROM audit_log WHERE entity_kind = 'leave_ledger' AND action = 'adjust'`))
}
  • Step 3: Run them to verify they fail

Run: go test ./internal/platform/hr/ -run 'TestTheGrantJob|TestARuleChange|TestAFirstYearHire|TestUnusedAnnualLeave|TestAnAdjustment' -count=1 Expected: FAIL to compile — undefined: hr.LeaveGrantsWorker, hr.Ledger, hr.AccrueYear, hr.Adjust, ….

  • Step 4: Write the ledger

Create internal/platform/hr/leave_ledger.go:

package hr
import (
"context"
"errors"
"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"
)
const (
EntryGrant = "grant"
EntryTake = "take"
EntryReverse = "reverse"
EntryCarryForward = "carry_forward"
EntryExpire = "expire"
EntryAdjust = "adjust"
ExpiryYearEnd = "year_end" // last year's remaining annual leave lapsed
ExpiryCarryForward = "carry_forward" // carried days not used in time
)
// Entry is one leave_ledger row. Days is signed as the ledger stores it.
type Entry struct {
ID uuid.UUID
Seq int64
EmployeeID uuid.UUID
TypeID uuid.UUID
Year int
Kind string
Days Days
FloorDays *Days // grants only: the statutory floor for the year (HD-7)
RuleID *uuid.UUID
RequestID *uuid.UUID
ExpiryOf *string
Reason string
RecordedBy *uuid.UUID // nil on what the system writes
RecordedAt time.Time
}
const entryColumns = `id, seq, employee_id, leave_type_id, leave_year, kind, (days * 2)::int, (floor_days * 2)::int,
rule_id, request_id, expiry_of, reason, recorded_by, recorded_at`
func scanEntry(row pgx.Row) (Entry, error) {
var e Entry
err := row.Scan(&e.ID, &e.Seq, &e.EmployeeID, &e.TypeID, &e.Year, &e.Kind, &e.Days, &e.FloorDays,
&e.RuleID, &e.RequestID, &e.ExpiryOf, &e.Reason, &e.RecordedBy, &e.RecordedAt)
e.RecordedAt = e.RecordedAt.UTC()
return e, err
}
// The partial unique indexes a system entry is written once under (Rulings
// H3-6, H3-7): a second write of the same entry is nothing, not an error.
const (
onceGrant = `ON CONFLICT (org_id, employee_id, leave_type_id, leave_year) WHERE kind = 'grant' DO NOTHING`
onceCarry = `ON CONFLICT (org_id, employee_id, leave_type_id, leave_year) WHERE kind = 'carry_forward' DO NOTHING`
onceExpire = `ON CONFLICT (org_id, employee_id, leave_type_id, leave_year, expiry_of) WHERE kind = 'expire' DO NOTHING`
)
// insertEntry writes one ledger row; false when once found it already there.
// leave_ledger_guard's refusals come back as ErrInsufficientBalance and
// ErrAdjustBelowFloor.
func insertEntry(ctx context.Context, tx pgx.Tx, c clock.Clock, org uuid.UUID, e Entry, once string) (Entry, bool, error) {
var floor *string
if e.FloorDays != nil {
s := e.FloorDays.String()
floor = &s
}
out, err := scanEntry(tx.QueryRow(ctx, `INSERT INTO leave_ledger (org_id, employee_id, leave_type_id, leave_year,
kind, days, floor_days, rule_id, request_id, expiry_of, reason, recorded_by, recorded_at)
VALUES ($1, $2, $3, $4, $5, $6::numeric, $7::numeric, $8, $9, $10, $11, $12, $13) `+once+` RETURNING `+entryColumns,
org, e.EmployeeID, e.TypeID, e.Year, e.Kind, e.Days.String(), floor, e.RuleID, e.RequestID, e.ExpiryOf,
e.Reason, e.RecordedBy, c.Now()))
if errors.Is(err, pgx.ErrNoRows) {
return Entry{}, false, nil
}
if err != nil {
return Entry{}, false, fmt.Errorf("hr: leave ledger %s: %w", e.Kind, mapLeaveConstraint(err))
}
return out, true, nil
}
// Ledger is the employee's entries for year, in recording order.
func Ledger(ctx context.Context, tx pgx.Tx, employeeID uuid.UUID, year int) ([]Entry, error) {
org, err := orgOf(ctx)
if err != nil {
return nil, err
}
rows, err := tx.Query(ctx, `SELECT `+entryColumns+` FROM leave_ledger
WHERE org_id = $1 AND employee_id = $2 AND leave_year = $3 ORDER BY seq`, org, employeeID, year)
if err != nil {
return nil, fmt.Errorf("hr: leave ledger: %w", err)
}
defer rows.Close()
out := []Entry{}
for rows.Next() {
e, err := scanEntry(rows)
if err != nil {
return nil, fmt.Errorf("hr: leave ledger: %w", err)
}
out = append(out, e)
}
return out, rows.Err()
}
// BalanceOf is the sum of the employee's entries for the type and year —
// leave_balance(), the one definition (spec H3.2).
func BalanceOf(ctx context.Context, tx pgx.Tx, employeeID, typeID uuid.UUID, year int) (Days, error) {
org, err := orgOf(ctx)
if err != nil {
return 0, err
}
var d Days
if err := tx.QueryRow(ctx, `SELECT (leave_balance($1, $2, $3, $4) * 2)::int`, org, employeeID, typeID, year).Scan(&d); err != nil {
return 0, fmt.Errorf("hr: leave balance: %w", err)
}
return d, nil
}
// Balance is one employee's year for one type, each kind of entry summed
// (Taken and Expired negative, as stored), and Pending the days of their
// requests still awaiting a decision. Balance is the sum of the six sums.
type Balance struct {
EmployeeID uuid.UUID
EmployeeName string
EmployeeNo string
TypeID uuid.UUID
Year int
Granted Days
CarriedForward Days
Taken Days
Reversed Days
Expired Days
Adjusted Days
Balance Days
Pending Days
}
// Balances is every employee and type with an entry or a request in year
// (spec H3.5 "balances per employee"); employeeID narrows it to one person.
func Balances(ctx context.Context, tx pgx.Tx, year int, employeeID *uuid.UUID) ([]Balance, error) {
org, err := orgOf(ctx)
if err != nil {
return nil, err
}
rows, err := tx.Query(ctx, `
WITH k AS (
SELECT l.employee_id, l.leave_type_id FROM leave_ledger l WHERE l.org_id = $1 AND l.leave_year = $2
UNION
SELECT r.employee_id, r.leave_type_id FROM leave_request r WHERE r.org_id = $1 AND r.leave_year = $2)
SELECT k.employee_id, p.legal_name, e.employee_no, k.leave_type_id,
(coalesce(sum(l.days) FILTER (WHERE l.kind = 'grant'), 0) * 2)::int,
(coalesce(sum(l.days) FILTER (WHERE l.kind = 'carry_forward'), 0) * 2)::int,
(coalesce(sum(l.days) FILTER (WHERE l.kind = 'take'), 0) * 2)::int,
(coalesce(sum(l.days) FILTER (WHERE l.kind = 'reverse'), 0) * 2)::int,
(coalesce(sum(l.days) FILTER (WHERE l.kind = 'expire'), 0) * 2)::int,
(coalesce(sum(l.days) FILTER (WHERE l.kind = 'adjust'), 0) * 2)::int,
(coalesce((SELECT sum(r.days) FROM leave_request r
WHERE r.org_id = $1 AND r.employee_id = k.employee_id AND r.leave_type_id = k.leave_type_id
AND r.leave_year = $2 AND leave_semantic(r.org_id, r.instance_id) = 'pending'), 0) * 2)::int
FROM k
JOIN employee e ON e.org_id = $1 AND e.id = k.employee_id
JOIN party p ON p.org_id = e.org_id AND p.id = e.party_id
LEFT JOIN leave_ledger l ON l.org_id = $1 AND l.employee_id = k.employee_id
AND l.leave_type_id = k.leave_type_id AND l.leave_year = $2
WHERE ($3::uuid IS NULL OR k.employee_id = $3)
GROUP BY k.employee_id, p.legal_name, e.employee_no, k.leave_type_id
ORDER BY e.employee_no, k.leave_type_id`, org, year, employeeID)
if err != nil {
return nil, fmt.Errorf("hr: leave balances: %w", err)
}
defer rows.Close()
out := []Balance{}
for rows.Next() {
b := Balance{Year: year}
if err := rows.Scan(&b.EmployeeID, &b.EmployeeName, &b.EmployeeNo, &b.TypeID, &b.Granted, &b.CarriedForward,
&b.Taken, &b.Reversed, &b.Expired, &b.Adjusted, &b.Pending); err != nil {
return nil, fmt.Errorf("hr: leave balances: %w", err)
}
b.Balance = b.Granted + b.CarriedForward + b.Taken + b.Reversed + b.Expired + b.Adjusted
out = append(out, b)
}
return out, rows.Err()
}
// AccrualDue is every employee who accrues leave: active or under notice,
// with a hire date (Ruling H3-6). A pending record has no hire date.
func AccrualDue(ctx context.Context, tx pgx.Tx) ([]uuid.UUID, error) {
org, err := orgOf(ctx)
if err != nil {
return nil, err
}
rows, err := tx.Query(ctx, `SELECT e.id FROM employee e CROSS JOIN LATERAL employee_spell(e.org_id, e.id) s
WHERE e.org_id = $1 AND s.status IN ('active', 'notice') AND s.hire_date IS NOT NULL ORDER BY e.id`, org)
if err != nil {
return nil, fmt.Errorf("hr: accrual due: %w", err)
}
ids, err := pgx.CollectRows(rows, pgx.RowTo[uuid.UUID])
if err != nil {
return nil, fmt.Errorf("hr: accrual due: %w", err)
}
return ids, nil
}
// AccrueYear grants the employee's leave for the leave year of today and
// rolls the annual year (Rulings H3-6, H3-7), idempotently: what is already
// written is not written again. It returns what it wrote.
func AccrueYear(ctx context.Context, tx pgx.Tx, c clock.Clock, employeeID uuid.UUID) ([]Entry, error) {
org, err := orgOf(ctx)
if err != nil {
return nil, err
}
e, err := lockEmployee(ctx, tx, employeeID)
if err != nil {
return nil, err
}
today := clock.Today(ctx, c)
if (e.Status != StatusActive && e.Status != StatusNotice) || e.HireDate == nil || e.HireDate.After(today) {
return nil, nil
}
year := today.Year()
yearStart := time.Date(year, time.January, 1, 0, 0, 0, 0, time.UTC)
grantOn, months := yearStart, 12
if e.HireDate.After(yearStart) {
grantOn = *e.HireDate
months = min(12, ServiceMonths(*e.HireDate, yearStart.AddDate(1, 0, 0)))
}
service := ServiceMonths(*e.HireDate, grantOn)
types, err := ListLeaveTypes(ctx, tx, true)
if err != nil {
return nil, err
}
setting, err := GetLeaveSetting(ctx, tx)
if err != nil {
return nil, err
}
var out []Entry
for _, t := range types {
if !t.Paid {
continue
}
rule, ok, err := ruleFor(ctx, tx, t.ID, grantOn, service)
if err != nil {
return nil, err
}
if ok {
floor, err := floorFor(ctx, tx, t.Kind, service)
if err != nil {
return nil, err
}
days := rule.Days
if t.Kind == KindAnnual {
days, floor = prorate(days, months), prorate(floor, months)
}
ruleID := rule.ID
g, wrote, err := insertEntry(ctx, tx, c, org, Entry{EmployeeID: e.ID, TypeID: t.ID, Year: year,
Kind: EntryGrant, Days: days, FloorDays: &floor, RuleID: &ruleID}, onceGrant)
if err != nil {
return nil, err
}
if wrote {
out = append(out, g)
}
}
if t.Kind == KindAnnual {
rolled, err := rollYear(ctx, tx, c, org, e.ID, t.ID, year, today, setting)
if err != nil {
return nil, err
}
out = append(out, rolled...)
}
}
if len(out) == 0 {
return nil, nil
}
return out, audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntityLeaveLedger, EntityID: e.ID, Action: "accrue", After: out})
}
// rollYear is Ruling H3-7 for one annual type: last year's remaining
// balance lapses and up to the setting's maximum is carried in; once the
// expiry months are up, carried days not yet used expire.
func rollYear(ctx context.Context, tx pgx.Tx, c clock.Clock, org, employeeID, typeID uuid.UUID, year int,
today time.Time, s LeaveSetting) ([]Entry, error) {
var out []Entry
remaining, err := BalanceOf(ctx, tx, employeeID, typeID, year-1)
if err != nil {
return nil, err
}
if remaining > 0 {
cause := ExpiryYearEnd
x, wrote, err := insertEntry(ctx, tx, c, org, Entry{EmployeeID: employeeID, TypeID: typeID, Year: year - 1,
Kind: EntryExpire, Days: -remaining, ExpiryOf: &cause}, onceExpire)
if err != nil {
return nil, err
}
if wrote {
out = append(out, x)
if carry := min(remaining, s.CarryForwardMax); carry > 0 {
cf, wrote, err := insertEntry(ctx, tx, c, org, Entry{EmployeeID: employeeID, TypeID: typeID, Year: year,
Kind: EntryCarryForward, Days: carry}, onceCarry)
if err != nil {
return nil, err
}
if wrote {
out = append(out, cf)
}
}
}
}
if s.CarryForwardExpiryMonths == nil ||
today.Before(time.Date(year, time.January, 1, 0, 0, 0, 0, time.UTC).AddDate(0, *s.CarryForwardExpiryMonths, 0)) {
return out, nil
}
var carried Days
if err := tx.QueryRow(ctx, `SELECT (coalesce(sum(days), 0) * 2)::int FROM leave_ledger
WHERE org_id = $1 AND employee_id = $2 AND leave_type_id = $3 AND leave_year = $4 AND kind = 'carry_forward'`,
org, employeeID, typeID, year).Scan(&carried); err != nil {
return nil, fmt.Errorf("hr: carried forward: %w", err)
}
if carried <= 0 {
return out, nil
}
balance, err := BalanceOf(ctx, tx, employeeID, typeID, year)
if err != nil {
return nil, err
}
if lapse := min(carried, balance); lapse > 0 {
cause := ExpiryCarryForward
x, wrote, err := insertEntry(ctx, tx, c, org, Entry{EmployeeID: employeeID, TypeID: typeID, Year: year,
Kind: EntryExpire, Days: -lapse, ExpiryOf: &cause}, onceExpire)
if err != nil {
return nil, err
}
if wrote {
out = append(out, x)
}
}
return out, nil
}
// AdjustInput is a manual correction by HR (spec H3.2): a non-zero number
// of half days, either sign, with a reason.
type AdjustInput struct {
EmployeeID uuid.UUID
TypeID uuid.UUID
Year int
Days Days
Reason string
Actor uuid.UUID
}
// Adjust records a manual adjustment, audited. The database refuses one
// that takes a statutory kind below the floor for that year, or a negative
// one in a year with no grant (HD-7): ErrAdjustBelowFloor.
func Adjust(ctx context.Context, tx pgx.Tx, c clock.Clock, in AdjustInput) (Entry, error) {
org, err := orgOf(ctx)
if err != nil {
return Entry{}, err
}
switch {
case in.Days == 0:
return Entry{}, &FieldError{Field: "days", Rule: "non_zero", Detail: "an adjustment changes the balance"}
case in.Year < 2000 || in.Year > 2999:
return Entry{}, &FieldError{Field: "year", Rule: "range", Detail: "a leave year from 2000"}
}
reason, err := checkReason("reason", in.Reason, true)
if err != nil {
return Entry{}, err
}
if _, err := lockEmployee(ctx, tx, in.EmployeeID); err != nil {
return Entry{}, err
}
if _, err := GetLeaveType(ctx, tx, in.TypeID); err != nil {
return Entry{}, err
}
actor := in.Actor
e, _, err := insertEntry(ctx, tx, c, org, Entry{EmployeeID: in.EmployeeID, TypeID: in.TypeID, Year: in.Year,
Kind: EntryAdjust, Days: in.Days, Reason: reason, RecordedBy: &actor}, "")
if err != nil {
return Entry{}, err
}
return e, audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntityLeaveLedger, EntityID: e.ID, Action: "adjust", After: e})
}
  • Step 5: Write the job

Create internal/platform/hr/leave_job.go:

package hr
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/riverqueue/river"
"github.com/mcom/erp/internal/kernel/clock"
"github.com/mcom/erp/internal/kernel/db"
"github.com/mcom/erp/internal/kernel/jobs"
"github.com/mcom/erp/internal/kernel/tenancy"
)
// orgIDs is every organization, through the worker's one allowlisted
// cross-tenant read (Ruling H1-17). Each job then works inside each
// organization's own tenant transaction.
func orgIDs(ctx context.Context, d *db.DB) ([]uuid.UUID, error) {
rows, err := d.Pool().Query(ctx, `SELECT org_id FROM ops.org_ids()`)
if err != nil {
return nil, err
}
return pgx.CollectRows(rows, pgx.RowTo[uuid.UUID])
}
// LeaveGrantsArgs is the daily tick that grants leave and rolls the year.
type LeaveGrantsArgs struct{}
func (LeaveGrantsArgs) Kind() string { return "hr_leave_grants" }
func (LeaveGrantsArgs) InsertOpts() river.InsertOpts {
return river.InsertOpts{Queue: jobs.QueueMaintenance}
}
// LeaveGrantsPeriodic runs daily and on start: the first run of a year is
// the leave-year-start grant (Ruling H3-6), and a hire recorded mid-year is
// granted within a day.
func LeaveGrantsPeriodic() *river.PeriodicJob {
return river.NewPeriodicJob(river.PeriodicInterval(24*time.Hour),
func() (river.JobArgs, *river.InsertOpts) { return LeaveGrantsArgs{}, nil },
&river.PeriodicJobOpts{RunOnStart: true})
}
// LeaveGrantsWorker accrues every due employee of every organization, each
// in its own tenant transaction, whether or not the leave module is on
// (Ruling H3-18). One failure is logged and retried tomorrow; the job
// itself succeeds.
type LeaveGrantsWorker struct {
river.WorkerDefaults[LeaveGrantsArgs]
DB *db.DB
Clock clock.Clock
Logger *slog.Logger
}
func (w *LeaveGrantsWorker) Work(ctx context.Context, _ *river.Job[LeaveGrantsArgs]) error {
// ponytail: every employee of every organization daily, one lock and a
// few indexed reads each; an index of who is due is the upgrade when
// the head count makes this slow.
orgs, err := orgIDs(ctx, w.DB)
if err != nil {
return fmt.Errorf("hr_leave_grants: org ids: %w", err)
}
for _, org := range orgs {
octx := tenancy.WithOrg(ctx, org)
var due []uuid.UUID
if err := w.DB.InTx(octx, func(ctx context.Context, tx pgx.Tx) error {
var err error
due, err = AccrualDue(ctx, tx)
return err
}); err != nil {
w.Logger.Error("hr_leave_grants: due", "org_id", org, "err", err)
continue
}
for _, id := range due {
if err := w.DB.InTx(octx, func(ctx context.Context, tx pgx.Tx) error {
_, err := AccrueYear(ctx, tx, w.Clock, id)
return err
}); err != nil {
w.Logger.Error("hr_leave_grants: employee", "org_id", org, "employee_id", id, "err", err)
}
}
}
return nil
}
  • Step 6: Register the job in the worker

In cmd/worker/exports.go, after H1’s river.AddWorker(workers, &hr.LeavingsWorker{…}) line:

river.AddWorker(workers, &hr.LeaveGrantsWorker{DB: database, Clock: clock.System(), Logger: cfg.Logger})

In cmd/worker/main.go, in the PeriodicJobs list, change hr.LeavingsPeriodic()}}, to:

opsjobs.ReconcilePeriodic(), opsjobs.DispatchPeriodic(), hr.LeavingsPeriodic(), hr.LeaveGrantsPeriodic()}},

(keeping the list’s first line as H1 left it).

  • Step 7: Run the tests

Run: go test ./internal/platform/hr/... ./cmd/worker/ ./internal/kernel/layering/ -count=1 && go build ./cmd/... Expected: PASS; the build succeeds.

  • Step 8: Watch each drift test fail once
  1. In 00841_leave.sql comment out CREATE UNIQUE INDEX leave_ledger_grant_once … and in leave_ledger.go set onceGrant = "". Run go test ./internal/platform/hr/ -run TestTheGrantJobRunTwiceGrantsOnce -count=1. Expected: FAIL — ABSENCE of a second grant: "[…]" should have 1 item(s), but has 2. Restore both; PASS.
  2. In ruleFor, replace x.effective_from <= $3 with x.effective_from <= $3::date + 3650. Run go test ./internal/platform/hr/ -run TestARuleChangeDoesNotAlterAPastGrant -count=1. Expected: FAIL — hired before the change: 10 × 8 ÷ 12 under the old rule: expected 14, actual 18 (half days). Restore; PASS.
  3. In leave_ledger_guard(), change total + NEW.days < fl to total + NEW.days < 0. Run go test ./internal/platform/hr/ -run TestAnAdjustmentCannotTakeAStatutoryType -count=1. Expected: FAIL — ABSENCE below it: Target error should be in err chain. Restore; PASS.
  • Step 9: Commit
Terminal window
git add internal/platform/hr cmd/worker
git commit -m "Phase 5 H3: leave ledger — idempotent grants, year roll, adjustments above the floor, balances
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 6: Leave requests — the leave_request workflow, takes and reversals, the balance check, medical certificates

Section titled “Task 6: Leave requests — the leave_request workflow, takes and reversals, the balance check, medical certificates”

Files:

  • Create: internal/platform/hr/leave_request.go
  • Modify: internal/platform/workflow/model.go (CommitmentKinds), internal/platform/hr/hrtest/leave.go (append Request, LeaveMove); if Phase 4 O1 merged, internal/platform/trade/graphs_test.go
  • Test: internal/platform/hr/leave_request_test.go, internal/platform/hr/leave_owner_test.go

Interfaces:

  • Consumes: Task 1’s posting.Committed and soleOwnerEdge; Task 4’s GetLeaveType, WorkingDays, LeaveYearWarnings, dateOnly; Task 5’s insertEntry, BalanceOf, Balances; H1’s GetByMember, lockEmployee, checkReason, ErrNotFound, ErrWrongStatus; workflow.Start, workflow.Transition, workflow.Get, workflow.Load, workflow.AvailableTransitions, workflow.Decisions, workflow.RegisterSeededGraph, workflow.AllowSoleOwnerException, workflow.SoleOwnerAudit; documents.Lock, documents.BoundElsewhere, documents.RegisterBinder; member.Can; money.Zero.

  • Produces:

    • const LeaveRequestKind = "leave_request"; func LeaveRequestGraph(ownerSeat uuid.UUID) workflow.Graph (registered as a seeded graph; opted into the sole-owner exception)
    • type LeaveRequest struct { ID, EmployeeID uuid.UUID; EmployeeName string; TypeID uuid.UUID; TypeKind string; From, To time.Time; FromHalf, ToHalf bool; Days Days; Year int; Reason string; AttachmentID *uuid.UUID; InstanceID, RequestedBy uuid.UUID; CreatedAt time.Time; State string; Semantic posting.Semantic }
    • type RequestInput struct { TypeID uuid.UUID; From, To time.Time; FromHalf, ToHalf bool; Reason string; AttachmentID *uuid.UUID; Actor uuid.UUID }
    • func RequestLeave(ctx context.Context, tx pgx.Tx, c clock.Clock, in RequestInput) (LeaveRequest, []string, error)
    • func GetLeaveRequest(ctx context.Context, tx pgx.Tx, id uuid.UUID) (LeaveRequest, error)
    • type RequestFilter struct { EmployeeID *uuid.UUID; Semantic string; From, To *time.Time }, func ListLeaveRequests(ctx context.Context, tx pgx.Tx, f RequestFilter) ([]LeaveRequest, error)
    • type LeaveTransitionInput struct { RequestID uuid.UUID; From, To string; Actor uuid.UUID; Reason string }, func TransitionLeave(ctx context.Context, tx pgx.Tx, c clock.Clock, in LeaveTransitionInput) (LeaveRequest, workflow.Decision, error)
    • func AwaitingLeave(ctx context.Context, tx pgx.Tx, c clock.Clock, member uuid.UUID) ([]LeaveRequest, error)
    • func CanReadLeave(ctx context.Context, tx pgx.Tx, c clock.Clock, r LeaveRequest, member uuid.UUID) (bool, error), func MayReadMedical(ctx context.Context, tx pgx.Tx, c clock.Clock, r LeaveRequest, member uuid.UUID) (bool, error)
    • workflow.CommitmentKinds gains "leave_request"
    • Ruling H3-20: var OwnerLeaveFloor = money.MustParse("1.00"); type OwnerLeaveReason string with ReasonOwnerLeaveApproverMissing, ReasonOwnerLeaveApproverVacant; type OwnerLeaveApproverError struct{ Reason OwnerLeaveReason }; (*hrtest.Env).CoOwner(t *testing.T, name string) uuid.UUID
    • (*hrtest.Env).Request(t *testing.T, member uuid.UUID, in hr.RequestInput) hr.LeaveRequest (created and submitted), (*hrtest.Env).LeaveMove(t *testing.T, id, by uuid.UUID, from, to, reason string) hr.LeaveRequest
  • Step 1: Extend the fixture

Append to internal/platform/hr/hrtest/leave.go:

// Request creates member's leave request and submits it.
func (e *Env) Request(t *testing.T, member uuid.UUID, in hr.RequestInput) hr.LeaveRequest {
t.Helper()
in.Actor = member
var r hr.LeaveRequest
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
var err error
r, _, err = hr.RequestLeave(ctx, tx, e.Clock, in)
return err
})
return e.LeaveMove(t, r.ID, member, "draft", "pending", "")
}
// LeaveMove moves request id from → to, decided by by, on e's clock.
func (e *Env) LeaveMove(t *testing.T, id, by uuid.UUID, from, to, reason string) hr.LeaveRequest {
t.Helper()
var r hr.LeaveRequest
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
var err error
r, _, err = hr.TransitionLeave(ctx, tx, e.Clock, hr.LeaveTransitionInput{RequestID: id, From: from, To: to,
Actor: by, Reason: reason})
return err
})
return r
}
// CoOwner is a new member (their own seat under the OWNER seat) who also
// holds the OWNER seat from 2026-01-01, as a second assignment: a
// partnership's second OWNER-seat holder (Ruling H3-20, case 1).
func (e *Env) CoOwner(t *testing.T, name string) uuid.UUID {
t.Helper()
m, _ := e.Member(t, name, &e.OwnerSeat)
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
_, err := orgstruct.Assign(ctx, tx, e.Clock, orgstruct.AssignmentInput{MemberID: m, PositionID: e.OwnerSeat,
ValidFrom: Day(2026, 1, 1)})
return err
})
return m
}

(Add "github.com/mcom/erp/internal/platform/orgstruct" to hrtest/leave.go’s imports.)

Create internal/platform/hr/leave_owner_test.go:

package hr_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"
"github.com/mcom/erp/internal/platform/hr/hrtest"
"github.com/mcom/erp/internal/platform/posting"
"github.com/mcom/erp/internal/platform/workflow"
)
// canApprove: member may take instance's approve edge now.
func canApprove(t *testing.T, e *hrtest.Env, instance, m uuid.UUID) bool {
t.Helper()
var ok bool
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
avail, err := workflow.AvailableTransitions(ctx, tx, e.Clock, instance, m)
for _, a := range avail {
ok = ok || (a.To == "approved" && a.Allowed)
}
return err
})
return ok
}
// Ruling H3-20 case 1 (owner decision 2026-09-30): with two holders of the
// OWNER seat, the other one approves; the requester never does — the engine
// refuses it and so does the database.
func TestAnotherOwnerSeatHolderApprovesTheOwnersLeave(t *testing.T) {
e := hrtest.New(t)
al := e.Annual(t, "AL", 8, 12, 16)
owner := e.Hired(t, e.Owner, "E-1")
e.Accrue(t, e.Clock, owner.ID)
partner := e.CoOwner(t, "Partner")
r := e.Request(t, e.Owner, hr.RequestInput{TypeID: al.ID, From: hrtest.Day(2026, 10, 5), To: hrtest.Day(2026, 10, 6)})
require.Equal(t, "pending", r.State, "PRESENCE: routable")
require.True(t, canApprove(t, e, r.InstanceID, partner), "the other OWNER-seat holder decides")
require.False(t, canApprove(t, e, r.InstanceID, e.Owner), "ABSENCE: never the requester")
r = e.LeaveMove(t, r.ID, partner, "pending", "approved", "")
require.Equal(t, posting.Committed, r.Semantic)
next := e.Request(t, e.Owner, hr.RequestInput{TypeID: al.ID, From: hrtest.Day(2026, 10, 12), To: hrtest.Day(2026, 10, 12)})
err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error {
_, err := tx.Exec(ctx, `INSERT INTO leave_ledger (org_id, employee_id, leave_type_id, leave_year, kind, days,
request_id, recorded_by, recorded_at) VALUES ($1, $2, $3, 2026, 'take', -1, $4, $5, now())`,
e.Org, owner.ID, al.ID, next.ID, e.Owner)
return err
})
require.ErrorContains(t, err, "leave_take_by_requester", "the database backstop")
}
// Ruling H3-20 case 3: several members and one OWNER-seat holder — refused
// by name until an administrator configures the approver through the
// workflow definition; that grant then decides OWNER-seat leave only.
func TestAnOwnersLeaveWaitsForAConfiguredApproverNamedInTheRefusal(t *testing.T) {
e := hrtest.New(t)
al := e.Annual(t, "AL", 8, 12, 16)
owner := e.Hired(t, e.Owner, "E-1")
e.Accrue(t, e.Clock, owner.ID)
staff := e.Hired(t, e.Staff, "E-100")
e.Accrue(t, e.Clock, staff.ID)
var draft hr.LeaveRequest
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
var err error
draft, _, err = hr.RequestLeave(ctx, tx, e.Clock, hr.RequestInput{TypeID: al.ID,
From: hrtest.Day(2026, 10, 5), To: hrtest.Day(2026, 10, 6), Actor: e.Owner})
return err
})
err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error {
_, _, err := hr.TransitionLeave(ctx, tx, e.Clock, hr.LeaveTransitionInput{RequestID: draft.ID,
From: "draft", To: "pending", Actor: e.Owner})
return err
})
var missing *hr.OwnerLeaveApproverError
require.ErrorAs(t, err, &missing, "ABSENCE of a configured approver: refused, never left unapproved")
require.Equal(t, hr.ReasonOwnerLeaveApproverMissing, missing.Reason)
// The administrator's setting: a grant on approved, at the OWNER-seat
// floor, naming the HR member — published as a new definition version.
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
g := hr.LeaveRequestGraph(e.OwnerSeat)
floor := hr.OwnerLeaveFloor
g.Grants = append(g.Grants, workflow.GrantSpec{StateKey: "approved", GranteeKind: workflow.GranteeMember,
MemberID: &e.HR, MinAmount: &floor, MinAmountCurrency: "MYR"})
_, err := workflow.PublishGraph(ctx, tx, e.Clock, g.Normalized(), &e.Owner)
return err
})
r := e.Request(t, e.Owner, hr.RequestInput{TypeID: al.ID, From: hrtest.Day(2026, 10, 12), To: hrtest.Day(2026, 10, 12)})
require.Equal(t, "pending", r.State, "PRESENCE: routable once configured")
require.True(t, canApprove(t, e, r.InstanceID, e.HR))
require.False(t, canApprove(t, e, r.InstanceID, e.Owner))
other := e.Request(t, e.Staff, hr.RequestInput{TypeID: al.ID, From: hrtest.Day(2026, 10, 5), To: hrtest.Day(2026, 10, 5)})
require.False(t, canApprove(t, e, other.InstanceID, e.HR), "the configured grant decides OWNER-seat leave only")
require.True(t, canApprove(t, e, other.InstanceID, e.Owner))
e.LeaveMove(t, r.ID, e.HR, "pending", "approved", "")
}

Case 2 (the one-member organization) is TestASoleOwnerApprovesTheirOwnLeaveWithAReason below.

Create internal/platform/hr/leave_request_test.go:

package hr_test
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/platform/documents"
"github.com/mcom/erp/internal/platform/hr"
"github.com/mcom/erp/internal/platform/hr/hrtest"
"github.com/mcom/erp/internal/platform/posting"
"github.com/mcom/erp/internal/platform/workflow"
"github.com/mcom/erp/internal/platform/workflow/workflowtest"
)
func TestTheLeaveRequestGraphPassesTheValidator(t *testing.T) {
workflowtest.RequireSeededGraphValid(t, hr.LeaveRequestKind)
}
// Spec H3 drift bullet: "A leave request beyond the paid annual balance is
// refused; cancelling an approved request restores it." Approval ends in
// committed and posts take; cancelling after approval posts reverse.
func TestALeaveRequestBeyondThePaidBalanceIsRefusedAndCancellingRestoresIt(t *testing.T) {
e := hrtest.New(t)
al := e.Annual(t, "AL", 8, 12, 16)
staff := e.Hired(t, e.Staff, "E-100")
e.Accrue(t, e.Clock, staff.ID) // eight days for 2026
// 2026-10-05 is a Monday.
week := e.Request(t, e.Staff, hr.RequestInput{TypeID: al.ID, From: hrtest.Day(2026, 10, 5), To: hrtest.Day(2026, 10, 9),
Reason: "Family trip to Kota Kinabalu"})
require.Equal(t, hr.WholeDays(5), week.Days)
require.Equal(t, posting.Pending, week.Semantic)
week = e.LeaveMove(t, week.ID, e.Owner, "pending", "approved", "")
require.Equal(t, posting.Committed, week.Semantic)
require.Equal(t, hr.WholeDays(3), e.Balance(t, staff.ID, al.ID, 2026), "PRESENCE: the approval took five days")
var next hr.LeaveRequest
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
var err error
next, _, err = hr.RequestLeave(ctx, tx, e.Clock, hr.RequestInput{TypeID: al.ID,
From: hrtest.Day(2026, 10, 12), To: hrtest.Day(2026, 10, 15), Actor: e.Staff})
return err
})
submit := func() error {
return e.TxErr(func(ctx context.Context, tx pgx.Tx) error {
_, _, err := hr.TransitionLeave(ctx, tx, e.Clock, hr.LeaveTransitionInput{RequestID: next.ID,
From: "draft", To: "pending", Actor: e.Staff})
return err
})
}
err := submit()
var short *hr.InsufficientBalanceError
require.ErrorAs(t, err, &short, "ABSENCE: four days asked, three left")
require.Equal(t, hr.WholeDays(3), short.Available)
require.Equal(t, hr.WholeDays(4), short.Requested)
require.ErrorIs(t, err, hr.ErrInsufficientBalance)
cancelled := e.LeaveMove(t, week.ID, e.Staff, "approved", "cancelled", "Trip postponed")
require.Equal(t, posting.Cancelled, cancelled.Semantic)
require.Equal(t, hr.WholeDays(8), e.Balance(t, staff.ID, al.ID, 2026), "the reverse restores the five days")
require.NoError(t, submit(), "and the four days now fit")
e.LeaveMove(t, next.ID, e.Owner, "pending", "approved", "")
require.Equal(t, hr.WholeDays(4), e.Balance(t, staff.ID, al.ID, 2026))
err = e.TxErr(func(ctx context.Context, tx pgx.Tx) error {
_, _, err := hr.TransitionLeave(ctx, tx, hrtest.At(hrtest.Day(2026, 10, 12)), hr.LeaveTransitionInput{
RequestID: next.ID, From: "approved", To: "cancelled", Actor: e.Staff, Reason: "Back early"})
return err
})
require.ErrorIs(t, err, hr.ErrLeaveStarted, "once the leave has begun it stands")
}
// Spec H3.3: unpaid leave is unlimited.
func TestUnpaidLeaveIsUnlimited(t *testing.T) {
e := hrtest.New(t)
ul := e.LeaveType(t, hr.LeaveTypeInput{Code: "UL", Kind: hr.KindUnpaid})
staff := e.Hired(t, e.Staff, "E-100")
r := e.Request(t, e.Staff, hr.RequestInput{TypeID: ul.ID, From: hrtest.Day(2026, 10, 5), To: hrtest.Day(2026, 10, 7)})
e.LeaveMove(t, r.ID, e.Owner, "pending", "approved", "")
require.Equal(t, hr.WholeDays(-3), e.Balance(t, staff.ID, ul.ID, 2026), "no grant, and nothing refused")
}
// Rulings H3-10, H3-12: a request is the member's own, inside their
// employment and one leave year, on working days, never overlapping another.
func TestARequestIsTheMembersOwnWithinTheirEmployment(t *testing.T) {
e := hrtest.New(t)
al := e.Annual(t, "AL", 8, 12, 16)
study := e.LeaveType(t, hr.LeaveTypeInput{Code: "STUDY", Kind: hr.KindOther, Paid: true})
e.Hired(t, e.Staff, "E-100")
request := func(member uuid.UUID, in hr.RequestInput) (hr.LeaveRequest, []string, error) {
in.Actor = member
var r hr.LeaveRequest
var w []string
err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error {
var err error
r, w, err = hr.RequestLeave(ctx, tx, e.Clock, in)
return err
})
return r, w, err
}
first, warnings, err := request(e.Staff, hr.RequestInput{TypeID: al.ID, From: hrtest.Day(2026, 10, 5), To: hrtest.Day(2026, 10, 9)})
require.NoError(t, err, "PRESENCE: a plain request")
require.Equal(t, "draft", first.State)
require.Equal(t, []string{hr.WarningNoPublicHolidays}, warnings, "no holidays entered for 2026")
_, _, err = request(e.Owner, hr.RequestInput{TypeID: al.ID, From: hrtest.Day(2026, 11, 2), To: hrtest.Day(2026, 11, 2)})
require.ErrorIs(t, err, hr.ErrNotAnEmployee, "the Owner has no employee record here")
_, _, err = request(e.Staff, hr.RequestInput{TypeID: al.ID, From: hrtest.Day(2026, 10, 8), To: hrtest.Day(2026, 10, 12)})
require.ErrorIs(t, err, hr.ErrOverlappingLeave, "a draft is live leave too")
var fe *hr.FieldError
for want, in := range map[string]hr.RequestInput{
"one_leave_year": {TypeID: al.ID, From: hrtest.Day(2026, 12, 30), To: hrtest.Day(2027, 1, 4)},
"before_hire": {TypeID: al.ID, From: hrtest.Day(2025, 12, 29), To: hrtest.Day(2025, 12, 30)},
"not_allowed": {TypeID: study.ID, From: hrtest.Day(2026, 11, 2), To: hrtest.Day(2026, 11, 3), FromHalf: true},
"no_working_days": {TypeID: al.ID, From: hrtest.Day(2026, 10, 10), To: hrtest.Day(2026, 10, 11)},
} {
_, _, err := request(e.Staff, in)
require.ErrorAs(t, err, &fe, want)
require.Equal(t, want, fe.Rule)
}
}
// Spec H3.3: a one-member organization's Owner may approve their own leave
// with a reason, audited.
func TestASoleOwnerApprovesTheirOwnLeaveWithAReason(t *testing.T) {
e := hrtest.New(t)
al := e.Annual(t, "AL", 8, 12, 16)
owner := e.Hired(t, e.Owner, "E-1")
e.Accrue(t, e.Clock, owner.ID)
e.DeactivateByAdmin(t, e.Staff)
e.DeactivateByAdmin(t, e.HR)
r := e.Request(t, e.Owner, hr.RequestInput{TypeID: al.ID, From: hrtest.Day(2026, 10, 5), To: hrtest.Day(2026, 10, 6)})
approve := func(reason string) (workflow.Decision, error) {
var d workflow.Decision
err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error {
var err error
_, d, err = hr.TransitionLeave(ctx, tx, e.Clock, hr.LeaveTransitionInput{RequestID: r.ID,
From: "pending", To: "approved", Actor: e.Owner, Reason: reason})
return err
})
return d, err
}
_, err := approve("")
require.ErrorIs(t, err, workflow.ErrSoleOwnerReasonRequired, "ABSENCE of a reason is refused")
d, err := approve("Sole proprietor; nobody else can approve")
require.NoError(t, err, "PRESENCE: with a reason")
require.Equal(t, workflow.ViaSoleOwner, d.Via)
require.Equal(t, hr.WholeDays(6), e.Balance(t, owner.ID, al.ID, 2026))
require.Equal(t, 1, e.Count(t, `SELECT count(*) FROM audit_log WHERE entity_kind = 'leave_request'
AND action = 'transition' AND (after->>'sole_owner_exception')::boolean`))
}
// Spec H3.5: a medical certificate is health data — the approver, HR and
// hr.leave.medical.read see it; hr.leave.read_all does not. The leave type
// is visible to the approver.
func TestAMedicalCertificateIsForTheApproverAndHROnly(t *testing.T) {
e := hrtest.New(t)
sick := e.LeaveType(t, hr.LeaveTypeInput{Code: "MC", Kind: hr.KindSick})
e.Rules(t, sick.ID, hrtest.Day(2000, 1, 1),
hr.Band{FromMonth: 0, ToMonth: hrtest.M(24), Days: hr.WholeDays(14)},
hr.Band{FromMonth: 24, ToMonth: hrtest.M(60), Days: hr.WholeDays(18)},
hr.Band{FromMonth: 60, Days: hr.WholeDays(22)})
staff := e.Hired(t, e.Staff, "E-100")
e.Accrue(t, e.Clock, staff.ID)
err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error {
_, _, err := hr.RequestLeave(ctx, tx, e.Clock, hr.RequestInput{TypeID: sick.ID,
From: hrtest.Day(2026, 10, 5), To: hrtest.Day(2026, 10, 6), Actor: e.Staff})
return err
})
var fe *hr.FieldError
require.ErrorAs(t, err, &fe)
require.Equal(t, "attachment_id", fe.Field)
doc := e.Document(t, e.Staff, "application/pdf", []byte("Klinik Kesihatan: medical certificate, two days"))
r := e.Request(t, e.Staff, hr.RequestInput{TypeID: sick.ID, From: hrtest.Day(2026, 10, 5),
To: hrtest.Day(2026, 10, 6), AttachmentID: &doc})
reader, _ := e.Member(t, "Reader", &e.OwnerSeat)
e.Grant(t, reader, hr.PermissionLeaveReadAll)
medical, _ := e.Member(t, "Medical", &e.OwnerSeat)
e.Grant(t, medical, hr.PermissionLeaveMedicalRead)
clerk, _ := e.Member(t, "Clerk", &e.OwnerSeat)
e.Grant(t, e.HR, hr.PermissionLeaveManage)
sees := func(m uuid.UUID) (doc, request bool) {
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
var err error
if doc, err = documents.MayRead(ctx, tx, e.Clock, *r.AttachmentID, m); err != nil {
return err
}
request, err = hr.CanReadLeave(ctx, tx, e.Clock, r, m)
return err
})
return doc, request
}
for name, m := range map[string]uuid.UUID{"the requester": e.Staff, "the approver": e.Owner, "HR": e.HR,
"hr.leave.medical.read": medical} {
d, _ := sees(m)
require.True(t, d, "PRESENCE: %s sees the certificate", name)
}
d, req := sees(reader)
require.True(t, req, "read_all opens the request")
require.False(t, d, "ABSENCE: but not the certificate")
d, req = sees(clerk)
require.False(t, d)
require.False(t, req, "a member with no part in it sees neither")
require.Equal(t, hr.KindSick, r.TypeKind, "the approver is shown the leave type")
err = e.TxErr(func(ctx context.Context, tx pgx.Tx) error {
_, _, err := hr.RequestLeave(ctx, tx, e.Clock, hr.RequestInput{TypeID: sick.ID,
From: hrtest.Day(2026, 10, 12), To: hrtest.Day(2026, 10, 12), AttachmentID: &doc, Actor: e.Staff})
return err
})
require.ErrorIs(t, err, hr.ErrAttachmentBound, "one certificate backs one request")
var awaiting []hr.LeaveRequest
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
var err error
awaiting, err = hr.AwaitingLeave(ctx, tx, e.Clock, e.Owner)
return err
})
require.Len(t, awaiting, 1, "the Owner's queue holds the staff member's request")
}
// Spec H3 drift bullet: "Leave balance equals the sum of leave_ledger for
// every employee and type" — across all six kinds of entry.
func TestLeaveBalanceEqualsTheSumOfTheLedger(t *testing.T) {
e := hrtest.New(t)
al := e.Annual(t, "AL", 12, 12, 16)
ul := e.LeaveType(t, hr.LeaveTypeInput{Code: "UL", Kind: hr.KindUnpaid})
staff := e.Hired(t, e.Staff, "E-100")
hrEmp := e.Hired(t, e.HR, "E-101")
e.Accrue(t, e.Clock, staff.ID)
e.Accrue(t, e.Clock, hrEmp.ID)
gone := e.Request(t, e.Staff, hr.RequestInput{TypeID: al.ID, From: hrtest.Day(2026, 10, 5), To: hrtest.Day(2026, 10, 6)})
e.LeaveMove(t, gone.ID, e.Owner, "pending", "approved", "")
e.LeaveMove(t, gone.ID, e.Staff, "approved", "cancelled", "Plans changed")
kept := e.Request(t, e.Staff, hr.RequestInput{TypeID: al.ID, From: hrtest.Day(2026, 10, 12), To: hrtest.Day(2026, 10, 12)})
e.LeaveMove(t, kept.ID, e.Owner, "pending", "approved", "")
unpaid := e.Request(t, e.HR, hr.RequestInput{TypeID: ul.ID, From: hrtest.Day(2026, 10, 5), To: hrtest.Day(2026, 10, 7)})
e.LeaveMove(t, unpaid.ID, e.Owner, "pending", "approved", "")
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
_, err := hr.Adjust(ctx, tx, e.Clock, hr.AdjustInput{EmployeeID: hrEmp.ID, TypeID: al.ID, Year: 2026,
Days: hr.WholeDays(1), Reason: "Correction of the 2026 grant", Actor: e.HR})
return err
})
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
_, err := hr.SetLeaveSetting(ctx, tx, e.Clock, hr.LeaveSetting{CarryForwardMax: hr.WholeDays(3)}, e.Owner)
return err
})
january := hrtest.At(hrtest.Day(2027, 1, 4))
e.Accrue(t, january, staff.ID)
e.Accrue(t, january, hrEmp.ID)
var kinds []string
require.NoError(t, e.H.Admin.QueryRow(context.Background(),
`SELECT array_agg(DISTINCT kind ORDER BY kind) FROM leave_ledger WHERE org_id = $1`, e.Org).Scan(&kinds))
require.Equal(t, []string{"adjust", "carry_forward", "expire", "grant", "reverse", "take"}, kinds,
"PRESENCE: every kind of entry is in the ledger")
for _, year := range []int{2026, 2027} {
raw := map[[2]uuid.UUID]hr.Days{}
rows, err := e.H.Admin.Query(context.Background(), `SELECT employee_id, leave_type_id, (sum(days) * 2)::int
FROM leave_ledger WHERE org_id = $1 AND leave_year = $2 GROUP BY 1, 2`, e.Org, year)
require.NoError(t, err)
for rows.Next() {
var emp, typ uuid.UUID
var d hr.Days
require.NoError(t, rows.Scan(&emp, &typ, &d))
raw[[2]uuid.UUID{emp, typ}] = d
}
rows.Close()
require.NotEmpty(t, raw, "PRESENCE: %d has entries", year)
var got []hr.Balance
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
var err error
got, err = hr.Balances(ctx, tx, year, nil)
return err
})
require.Len(t, got, len(raw), "one balance per employee and type with entries in %d", year)
for _, b := range got {
require.Equal(t, raw[[2]uuid.UUID{b.EmployeeID, b.TypeID}], b.Balance, "%d %s %s", year, b.EmployeeNo, b.TypeID)
require.Equal(t, b.Balance, e.Balance(t, b.EmployeeID, b.TypeID, year))
}
}
}
  • Step 3: Run them to verify they fail

Run: go test ./internal/platform/hr/ -run 'TestTheLeaveRequestGraph|TestALeaveRequestBeyond|TestUnpaidLeave|TestARequestIsTheMembers|TestASoleOwnerApproves|TestAMedicalCertificate|TestLeaveBalanceEquals|TestAnotherOwnerSeatHolder|TestAnOwnersLeaveWaits' -count=1 Expected: FAIL to compile — undefined: hr.LeaveRequestKind, hr.RequestLeave, hr.TransitionLeave, ….

  • Step 4: Make leave_request a commitment kind

In internal/platform/workflow/model.go, append "leave_request" to the CommitmentKinds literal, so it reads (with H2’s "vacancy" too if H2 merged first):

var CommitmentKinds = []string{"purchase_requisition", "purchase_order", "quotation", "sales_order", "leave_request"}

and add to its doc comment: Phase 5 H3 adds leave_request (approved to take; the leave ledger, not the general ledger, records it).

If Phase 4 O1 has merged: its internal/platform/trade/graphs_test.go asserts require.Len(t, workflow.CommitmentKinds, 4, …) and runs RequireSeededGraphValid for every kind in a test binary that does not link platform/hr, so leave_request would fail there as unseeded. Replace the body of TestTheCommitmentKindsAreSeededAndValid with:

trade := []string{"purchase_requisition", "purchase_order", "quotation", "sales_order"}
require.Subset(t, workflow.CommitmentKinds, trade, "PRESENCE: the four trade kinds are commitment kinds")
for _, k := range trade {
workflowtest.RequireSeededGraphValid(t, k)
}

(leave_request is judged by this task’s TestTheLeaveRequestGraphPassesTheValidator; O1’s internal/app and cmd/orgadmin tests, which link every module, still require every commitment kind to be seeded. H2 makes the same change for vacancy; whichever lands second keeps the trade-only loop.)

  • Step 5: Write the requests

Create internal/platform/hr/leave_request.go:

package hr
import (
"context"
"errors"
"fmt"
"slices"
"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/documents"
"github.com/mcom/erp/internal/platform/identity/member"
"github.com/mcom/erp/internal/platform/orgstruct"
"github.com/mcom/erp/internal/platform/posting"
"github.com/mcom/erp/internal/platform/workflow"
)
// LeaveRequestKind is the workflow kind of a leave request and the kind its
// document binder answers for.
const LeaveRequestKind = "leave_request"
// OwnerLeaveFloor is the amount an OWNER-seat holder's leave request starts
// at (Ruling H3-20, owner decision 2026-09-30): a routing tier, not money —
// nothing posts from it. An administrator's grant at this floor on the
// approved step decides OWNER-seat leave and no one else's.
var OwnerLeaveFloor = money.MustParse("1.00")
// OwnerLeaveReason names the setting an administrator must fix before an
// OWNER-seat holder's leave can be submitted (orgstruct.NoSuperiorReason's
// style: the repair is in the name).
type OwnerLeaveReason string
const (
// ReasonOwnerLeaveApproverMissing: no grant at OwnerLeaveFloor in the
// leave_request definition. Fix: add one (a member or a position) on the
// approved step in the workflow definition editor, and publish.
ReasonOwnerLeaveApproverMissing OwnerLeaveReason = "owner_leave_approver_missing"
// ReasonOwnerLeaveApproverVacant: there is one, and what it names cannot
// decide (a member who left, a vacant seat). Fix: re-point it.
ReasonOwnerLeaveApproverVacant OwnerLeaveReason = "owner_leave_approver_vacant"
)
// OwnerLeaveApproverError is the refusal of an OWNER-seat holder's
// submission that nobody but themselves could decide, in an organization of
// more than one member with no other OWNER-seat holder.
type OwnerLeaveApproverError struct{ Reason OwnerLeaveReason }
func (e *OwnerLeaveApproverError) Error() string {
return "hr: nobody is configured to approve the Owner's leave: " + string(e.Reason)
}
// ownerApproverError names which setting is missing.
func ownerApproverError(def workflow.Definition) error {
for _, g := range def.Grants {
if !g.AllStates && g.Floor().Cmp(OwnerLeaveFloor) >= 0 {
return &OwnerLeaveApproverError{Reason: ReasonOwnerLeaveApproverVacant}
}
}
return &OwnerLeaveApproverError{Reason: ReasonOwnerLeaveApproverMissing}
}
func init() {
workflow.RegisterSeededGraph(LeaveRequestKind, LeaveRequestGraph)
// Spec H3.3: a one-member organization's Owner approves their own
// leave with a reason, audited (the approval into committed is an
// issue soleOwnerEdge covers).
workflow.AllowSoleOwnerException(LeaveRequestKind, true)
documents.RegisterBinder(leaveBinder{})
}
func leaveLabels(en, zh, ms string) workflow.Labels { return workflow.Labels{"en": en, "zh": zh, "ms": ms} }
// LeaveRequestGraph is spec H3.3's flow (Rulings H3-10, H3-11):
//
// draft → pending → approved (committed: the leave ledger takes the days)
// │ ├──→ rejected
// └────────┴──→ cancelled ←── approved (before the first day: the days come back)
//
// Approved by the seat above the requester's (escalating past a vacancy)
// or the OWNER seat; nobody names an approver.
func LeaveRequestGraph(ownerSeat uuid.UUID) workflow.Graph {
st := func(order int, key string, sem posting.Semantic, terminal bool, l workflow.Labels) workflow.StateSpec {
return workflow.StateSpec{Key: key, Semantic: sem, DisplayOrder: order, IsTerminal: terminal, Labels: l}
}
ed := func(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}
}
why := func(e workflow.EdgeSpec) workflow.EdgeSpec {
e.RequiresReason = true
return e
}
one := 1
withdraw := leaveLabels("Withdraw", "撤回", "Tarik balik")
return workflow.Graph{
Kind: LeaveRequestKind,
PostingOrder: workflow.AccrualFirst,
States: []workflow.StateSpec{
st(1, "draft", posting.Draft, false, leaveLabels("Draft", "草稿", "Draf")),
st(2, "pending", posting.Pending, false, leaveLabels("Awaiting approval", "待批准", "Menunggu kelulusan")),
st(3, "approved", posting.Committed, false, leaveLabels("Approved", "已批准", "Diluluskan")),
st(4, "rejected", posting.Rejected, true, leaveLabels("Rejected", "已拒绝", "Ditolak")),
st(5, "cancelled", posting.Cancelled, true, leaveLabels("Cancelled", "已取消", "Dibatalkan")),
},
Edges: []workflow.EdgeSpec{
ed(1, "draft", "pending", workflow.GateReporter, "submit", leaveLabels("Submit", "提交", "Hantar")),
ed(2, "pending", "approved", workflow.GateTarget, "approve", leaveLabels("Approve", "批准", "Luluskan")),
why(ed(3, "pending", "rejected", workflow.GateAdvance, "reject", leaveLabels("Reject", "拒绝", "Tolak"))),
why(ed(4, "draft", "cancelled", workflow.GateReporter, "withdraw", withdraw)),
why(ed(5, "pending", "cancelled", workflow.GateReporter, "withdraw", withdraw)),
why(ed(6, "approved", "cancelled", workflow.GateReporter, "cancel",
leaveLabels("Cancel leave", "取消休假", "Batalkan cuti"))),
},
Grants: []workflow.GrantSpec{
{StateKey: "approved", GranteeKind: workflow.GranteeLevelsUp, LevelsUp: &one},
{StateKey: "approved", GranteeKind: workflow.GranteePosition, PositionID: &ownerSeat},
},
}.Normalized()
}
// LeaveRequest is one request with its current state, read by semantic.
type LeaveRequest struct {
ID uuid.UUID
EmployeeID uuid.UUID
EmployeeName string
TypeID uuid.UUID
TypeKind string
From, To time.Time
FromHalf bool
ToHalf bool
Days Days
Year int
Reason string
AttachmentID *uuid.UUID
InstanceID uuid.UUID
RequestedBy uuid.UUID
CreatedAt time.Time
State string
Semantic posting.Semantic
}
// requestSelect is the one read of a request: every table tied to $1.
const requestSelect = `
SELECT r.id, r.employee_id, p.legal_name, r.leave_type_id, t.kind, r.from_date, r.to_date, r.from_half, r.to_half,
(r.days * 2)::int, r.leave_year, r.reason, r.attachment_document_id, r.instance_id, r.requested_by,
r.created_at, i.state_key, s.semantic
FROM leave_request r
JOIN leave_type t ON t.org_id = r.org_id AND t.id = r.leave_type_id
JOIN employee e ON e.org_id = r.org_id AND e.id = r.employee_id
JOIN party p ON p.org_id = e.org_id AND p.id = e.party_id
JOIN workflow_instance i ON i.org_id = r.org_id AND i.id = r.instance_id
JOIN workflow_state s ON s.org_id = i.org_id AND s.definition_id = i.definition_id AND s.state_key = i.state_key
WHERE r.org_id = $1 AND `
func scanRequest(row pgx.Row) (LeaveRequest, error) {
var r LeaveRequest
err := row.Scan(&r.ID, &r.EmployeeID, &r.EmployeeName, &r.TypeID, &r.TypeKind, &r.From, &r.To, &r.FromHalf,
&r.ToHalf, &r.Days, &r.Year, &r.Reason, &r.AttachmentID, &r.InstanceID, &r.RequestedBy, &r.CreatedAt,
&r.State, &r.Semantic)
r.From, r.To, r.CreatedAt = r.From.UTC(), r.To.UTC(), r.CreatedAt.UTC()
return r, err
}
func GetLeaveRequest(ctx context.Context, tx pgx.Tx, id uuid.UUID) (LeaveRequest, error) {
org, err := orgOf(ctx)
if err != nil {
return LeaveRequest{}, err
}
r, err := scanRequest(tx.QueryRow(ctx, requestSelect+`r.id = $2`, org, id))
if errors.Is(err, pgx.ErrNoRows) {
return LeaveRequest{}, ErrLeaveRequestNotFound
}
if err != nil {
return LeaveRequest{}, fmt.Errorf("hr: leave request: %w", err)
}
return r, nil
}
// RequestFilter narrows ListLeaveRequests: one employee, one semantic
// ("pending", "committed", …), and requests overlapping [From, To].
type RequestFilter struct {
EmployeeID *uuid.UUID
Semantic string
From, To *time.Time
}
// ListLeaveRequests is the matching requests, latest first.
func ListLeaveRequests(ctx context.Context, tx pgx.Tx, f RequestFilter) ([]LeaveRequest, error) {
org, err := orgOf(ctx)
if err != nil {
return nil, err
}
// ponytail: at most 500 rows; a cursor is the upgrade when one screen needs more.
rows, err := tx.Query(ctx, requestSelect+`($2::uuid IS NULL OR r.employee_id = $2) AND ($3 = '' OR s.semantic = $3)
AND ($4::date IS NULL OR r.to_date >= $4) AND ($5::date IS NULL OR r.from_date <= $5)
ORDER BY r.from_date DESC, r.id LIMIT 500`, org, f.EmployeeID, f.Semantic, f.From, f.To)
if err != nil {
return nil, fmt.Errorf("hr: leave requests: %w", err)
}
defer rows.Close()
out := []LeaveRequest{}
for rows.Next() {
r, err := scanRequest(rows)
if err != nil {
return nil, fmt.Errorf("hr: leave requests: %w", err)
}
out = append(out, r)
}
return out, rows.Err()
}
// RequestInput is a member's request for their own leave (Ruling H3-10).
type RequestInput struct {
TypeID uuid.UUID
From, To time.Time
FromHalf bool
ToHalf bool
Reason string
AttachmentID *uuid.UUID
Actor uuid.UUID
}
// RequestLeave records the actor's own request as a draft, with its working
// days counted now, and returns the leave year's warnings
// (no_public_holidays). Submitting it is TransitionLeave to pending.
func RequestLeave(ctx context.Context, tx pgx.Tx, c clock.Clock, in RequestInput) (LeaveRequest, []string, error) {
org, err := orgOf(ctx)
if err != nil {
return LeaveRequest{}, nil, err
}
who, err := GetByMember(ctx, tx, in.Actor)
if errors.Is(err, ErrNotFound) {
return LeaveRequest{}, nil, ErrNotAnEmployee
}
if err != nil {
return LeaveRequest{}, nil, err
}
emp, err := lockEmployee(ctx, tx, who.ID)
if err != nil {
return LeaveRequest{}, nil, err
}
if emp.Status != StatusActive && emp.Status != StatusNotice {
return LeaveRequest{}, nil, ErrWrongStatus
}
from, to := dateOnly(in.From), dateOnly(in.To)
switch {
case from.Year() != to.Year():
return LeaveRequest{}, nil, &FieldError{Field: "to_date", Rule: "one_leave_year",
Detail: "a request lies inside one leave year; split it at 31 December"}
case from.Before(*emp.HireDate):
return LeaveRequest{}, nil, &FieldError{Field: "from_date", Rule: "before_hire", Detail: "leave starts on or after the hire date"}
case emp.LastDay != nil && to.After(*emp.LastDay):
return LeaveRequest{}, nil, &FieldError{Field: "to_date", Rule: "after_last_day", Detail: "leave ends by the last day"}
}
t, err := GetLeaveType(ctx, tx, in.TypeID)
if err != nil {
return LeaveRequest{}, nil, err
}
switch {
case !t.Active:
return LeaveRequest{}, nil, &FieldError{Field: "type_id", Rule: "active", Detail: "this leave type is no longer offered"}
case (in.FromHalf || in.ToHalf) && !t.HalfDays:
return LeaveRequest{}, nil, &FieldError{Field: "half_days", Rule: "not_allowed", Detail: "this leave is taken in whole days"}
case t.AttachmentRequired && in.AttachmentID == nil:
return LeaveRequest{}, nil, &FieldError{Field: "attachment_id", Rule: "required", Detail: "this leave needs a medical certificate"}
}
days, err := WorkingDays(ctx, tx, t.Kind, from, to, in.FromHalf, in.ToHalf)
if err != nil {
return LeaveRequest{}, nil, err
}
if days == 0 {
return LeaveRequest{}, nil, &FieldError{Field: "to_date", Rule: "no_working_days", Detail: "these dates hold no working day"}
}
reason, err := checkReason("reason", in.Reason, false)
if err != nil {
return LeaveRequest{}, nil, err
}
if in.AttachmentID != nil {
if err := checkAttachment(ctx, tx, *in.AttachmentID, in.Actor); err != nil {
return LeaveRequest{}, nil, err
}
}
var overlap bool
if err := tx.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM leave_request r
WHERE r.org_id = $1 AND r.employee_id = $2 AND r.from_date <= $4 AND r.to_date >= $3
AND leave_semantic(r.org_id, r.instance_id) NOT IN ('rejected', 'cancelled'))`,
org, emp.ID, from, to).Scan(&overlap); err != nil {
return LeaveRequest{}, nil, fmt.Errorf("hr: request leave: %w", err)
}
if overlap {
return LeaveRequest{}, nil, ErrOverlappingLeave
}
// Ruling H3-20: an OWNER-seat holder's request starts at the OWNER-seat
// floor, so only a grant an administrator set at that floor (and the
// floor-0 seeded grants) can decide it, and that grant decides nobody
// else's.
amount := money.Zero()
ownerSeat, err := workflow.OwnerSeat(ctx, tx)
if err != nil {
return LeaveRequest{}, nil, err
}
holders, err := orgstruct.HoldersOf(ctx, tx, ownerSeat, clock.Today(ctx, c))
if err != nil {
return LeaveRequest{}, nil, err
}
if slices.Contains(holders, in.Actor) {
amount = OwnerLeaveFloor
}
id := uuid.New()
inst, err := workflow.Start(ctx, tx, c, workflow.StartInput{Kind: LeaveRequestKind, SourceID: id,
Reporter: in.Actor, Amount: amount})
if err != nil {
return LeaveRequest{}, nil, err
}
if _, err := tx.Exec(ctx, `INSERT INTO leave_request (id, org_id, employee_id, leave_type_id, from_date, to_date,
from_half, to_half, days, leave_year, reason, attachment_document_id, instance_id, requested_by, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::numeric, $10, $11, $12, $13, $14, $15)`,
id, org, emp.ID, t.ID, from, to, in.FromHalf, in.ToHalf, days.String(), from.Year(), reason, in.AttachmentID,
inst.ID, in.Actor, c.Now()); err != nil {
return LeaveRequest{}, nil, fmt.Errorf("hr: request leave: %w", err)
}
warnings, err := LeaveYearWarnings(ctx, tx, from.Year())
if err != nil {
return LeaveRequest{}, nil, err
}
r, err := GetLeaveRequest(ctx, tx, id)
if err != nil {
return LeaveRequest{}, nil, err
}
return r, warnings, audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntityLeaveRequest, EntityID: id, Action: "create", After: r})
}
// checkAttachment: a verified document the requester uploaded, backing no
// other live record (Ruling H3-13). It locks the document row for the rest
// of the transaction, which is what makes the one-record rule a rule.
func checkAttachment(ctx context.Context, tx pgx.Tx, docID, actor uuid.UUID) error {
doc, err := documents.Lock(ctx, tx, docID)
switch {
case errors.Is(err, documents.ErrNotFound):
return &FieldError{Field: "attachment_id", Rule: "not_found", Detail: "no such document"}
case err != nil:
return err
case doc.UploadedBy != actor:
return &FieldError{Field: "attachment_id", Rule: "not_yours", Detail: "attach a document you uploaded"}
case doc.Status != documents.StatusVerified:
return &FieldError{Field: "attachment_id", Rule: "not_verified", Detail: "the document has not been verified"}
}
bound, err := documents.BoundElsewhere(ctx, tx, docID, documents.Binding{Kind: LeaveRequestKind})
if err != nil {
return err
}
if len(bound) > 0 {
return ErrAttachmentBound
}
return nil
}
// LeaveTransitionInput moves one request along one edge of its workflow.
type LeaveTransitionInput struct {
RequestID uuid.UUID
From, To string
Actor uuid.UUID
Reason string
}
// TransitionLeave is the one way a request moves. Under the organization and
// employee locks it checks the balance on submission and approval (Ruling
// H3-9) and refuses a cancel of leave that has begun, then moves the
// instance and, by semantic: entering committed takes the days; leaving
// committed for cancelled reverses them.
func TransitionLeave(ctx context.Context, tx pgx.Tx, c clock.Clock, in LeaveTransitionInput) (LeaveRequest, workflow.Decision, error) {
org, err := orgOf(ctx)
if err != nil {
return LeaveRequest{}, workflow.Decision{}, err
}
r, err := GetLeaveRequest(ctx, tx, in.RequestID)
if err != nil {
return LeaveRequest{}, workflow.Decision{}, err
}
if _, err := lockEmployee(ctx, tx, r.EmployeeID); err != nil {
return LeaveRequest{}, workflow.Decision{}, err
}
inst, err := workflow.Get(ctx, tx, r.InstanceID)
if err != nil {
return LeaveRequest{}, workflow.Decision{}, err
}
def, err := workflow.Load(ctx, tx, inst.DefinitionID)
if err != nil {
return LeaveRequest{}, workflow.Decision{}, err
}
from, _ := def.State(inst.StateKey)
to, ok := def.State(in.To)
if !ok {
return LeaveRequest{}, workflow.Decision{}, workflow.ErrNoSuchEdge
}
t, err := GetLeaveType(ctx, tx, r.TypeID)
if err != nil {
return LeaveRequest{}, workflow.Decision{}, err
}
cancelsTaken := from.Semantic == posting.Committed && to.Semantic == posting.Cancelled
switch {
case t.Paid && (to.Semantic == posting.Pending || to.Semantic == posting.Committed):
balance, err := BalanceOf(ctx, tx, r.EmployeeID, r.TypeID, r.Year)
if err != nil {
return LeaveRequest{}, workflow.Decision{}, err
}
held := Days(0)
if to.Semantic == posting.Pending {
if held, err = pendingDays(ctx, tx, org, r); err != nil {
return LeaveRequest{}, workflow.Decision{}, err
}
}
if balance-held < r.Days {
return LeaveRequest{}, workflow.Decision{}, &InsufficientBalanceError{Available: balance - held, Requested: r.Days}
}
case cancelsTaken && !clock.Today(ctx, c).Before(r.From):
return LeaveRequest{}, workflow.Decision{}, ErrLeaveStarted
}
dec, err := workflow.Transition(ctx, tx, c, workflow.TransitionInput{InstanceID: r.InstanceID, From: in.From,
To: in.To, Actor: in.Actor, Reason: in.Reason})
var unroutable *workflow.SubmissionRefusedError
if errors.As(err, &unroutable) && inst.Amount.Cmp(OwnerLeaveFloor) >= 0 {
return LeaveRequest{}, workflow.Decision{}, ownerApproverError(def)
}
if err != nil {
return LeaveRequest{}, workflow.Decision{}, err
}
actor, requestID := in.Actor, r.ID
switch {
case to.Semantic == posting.Committed:
if _, _, err := insertEntry(ctx, tx, c, org, Entry{EmployeeID: r.EmployeeID, TypeID: r.TypeID, Year: r.Year,
Kind: EntryTake, Days: -r.Days, RequestID: &requestID, RecordedBy: &actor}, ""); err != nil {
return LeaveRequest{}, workflow.Decision{}, err
}
case cancelsTaken:
if _, _, err := insertEntry(ctx, tx, c, org, Entry{EmployeeID: r.EmployeeID, TypeID: r.TypeID, Year: r.Year,
Kind: EntryReverse, Days: r.Days, RequestID: &requestID, RecordedBy: &actor}, ""); err != nil {
return LeaveRequest{}, workflow.Decision{}, err
}
}
after, err := GetLeaveRequest(ctx, tx, r.ID)
if err != nil {
return LeaveRequest{}, workflow.Decision{}, err
}
return after, dec, audit.Write(ctx, tx, c, audit.Entry{EntityKind: EntityLeaveRequest, EntityID: r.ID,
Action: "transition", Before: map[string]string{"state": r.State}, After: workflow.SoleOwnerAudit(after.State, dec)})
}
// pendingDays is the employee's other requests of r's type and year still
// awaiting a decision: days a submission may not count on.
func pendingDays(ctx context.Context, tx pgx.Tx, org uuid.UUID, r LeaveRequest) (Days, error) {
var d Days
err := tx.QueryRow(ctx, `SELECT (coalesce(sum(x.days), 0) * 2)::int FROM leave_request x
WHERE x.org_id = $1 AND x.employee_id = $2 AND x.leave_type_id = $3 AND x.leave_year = $4 AND x.id <> $5
AND leave_semantic(x.org_id, x.instance_id) = 'pending'`, org, r.EmployeeID, r.TypeID, r.Year, r.ID).Scan(&d)
if err != nil {
return 0, fmt.Errorf("hr: pending leave: %w", err)
}
return d, nil
}
// decides reports whether m may decide r now (an allowed exit other than the
// requester's own) or decided it before.
func decides(ctx context.Context, tx pgx.Tx, c clock.Clock, r LeaveRequest, m uuid.UUID) (bool, error) {
avail, err := workflow.AvailableTransitions(ctx, tx, c, r.InstanceID, m)
if err != nil {
return false, err
}
if slices.ContainsFunc(avail, func(a workflow.Available) bool { return a.Allowed && a.Gate != workflow.GateReporter }) {
return true, nil
}
ds, err := workflow.Decisions(ctx, tx, r.InstanceID)
if err != nil {
return false, err
}
return slices.ContainsFunc(ds, func(d workflow.Decision) bool { return d.Actor == m && d.Gate != workflow.GateReporter }), nil
}
// AwaitingLeave is every pending request member may decide now.
func AwaitingLeave(ctx context.Context, tx pgx.Tx, c clock.Clock, m uuid.UUID) ([]LeaveRequest, error) {
pending, err := ListLeaveRequests(ctx, tx, RequestFilter{Semantic: string(posting.Pending)})
if err != nil {
return nil, err
}
// ponytail: one AvailableTransitions per pending request in the
// organization; an approver index is the upgrade when pending leave
// runs into the hundreds.
out := []LeaveRequest{}
for _, r := range pending {
if r.RequestedBy == m {
continue
}
avail, err := workflow.AvailableTransitions(ctx, tx, c, r.InstanceID, m)
if err != nil {
return nil, err
}
if slices.ContainsFunc(avail, func(a workflow.Available) bool { return a.Allowed && a.Gate != workflow.GateReporter }) {
out = append(out, r)
}
}
return out, nil
}
// CanReadLeave: the requester, whoever may decide it or did, and holders of
// hr.leave.read_all or hr.leave.manage. The leave type is part of what
// they see (spec H3.5: "the leave type is visible to the approver").
func CanReadLeave(ctx context.Context, tx pgx.Tx, c clock.Clock, r LeaveRequest, m uuid.UUID) (bool, error) {
if r.RequestedBy == m {
return true, nil
}
for _, key := range []string{PermissionLeaveReadAll, PermissionLeaveManage} {
if ok, err := member.Can(ctx, tx, m, key); err != nil || ok {
return ok, err
}
}
return decides(ctx, tx, c, r, m)
}
// MayReadMedical is who may open a request's attachment (spec H3.5, Ruling
// H3-13): the requester, whoever may decide it or did, HR (hr.leave.manage)
// and hr.leave.medical.read — never hr.leave.read_all alone.
func MayReadMedical(ctx context.Context, tx pgx.Tx, c clock.Clock, r LeaveRequest, m uuid.UUID) (bool, error) {
if r.RequestedBy == m {
return true, nil
}
for _, key := range []string{PermissionLeaveManage, PermissionLeaveMedicalRead} {
if ok, err := member.Can(ctx, tx, m, key); err != nil || ok {
return ok, err
}
}
return decides(ctx, tx, c, r, m)
}
// leaveBinder is the leave request's documents.Binder (wave-3 contract
// §1.4): a medical certificate backs one live request, and MayReadMedical
// decides who opens it.
type leaveBinder struct{}
func (leaveBinder) Kind() string { return LeaveRequestKind }
func (leaveBinder) BoundTo(ctx context.Context, tx pgx.Tx, documentID uuid.UUID) ([]documents.Binding, error) {
org, err := orgOf(ctx)
if err != nil {
return nil, err
}
rows, err := tx.Query(ctx, `SELECT r.id FROM leave_request r
WHERE r.org_id = $1 AND r.attachment_document_id = $2
AND leave_semantic(r.org_id, r.instance_id) NOT IN ('rejected', 'cancelled')
ORDER BY r.created_at`, org, documentID)
if err != nil {
return nil, fmt.Errorf("hr: leave document bindings: %w", err)
}
ids, err := pgx.CollectRows(rows, pgx.RowTo[uuid.UUID])
if err != nil {
return nil, fmt.Errorf("hr: leave document bindings: %w", err)
}
out := make([]documents.Binding, len(ids))
for i, id := range ids {
out[i] = documents.Binding{Kind: LeaveRequestKind, RecordID: id, Reference: "LEAVE-" + id.String()[:8]}
}
return out, nil
}
func (leaveBinder) MayRead(ctx context.Context, tx pgx.Tx, c clock.Clock, documentID, memberID uuid.UUID) (bool, error) {
org, err := orgOf(ctx)
if err != nil {
return false, err
}
rows, err := tx.Query(ctx, `SELECT r.id FROM leave_request r WHERE r.org_id = $1 AND r.attachment_document_id = $2`,
org, documentID)
if err != nil {
return false, fmt.Errorf("hr: leave document read: %w", err)
}
ids, err := pgx.CollectRows(rows, pgx.RowTo[uuid.UUID])
if err != nil {
return false, fmt.Errorf("hr: leave document read: %w", err)
}
for _, id := range ids {
r, err := GetLeaveRequest(ctx, tx, id)
if err != nil {
return false, err
}
if ok, err := MayReadMedical(ctx, tx, c, r, memberID); err != nil || ok {
return ok, err
}
}
return false, nil
}
  • Step 6: Run the tests

Run: go test ./internal/platform/hr/... ./internal/platform/workflow/... ./internal/kernel/layering/ -count=1 Expected: PASS.

  • Step 7: Watch each drift test fail once
  1. In TransitionLeave, change if balance-held < r.Days { to if false && balance-held < r.Days {. Run go test ./internal/platform/hr/ -run TestALeaveRequestBeyondThePaidBalance -count=1. Expected: FAIL — ABSENCE: four days asked, three left: Should be in error chain / expected: *hr.InsufficientBalanceError. (The approval would still be stopped by leave_balance_insufficient; the submission is what this guards.) Restore.
  2. In TransitionLeave, delete the case cancelsTaken: branch of the second switch. Expected: FAIL — the reverse restores the five days: expected 16, actual 6. Restore.
  3. In Balances, change b.Balance = b.Granted + b.CarriedForward + b.Taken + b.Reversed + b.Expired + b.Adjusted to drop + b.Expired. Run go test ./internal/platform/hr/ -run TestLeaveBalanceEqualsTheSumOfTheLedger -count=1. Expected: FAIL — the 2026 balances differ by the year-end expiry. Restore.
  4. In MayReadMedical, add PermissionLeaveReadAll to its key list. Run go test ./internal/platform/hr/ -run TestAMedicalCertificate -count=1. Expected: FAIL — ABSENCE: but not the certificate. Restore.
  5. In leave_ledger_guard() (00841), change NEW.recorded_by = r.requested_by to false. Run go test ./internal/platform/hr/ -run TestAnotherOwnerSeatHolder -count=1. Expected: FAIL — the database backstop: An error is expected but got nil. Restore.
  6. In RequestLeave, delete amount = OwnerLeaveFloor. Run go test ./internal/platform/hr/ -run TestAnOwnersLeaveWaits -count=1. Expected: FAIL — the configured grant’s floor is above a 0 amount, so the submission after publishing is still refused (PRESENCE: routable once configured). Restore. Then in TransitionLeave delete the ownerApproverError branch. Expected: FAIL — ABSENCE of a configured approver…: the error is a *workflow.SubmissionRefusedError, not the named reason. Restore; PASS.
  • Step 8: Commit
Terminal window
git add internal/platform/hr internal/platform/workflow/model.go # and internal/platform/trade/graphs_test.go if Step 4 changed it
git commit -m "Phase 5 H3: leave requests — committed approvals take, cancellations reverse, balance check, medical privacy
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 7: Leave and approval routing — hr registers Unavailable, the daily reroute log (HD-2)

Section titled “Task 7: Leave and approval routing — hr registers Unavailable, the daily reroute log (HD-2)”

Files:

  • Create: internal/platform/hr/leave_routing.go
  • Modify: internal/platform/workflow/unavailable.go (append Reroute, ReroutedOn), internal/platform/hr/leave_job.go (append the reroute job), internal/platform/hr/hrtest/leave.go (append ApprovedLeave), cmd/worker/exports.go, cmd/worker/main.go, cmd/worker/wiring_test.go, cmd/api/wiring_test.go
  • Test: internal/platform/hr/leave_routing_test.go

Interfaces:

  • Consumes: Task 2’s passOver (through Resolve), NoteApproverOnLeave, NoteNoAlternate, RegisterUnavailable; Task 5’s orgIDs; Task 6’s RequestLeave, LeaveRequest; workflow.Get, workflow.Load, the package’s orgOf.

  • Produces:

    • workflow.Reroute{InstanceID uuid.UUID; Kind, State string; GrantID uuid.UUID; Away, Members []uuid.UUID; Note string}, func workflow.ReroutedOn(ctx context.Context, tx pgx.Tx, day time.Time) ([]Reroute, error)
    • func hr.OnLeave(ctx context.Context, tx pgx.Tx, member uuid.UUID, day time.Time) (bool, error), registered from hr’s init() as workflow.Unavailable
    • hr.RoutingMove{ID, InstanceID uuid.UUID; Kind, State string; GrantID uuid.UUID; On time.Time; Away, MovedTo []uuid.UUID; NoAlternate bool; LoggedAt time.Time}
    • func hr.RecordRoutingMoves(ctx context.Context, tx pgx.Tx, c clock.Clock) ([]RoutingMove, error), func hr.RoutingMoves(ctx context.Context, tx pgx.Tx, from, to time.Time) ([]RoutingMove, error)
    • River: hr.LeaveRerouteArgs (kind hr_leave_reroute, queue maintenance), hr.LeaveReroutePeriodic() *river.PeriodicJob (daily, on start), hr.LeaveRerouteWorker{DB *db.DB; Clock clock.Clock; Logger *slog.Logger}
    • (*hrtest.Env).ApprovedLeave(t *testing.T, member, typeID uuid.UUID, from, to time.Time) hr.LeaveRequest
  • Step 1: Extend the fixture

Append to internal/platform/hr/hrtest/leave.go:

// ApprovedLeave puts member on approved leave from..to: it creates their
// request and writes its instance's state as approved through the admin
// pool. A fixture for "this person is away", not the approval path (which
// TransitionLeave tests): no take is written, and anyone's leave can be
// placed without configuring its approver.
func (e *Env) ApprovedLeave(t *testing.T, member, typeID uuid.UUID, from, to time.Time) hr.LeaveRequest {
t.Helper()
var r hr.LeaveRequest
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
var err error
r, _, err = hr.RequestLeave(ctx, tx, e.Clock, hr.RequestInput{TypeID: typeID, From: from, To: to, Actor: member})
return err
})
_, err := e.H.Admin.Exec(context.Background(),
`UPDATE workflow_instance SET state_key = 'approved' WHERE org_id = $1 AND id = $2`, e.Org, r.InstanceID)
require.NoError(t, err)
return r
}
  • Step 2: Write the failing tests

Create internal/platform/hr/leave_routing_test.go:

package hr_test
import (
"context"
"io"
"log/slog"
"testing"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/riverqueue/river"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/clock"
"github.com/mcom/erp/internal/platform/hr"
"github.com/mcom/erp/internal/platform/hr/hrtest"
"github.com/mcom/erp/internal/platform/workflow"
)
func runReroute(t *testing.T, e *hrtest.Env, c clock.Clock) {
t.Helper()
w := &hr.LeaveRerouteWorker{DB: e.DB, Clock: c, Logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
require.NoError(t, w.Work(context.Background(), &river.Job[hr.LeaveRerouteArgs]{}))
}
func movesOn(t *testing.T, e *hrtest.Env, day string) []hr.RoutingMove {
t.Helper()
var out []hr.RoutingMove
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
var err error
out, err = hr.RoutingMoves(ctx, tx, mustDay(day), mustDay(day))
return err
})
return out
}
func decisionResolution(t *testing.T, e *hrtest.Env, instance uuid.UUID) string {
t.Helper()
var s string
require.NoError(t, e.H.Admin.QueryRow(context.Background(), `SELECT resolution::text FROM workflow_decision
WHERE instance_id = $1 AND to_key = 'approved'`, instance).Scan(&s))
return s
}
// Spec H3 drift bullet (first half) and HD-2: "A step whose approver is on
// leave escalates and is logged." The decision log records "approver on
// leave"; no delegation is created.
func TestAStepWhoseApproverIsOnLeaveEscalatesAndIsLogged(t *testing.T) {
e := hrtest.New(t)
al := e.Annual(t, "AL", 12, 12, 16)
mgr, mgrSeat := e.Member(t, "Manager", &e.OwnerSeat)
clerk, _ := e.Member(t, "Clerk", &mgrSeat)
e.Hired(t, mgr, "E-200")
clerkEmp := e.Hired(t, clerk, "E-201")
e.Accrue(t, e.Clock, clerkEmp.ID)
r := e.Request(t, clerk, hr.RequestInput{TypeID: al.ID, From: hrtest.Day(2026, 10, 5), To: hrtest.Day(2026, 10, 6)})
require.True(t, canApprove(t, e, r.InstanceID, mgr), "PRESENCE: the clerk's manager decides")
runReroute(t, e, e.Clock)
require.Empty(t, movesOn(t, e, "2026-09-21"), "nobody is away: nothing moves")
e.ApprovedLeave(t, mgr, al.ID, hrtest.Day(2026, 9, 21), hrtest.Day(2026, 9, 25)) // away today
require.False(t, canApprove(t, e, r.InstanceID, mgr), "ABSENCE: the manager, on leave today, is passed over")
require.True(t, canApprove(t, e, r.InstanceID, e.Owner), "the next filled seat above decides")
runReroute(t, e, e.Clock)
runReroute(t, e, e.Clock)
moves := movesOn(t, e, "2026-09-21")
require.Len(t, moves, 1, "logged once a day, however often the job runs")
require.Equal(t, r.InstanceID, moves[0].InstanceID)
require.Equal(t, []uuid.UUID{mgr}, moves[0].Away)
require.Equal(t, []uuid.UUID{e.Owner}, moves[0].MovedTo)
require.False(t, moves[0].NoAlternate)
require.Equal(t, 1, e.Count(t, `SELECT count(*) FROM audit_log WHERE entity_kind = 'workflow_instance'
AND entity_id = $1 AND action = 'approver_on_leave'`, r.InstanceID))
require.Zero(t, e.Count(t, `SELECT count(*) FROM workflow_delegation WHERE org_id = $1`, e.Org), "no delegation is created")
e.LeaveMove(t, r.ID, e.Owner, "pending", "approved", "")
require.Contains(t, decisionResolution(t, e, r.InstanceID), `"note": "approver_on_leave"`,
"the decision log records approver on leave")
runReroute(t, e, hrtest.At(hrtest.Day(2026, 9, 28)))
require.Empty(t, movesOn(t, e, "2026-09-28"), "back from leave: nothing to log")
}
// Spec H3 drift bullet (second half) and H3.4: "with nobody above, it stays
// and is flagged" — never refused.
func TestWithNobodyAboveAvailableTheStepStaysFlaggedAndIsNeverRefused(t *testing.T) {
e := hrtest.New(t)
al := e.Annual(t, "AL", 12, 12, 16)
e.Hired(t, e.Owner, "E-1")
staff := e.Hired(t, e.Staff, "E-100")
e.Accrue(t, e.Clock, staff.ID)
e.ApprovedLeave(t, e.Owner, al.ID, hrtest.Day(2026, 9, 21), hrtest.Day(2026, 9, 25))
// The staff member reports to the Owner's root seat: nobody is above it.
r := e.Request(t, e.Staff, hr.RequestInput{TypeID: al.ID, From: hrtest.Day(2026, 10, 5), To: hrtest.Day(2026, 10, 6)})
require.Equal(t, "pending", r.State, "PRESENCE: the submission was not refused")
require.True(t, canApprove(t, e, r.InstanceID, e.Owner), "it stays with the Owner, who may still decide")
runReroute(t, e, e.Clock)
moves := movesOn(t, e, "2026-09-21")
require.Len(t, moves, 2, "both grants stand on the Owner's seat")
for _, m := range moves {
require.True(t, m.NoAlternate, "flagged: approver on leave, no alternate")
require.Equal(t, []uuid.UUID{e.Owner}, m.Away)
require.Equal(t, []uuid.UUID{e.Owner}, m.MovedTo, "it stayed")
}
e.LeaveMove(t, r.ID, e.Owner, "pending", "approved", "")
require.Contains(t, decisionResolution(t, e, r.InstanceID), `"note": "approver_on_leave_no_alternate"`)
}
// Ruling H3-14: a half day of leave is not unavailability.
func TestAHalfDayOfLeaveLeavesTheApproverAvailable(t *testing.T) {
e := hrtest.New(t)
al := e.Annual(t, "AL", 12, 12, 16)
mgr, _ := e.Member(t, "Manager", &e.OwnerSeat)
e.Hired(t, mgr, "E-200")
var r hr.LeaveRequest
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
var err error
r, _, err = hr.RequestLeave(ctx, tx, e.Clock, hr.RequestInput{TypeID: al.ID, From: hrtest.Day(2026, 9, 21),
To: hrtest.Day(2026, 9, 22), ToHalf: true, Actor: mgr})
return err
})
_, err := e.H.Admin.Exec(context.Background(), `UPDATE workflow_instance SET state_key = 'approved' WHERE id = $1`, r.InstanceID)
require.NoError(t, err)
on := func(day string) bool {
var away bool
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
var err error
away, err = hr.OnLeave(ctx, tx, mgr, mustDay(day))
return err
})
return away
}
require.True(t, on("2026-09-21"), "PRESENCE: a whole day away")
require.False(t, on("2026-09-22"), "a morning off is not a day away")
require.False(t, on("2026-09-23"))
require.True(t, workflow.UnavailableRegistered(), "hr registers the answer at init")
}
  • Step 3: Run them to verify they fail

Run: go test ./internal/platform/hr/ -run 'TestAStepWhoseApprover|TestWithNobodyAbove|TestAHalfDayOfLeave' -count=1 Expected: FAIL to compile — undefined: hr.LeaveRerouteWorker, hr.RoutingMoves, hr.OnLeave, (*hrtest.Env).ApprovedLeave.

  • Step 4: Let workflow list what is routed around somebody

Append to internal/platform/workflow/unavailable.go:

// Reroute is one grant of a step still awaiting a decision that, resolved
// for a day, passes over somebody unavailable (Note is NoteApproverOnLeave
// or NoteNoAlternate). Members is who decides instead — or, under
// NoteNoAlternate, who it stays with.
type Reroute struct {
InstanceID uuid.UUID
Kind string
State string
GrantID uuid.UUID
Away []uuid.UUID
Members []uuid.UUID
Note string
}
// ReroutedOn resolves every pending step of the organization for day and
// returns each grant that passes over somebody, once per instance and grant
// (Phase 5 H3.4: the daily re-check). Nothing is written: approvers are
// resolved when a decision is attempted, so the step has already moved —
// this is what lets hr log it. Sign-off steps are not included (Ruling
// H3-14).
func ReroutedOn(ctx context.Context, tx pgx.Tx, day time.Time) ([]Reroute, error) {
org, err := orgOf(ctx)
if err != nil {
return nil, err
}
rows, err := tx.Query(ctx, `
SELECT i.id FROM workflow_instance i
JOIN workflow_state s ON s.org_id = i.org_id AND s.definition_id = i.definition_id AND s.state_key = i.state_key
WHERE i.org_id = $1 AND s.semantic = 'pending' ORDER BY i.id`, org)
if err != nil {
return nil, fmt.Errorf("workflow: rerouted: %w", err)
}
ids, err := pgx.CollectRows(rows, pgx.RowTo[uuid.UUID])
if err != nil {
return nil, fmt.Errorf("workflow: rerouted: %w", err)
}
// ponytail: resolves every pending step of the organization; hr calls
// it only on a day somebody is on leave. An index of steps by resolved
// member is the upgrade if that ever becomes slow.
var out []Reroute
for _, id := range ids {
inst, err := Get(ctx, tx, id)
if err != nil {
return nil, err
}
def, err := Load(ctx, tx, inst.DefinitionID)
if err != nil {
return nil, err
}
seen := map[uuid.UUID]bool{}
for _, e := range def.EdgesFrom(inst.StateKey) {
gs, ok := def.GateState(e)
if !ok {
continue // a reporter edge: no grant decides it
}
if _, signoff := def.SignoffGrant(gs); signoff {
continue
}
res, err := Resolve(ctx, tx, def.GrantsOn(gs), inst.Reporter, day)
if err != nil {
return nil, err
}
for _, r := range res {
if r.Note == "" || seen[r.GrantID] {
continue
}
seen[r.GrantID] = true
out = append(out, Reroute{InstanceID: inst.ID, Kind: inst.Kind, State: inst.StateKey, GrantID: r.GrantID,
Away: r.Away, Members: r.Members, Note: r.Note})
}
}
}
return out, nil
}
  • Step 5: Write hr’s answer and the log

Create internal/platform/hr/leave_routing.go:

package hr
import (
"context"
"errors"
"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/platform/workflow"
)
// HD-2: hr tells workflow who is unavailable. workflow may not import hr;
// every binary that links hr (cmd/api through modules/hr, cmd/worker
// directly) has the answer registered. cmd/*/wiring_test.go pins it.
func init() { workflow.RegisterUnavailable(OnLeave) }
// OnLeave reports whether member is on approved leave for the whole of day:
// their employee record has a committed request covering it, and day is not
// a half day of it (Ruling H3-14). A member who is not an employee (an
// outside approver) is never on leave.
func OnLeave(ctx context.Context, tx pgx.Tx, m uuid.UUID, day time.Time) (bool, error) {
org, err := orgOf(ctx)
if err != nil {
return false, err
}
var away bool
if err := tx.QueryRow(ctx, `SELECT EXISTS (
SELECT 1 FROM organization_member om
JOIN employee e ON e.org_id = om.org_id AND e.party_id = om.party_id
JOIN leave_request r ON r.org_id = e.org_id AND r.employee_id = e.id
WHERE om.org_id = $1 AND om.id = $2 AND r.from_date <= $3 AND r.to_date >= $3
AND NOT (r.from_half AND r.from_date = $3) AND NOT (r.to_half AND r.to_date = $3)
AND leave_semantic(r.org_id, r.instance_id) = 'committed')`, org, m, dateOnly(day)).Scan(&away); err != nil {
return false, fmt.Errorf("hr: on leave: %w", err)
}
return away, nil
}
// RoutingMove is one approval routed around an approver on leave, as the
// daily job found it (Ruling H3-15). NoAlternate: nobody above was
// available, so the step stayed with Away.
type RoutingMove struct {
ID uuid.UUID
InstanceID uuid.UUID
Kind string
State string
GrantID uuid.UUID
On time.Time
Away []uuid.UUID
MovedTo []uuid.UUID
NoAlternate bool
LoggedAt time.Time
}
const moveColumns = `id, instance_id, kind, state_key, grant_id, on_day, away_member_ids, moved_to_member_ids,
no_alternate, logged_at`
func scanMove(row pgx.Row) (RoutingMove, error) {
var m RoutingMove
err := row.Scan(&m.ID, &m.InstanceID, &m.Kind, &m.State, &m.GrantID, &m.On, &m.Away, &m.MovedTo, &m.NoAlternate, &m.LoggedAt)
m.On, m.LoggedAt = m.On.UTC(), m.LoggedAt.UTC()
return m, err
}
// RecordRoutingMoves logs, for today, every pending step that passes over
// somebody on approved leave — once per instance, step, grant and day, each
// with an audit row on the instance. On a day nobody in the organization
// is on leave it reads one row and does nothing else.
func RecordRoutingMoves(ctx context.Context, tx pgx.Tx, c clock.Clock) ([]RoutingMove, error) {
org, err := orgOf(ctx)
if err != nil {
return nil, err
}
today := clock.Today(ctx, c)
var anyone bool
if err := tx.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM leave_request r
WHERE r.org_id = $1 AND r.from_date <= $2 AND r.to_date >= $2
AND leave_semantic(r.org_id, r.instance_id) = 'committed')`, org, today).Scan(&anyone); err != nil {
return nil, fmt.Errorf("hr: routing moves: %w", err)
}
if !anyone {
return nil, nil
}
rs, err := workflow.ReroutedOn(ctx, tx, today)
if err != nil {
return nil, err
}
now := c.Now()
var out []RoutingMove
for _, r := range rs {
m, err := scanMove(tx.QueryRow(ctx, `INSERT INTO leave_routing_move (org_id, instance_id, kind, state_key,
grant_id, on_day, away_member_ids, moved_to_member_ids, no_alternate, logged_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT ON CONSTRAINT leave_routing_move_once DO NOTHING RETURNING `+moveColumns,
org, r.InstanceID, r.Kind, r.State, r.GrantID, today, r.Away, r.Members, r.Note == workflow.NoteNoAlternate, now))
if errors.Is(err, pgx.ErrNoRows) {
continue
}
if err != nil {
return nil, fmt.Errorf("hr: routing moves: %w", err)
}
out = append(out, m)
}
for _, m := range out {
if err := audit.Write(ctx, tx, c, audit.Entry{EntityKind: "workflow_instance", EntityID: m.InstanceID,
Action: "approver_on_leave", After: m}); err != nil {
return nil, err
}
}
return out, nil
}
// RoutingMoves is every logged move on days from..to, oldest first.
func RoutingMoves(ctx context.Context, tx pgx.Tx, from, to time.Time) ([]RoutingMove, error) {
org, err := orgOf(ctx)
if err != nil {
return nil, err
}
rows, err := tx.Query(ctx, `SELECT `+moveColumns+` FROM leave_routing_move
WHERE org_id = $1 AND on_day BETWEEN $2 AND $3 ORDER BY on_day, logged_at, id`, org, dateOnly(from), dateOnly(to))
if err != nil {
return nil, fmt.Errorf("hr: routing moves: %w", err)
}
defer rows.Close()
out := []RoutingMove{}
for rows.Next() {
m, err := scanMove(rows)
if err != nil {
return nil, fmt.Errorf("hr: routing moves: %w", err)
}
out = append(out, m)
}
return out, rows.Err()
}

Append to internal/platform/hr/leave_job.go:

// LeaveRerouteArgs is the daily re-check of pending steps against who is on
// leave (spec H3.4).
type LeaveRerouteArgs struct{}
func (LeaveRerouteArgs) Kind() string { return "hr_leave_reroute" }
func (LeaveRerouteArgs) InsertOpts() river.InsertOpts {
return river.InsertOpts{Queue: jobs.QueueMaintenance}
}
// LeaveReroutePeriodic runs daily and on start.
func LeaveReroutePeriodic() *river.PeriodicJob {
return river.NewPeriodicJob(river.PeriodicInterval(24*time.Hour),
func() (river.JobArgs, *river.InsertOpts) { return LeaveRerouteArgs{}, nil },
&river.PeriodicJobOpts{RunOnStart: true})
}
// LeaveRerouteWorker logs every organization's approvals routed around
// leave today, in each organization's own transaction (Ruling H3-15),
// whether or not its leave module is on (Ruling H3-18).
type LeaveRerouteWorker struct {
river.WorkerDefaults[LeaveRerouteArgs]
DB *db.DB
Clock clock.Clock
Logger *slog.Logger
}
func (w *LeaveRerouteWorker) Work(ctx context.Context, _ *river.Job[LeaveRerouteArgs]) error {
orgs, err := orgIDs(ctx, w.DB)
if err != nil {
return fmt.Errorf("hr_leave_reroute: org ids: %w", err)
}
for _, org := range orgs {
var moves []RoutingMove
if err := w.DB.InTx(tenancy.WithOrg(ctx, org), func(ctx context.Context, tx pgx.Tx) error {
var err error
moves, err = RecordRoutingMoves(ctx, tx, w.Clock)
return err
}); err != nil {
w.Logger.Error("hr_leave_reroute", "org_id", org, "err", err)
continue
}
if len(moves) > 0 {
w.Logger.Info("hr_leave_reroute: approvals routed around leave", "org_id", org, "moves", len(moves))
}
}
return nil
}
  • Step 6: Register the job and pin the seam in both binaries

In cmd/worker/exports.go, after the hr.LeaveGrantsWorker line:

river.AddWorker(workers, &hr.LeaveRerouteWorker{DB: database, Clock: clock.System(), Logger: cfg.Logger})

In cmd/worker/main.go, change hr.LeavingsPeriodic(), hr.LeaveGrantsPeriodic()}}, to hr.LeavingsPeriodic(), hr.LeaveGrantsPeriodic(), hr.LeaveReroutePeriodic()}},.

Append to cmd/worker/wiring_test.go:

// Phase 5 H3 (HD-2): approvals this binary resolves pass over an approver on
// leave only if internal/platform/hr's init() registered its answer.
func TestWorkerKnowsWhoIsOnLeave(t *testing.T) {
require.True(t, workflow.UnavailableRegistered(),
"cmd/worker must link internal/platform/hr: its init() registers workflow.Unavailable")
}

Append to cmd/api/wiring_test.go (and add "github.com/mcom/erp/internal/platform/workflow" to its imports):

// Phase 5 H3 (HD-2): the API resolves approvers on every decision; it links
// internal/modules/hr, whose platform package registers who is on leave.
func TestAPIKnowsWhoIsOnLeave(t *testing.T) {
require.True(t, workflow.UnavailableRegistered(),
"cmd/api must link internal/platform/hr (through internal/modules/hr or /leave): its init() registers workflow.Unavailable")
}
  • Step 7: Run the tests

Run: go test ./internal/platform/hr/... ./internal/platform/workflow/... ./cmd/worker/ ./cmd/api/ ./internal/kernel/layering/ -count=1 && go build ./cmd/... Expected: PASS; the build succeeds. H1’s TestPassingTheLastDayEndsTheSeatAndTheLoginAndApprovalsMoveUp still passes: nobody in it is on leave, so OnLeave answers false for everyone.

  • Step 8: Watch each drift test fail once
  1. In OnLeave, change = 'committed' to = 'pending'. Run go test ./internal/platform/hr/ -run TestAStepWhoseApproverIsOnLeave -count=1. Expected: FAIL — ABSENCE: the manager, on leave today, is passed over: Should be false. Restore.
  2. In RecordRoutingMoves, change r.Note == workflow.NoteNoAlternate to false. Run go test ./internal/platform/hr/ -run TestWithNobodyAboveAvailable -count=1. Expected: FAIL — flagged: approver on leave, no alternate: Should be true. Restore.
  3. In OnLeave, delete AND NOT (r.to_half AND r.to_date = $3). Run go test ./internal/platform/hr/ -run TestAHalfDayOfLeave -count=1. Expected: FAIL — a morning off is not a day away. Restore; PASS.
  • Step 9: Commit
Terminal window
git add internal/platform/hr internal/platform/workflow/unavailable.go cmd/worker cmd/api/wiring_test.go
git commit -m "Phase 5 H3: approvers on leave are passed over and logged daily; never refused (HD-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 8: Reports — leave taken by type and period, the team calendar

Section titled “Task 8: Reports — leave taken by type and period, the team calendar”

Files:

  • Create: internal/platform/hr/leave_reports.go
  • Test: internal/platform/hr/leave_reports_test.go

Interfaces:

  • Consumes: Task 3’s leave_semantic; Task 4’s dateOnly; Task 6’s requests. (Balances per employee is Task 5’s Balances.)

  • Produces:

    • type TakenRow struct { TypeID uuid.UUID; Requests int; Days Days }, func LeaveTaken(ctx context.Context, tx pgx.Tx, from, to time.Time) ([]TakenRow, error) — approved (committed) requests whose first day falls in [from, to], by type
    • type CalendarEntry struct { RequestID, EmployeeID uuid.UUID; EmployeeName string; TypeID uuid.UUID; From, To time.Time; FromHalf, ToHalf bool; Days Days }, func TeamCalendar(ctx context.Context, tx pgx.Tx, from, to time.Time) ([]CalendarEntry, error) — approved leave overlapping [from, to] (the module withholds TypeID from callers without hr.leave.read_all)
  • Step 1: Write the failing test

Create internal/platform/hr/leave_reports_test.go:

package hr_test
import (
"context"
"testing"
"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"
)
// Spec H3.5: leave taken by type and period, and a team calendar — approved
// leave only: pending, rejected and cancelled requests are not leave taken.
func TestTheReportsCountApprovedLeaveOnly(t *testing.T) {
e := hrtest.New(t)
al := e.Annual(t, "AL", 12, 12, 16)
ul := e.LeaveType(t, hr.LeaveTypeInput{Code: "UL", Kind: hr.KindUnpaid})
staff := e.Hired(t, e.Staff, "E-100")
e.Hired(t, e.HR, "E-101")
e.Accrue(t, e.Clock, staff.ID)
taken := e.Request(t, e.Staff, hr.RequestInput{TypeID: al.ID, From: hrtest.Day(2026, 10, 5), To: hrtest.Day(2026, 10, 6)})
e.LeaveMove(t, taken.ID, e.Owner, "pending", "approved", "")
gone := e.Request(t, e.Staff, hr.RequestInput{TypeID: al.ID, From: hrtest.Day(2026, 10, 12), To: hrtest.Day(2026, 10, 12)})
e.LeaveMove(t, gone.ID, e.Owner, "pending", "approved", "")
e.LeaveMove(t, gone.ID, e.Staff, "approved", "cancelled", "Not needed")
e.Request(t, e.Staff, hr.RequestInput{TypeID: al.ID, From: hrtest.Day(2026, 10, 19), To: hrtest.Day(2026, 10, 19)}) // pending
unpaid := e.Request(t, e.HR, hr.RequestInput{TypeID: ul.ID, From: hrtest.Day(2026, 10, 6), To: hrtest.Day(2026, 10, 8)})
e.LeaveMove(t, unpaid.ID, e.Owner, "pending", "approved", "")
var rows []hr.TakenRow
var cal []hr.CalendarEntry
e.Tx(t, func(ctx context.Context, tx pgx.Tx) error {
var err error
if rows, err = hr.LeaveTaken(ctx, tx, hrtest.Day(2026, 10, 1), hrtest.Day(2026, 10, 31)); err != nil {
return err
}
cal, err = hr.TeamCalendar(ctx, tx, hrtest.Day(2026, 10, 6), hrtest.Day(2026, 10, 20))
return err
})
byType := map[string]hr.TakenRow{}
for _, r := range rows {
byType[r.TypeID.String()] = r
}
require.Equal(t, hr.TakenRow{TypeID: al.ID, Requests: 1, Days: hr.WholeDays(2)}, byType[al.ID.String()],
"PRESENCE: the approved week; ABSENCE: the cancelled and the pending day")
require.Equal(t, hr.TakenRow{TypeID: ul.ID, Requests: 1, Days: hr.WholeDays(3)}, byType[ul.ID.String()])
require.Len(t, cal, 2, "the approved leave overlapping 6–20 October")
require.Equal(t, taken.ID, cal[0].RequestID, "earliest first")
require.Equal(t, unpaid.ID, cal[1].RequestID)
err := e.TxErr(func(ctx context.Context, tx pgx.Tx) error {
_, err := hr.TeamCalendar(ctx, tx, hrtest.Day(2026, 1, 1), hrtest.Day(2027, 6, 1))
return err
})
var fe *hr.FieldError
require.ErrorAs(t, err, &fe, "at most a year at a time")
}
  • Step 2: Run it to verify it fails

Run: go test ./internal/platform/hr/ -run TestTheReportsCountApprovedLeaveOnly -count=1 Expected: FAIL to compile — undefined: hr.TakenRow, hr.LeaveTaken, hr.TeamCalendar.

  • Step 3: Write the reports

Create internal/platform/hr/leave_reports.go:

package hr
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
)
// TakenRow is approved leave of one type whose first day falls in a period.
type TakenRow struct {
TypeID uuid.UUID
Requests int
Days Days
}
// CalendarEntry is one approved leave on the team calendar.
type CalendarEntry struct {
RequestID uuid.UUID
EmployeeID uuid.UUID
EmployeeName string
TypeID uuid.UUID
From, To time.Time
FromHalf bool
ToHalf bool
Days Days
}
func checkPeriod(from, to time.Time) (time.Time, time.Time, error) {
from, to = dateOnly(from), dateOnly(to)
switch {
case to.Before(from):
return from, to, &FieldError{Field: "to", Rule: "order", Detail: "the period ends on or after it starts"}
case to.Sub(from) > 366*24*time.Hour:
return from, to, &FieldError{Field: "to", Rule: "range", Detail: "a report covers at most a year"}
}
return from, to, nil
}
// LeaveTaken is approved (committed) leave by type whose first day is in
// [from, to] (spec H3.5 "leave taken by type and period"). A cancelled
// request is not leave taken.
func LeaveTaken(ctx context.Context, tx pgx.Tx, from, to time.Time) ([]TakenRow, error) {
org, err := orgOf(ctx)
if err != nil {
return nil, err
}
if from, to, err = checkPeriod(from, to); err != nil {
return nil, err
}
rows, err := tx.Query(ctx, `SELECT r.leave_type_id, count(*), (sum(r.days) * 2)::int FROM leave_request r
WHERE r.org_id = $1 AND r.from_date BETWEEN $2 AND $3 AND leave_semantic(r.org_id, r.instance_id) = 'committed'
GROUP BY r.leave_type_id ORDER BY r.leave_type_id`, org, from, to)
if err != nil {
return nil, fmt.Errorf("hr: leave taken: %w", err)
}
defer rows.Close()
out := []TakenRow{}
for rows.Next() {
var r TakenRow
if err := rows.Scan(&r.TypeID, &r.Requests, &r.Days); err != nil {
return nil, fmt.Errorf("hr: leave taken: %w", err)
}
out = append(out, r)
}
return out, rows.Err()
}
// TeamCalendar is every approved leave overlapping [from, to], earliest
// first (spec H3.5 "a team calendar").
func TeamCalendar(ctx context.Context, tx pgx.Tx, from, to time.Time) ([]CalendarEntry, error) {
org, err := orgOf(ctx)
if err != nil {
return nil, err
}
if from, to, err = checkPeriod(from, to); err != nil {
return nil, err
}
rows, err := tx.Query(ctx, `SELECT r.id, r.employee_id, p.legal_name, r.leave_type_id, r.from_date, r.to_date,
r.from_half, r.to_half, (r.days * 2)::int
FROM leave_request r
JOIN employee e ON e.org_id = r.org_id AND e.id = r.employee_id
JOIN party p ON p.org_id = e.org_id AND p.id = e.party_id
WHERE r.org_id = $1 AND r.from_date <= $3 AND r.to_date >= $2
AND leave_semantic(r.org_id, r.instance_id) = 'committed'
ORDER BY r.from_date, p.legal_name, r.id`, org, from, to)
if err != nil {
return nil, fmt.Errorf("hr: team calendar: %w", err)
}
defer rows.Close()
out := []CalendarEntry{}
for rows.Next() {
var c CalendarEntry
if err := rows.Scan(&c.RequestID, &c.EmployeeID, &c.EmployeeName, &c.TypeID, &c.From, &c.To, &c.FromHalf,
&c.ToHalf, &c.Days); err != nil {
return nil, fmt.Errorf("hr: team calendar: %w", err)
}
c.From, c.To = c.From.UTC(), c.To.UTC()
out = append(out, c)
}
return out, rows.Err()
}
  • Step 4: Run it

Run: go test ./internal/platform/hr/ ./internal/kernel/layering/ -count=1 Expected: PASS.

  • Step 5: Watch it fail once

In LeaveTaken, change = 'committed' to NOT IN ('rejected'). Run the test. Expected: FAIL — PRESENCE: the approved week; ABSENCE: the cancelled and the pending day: Requests 3, Days 8. Restore; PASS.

  • Step 6: Commit
Terminal window
git add internal/platform/hr/leave_reports.go internal/platform/hr/leave_reports_test.go
git commit -m "Phase 5 H3: leave reports — taken by type and period, team calendar
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 9: Permissions, role grants (00842) and the leave module switch

Section titled “Task 9: Permissions, role grants (00842) and the leave module switch”

Files:

  • Modify: constants.yaml (permissions), then go run ./tools/vocabgen
  • Create: db/migrations/00842_leave_grants.sql, internal/platform/hr/migration00842_test.go
  • Modify: internal/platform/moduleinstall/moduleinstall.go (Catalog, OwnsPermission), internal/platform/moduleinstall/moduleinstall_test.go, internal/platform/identity/member/permission.go (Permissions)

Interfaces:

  • Consumes: H1’s module key hr in moduleinstall.Catalog; the permissions vocabulary.

  • Produces: the six hr.leave.* keys in the vocabulary; existing Owners hold all six, every seeded non-Owner role holds hr.leave.request, Approver also hr.leave.calendar; module leave (Requires: hr); a switched-off module’s keys are those whose first or second segment is its key (member.Permissions, moduleinstall.OwnsPermission, Ruling H3-17).

  • Step 1: Write the failing tests

Create internal/platform/hr/migration00842_test.go:

package hr_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"
)
// 00842 (Ruling H3-17): the Owner gains all six leave keys, every seeded
// role the self-service key, the Approver the team calendar; a role the
// organization made itself gains nothing; the Down takes them back.
func TestMigration00842GrantsTheLeaveKeys(t *testing.T) {
h := testdb.StartAt(t, 841)
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, system bool) uuid.UUID {
var id uuid.UUID
require.NoError(t, h.Admin.QueryRow(ctx, `INSERT INTO role (org_id, name, system) VALUES ($1, $2, $3) RETURNING id`,
org, name, system).Scan(&id))
return id
}
owner, employee, approver, finance := role("Owner", true), role("Employee", true), role("Approver", true), role("Finance", true)
admin, custom := role("Admin", true), role("Auditors", false)
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 'hr.leave.%' ORDER BY 1`, id)
require.NoError(t, err)
out, err := pgx.CollectRows(rows, pgx.RowTo[string])
require.NoError(t, err)
return out
}
version := func() int {
var v int
require.NoError(t, h.Admin.QueryRow(ctx, `SELECT perm_version FROM organization WHERE id = $1`, org).Scan(&v))
return v
}
require.Empty(t, keys(owner), "ABSENCE before the migration")
before := version()
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), 842, goose.WithAllowMissing()))
require.Len(t, keys(owner), 6, "PRESENCE: the Owner holds all six")
for _, r := range []uuid.UUID{employee, finance, admin} {
require.Equal(t, []string{"hr.leave.request"}, keys(r))
}
require.Equal(t, []string{"hr.leave.calendar", "hr.leave.request"}, keys(approver))
require.Empty(t, keys(custom), "the organization's own roles are its own")
require.Greater(t, version(), before, "perm_version bumped")
require.NoError(t, goose.Down(conn, testdb.MigrationsDir(t)))
require.Empty(t, keys(owner), "the Down removes them")
require.Empty(t, keys(approver))
}

Append to internal/platform/moduleinstall/moduleinstall_test.go:

// Phase 5 H3 (Ruling H3-17): leave's keys nest under hr (hr.leave.*).
// Switching leave off withholds them and nothing of hr's own; hr cannot go
// off under it.
func TestANestedModulesKeysGoOffWithIt(t *testing.T) {
e := expensestest.New(t)
perms := func() map[string]struct{} {
var p map[string]struct{}
e.Tx(t, func(ctx context.Context, tx pgx.Tx) (err error) {
p, err = member.Permissions(ctx, tx, e.Owner)
return
})
return p
}
set := func(key string, on bool) error {
return e.TxErr(func(ctx context.Context, tx pgx.Tx) error {
_, err := moduleinstall.Set(ctx, tx, e.Clock, key, on, e.Owner)
return err
})
}
grant(t, e, "hr.employee.read", "hr.leave.manage", "hr.leave.medical.read")
require.Contains(t, perms(), "hr.leave.manage", "PRESENCE: held while leave is on")
require.NoError(t, set("leave", false))
p := perms()
require.NotContains(t, p, "hr.leave.manage", "ABSENCE: withheld with leave off")
require.NotContains(t, p, "hr.leave.medical.read")
require.Contains(t, p, "hr.employee.read", "hr's own key stays")
require.True(t, moduleinstall.OwnsPermission(map[string]bool{"leave": true}, "hr.leave.manage"))
require.False(t, moduleinstall.OwnsPermission(map[string]bool{"leave": true}, "hr.employee.read"))
require.NoError(t, set("leave", true))
require.Contains(t, perms(), "hr.leave.manage", "switched back on, as it was")
var needed *moduleinstall.RequiredByError
require.ErrorAs(t, set("hr", false), &needed, "hr cannot go off while leave needs it")
}
  • Step 2: Run them to verify they fail

Run: go test ./internal/platform/hr/ ./internal/platform/moduleinstall/ -run 'TestMigration00842|TestANestedModulesKeys' -count=1 Expected: FAIL — goose finds no migration 842 (the owner has no keys); moduleinstall: no such optional module for leave.

  • Step 3: Declare the keys

In constants.yaml, append to permissions after H1’s - hr.employee.lifecycle_override:

- hr.leave.request
- hr.leave.manage
- hr.leave.read_all
- hr.leave.calendar
- hr.leave.adjust
- hr.leave.medical.read

Run: go run ./tools/vocabgen Expected: internal/kernel/vocab/vocab.gen.go and packages/contracts/vocab.ts list the six keys.

  • Step 4: Write the migration

Create db/migrations/00842_leave_grants.sql:

-- +goose Up
-- Phase 5 H3 (Ruling H3-17): existing organizations' seeded roles get their
-- leave keys, as provisioning gives a new organization's (the Owner every
-- registered key; the others through provisioning.RegisterRolePermissions).
-- Every seeded role may request leave; the Approver sees the team calendar;
-- the rest is the Owner's. A role the organization made itself is left to
-- it. 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 ('hr.leave.request'), ('hr.leave.manage'), ('hr.leave.read_all'), ('hr.leave.calendar'),
('hr.leave.adjust'), ('hr.leave.medical.read')) AS k(key)
WHERE r.system AND r.name = 'Owner'
ON CONFLICT (role_id, permission_key) DO NOTHING;
INSERT INTO role_permission (org_id, role_id, permission_key)
SELECT r.org_id, r.id, 'hr.leave.request'
FROM role r
WHERE r.system AND r.name IN ('Employee', 'Approver', 'Finance', 'Admin')
ON CONFLICT (role_id, permission_key) DO NOTHING;
INSERT INTO role_permission (org_id, role_id, permission_key)
SELECT r.org_id, r.id, 'hr.leave.calendar'
FROM role r
WHERE r.system AND r.name = 'Approver'
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);
-- +goose Down
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 'hr.leave.%';
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);
  • Step 5: Add the module and nest its keys

In internal/platform/moduleinstall/moduleinstall.go, append to Catalog after H1’s {Key: "hr"}, line:

{Key: "leave", Requires: []string{"hr"}}, // Phase 5 H3; its keys are hr.leave.* (Ruling H3-17)

and replace OwnsPermission with:

// OwnsPermission reports whether permission key belongs to an optional
// module that is in off: a module's permissions are the keys whose first
// segment is its key — or, for a module nested under the one it requires,
// whose second segment is (leave's hr.leave.*, Phase 5 H3 Ruling H3-17).
// internal/app's TestANestedPermissionBelongsToAModuleThatRequiresItsParent
// proves no other key has an optional module's key as its second segment.
func OwnsPermission(off map[string]bool, permission string) bool {
first, rest, _ := strings.Cut(permission, ".")
second, _, _ := strings.Cut(rest, ".")
return off[first] || off[second]
}

In internal/platform/identity/member/permission.go, in Permissions, replace

-- A switched-off module's permissions are held by no one
-- (platform/moduleinstall): its key is their first segment.
AND NOT EXISTS (SELECT 1 FROM module_install mi
WHERE mi.org_id = $2 AND NOT mi.enabled
AND mi.module_key = split_part(rp.permission_key, '.', 1))`, memberID, orgID)

with

-- A switched-off module's permissions are held by no one
-- (platform/moduleinstall): its key is their first segment, or
-- the second for a module nested under its parent's keys
-- (hr.leave.*, Phase 5 H3; moduleinstall.OwnsPermission).
AND NOT EXISTS (SELECT 1 FROM module_install mi
WHERE mi.org_id = $2 AND NOT mi.enabled
AND mi.module_key IN (split_part(rp.permission_key, '.', 1),
split_part(rp.permission_key, '.', 2)))`, memberID, orgID)
  • Step 6: Run the tests

Run: go test ./internal/platform/hr/ ./internal/platform/moduleinstall/ ./internal/platform/identity/member/ ./internal/kernel/vocab/ ./internal/kernel/db/ -count=1 Expected: PASS.

  • Step 7: Watch the switch fail once

In member.Permissions, put back AND mi.module_key = split_part(rp.permission_key, '.', 1)) alone. Run go test ./internal/platform/moduleinstall/ -run TestANestedModulesKeysGoOffWithIt -count=1. Expected: FAIL — ABSENCE: withheld with leave off: the map contains hr.leave.manage. Restore; PASS.

  • Step 8: Commit
Terminal window
git add constants.yaml internal/kernel/vocab packages/contracts/vocab.ts db/migrations/00842_leave_grants.sql \
internal/platform/hr/migration00842_test.go internal/platform/moduleinstall internal/platform/identity/member/permission.go
git commit -m "Phase 5 H3: leave permissions, role grants and the leave module switch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 10: The HTTP surface (modules/leave), the contract, and the wiring

Section titled “Task 10: The HTTP surface (modules/leave), the contract, and the wiring”

Files:

  • Modify: api/openapi.yaml (paths, responses, schemas; OrgModule.key), then make generate
  • Create: internal/modules/leave/{permissions,routes,http,errors,dto,setup_handlers,request_handlers,report_handlers}.go
  • Test: internal/modules/leave/{routes,env,handlers,errors}_test.go, internal/app/modulekeys_test.go
  • Modify: internal/app/app.go, internal/app/routes_test.go, cmd/api/main.go, cmd/{orgadmin,migrate,worker}/main.go, cmd/{orgadmin,migrate,worker}/wiring_test.go
  • Modify: web/src/lib/i18n/locales/{en,ms,zh}/modules.json

Interfaces:

  • Consumes: every platform/hr leave function of Tasks 4–8; workflow.Available, workflow.AvailableTransitions; member.Can, member.Permissions, member.RegisterPermission; provisioning.RegisterRolePermissions; httpx.Protected; H1’s platformhr.GetByMember, platformhr.ErrNotFound, platformhr.ErrWrongStatus, *platformhr.FieldError, *platformhr.RevisionMismatchError; hrtest (tests).

  • Produces:

    • leave.PermissionRequest, PermissionManage, PermissionReadAll, PermissionCalendar, PermissionAdjust, PermissionMedicalRead (re-exports of platform/hr’s), leave.Deps{DB *db.DB; Clock clock.Clock}, leave.Register(g *gin.RouterGroup, deps Deps)
    • routes (all under /v1): GET|POST /leave/types, PUT /leave/types/{id}, GET|POST /leave/types/{id}/rules, GET /leave/floors, GET|PUT /leave/settings, GET|POST /leave/holidays, POST /leave/holidays/{id}/retire, GET /leave/me, GET|POST /leave/requests, GET /leave/requests/{id}, POST /leave/requests/{id}/transitions, POST /leave/adjustments, GET /leave/reports/balances, GET /leave/reports/taken, GET /leave/calendar, GET /leave/routing-moves
    • schemas LeaveKind, LeaveType, LeaveTypeInput, LeaveTypeUpdate, LeaveBand, LeaveRule, LeaveRuleSetInput, LeaveFloor, LeaveSettings, PublicHoliday, PublicHolidayInput, LeaveRequest, LeaveRequestView, LeaveRequestInput, LeaveBalance, LeaveMe, LeaveAdjustmentInput, LeaveEntry, LeaveTakenRow, LeaveCalendarEntry, LeaveRoutingMove; responses LeaveForbidden, LeaveNotFound, LeaveConflict, LeaveInvalid
    • refusal codes: owner_leave_approver_missing, owner_leave_approver_vacant, duplicate_leave_type, rule_set_exists, duplicate_holiday, not_an_employee, employee_status, overlapping_leave, attachment_bound, leave_started, below_statutory_floor, insufficient_balance (with available, requested), revision_mismatch, sole_owner_reason_required, read_all_required
    • module key leave
  • Step 1: Write the failing module tests

Create internal/modules/leave/routes_test.go:

package leave_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/leave"
)
// Each route's exact permission (Ruling H3-17): a setup write re-registered
// under the self-service key would let every employee rewrite entitlements.
func TestEveryRouteRequiresItsExactPermission(t *testing.T) {
gin.SetMode(gin.TestMode)
httpx.ResetAuthorizedRoutes()
_, api := httpx.NewRouter(httpx.Deps{})
leave.Register(api, leave.Deps{})
require.Equal(t, map[string]string{
"GET /v1/leave/types": leave.PermissionRequest,
"POST /v1/leave/types": leave.PermissionManage,
"PUT /v1/leave/types/:id": leave.PermissionManage,
"GET /v1/leave/types/:id/rules": leave.PermissionManage,
"POST /v1/leave/types/:id/rules": leave.PermissionManage,
"GET /v1/leave/floors": leave.PermissionManage,
"GET /v1/leave/settings": leave.PermissionManage,
"PUT /v1/leave/settings": leave.PermissionManage,
"GET /v1/leave/holidays": leave.PermissionRequest,
"POST /v1/leave/holidays": leave.PermissionManage,
"POST /v1/leave/holidays/:id/retire": leave.PermissionManage,
"GET /v1/leave/me": leave.PermissionRequest,
"GET /v1/leave/requests": leave.PermissionRequest,
"POST /v1/leave/requests": leave.PermissionRequest,
"GET /v1/leave/requests/:id": leave.PermissionRequest,
"POST /v1/leave/requests/:id/transitions": leave.PermissionRequest,
"POST /v1/leave/adjustments": leave.PermissionAdjust,
"GET /v1/leave/reports/balances": leave.PermissionReadAll,
"GET /v1/leave/reports/taken": leave.PermissionReadAll,
"GET /v1/leave/calendar": leave.PermissionCalendar,
"GET /v1/leave/routing-moves": leave.PermissionManage,
}, httpx.AuthorizedRoutes())
require.Equal(t, "hr.leave.request", leave.PermissionRequest)
require.Equal(t, "hr.leave.medical.read", leave.PermissionMedicalRead)
}

Create internal/modules/leave/env_test.go:

package leave_test
import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/authn"
"github.com/mcom/erp/internal/kernel/clock"
"github.com/mcom/erp/internal/kernel/httpx"
"github.com/mcom/erp/internal/modules/leave"
"github.com/mcom/erp/internal/platform/hr/hrtest"
"github.com/mcom/erp/internal/platform/identity/token"
)
type alwaysLive struct{}
func (alwaysLive) Check(context.Context, authn.Claims) error { return nil }
// world is hrtest's organization with THIS module mounted alone on a test
// router.
type world struct {
e *hrtest.Env
r *gin.Engine
signer *token.Signer
}
func newWorld(t *testing.T) *world {
t.Helper()
gin.SetMode(gin.TestMode)
e := hrtest.New(t)
signer, err := token.NewSigner(map[string][]byte{"k1": []byte("01234567890123456789012345678901")},
"k1", clock.Fixed(e.Clock.Now()))
require.NoError(t, err)
r, api := httpx.NewRouter(httpx.Deps{DB: e.DB, Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
Version: "test", TokenVerifier: signer, Liveness: alwaysLive{}})
leave.Register(api, leave.Deps{DB: e.DB, Clock: e.Clock})
return &world{e: e, r: r, signer: signer}
}
// token grants member a fresh role holding keys and mints their token.
func (w *world) token(t *testing.T, member uuid.UUID, keys ...string) string {
t.Helper()
w.e.Grant(t, member, keys...)
tok, err := w.signer.Mint(authn.Claims{UserID: uuid.New(), OrgID: w.e.Org, MemberID: member, SessionID: uuid.New()})
require.NoError(t, err)
return tok
}
func (w *world) do(t *testing.T, method, path, body, tok, ifMatch string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
if tok != "" {
req.Header.Set("Authorization", "Bearer "+tok)
}
if ifMatch != "" {
req.Header.Set("If-Match", ifMatch)
}
rec := httptest.NewRecorder()
w.r.ServeHTTP(rec, req)
return rec
}
func field(t *testing.T, rec *httptest.ResponseRecorder, name string) any {
t.Helper()
var body map[string]any
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body), rec.Body.String())
return body[name]
}

Create internal/modules/leave/handlers_test.go:

package leave_test
import (
"net/http"
"testing"
"github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/modules/leave"
"github.com/mcom/erp/internal/platform/hr"
"github.com/mcom/erp/internal/platform/hr/hrtest"
)
// Setup: types, rule sets over the floor, holidays with their warning, the
// working week and carry-forward.
func TestTheSetupRoutes(t *testing.T) {
w := newWorld(t)
manager := w.token(t, w.e.HR, leave.PermissionManage, leave.PermissionRequest)
staff := w.token(t, w.e.Staff, leave.PermissionRequest)
res := w.do(t, "POST", "/v1/leave/types", `{"code":"AL","names":{"en":"Annual leave","ms":"Cuti tahunan"},
"kind":"annual","paid":true,"half_days":true,"attachment_required":false}`, manager, "")
require.Equal(t, http.StatusCreated, res.Code, res.Body.String())
id := field(t, res, "id").(string)
require.Equal(t, http.StatusForbidden, w.do(t, "POST", "/v1/leave/types", `{"code":"X","names":{"en":"X","ms":"X"},
"kind":"other","paid":false,"half_days":false,"attachment_required":false}`, staff, "").Code)
dup := w.do(t, "POST", "/v1/leave/types", `{"code":"AL","names":{"en":"A","ms":"B"},"kind":"other","paid":false,
"half_days":false,"attachment_required":false}`, manager, "")
require.Equal(t, http.StatusConflict, dup.Code)
require.Contains(t, dup.Body.String(), "duplicate_leave_type")
low := w.do(t, "POST", "/v1/leave/types/"+id+"/rules", `{"effective_from":"2026-01-01","bands":[
{"from_month":0,"to_month":36,"days":"10"},{"from_month":36,"to_month":null,"days":"16"}]}`, manager, "")
require.Equal(t, http.StatusUnprocessableEntity, low.Code, "HD-3: 0–36 at 10 breaks the 12-day floor")
require.Contains(t, low.Body.String(), "statutory_floor")
ok := w.do(t, "POST", "/v1/leave/types/"+id+"/rules", `{"effective_from":"2026-01-01","bands":[
{"from_month":0,"to_month":24,"days":"8"},{"from_month":24,"to_month":60,"days":"12"},
{"from_month":60,"to_month":null,"days":"16"}]}`, manager, "")
require.Equal(t, http.StatusCreated, ok.Code, ok.Body.String())
require.Contains(t, w.do(t, "GET", "/v1/leave/types/"+id+"/rules", "", manager, "").Body.String(), `"days":"12"`)
require.Contains(t, w.do(t, "GET", "/v1/leave/floors", "", manager, "").Body.String(), `"kind":"maternity"`)
hol := w.do(t, "GET", "/v1/leave/holidays?year=2027", "", staff, "")
require.Equal(t, http.StatusOK, hol.Code)
require.Contains(t, hol.Body.String(), `"warnings":["no_public_holidays"]`, "PRESENCE of the warning")
add := w.do(t, "POST", "/v1/leave/holidays", `{"date":"2027-01-01","state":null,
"names":{"en":"New Year's Day","ms":"Hari Tahun Baru"}}`, manager, "")
require.Equal(t, http.StatusCreated, add.Code, add.Body.String())
require.Contains(t, w.do(t, "GET", "/v1/leave/holidays?year=2027", "", staff, "").Body.String(), `"warnings":[]`)
set := w.do(t, "PUT", "/v1/leave/settings", `{"weekdays":[1,2,3,4,5,6],"state":"MY-10",
"carry_forward_max":"5","carry_forward_expiry_months":3}`, manager, "")
require.Equal(t, http.StatusOK, set.Code, set.Body.String())
got := w.do(t, "GET", "/v1/leave/settings", "", manager, "")
require.JSONEq(t, `{"weekdays":[1,2,3,4,5,6],"state":"MY-10","carry_forward_max":"5","carry_forward_expiry_months":3}`,
got.Body.String())
}
// A request from creation to approval, the balance check, and the routes'
// privacy rules.
func TestTheRequestRoutes(t *testing.T) {
w := newWorld(t)
al := w.e.Annual(t, "AL", 12, 12, 16)
staffEmp := w.e.Hired(t, w.e.Staff, "E-100")
w.e.Accrue(t, w.e.Clock, staffEmp.ID)
staff := w.token(t, w.e.Staff, leave.PermissionRequest)
owner := w.token(t, w.e.Owner, leave.PermissionRequest)
res := w.do(t, "POST", "/v1/leave/requests", `{"type_id":"`+al.ID.String()+`","from_date":"2026-10-05",
"to_date":"2026-10-06","from_half":false,"to_half":false,"reason":"Family matters"}`, staff, "")
require.Equal(t, http.StatusCreated, res.Code, res.Body.String())
require.Contains(t, res.Body.String(), `"days":"2"`)
require.Contains(t, res.Body.String(), `"warnings":["no_public_holidays"]`)
require.Contains(t, res.Body.String(), `"action_key":"submit"`)
id := field(t, res, "id").(string)
sub := w.do(t, "POST", "/v1/leave/requests/"+id+"/transitions", `{"from":"draft","to":"pending"}`, staff, "")
require.Equal(t, http.StatusOK, sub.Code, sub.Body.String())
require.Contains(t, w.do(t, "GET", "/v1/leave/requests?scope=awaiting", "", owner, "").Body.String(), id,
"PRESENCE: in the Owner's queue")
require.NotContains(t, w.do(t, "GET", "/v1/leave/requests?scope=awaiting", "", staff, "").Body.String(), id,
"ABSENCE: nobody approves their own")
appr := w.do(t, "POST", "/v1/leave/requests/"+id+"/transitions", `{"from":"pending","to":"approved"}`, owner, "")
require.Equal(t, http.StatusOK, appr.Code, appr.Body.String())
require.Contains(t, appr.Body.String(), `"semantic":"committed"`)
me := w.do(t, "GET", "/v1/leave/me?year=2026", "", staff, "")
require.Equal(t, http.StatusOK, me.Code)
require.Contains(t, me.Body.String(), `"balance":"10"`)
big := w.do(t, "POST", "/v1/leave/requests", `{"type_id":"`+al.ID.String()+`","from_date":"2026-11-02",
"to_date":"2026-11-16","from_half":false,"to_half":false,"reason":""}`, staff, "")
require.Equal(t, http.StatusCreated, big.Code, big.Body.String())
over := w.do(t, "POST", "/v1/leave/requests/"+field(t, big, "id").(string)+"/transitions",
`{"from":"draft","to":"pending"}`, staff, "")
require.Equal(t, http.StatusConflict, over.Code)
require.Contains(t, over.Body.String(), `"code":"insufficient_balance"`)
require.Contains(t, over.Body.String(), `"available":"10"`)
require.Equal(t, http.StatusForbidden, w.do(t, "GET", "/v1/leave/requests?scope=all", "", staff, "").Code)
stranger := w.token(t, w.e.HR, leave.PermissionRequest)
require.Equal(t, http.StatusNotFound, w.do(t, "GET", "/v1/leave/requests/"+id, "", stranger, "").Code,
"a request its reader may not see does not exist for them")
}
// Spec H3.5: the team calendar shows who is away; the leave type only to
// hr.leave.read_all.
func TestTheCalendarWithholdsTheLeaveType(t *testing.T) {
w := newWorld(t)
sick := w.e.LeaveType(t, hr.LeaveTypeInput{Code: "MC", Kind: hr.KindSick})
w.e.Rules(t, sick.ID, hrtest.Day(2000, 1, 1),
hr.Band{FromMonth: 0, ToMonth: hrtest.M(24), Days: hr.WholeDays(14)},
hr.Band{FromMonth: 24, ToMonth: hrtest.M(60), Days: hr.WholeDays(18)},
hr.Band{FromMonth: 60, Days: hr.WholeDays(22)})
emp := w.e.Hired(t, w.e.Staff, "E-100")
w.e.Accrue(t, w.e.Clock, emp.ID)
doc := w.e.Document(t, w.e.Staff, "application/pdf", []byte("medical certificate"))
r := w.e.Request(t, w.e.Staff, hr.RequestInput{TypeID: sick.ID, From: hrtest.Day(2026, 10, 5),
To: hrtest.Day(2026, 10, 5), AttachmentID: &doc})
w.e.LeaveMove(t, r.ID, w.e.Owner, "pending", "approved", "")
colleague, _ := w.e.Member(t, "Colleague", &w.e.OwnerSeat)
calendar := w.token(t, colleague, leave.PermissionCalendar)
hrAll := w.token(t, w.e.HR, leave.PermissionCalendar, leave.PermissionReadAll, leave.PermissionRequest)
path := "/v1/leave/calendar?from=2026-10-01&to=2026-10-31"
all := w.do(t, "GET", path, "", hrAll, "")
require.Equal(t, http.StatusOK, all.Code, all.Body.String())
require.Contains(t, all.Body.String(), sick.ID.String(), "PRESENCE: read_all sees the type")
plain := w.do(t, "GET", path, "", calendar, "")
require.Contains(t, plain.Body.String(), `"employee_name"`)
require.NotContains(t, plain.Body.String(), sick.ID.String(), "ABSENCE: the calendar alone does not")
require.Contains(t, plain.Body.String(), `"type_id":null`)
view := w.do(t, "GET", "/v1/leave/requests/"+r.ID.String(), "", hrAll, "")
require.Equal(t, http.StatusOK, view.Code)
require.Contains(t, view.Body.String(), `"attachment_hidden":true`, "read_all sees the request, not the certificate")
require.NotContains(t, view.Body.String(), doc.String())
}

Create internal/modules/leave/errors_test.go:

package leave
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
apperr "github.com/mcom/erp/internal/kernel/errors"
p "github.com/mcom/erp/internal/platform/hr"
"github.com/mcom/erp/internal/platform/workflow"
)
// Every leave refusal a screen shows is named by a stable code.
func TestTheRefusalsCarryTheirCodes(t *testing.T) {
cases := map[error]string{
p.ErrDuplicateLeaveType: "duplicate_leave_type",
p.ErrRuleSetExists: "rule_set_exists",
p.ErrDuplicateHoliday: "duplicate_holiday",
p.ErrNotAnEmployee: "not_an_employee",
p.ErrWrongStatus: "employee_status",
p.ErrOverlappingLeave: "overlapping_leave",
p.ErrAttachmentBound: "attachment_bound",
p.ErrLeaveStarted: "leave_started",
p.ErrAdjustBelowFloor: "below_statutory_floor",
p.ErrInsufficientBalance: "insufficient_balance",
&p.InsufficientBalanceError{Available: p.WholeDays(3), Requested: p.WholeDays(4)}: "insufficient_balance",
&p.RevisionMismatchError{Current: 2}: "revision_mismatch",
workflow.ErrSoleOwnerReasonRequired: "sole_owner_reason_required",
&p.OwnerLeaveApproverError{Reason: p.ReasonOwnerLeaveApproverMissing}: "owner_leave_approver_missing",
}
for err, code := range cases {
prob, ok := mapErr(err).(*apperr.Problem)
require.True(t, ok, "%v is mapped", err)
b, jerr := json.Marshal(prob)
require.NoError(t, jerr)
require.Contains(t, string(b), `"code":"`+code+`"`, "%v", err)
}
b, _ := json.Marshal(mapErr(&p.InsufficientBalanceError{Available: p.WholeDays(3), Requested: p.WholeDays(4)}))
require.Contains(t, string(b), `"available":"3"`)
require.Contains(t, string(b), `"requested":"4"`)
}

Create internal/app/modulekeys_test.go:

package app_test
import (
"strings"
"testing"
"github.com/stretchr/testify/require"
_ "github.com/mcom/erp/internal/app"
"github.com/mcom/erp/internal/platform/identity/member"
"github.com/mcom/erp/internal/platform/moduleinstall"
)
// Ruling H3-17: member.Permissions and moduleinstall.OwnsPermission withhold
// a switched-off module's keys by their first OR second segment. That is
// exact only while every key whose second segment names an optional module
// belongs to a module nested under its first (leave's hr.leave.*).
func TestANestedPermissionBelongsToAModuleThatRequiresItsParent(t *testing.T) {
var checked, nested int
for _, key := range member.RegisteredPermissionSet().Keys() {
parts := strings.Split(key, ".")
if len(parts) < 2 {
continue
}
checked++
for _, m := range moduleinstall.Catalog {
if m.Key != parts[1] {
continue
}
nested++
require.Contains(t, m.Requires, parts[0],
"%s's second segment names the optional module %s, which does not require %s: switching %s off "+
"would withhold a key that is not its own", key, m.Key, parts[0], m.Key)
}
}
require.Positive(t, checked, "no registered keys were read; the registry is not linked")
require.Positive(t, nested, "PRESENCE: leave's hr.leave.* keys are nested under hr")
}
  • Step 2: Run them to verify they fail

Run: go test ./internal/modules/leave/ ./internal/app/ -run 'TestEveryRouteRequires|TestTheSetupRoutes|TestTheRequestRoutes|TestTheCalendar|TestTheRefusals|TestANestedPermission' -count=1 Expected: FAIL to compile — package internal/modules/leave has no Go files; internal/app’s nested test fails PRESENCE: leave's hr.leave.* keys are nested under hr (nothing registers them yet).

  • Step 3: Register the permissions

Create internal/modules/leave/permissions.go:

package leave
import (
platformhr "github.com/mcom/erp/internal/platform/hr"
"github.com/mcom/erp/internal/platform/identity/member"
"github.com/mcom/erp/internal/platform/provisioning"
)
// The six keys (Ruling H3-17). They nest under hr (the leave module
// requires it); switching leave off withholds them all.
const (
PermissionRequest = platformhr.PermissionLeaveRequest
PermissionManage = platformhr.PermissionLeaveManage
PermissionReadAll = platformhr.PermissionLeaveReadAll
PermissionCalendar = platformhr.PermissionLeaveCalendar
PermissionAdjust = platformhr.PermissionLeaveAdjust
PermissionMedicalRead = platformhr.PermissionLeaveMedicalRead
)
func init() {
member.RegisterPermission(PermissionRequest, "Request your own leave, see your balances, and decide the leave routed to you")
member.RegisterPermission(PermissionManage,
"Set up leave types, entitlement rules, public holidays, the working week and carry-forward; open medical certificates as HR")
member.RegisterPermission(PermissionReadAll, "Read every employee's leave requests and balances")
member.RegisterPermission(PermissionCalendar, "See the team leave calendar")
member.RegisterPermission(PermissionAdjust, "Adjust an employee's leave balance, with a reason")
member.RegisterPermission(PermissionMedicalRead, "Open medical certificates attached to leave requests (health data)")
// New organizations' seeded roles; 00842 gave existing ones the same.
for _, role := range []string{"Employee", "Approver", "Finance", "Admin"} {
provisioning.RegisterRolePermissions(role, PermissionRequest)
}
provisioning.RegisterRolePermissions("Approver", PermissionCalendar)
}
  • Step 4: Write the HTTP plumbing

Create internal/modules/leave/http.go:

// Package leave is the HTTP surface of Phase 5 H3's leave: internal/platform/hr
// owns every rule and writes every audit row; this package parses requests,
// calls it inside one transaction, and maps its refusals onto HTTP statuses.
// Errors are recorded (c.Error), never written; every request, reads
// included, runs in a transaction.
package leave
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/audit"
"github.com/mcom/erp/internal/kernel/clock"
apperr "github.com/mcom/erp/internal/kernel/errors"
)
const dateLayout = "2006-01-02"
// decodeStrict reads exactly one JSON object into v and refuses a field v
// does not declare (the other modules' rule, copied: a module does not
// import another module).
func decodeStrict(c *gin.Context, v any) error {
if c.Request.Body == nil {
return apperr.New(apperr.KindBadRequest, "a JSON request body is required")
}
raw, err := io.ReadAll(c.Request.Body)
if err != nil {
return apperr.New(apperr.KindBadRequest, "the request body could not be read")
}
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(v); err != nil {
if errors.Is(err, io.EOF) {
return apperr.New(apperr.KindBadRequest, "a JSON request body is required")
}
const unknown = "json: unknown field "
if msg := err.Error(); strings.HasPrefix(msg, unknown) {
return apperr.New(apperr.KindBadRequest, "field "+strings.TrimPrefix(msg, unknown)+" is not accepted by this endpoint")
}
return apperr.New(apperr.KindBadRequest, "the request body is not this endpoint's JSON shape")
}
if _, err := dec.Token(); !errors.Is(err, io.EOF) {
return apperr.New(apperr.KindBadRequest, "the request body must be a single JSON object")
}
return nil
}
func parseID(c *gin.Context, name string) (uuid.UUID, error) {
id, err := uuid.Parse(c.Param(name))
if err != nil {
return uuid.Nil, apperr.New(apperr.KindValidation, "the "+name+" in the path must be a UUID")
}
return id, nil
}
// parseDate reads a YYYY-MM-DD value as midnight UTC.
func parseDate(field, s string) (time.Time, error) {
t, err := time.Parse(dateLayout, s)
if err != nil {
return time.Time{}, apperr.New(apperr.KindValidation, "field="+field+" rule=date: a date is YYYY-MM-DD").With("field", field)
}
return t, nil
}
// optionalDate is parseDate for a query parameter that may be absent.
func optionalDate(c *gin.Context, name string) (*time.Time, error) {
s := c.Query(name)
if s == "" {
return nil, nil
}
t, err := parseDate(name, s)
return &t, err
}
func mustActor(ctx context.Context) (uuid.UUID, error) {
a, ok := audit.ActorFromContext(ctx)
if !ok {
return uuid.Nil, apperr.New(apperr.KindUnauthorized, "authentication required")
}
return a.MemberID, nil
}
func ifMatch(c *gin.Context) (int, error) {
raw := strings.Trim(strings.TrimPrefix(strings.TrimSpace(c.GetHeader("If-Match")), "W/"), `"`)
if raw == "" {
return 0, apperr.New(apperr.KindBadRequest, "If-Match is required: send the ETag of the record you are editing")
}
rev, err := strconv.Atoi(raw)
if err != nil || rev < 1 {
return 0, apperr.New(apperr.KindBadRequest, "If-Match must be the ETag of the record you are editing")
}
return rev, nil
}
func setETag(c *gin.Context, rev int) { c.Header("ETag", strconv.Quote(strconv.Itoa(rev))) }
func firstErr(errs ...error) error {
for _, err := range errs {
if err != nil {
return err
}
}
return nil
}
// year is the ?year= parameter, defaulting to the current leave year.
func (h handlers) year(c *gin.Context) (int, error) {
s := c.Query("year")
if s == "" {
return clock.Today(c.Request.Context(), h.deps.Clock).Year(), nil
}
y, err := strconv.Atoi(s)
if err != nil || y < 2000 || y > 2999 {
return 0, apperr.New(apperr.KindValidation, "field=year rule=range: a leave year from 2000").With("field", "year")
}
return y, nil
}
// inTx runs fn in one transaction as the calling member; false when it
// failed, the mapped error recorded.
func (h handlers) inTx(c *gin.Context, fn func(ctx context.Context, tx pgx.Tx, who uuid.UUID) error) bool {
who, err := mustActor(c.Request.Context())
if err != nil {
_ = c.Error(err)
return false
}
if err := h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error {
return fn(ctx, tx, who)
}); err != nil {
_ = c.Error(mapErr(err))
return false
}
return true
}

Create internal/modules/leave/routes.go:

package leave
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 leave routes off the module's group (app.Router gives
// it the leave install switch). Every route is behind httpx.Protected.
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", "/leave/types", PermissionRequest, h.listTypes)
route("POST", "/leave/types", PermissionManage, h.createType)
route("PUT", "/leave/types/:id", PermissionManage, h.updateType)
route("GET", "/leave/types/:id/rules", PermissionManage, h.listRules)
route("POST", "/leave/types/:id/rules", PermissionManage, h.setRules)
route("GET", "/leave/floors", PermissionManage, h.floors)
route("GET", "/leave/settings", PermissionManage, h.getSettings)
route("PUT", "/leave/settings", PermissionManage, h.putSettings)
route("GET", "/leave/holidays", PermissionRequest, h.listHolidays)
route("POST", "/leave/holidays", PermissionManage, h.addHoliday)
route("POST", "/leave/holidays/:id/retire", PermissionManage, h.retireHoliday)
route("GET", "/leave/me", PermissionRequest, h.me)
route("GET", "/leave/requests", PermissionRequest, h.listRequests)
route("POST", "/leave/requests", PermissionRequest, h.createRequest)
route("GET", "/leave/requests/:id", PermissionRequest, h.getRequest)
route("POST", "/leave/requests/:id/transitions", PermissionRequest, h.transitionRequest)
route("POST", "/leave/adjustments", PermissionAdjust, h.adjust)
route("GET", "/leave/reports/balances", PermissionReadAll, h.balances)
route("GET", "/leave/reports/taken", PermissionReadAll, h.taken)
route("GET", "/leave/calendar", PermissionCalendar, h.calendar)
route("GET", "/leave/routing-moves", PermissionManage, h.routingMoves)
}
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("leave: load permissions: %w", err)
}
return out, nil
}

Create internal/modules/leave/errors.go:

package leave
import (
"errors"
"fmt"
"strings"
apperr "github.com/mcom/erp/internal/kernel/errors"
p "github.com/mcom/erp/internal/platform/hr"
"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 turns platform refusals into RFC 7807 problems: 409 the same
// request would succeed against the records in another state; 422 the
// request itself is wrong; 403 the workflow's grants name someone else.
func mapErr(err error) error {
var (
field *p.FieldError
rev *p.RevisionMismatchError
short *p.InsufficientBalanceError
refused *workflow.RefusedError
unroutable *workflow.SubmissionRefusedError
)
switch {
case errors.Is(err, p.ErrLeaveTypeNotFound):
return apperr.New(apperr.KindNotFound, "no such leave type in this organization")
case errors.Is(err, p.ErrHolidayNotFound):
return apperr.New(apperr.KindNotFound, "no such public holiday in this organization")
case errors.Is(err, p.ErrLeaveRequestNotFound):
return apperr.New(apperr.KindNotFound, "no such leave request")
case errors.Is(err, p.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)
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, &short):
return coded(apperr.KindConflict, "insufficient_balance",
fmt.Sprintf("%s days left, %s requested", short.Available, short.Requested)).
With("available", short.Available.String()).With("requested", short.Requested.String())
case errors.Is(err, p.ErrInsufficientBalance):
return coded(apperr.KindConflict, "insufficient_balance", "there is not enough of this leave left")
case errors.Is(err, p.ErrDuplicateLeaveType):
return coded(apperr.KindConflict, "duplicate_leave_type", "another leave type has this code").With("field", "code")
case errors.Is(err, p.ErrRuleSetExists):
return coded(apperr.KindConflict, "rule_set_exists", "this leave type already has a rule set from that date").
With("field", "effective_from")
case errors.Is(err, p.ErrDuplicateHoliday):
return coded(apperr.KindConflict, "duplicate_holiday", "that day is already a public holiday here").With("field", "date")
case errors.Is(err, p.ErrNotAnEmployee):
return coded(apperr.KindConflict, "not_an_employee", "you have no employee record here, so no leave")
case errors.Is(err, p.ErrWrongStatus):
return coded(apperr.KindConflict, "employee_status", "the employee's status does not allow this")
case errors.Is(err, p.ErrOverlappingLeave):
return coded(apperr.KindConflict, "overlapping_leave", "you already have leave on some of these days")
case errors.Is(err, p.ErrAttachmentBound):
return coded(apperr.KindConflict, "attachment_bound", "that document already supports another record").
With("field", "attachment_id")
case errors.As(err, new(*p.OwnerLeaveApproverError)):
var o *p.OwnerLeaveApproverError
errors.As(err, &o)
return coded(apperr.KindValidation, string(o.Reason),
"nobody is configured to approve the Owner's own leave: an administrator adds a grant at the OWNER-seat floor "+
"(1.00) to the leave_request workflow's approved step, naming a member or a position")
case errors.Is(err, p.ErrLeaveStarted):
return coded(apperr.KindConflict, "leave_started", "the leave has started; it can no longer be cancelled")
case errors.Is(err, p.ErrAdjustBelowFloor):
return coded(apperr.KindConflict, "below_statutory_floor",
"this would take the leave below its statutory floor for the year (HD-7)").With("field", "days")
case errors.Is(err, workflow.ErrStaleState):
return apperr.New(apperr.KindConflict, "the request 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)
case errors.As(err, &unroutable):
steps := make([]string, 0, len(unroutable.Steps))
for _, s := range unroutable.Steps {
steps = append(steps, s.State)
}
return apperr.New(apperr.KindValidation, "this request cannot be submitted: nobody can decide "+strings.Join(steps, ", "))
}
return err
}
  • Step 5: Write the DTOs

Create internal/modules/leave/dto.go:

package leave
import (
"time"
"github.com/google/uuid"
platformhr "github.com/mcom/erp/internal/platform/hr"
"github.com/mcom/erp/internal/platform/workflow"
)
type typeDTO struct {
ID uuid.UUID `json:"id"`
Code string `json:"code"`
Names platformhr.Names `json:"names"`
Kind string `json:"kind"`
Paid bool `json:"paid"`
HalfDays bool `json:"half_days"`
AttachmentRequired bool `json:"attachment_required"`
Active bool `json:"active"`
Revision int `json:"revision"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func toTypeDTO(t platformhr.LeaveType) typeDTO {
return typeDTO{ID: t.ID, Code: t.Code, Names: t.Names, Kind: t.Kind, Paid: t.Paid, HalfDays: t.HalfDays,
AttachmentRequired: t.AttachmentRequired, Active: t.Active, Revision: t.Revision, CreatedAt: t.CreatedAt,
UpdatedAt: t.UpdatedAt}
}
type bandDTO struct {
FromMonth int `json:"from_month"`
ToMonth *int `json:"to_month"`
Days string `json:"days"`
}
type ruleDTO struct {
ID uuid.UUID `json:"id"`
TypeID uuid.UUID `json:"type_id"`
EffectiveFrom string `json:"effective_from"`
bandDTO
CreatedAt time.Time `json:"created_at"`
}
func toRuleDTOs(rs []platformhr.LeaveRule) []ruleDTO {
out := make([]ruleDTO, len(rs))
for i, r := range rs {
out[i] = ruleDTO{ID: r.ID, TypeID: r.TypeID, EffectiveFrom: r.EffectiveFrom.Format(dateLayout),
bandDTO: bandDTO{FromMonth: r.FromMonth, ToMonth: r.ToMonth, Days: r.Days.String()}, CreatedAt: r.CreatedAt}
}
return out
}
type floorDTO struct {
Kind string `json:"kind"`
FromMonth int `json:"from_month"`
ToMonth *int `json:"to_month"`
Days string `json:"days"`
}
type settingsDTO struct {
Weekdays []int `json:"weekdays"`
State *string `json:"state"`
CarryForwardMax string `json:"carry_forward_max"`
CarryForwardExpiryMonths *int `json:"carry_forward_expiry_months"`
}
type holidayDTO struct {
ID uuid.UUID `json:"id"`
Date string `json:"date"`
State *string `json:"state"`
Names platformhr.Names `json:"names"`
Active bool `json:"active"`
CreatedAt time.Time `json:"created_at"`
}
func toHolidayDTO(h platformhr.Holiday) holidayDTO {
return holidayDTO{ID: h.ID, Date: h.Date.Format(dateLayout), State: h.State, Names: h.Names, Active: h.Active,
CreatedAt: h.CreatedAt}
}
type requestDTO struct {
ID uuid.UUID `json:"id"`
EmployeeID uuid.UUID `json:"employee_id"`
EmployeeName string `json:"employee_name"`
TypeID uuid.UUID `json:"type_id"`
TypeKind string `json:"type_kind"`
FromDate string `json:"from_date"`
ToDate string `json:"to_date"`
FromHalf bool `json:"from_half"`
ToHalf bool `json:"to_half"`
Days string `json:"days"`
Year int `json:"year"`
Reason string `json:"reason"`
AttachmentID *uuid.UUID `json:"attachment_id"`
AttachmentHidden bool `json:"attachment_hidden"`
State string `json:"state"`
Semantic string `json:"semantic"`
CreatedAt time.Time `json:"created_at"`
}
// toRequestDTO withholds the attachment from a reader who may not open a
// medical certificate (Ruling H3-13).
func toRequestDTO(r platformhr.LeaveRequest, medical bool) requestDTO {
d := requestDTO{ID: r.ID, EmployeeID: r.EmployeeID, EmployeeName: r.EmployeeName, TypeID: r.TypeID,
TypeKind: r.TypeKind, FromDate: r.From.Format(dateLayout), ToDate: r.To.Format(dateLayout), FromHalf: r.FromHalf,
ToHalf: r.ToHalf, Days: r.Days.String(), Year: r.Year, Reason: r.Reason, AttachmentID: r.AttachmentID,
State: r.State, Semantic: string(r.Semantic), CreatedAt: r.CreatedAt}
if !medical && r.AttachmentID != nil {
d.AttachmentID, d.AttachmentHidden = nil, true
}
return d
}
type requestView struct {
requestDTO
Available []workflow.Available `json:"available"`
Warnings []string `json:"warnings"`
}
type balanceDTO struct {
EmployeeID uuid.UUID `json:"employee_id"`
EmployeeName string `json:"employee_name"`
EmployeeNo string `json:"employee_no"`
TypeID uuid.UUID `json:"type_id"`
Year int `json:"year"`
Granted string `json:"granted"`
CarriedForward string `json:"carried_forward"`
Taken string `json:"taken"`
Reversed string `json:"reversed"`
Expired string `json:"expired"`
Adjusted string `json:"adjusted"`
Balance string `json:"balance"`
Pending string `json:"pending"`
}
func toBalanceDTOs(bs []platformhr.Balance) []balanceDTO {
out := make([]balanceDTO, len(bs))
for i, b := range bs {
out[i] = balanceDTO{EmployeeID: b.EmployeeID, EmployeeName: b.EmployeeName, EmployeeNo: b.EmployeeNo,
TypeID: b.TypeID, Year: b.Year, Granted: b.Granted.String(), CarriedForward: b.CarriedForward.String(),
Taken: b.Taken.String(), Reversed: b.Reversed.String(), Expired: b.Expired.String(),
Adjusted: b.Adjusted.String(), Balance: b.Balance.String(), Pending: b.Pending.String()}
}
return out
}
type entryDTO struct {
ID uuid.UUID `json:"id"`
EmployeeID uuid.UUID `json:"employee_id"`
TypeID uuid.UUID `json:"type_id"`
Year int `json:"year"`
Kind string `json:"kind"`
Days string `json:"days"`
Reason string `json:"reason"`
RecordedAt time.Time `json:"recorded_at"`
}
type takenDTO struct {
TypeID uuid.UUID `json:"type_id"`
Requests int `json:"requests"`
Days string `json:"days"`
}
type calendarDTO struct {
RequestID uuid.UUID `json:"request_id"`
EmployeeID uuid.UUID `json:"employee_id"`
EmployeeName string `json:"employee_name"`
TypeID *uuid.UUID `json:"type_id"`
FromDate string `json:"from_date"`
ToDate string `json:"to_date"`
FromHalf bool `json:"from_half"`
ToHalf bool `json:"to_half"`
Days string `json:"days"`
}
type moveDTO struct {
ID uuid.UUID `json:"id"`
InstanceID uuid.UUID `json:"instance_id"`
Kind string `json:"kind"`
State string `json:"state"`
GrantID uuid.UUID `json:"grant_id"`
OnDay string `json:"on_day"`
AwayMemberIDs []uuid.UUID `json:"away_member_ids"`
MovedToMemberIDs []uuid.UUID `json:"moved_to_member_ids"`
NoAlternate bool `json:"no_alternate"`
LoggedAt time.Time `json:"logged_at"`
}
  • Step 6: Write the handlers

Create internal/modules/leave/setup_handlers.go:

package leave
import (
"context"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
platformhr "github.com/mcom/erp/internal/platform/hr"
)
func (h handlers) listTypes(c *gin.Context) {
out := []typeDTO{}
if !h.inTx(c, func(ctx context.Context, tx pgx.Tx, _ uuid.UUID) error {
ts, err := platformhr.ListLeaveTypes(ctx, tx, c.Query("all") != "true")
for _, t := range ts {
out = append(out, toTypeDTO(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 platformhr.Names `json:"names"`
Kind string `json:"kind"`
Paid bool `json:"paid"`
HalfDays bool `json:"half_days"`
AttachmentRequired bool `json:"attachment_required"`
}
if err := decodeStrict(c, &body); err != nil {
_ = c.Error(err)
return
}
var out platformhr.LeaveType
if !h.inTx(c, func(ctx context.Context, tx pgx.Tx, who uuid.UUID) error {
var err error
out, err = platformhr.CreateLeaveType(ctx, tx, h.deps.Clock, platformhr.LeaveTypeInput{Code: body.Code,
Names: body.Names, Kind: body.Kind, Paid: body.Paid, HalfDays: body.HalfDays,
AttachmentRequired: body.AttachmentRequired, Actor: who})
return err
}) {
return
}
setETag(c, out.Revision)
c.JSON(http.StatusCreated, toTypeDTO(out))
}
func (h handlers) updateType(c *gin.Context) {
id, err := parseID(c, "id")
rev, err2 := ifMatch(c)
var body struct {
Names platformhr.Names `json:"names"`
HalfDays bool `json:"half_days"`
AttachmentRequired bool `json:"attachment_required"`
Active bool `json:"active"`
}
if err := firstErr(err, err2, decodeStrict(c, &body)); err != nil {
_ = c.Error(err)
return
}
var out platformhr.LeaveType
if !h.inTx(c, func(ctx context.Context, tx pgx.Tx, who uuid.UUID) error {
var err error
out, err = platformhr.UpdateLeaveType(ctx, tx, h.deps.Clock, id, rev, platformhr.LeaveTypeUpdate{
Names: body.Names, HalfDays: body.HalfDays, AttachmentRequired: body.AttachmentRequired,
Active: body.Active, Actor: who})
return err
}) {
return
}
setETag(c, out.Revision)
c.JSON(http.StatusOK, toTypeDTO(out))
}
func (h handlers) listRules(c *gin.Context) {
id, err := parseID(c, "id")
if err != nil {
_ = c.Error(err)
return
}
var out []ruleDTO
if !h.inTx(c, func(ctx context.Context, tx pgx.Tx, _ uuid.UUID) error {
if _, err := platformhr.GetLeaveType(ctx, tx, id); err != nil {
return err
}
rs, err := platformhr.LeaveRules(ctx, tx, id)
out = toRuleDTOs(rs)
return err
}) {
return
}
c.JSON(http.StatusOK, gin.H{"rules": out})
}
func (h handlers) setRules(c *gin.Context) {
id, err := parseID(c, "id")
var body struct {
EffectiveFrom string `json:"effective_from"`
Bands []bandDTO `json:"bands"`
}
if err := firstErr(err, decodeStrict(c, &body)); err != nil {
_ = c.Error(err)
return
}
eff, err := parseDate("effective_from", body.EffectiveFrom)
if err != nil {
_ = c.Error(err)
return
}
bands := make([]platformhr.Band, len(body.Bands))
for i, b := range body.Bands {
d, err := platformhr.ParseDays(b.Days)
if err != nil {
_ = c.Error(mapErr(&platformhr.FieldError{Field: "bands", Rule: "half_days", Detail: "each band's days are whole or half days"}))
return
}
bands[i] = platformhr.Band{FromMonth: b.FromMonth, ToMonth: b.ToMonth, Days: d}
}
var out []ruleDTO
if !h.inTx(c, func(ctx context.Context, tx pgx.Tx, who uuid.UUID) error {
rs, err := platformhr.SetLeaveRules(ctx, tx, h.deps.Clock, platformhr.RuleSetInput{TypeID: id,
EffectiveFrom: eff, Bands: bands, Actor: who})
out = toRuleDTOs(rs)
return err
}) {
return
}
c.JSON(http.StatusCreated, gin.H{"rules": out})
}
func (h handlers) floors(c *gin.Context) {
out := []floorDTO{}
if !h.inTx(c, func(ctx context.Context, tx pgx.Tx, _ uuid.UUID) error {
fs, err := platformhr.StatutoryFloors(ctx, tx)
for _, f := range fs {
out = append(out, floorDTO{Kind: f.Kind, FromMonth: f.FromMonth, ToMonth: f.ToMonth, Days: f.Days.String()})
}
return err
}) {
return
}
c.JSON(http.StatusOK, gin.H{"floors": out})
}
func settingsOf(ctx context.Context, tx pgx.Tx) (settingsDTO, error) {
cal, err := platformhr.GetWorkCalendar(ctx, tx)
if err != nil {
return settingsDTO{}, err
}
s, err := platformhr.GetLeaveSetting(ctx, tx)
return settingsDTO{Weekdays: cal.Weekdays, State: cal.State, CarryForwardMax: s.CarryForwardMax.String(),
CarryForwardExpiryMonths: s.CarryForwardExpiryMonths}, err
}
func (h handlers) getSettings(c *gin.Context) {
var out settingsDTO
if !h.inTx(c, func(ctx context.Context, tx pgx.Tx, _ uuid.UUID) error {
var err error
out, err = settingsOf(ctx, tx)
return err
}) {
return
}
c.JSON(http.StatusOK, out)
}
func (h handlers) putSettings(c *gin.Context) {
var body settingsDTO
if err := decodeStrict(c, &body); err != nil {
_ = c.Error(err)
return
}
carry, err := platformhr.ParseDays(body.CarryForwardMax)
if err != nil {
_ = c.Error(mapErr(&platformhr.FieldError{Field: "carry_forward_max", Rule: "half_days", Detail: "whole or half days"}))
return
}
var out settingsDTO
if !h.inTx(c, func(ctx context.Context, tx pgx.Tx, who uuid.UUID) error {
if _, err := platformhr.SetWorkCalendar(ctx, tx, h.deps.Clock,
platformhr.WorkCalendar{Weekdays: body.Weekdays, State: body.State}, who); err != nil {
return err
}
if _, err := platformhr.SetLeaveSetting(ctx, tx, h.deps.Clock, platformhr.LeaveSetting{CarryForwardMax: carry,
CarryForwardExpiryMonths: body.CarryForwardExpiryMonths}, who); err != nil {
return err
}
var err error
out, err = settingsOf(ctx, tx)
return err
}) {
return
}
c.JSON(http.StatusOK, out)
}
func (h handlers) listHolidays(c *gin.Context) {
year, err := h.year(c)
if err != nil {
_ = c.Error(err)
return
}
out, warnings := []holidayDTO{}, []string{}
if !h.inTx(c, func(ctx context.Context, tx pgx.Tx, _ uuid.UUID) error {
hs, err := platformhr.Holidays(ctx, tx, year)
if err != nil {
return err
}
for _, x := range hs {
out = append(out, toHolidayDTO(x))
}
warnings, err = platformhr.LeaveYearWarnings(ctx, tx, year)
return err
}) {
return
}
c.JSON(http.StatusOK, gin.H{"holidays": out, "warnings": warnings})
}
func (h handlers) addHoliday(c *gin.Context) {
var body struct {
Date string `json:"date"`
State *string `json:"state"`
Names platformhr.Names `json:"names"`
}
if err := decodeStrict(c, &body); err != nil {
_ = c.Error(err)
return
}
day, err := parseDate("date", body.Date)
if err != nil {
_ = c.Error(err)
return
}
var out platformhr.Holiday
if !h.inTx(c, func(ctx context.Context, tx pgx.Tx, who uuid.UUID) error {
var err error
out, err = platformhr.AddHoliday(ctx, tx, h.deps.Clock, platformhr.HolidayInput{Date: day, State: body.State,
Names: body.Names, Actor: who})
return err
}) {
return
}
c.JSON(http.StatusCreated, toHolidayDTO(out))
}
func (h handlers) retireHoliday(c *gin.Context) {
id, err := parseID(c, "id")
if err != nil {
_ = c.Error(err)
return
}
var out platformhr.Holiday
if !h.inTx(c, func(ctx context.Context, tx pgx.Tx, who uuid.UUID) error {
var err error
out, err = platformhr.RetireHoliday(ctx, tx, h.deps.Clock, id, who)
return err
}) {
return
}
c.JSON(http.StatusOK, toHolidayDTO(out))
}
// period reads the required ?from= and ?to= of a report.
func period(c *gin.Context) (time.Time, time.Time, error) {
from, err := parseDate("from", c.Query("from"))
if err != nil {
return from, from, err
}
to, err := parseDate("to", c.Query("to"))
return from, to, err
}

Create internal/modules/leave/request_handlers.go:

package leave
import (
"context"
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
apperr "github.com/mcom/erp/internal/kernel/errors"
platformhr "github.com/mcom/erp/internal/platform/hr"
"github.com/mcom/erp/internal/platform/identity/member"
"github.com/mcom/erp/internal/platform/workflow"
)
// ownEmployee is the caller's employee record; nil when they have none (an
// outside approver may still decide leave routed to them).
func ownEmployee(ctx context.Context, tx pgx.Tx, who uuid.UUID) (*platformhr.Employee, error) {
e, err := platformhr.GetByMember(ctx, tx, who)
if errors.Is(err, platformhr.ErrNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &e, nil
}
// view is a request as who may see it, with what who may do to it now.
func (h handlers) view(ctx context.Context, tx pgx.Tx, r platformhr.LeaveRequest, who uuid.UUID, warnings []string) (requestView, error) {
medical, err := platformhr.MayReadMedical(ctx, tx, h.deps.Clock, r, who)
if err != nil {
return requestView{}, err
}
avail, err := workflow.AvailableTransitions(ctx, tx, h.deps.Clock, r.InstanceID, who)
if err != nil {
return requestView{}, err
}
if warnings == nil {
warnings = []string{}
}
return requestView{requestDTO: toRequestDTO(r, medical), Available: avail, Warnings: warnings}, nil
}
func (h handlers) me(c *gin.Context) {
year, err := h.year(c)
if err != nil {
_ = c.Error(err)
return
}
var employeeID *uuid.UUID
balances, requests, warnings := []balanceDTO{}, []requestDTO{}, []string{}
if !h.inTx(c, func(ctx context.Context, tx pgx.Tx, who uuid.UUID) error {
var err error
if warnings, err = platformhr.LeaveYearWarnings(ctx, tx, year); err != nil {
return err
}
emp, err := ownEmployee(ctx, tx, who)
if err != nil || emp == nil {
return err
}
employeeID = &emp.ID
bs, err := platformhr.Balances(ctx, tx, year, &emp.ID)
if err != nil {
return err
}
balances = toBalanceDTOs(bs)
rs, err := platformhr.ListLeaveRequests(ctx, tx, platformhr.RequestFilter{EmployeeID: &emp.ID})
for _, r := range rs {
requests = append(requests, toRequestDTO(r, true))
}
return err
}) {
return
}
c.JSON(http.StatusOK, gin.H{"employee_id": employeeID, "year": year, "balances": balances,
"requests": requests, "warnings": warnings})
}
// listRequests: scope mine (default) — the caller's own; awaiting — those
// the caller may decide now; all — every request, for hr.leave.read_all.
func (h handlers) listRequests(c *gin.Context) {
from, err := optionalDate(c, "from")
to, err2 := optionalDate(c, "to")
if err := firstErr(err, err2); err != nil {
_ = c.Error(err)
return
}
out := []requestDTO{}
if !h.inTx(c, func(ctx context.Context, tx pgx.Tx, who uuid.UUID) error {
f := platformhr.RequestFilter{Semantic: c.Query("semantic"), From: from, To: to}
switch c.DefaultQuery("scope", "mine") {
case "mine":
emp, err := ownEmployee(ctx, tx, who)
if err != nil || emp == nil {
return err
}
f.EmployeeID = &emp.ID
rs, err := platformhr.ListLeaveRequests(ctx, tx, f)
for _, r := range rs {
out = append(out, toRequestDTO(r, true))
}
return err
case "awaiting":
rs, err := platformhr.AwaitingLeave(ctx, tx, h.deps.Clock, who)
for _, r := range rs {
out = append(out, toRequestDTO(r, true))
}
return err
case "all":
if ok, err := member.Can(ctx, tx, who, PermissionReadAll); err != nil || !ok {
if err != nil {
return err
}
return coded(apperr.KindForbidden, "read_all_required", "every request is for hr.leave.read_all")
}
medical := false
for _, key := range []string{PermissionManage, PermissionMedicalRead} {
ok, err := member.Can(ctx, tx, who, key)
if err != nil {
return err
}
medical = medical || ok
}
rs, err := platformhr.ListLeaveRequests(ctx, tx, f)
for _, r := range rs {
out = append(out, toRequestDTO(r, medical))
}
return err
}
return apperr.New(apperr.KindValidation, "field=scope rule=known: mine, awaiting or all").With("field", "scope")
}) {
return
}
c.JSON(http.StatusOK, gin.H{"requests": out})
}
func (h handlers) createRequest(c *gin.Context) {
var body struct {
TypeID uuid.UUID `json:"type_id"`
FromDate string `json:"from_date"`
ToDate string `json:"to_date"`
FromHalf bool `json:"from_half"`
ToHalf bool `json:"to_half"`
Reason string `json:"reason"`
AttachmentID *uuid.UUID `json:"attachment_id"`
}
if err := decodeStrict(c, &body); err != nil {
_ = c.Error(err)
return
}
from, err := parseDate("from_date", body.FromDate)
to, err2 := parseDate("to_date", body.ToDate)
if err := firstErr(err, err2); err != nil {
_ = c.Error(err)
return
}
var out requestView
if !h.inTx(c, func(ctx context.Context, tx pgx.Tx, who uuid.UUID) error {
r, warnings, err := platformhr.RequestLeave(ctx, tx, h.deps.Clock, platformhr.RequestInput{TypeID: body.TypeID,
From: from, To: to, FromHalf: body.FromHalf, ToHalf: body.ToHalf, Reason: body.Reason,
AttachmentID: body.AttachmentID, Actor: who})
if err != nil {
return err
}
out, err = h.view(ctx, tx, r, who, warnings)
return err
}) {
return
}
c.JSON(http.StatusCreated, out)
}
// getRequest answers 404 to a caller who may not read the request: its
// existence is itself personal data.
func (h handlers) getRequest(c *gin.Context) {
id, err := parseID(c, "id")
if err != nil {
_ = c.Error(err)
return
}
var out requestView
if !h.inTx(c, func(ctx context.Context, tx pgx.Tx, who uuid.UUID) error {
r, err := platformhr.GetLeaveRequest(ctx, tx, id)
if err != nil {
return err
}
ok, err := platformhr.CanReadLeave(ctx, tx, h.deps.Clock, r, who)
if err != nil {
return err
}
if !ok {
return platformhr.ErrLeaveRequestNotFound
}
out, err = h.view(ctx, tx, r, who, nil)
return err
}) {
return
}
c.JSON(http.StatusOK, out)
}
func (h handlers) transitionRequest(c *gin.Context) {
id, err := parseID(c, "id")
var body struct {
From string `json:"from"`
To string `json:"to"`
Reason string `json:"reason"`
}
if err := firstErr(err, decodeStrict(c, &body)); err != nil {
_ = c.Error(err)
return
}
var out requestView
if !h.inTx(c, func(ctx context.Context, tx pgx.Tx, who uuid.UUID) error {
r, _, err := platformhr.TransitionLeave(ctx, tx, h.deps.Clock, platformhr.LeaveTransitionInput{RequestID: id,
From: body.From, To: body.To, Actor: who, Reason: body.Reason})
if err != nil {
return err
}
out, err = h.view(ctx, tx, r, who, nil)
return err
}) {
return
}
c.JSON(http.StatusOK, out)
}
func (h handlers) adjust(c *gin.Context) {
var body struct {
EmployeeID uuid.UUID `json:"employee_id"`
TypeID uuid.UUID `json:"type_id"`
Year int `json:"year"`
Days string `json:"days"`
Reason string `json:"reason"`
}
if err := decodeStrict(c, &body); err != nil {
_ = c.Error(err)
return
}
days, err := platformhr.ParseDays(body.Days)
if err != nil {
_ = c.Error(mapErr(err))
return
}
var out platformhr.Entry
if !h.inTx(c, func(ctx context.Context, tx pgx.Tx, who uuid.UUID) error {
var err error
out, err = platformhr.Adjust(ctx, tx, h.deps.Clock, platformhr.AdjustInput{EmployeeID: body.EmployeeID,
TypeID: body.TypeID, Year: body.Year, Days: days, Reason: body.Reason, Actor: who})
return err
}) {
return
}
c.JSON(http.StatusCreated, entryDTO{ID: out.ID, EmployeeID: out.EmployeeID, TypeID: out.TypeID, Year: out.Year,
Kind: out.Kind, Days: out.Days.String(), Reason: out.Reason, RecordedAt: out.RecordedAt})
}

Create internal/modules/leave/report_handlers.go:

package leave
import (
"context"
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
platformhr "github.com/mcom/erp/internal/platform/hr"
"github.com/mcom/erp/internal/platform/identity/member"
)
func (h handlers) balances(c *gin.Context) {
year, err := h.year(c)
if err != nil {
_ = c.Error(err)
return
}
var out []balanceDTO
if !h.inTx(c, func(ctx context.Context, tx pgx.Tx, _ uuid.UUID) error {
bs, err := platformhr.Balances(ctx, tx, year, nil)
out = toBalanceDTOs(bs)
return err
}) {
return
}
c.JSON(http.StatusOK, gin.H{"year": year, "balances": out})
}
func (h handlers) taken(c *gin.Context) {
from, to, err := period(c)
if err != nil {
_ = c.Error(err)
return
}
out := []takenDTO{}
if !h.inTx(c, func(ctx context.Context, tx pgx.Tx, _ uuid.UUID) error {
rows, err := platformhr.LeaveTaken(ctx, tx, from, to)
for _, r := range rows {
out = append(out, takenDTO{TypeID: r.TypeID, Requests: r.Requests, Days: r.Days.String()})
}
return err
}) {
return
}
c.JSON(http.StatusOK, gin.H{"rows": out})
}
// calendar withholds the leave type from a caller without hr.leave.read_all
// (Ruling H3-13): who is away is the team's business, why is not.
func (h handlers) calendar(c *gin.Context) {
from, to, err := period(c)
if err != nil {
_ = c.Error(err)
return
}
out := []calendarDTO{}
if !h.inTx(c, func(ctx context.Context, tx pgx.Tx, who uuid.UUID) error {
readAll, err := member.Can(ctx, tx, who, PermissionReadAll)
if err != nil {
return err
}
entries, err := platformhr.TeamCalendar(ctx, tx, from, to)
for _, e := range entries {
d := calendarDTO{RequestID: e.RequestID, EmployeeID: e.EmployeeID, EmployeeName: e.EmployeeName,
FromDate: e.From.Format(dateLayout), ToDate: e.To.Format(dateLayout), FromHalf: e.FromHalf,
ToHalf: e.ToHalf, Days: e.Days.String()}
if readAll {
typ := e.TypeID
d.TypeID = &typ
}
out = append(out, d)
}
return err
}) {
return
}
c.JSON(http.StatusOK, gin.H{"entries": out})
}
func (h handlers) routingMoves(c *gin.Context) {
from, to, err := period(c)
if err != nil {
_ = c.Error(err)
return
}
out := []moveDTO{}
if !h.inTx(c, func(ctx context.Context, tx pgx.Tx, _ uuid.UUID) error {
ms, err := platformhr.RoutingMoves(ctx, tx, from, to)
for _, m := range ms {
out = append(out, moveDTO{ID: m.ID, InstanceID: m.InstanceID, Kind: m.Kind, State: m.State, GrantID: m.GrantID,
OnDay: m.On.Format(dateLayout), AwayMemberIDs: m.Away, MovedToMemberIDs: m.MovedTo,
NoAlternate: m.NoAlternate, LoggedAt: m.LoggedAt})
}
return err
}) {
return
}
c.JSON(http.StatusOK, gin.H{"moves": out})
}
  • Step 7: Declare the routes in the contract

In api/openapi.yaml, insert immediately above the top-level components: line (after H1’s /hr/employees/{id}/leaving-overrides block):

/leave/types:
get:
operationId: listLeaveTypes
tags: [Leave]
summary: Leave types
description: The organization's leave types by code; retired ones only with all=true. Requires hr.leave.request.
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/LeaveType' } }
'401': { $ref: '#/components/responses/Unauthenticated' }
'403': { $ref: '#/components/responses/LeaveForbidden' }
post:
operationId: createLeaveType
tags: [Leave]
summary: Create a leave type
description: >
Code, kind and pay are fixed once created. A statutory kind is paid,
unpaid is not; sick and hospitalisation need a medical certificate;
maternity and paternity take no half days. 409 duplicate_leave_type.
Requires hr.leave.manage.
requestBody:
required: true
content: { application/json: { schema: { $ref: '#/components/schemas/LeaveTypeInput' } } }
responses:
'201':
description: The type
headers: { ETag: { $ref: '#/components/headers/LeaveRevision' } }
content: { application/json: { schema: { $ref: '#/components/schemas/LeaveType' } } }
'400': { $ref: '#/components/responses/BadRequestBody' }
'401': { $ref: '#/components/responses/Unauthenticated' }
'403': { $ref: '#/components/responses/LeaveForbidden' }
'409': { $ref: '#/components/responses/LeaveConflict' }
'422': { $ref: '#/components/responses/LeaveInvalid' }
/leave/types/{id}:
put:
operationId: updateLeaveType
tags: [Leave]
summary: Rename, retire or reconfigure a leave type
description: Names, half days, the attachment rule and whether it is offered. 409 revision_mismatch. Requires hr.leave.manage.
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
- { name: If-Match, in: header, required: true, schema: { type: string }, description: The ETag of the type being changed. }
requestBody:
required: true
content: { application/json: { schema: { $ref: '#/components/schemas/LeaveTypeUpdate' } } }
responses:
'200':
description: The type
headers: { ETag: { $ref: '#/components/headers/LeaveRevision' } }
content: { application/json: { schema: { $ref: '#/components/schemas/LeaveType' } } }
'400': { $ref: '#/components/responses/BadRequestBody' }
'401': { $ref: '#/components/responses/Unauthenticated' }
'403': { $ref: '#/components/responses/LeaveForbidden' }
'404': { $ref: '#/components/responses/LeaveNotFound' }
'409': { $ref: '#/components/responses/LeaveConflict' }
'422': { $ref: '#/components/responses/LeaveInvalid' }
/leave/types/{id}/rules:
get:
operationId: listLeaveRules
tags: [Leave]
summary: A leave type's rule sets
description: Every band of every set, the newest set first. Requires hr.leave.manage.
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
responses:
'200':
description: The rules
content:
application/json:
schema:
type: object
required: [rules]
properties:
rules: { type: array, items: { $ref: '#/components/schemas/LeaveRule' } }
'401': { $ref: '#/components/responses/Unauthenticated' }
'403': { $ref: '#/components/responses/LeaveForbidden' }
'404': { $ref: '#/components/responses/LeaveNotFound' }
post:
operationId: setLeaveRules
tags: [Leave]
summary: Record a rule set from a date
description: >
HD-8: applies to grants made on or after effective_from; past grants
are never rewritten. Bands run from month 0 without gaps, the last
open-ended. HD-3: 422 field=bands rule=statutory_floor when any band
is below the highest statutory floor it overlaps; 409 rule_set_exists
for a second set on one date. Requires hr.leave.manage.
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
requestBody:
required: true
content: { application/json: { schema: { $ref: '#/components/schemas/LeaveRuleSetInput' } } }
responses:
'201':
description: The set
content:
application/json:
schema:
type: object
required: [rules]
properties:
rules: { type: array, items: { $ref: '#/components/schemas/LeaveRule' } }
'400': { $ref: '#/components/responses/BadRequestBody' }
'401': { $ref: '#/components/responses/Unauthenticated' }
'403': { $ref: '#/components/responses/LeaveForbidden' }
'404': { $ref: '#/components/responses/LeaveNotFound' }
'409': { $ref: '#/components/responses/LeaveConflict' }
'422': { $ref: '#/components/responses/LeaveInvalid' }
/leave/floors:
get:
operationId: listLeaveFloors
tags: [Leave]
summary: The statutory leave floors
description: The seeded Employment Act minimums (UNVERIFIED; see the ROADMAP). Requires hr.leave.manage.
responses:
'200':
description: The floors
content:
application/json:
schema:
type: object
required: [floors]
properties:
floors: { type: array, items: { $ref: '#/components/schemas/LeaveFloor' } }
'401': { $ref: '#/components/responses/Unauthenticated' }
'403': { $ref: '#/components/responses/LeaveForbidden' }
/leave/settings:
get:
operationId: getLeaveSettings
tags: [Leave]
summary: The working week and carry-forward
description: Monday to Friday and no carry-forward until set. Requires hr.leave.manage.
responses:
'200':
description: The settings
content: { application/json: { schema: { $ref: '#/components/schemas/LeaveSettings' } } }
'401': { $ref: '#/components/responses/Unauthenticated' }
'403': { $ref: '#/components/responses/LeaveForbidden' }
put:
operationId: putLeaveSettings
tags: [Leave]
summary: Set the working week and carry-forward
description: Requires hr.leave.manage.
requestBody:
required: true
content: { application/json: { schema: { $ref: '#/components/schemas/LeaveSettings' } } }
responses:
'200':
description: The settings
content: { application/json: { schema: { $ref: '#/components/schemas/LeaveSettings' } } }
'400': { $ref: '#/components/responses/BadRequestBody' }
'401': { $ref: '#/components/responses/Unauthenticated' }
'403': { $ref: '#/components/responses/LeaveForbidden' }
'422': { $ref: '#/components/responses/LeaveInvalid' }
/leave/holidays:
get:
operationId: listPublicHolidays
tags: [Leave]
summary: Public holidays of a year
description: >
Active holidays of the year (default: this year), and warnings:
no_public_holidays when none applies to the organization. Requires
hr.leave.request.
parameters:
- { name: year, in: query, required: false, schema: { type: integer } }
responses:
'200':
description: The holidays
content:
application/json:
schema:
type: object
required: [holidays, warnings]
properties:
holidays: { type: array, items: { $ref: '#/components/schemas/PublicHoliday' } }
warnings: { type: array, items: { type: string, enum: [no_public_holidays] } }
'401': { $ref: '#/components/responses/Unauthenticated' }
'403': { $ref: '#/components/responses/LeaveForbidden' }
'422': { $ref: '#/components/responses/LeaveInvalid' }
post:
operationId: addPublicHoliday
tags: [Leave]
summary: Enter a public holiday
description: National when state is null. 409 duplicate_holiday. Requires hr.leave.manage.
requestBody:
required: true
content: { application/json: { schema: { $ref: '#/components/schemas/PublicHolidayInput' } } }
responses:
'201':
description: The holiday
content: { application/json: { schema: { $ref: '#/components/schemas/PublicHoliday' } } }
'400': { $ref: '#/components/responses/BadRequestBody' }
'401': { $ref: '#/components/responses/Unauthenticated' }
'403': { $ref: '#/components/responses/LeaveForbidden' }
'409': { $ref: '#/components/responses/LeaveConflict' }
'422': { $ref: '#/components/responses/LeaveInvalid' }
/leave/holidays/{id}/retire:
post:
operationId: retirePublicHoliday
tags: [Leave]
summary: Retire a public holiday
description: It no longer counts; requests already made keep their days. Requires hr.leave.manage.
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
responses:
'200':
description: The holiday
content: { application/json: { schema: { $ref: '#/components/schemas/PublicHoliday' } } }
'401': { $ref: '#/components/responses/Unauthenticated' }
'403': { $ref: '#/components/responses/LeaveForbidden' }
'404': { $ref: '#/components/responses/LeaveNotFound' }
/leave/me:
get:
operationId: getMyLeave
tags: [Leave]
summary: My balances and requests
description: >
The caller's balances for the year (default: this year) and their
requests; employee_id is null for a member with no employee record.
Requires hr.leave.request.
parameters:
- { name: year, in: query, required: false, schema: { type: integer } }
responses:
'200':
description: The caller's leave
content: { application/json: { schema: { $ref: '#/components/schemas/LeaveMe' } } }
'401': { $ref: '#/components/responses/Unauthenticated' }
'403': { $ref: '#/components/responses/LeaveForbidden' }
'422': { $ref: '#/components/responses/LeaveInvalid' }
/leave/requests:
get:
operationId: listLeaveRequests
tags: [Leave]
summary: Leave requests
description: >
scope=mine (default) the caller's own; awaiting — those the caller
may decide now; all — every request, 403 read_all_required without
hr.leave.read_all. semantic narrows by state semantic (pending,
committed, …); from/to to requests overlapping the period.
Requires hr.leave.request.
parameters:
- { name: scope, in: query, required: false, schema: { type: string, enum: [mine, awaiting, all] } }
- { name: semantic, in: query, required: false, schema: { type: string } }
- { name: from, in: query, required: false, schema: { type: string, format: date } }
- { name: to, in: query, required: false, schema: { type: string, format: date } }
responses:
'200':
description: The requests
content:
application/json:
schema:
type: object
required: [requests]
properties:
requests: { type: array, items: { $ref: '#/components/schemas/LeaveRequest' } }
'401': { $ref: '#/components/responses/Unauthenticated' }
'403': { $ref: '#/components/responses/LeaveForbidden' }
'422': { $ref: '#/components/responses/LeaveInvalid' }
post:
operationId: createLeaveRequest
tags: [Leave]
summary: Request leave (as a draft)
description: >
The caller's own leave, counted in working days now; submit it with
a transition to pending. warnings carries no_public_holidays. 409
not_an_employee, overlapping_leave, attachment_bound,
employee_status; 422 field=… (one_leave_year, before_hire,
after_last_day, no_working_days, attachment_id). Requires
hr.leave.request.
requestBody:
required: true
content: { application/json: { schema: { $ref: '#/components/schemas/LeaveRequestInput' } } }
responses:
'201':
description: The request
content: { application/json: { schema: { $ref: '#/components/schemas/LeaveRequestView' } } }
'400': { $ref: '#/components/responses/BadRequestBody' }
'401': { $ref: '#/components/responses/Unauthenticated' }
'403': { $ref: '#/components/responses/LeaveForbidden' }
'404': { $ref: '#/components/responses/LeaveNotFound' }
'409': { $ref: '#/components/responses/LeaveConflict' }
'422': { $ref: '#/components/responses/LeaveInvalid' }
/leave/requests/{id}:
get:
operationId: getLeaveRequest
tags: [Leave]
summary: One leave request
description: >
For the requester, whoever may decide it or did, hr.leave.read_all
and hr.leave.manage; 404 to anyone else. attachment_id is null and
attachment_hidden true unless the caller may open a medical
certificate. Requires hr.leave.request.
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
responses:
'200':
description: The request and what the caller may do to it
content: { application/json: { schema: { $ref: '#/components/schemas/LeaveRequestView' } } }
'401': { $ref: '#/components/responses/Unauthenticated' }
'403': { $ref: '#/components/responses/LeaveForbidden' }
'404': { $ref: '#/components/responses/LeaveNotFound' }
'422': { $ref: '#/components/responses/LeaveInvalid' }
/leave/requests/{id}/transitions:
post:
operationId: transitionLeaveRequest
tags: [Leave]
summary: Submit, approve, reject, withdraw or cancel
description: >
Approval (into a committed state) takes the days; cancelling approved
leave before its first day returns them (409 leave_started after).
Submission and approval of a paid type are refused 409
insufficient_balance (with available and requested) beyond the
balance. 403 when the workflow's grants name someone else; 422
sole_owner_reason_required for a one-member organization's Owner
without a reason; 422 owner_leave_approver_missing or
owner_leave_approver_vacant when an OWNER-seat holder's leave has no
other approver and the administrator has not configured one (a
grant at the OWNER-seat floor on the approved step). Requires
hr.leave.request.
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
requestBody:
required: true
content: { application/json: { schema: { $ref: '#/components/schemas/DocumentTransition' } } }
responses:
'200':
description: The request
content: { application/json: { schema: { $ref: '#/components/schemas/LeaveRequestView' } } }
'400': { $ref: '#/components/responses/BadRequestBody' }
'401': { $ref: '#/components/responses/Unauthenticated' }
'403': { $ref: '#/components/responses/LeaveForbidden' }
'404': { $ref: '#/components/responses/LeaveNotFound' }
'409': { $ref: '#/components/responses/LeaveConflict' }
'422': { $ref: '#/components/responses/LeaveInvalid' }
/leave/adjustments:
post:
operationId: adjustLeave
tags: [Leave]
summary: Adjust a leave balance
description: >
A non-zero number of half days with a reason, audited. HD-7: 409
below_statutory_floor when it takes a statutory type below the floor
for that leave year. Requires hr.leave.adjust.
requestBody:
required: true
content: { application/json: { schema: { $ref: '#/components/schemas/LeaveAdjustmentInput' } } }
responses:
'201':
description: The ledger entry
content: { application/json: { schema: { $ref: '#/components/schemas/LeaveEntry' } } }
'400': { $ref: '#/components/responses/BadRequestBody' }
'401': { $ref: '#/components/responses/Unauthenticated' }
'403': { $ref: '#/components/responses/LeaveForbidden' }
'404': { $ref: '#/components/responses/LeaveNotFound' }
'409': { $ref: '#/components/responses/LeaveConflict' }
'422': { $ref: '#/components/responses/LeaveInvalid' }
/leave/reports/balances:
get:
operationId: leaveBalancesReport
tags: [Leave]
summary: Balances per employee
description: Every employee and type with entries or requests in the year; balance is the sum of the ledger. Requires hr.leave.read_all.
parameters:
- { name: year, in: query, required: false, schema: { type: integer } }
responses:
'200':
description: The balances
content:
application/json:
schema:
type: object
required: [year, balances]
properties:
year: { type: integer }
balances: { type: array, items: { $ref: '#/components/schemas/LeaveBalance' } }
'401': { $ref: '#/components/responses/Unauthenticated' }
'403': { $ref: '#/components/responses/LeaveForbidden' }
'422': { $ref: '#/components/responses/LeaveInvalid' }
/leave/reports/taken:
get:
operationId: leaveTakenReport
tags: [Leave]
summary: Leave taken by type and period
description: Approved leave whose first day is in [from, to], by type (at most a year). Requires hr.leave.read_all.
parameters:
- { name: from, in: query, required: true, schema: { type: string, format: date } }
- { name: to, in: query, required: true, schema: { type: string, format: date } }
responses:
'200':
description: The rows
content:
application/json:
schema:
type: object
required: [rows]
properties:
rows: { type: array, items: { $ref: '#/components/schemas/LeaveTakenRow' } }
'401': { $ref: '#/components/responses/Unauthenticated' }
'403': { $ref: '#/components/responses/LeaveForbidden' }
'422': { $ref: '#/components/responses/LeaveInvalid' }
/leave/calendar:
get:
operationId: leaveCalendar
tags: [Leave]
summary: The team calendar
description: >
Approved leave overlapping [from, to] (at most a year). type_id is
null unless the caller holds hr.leave.read_all. Requires
hr.leave.calendar.
parameters:
- { name: from, in: query, required: true, schema: { type: string, format: date } }
- { name: to, in: query, required: true, schema: { type: string, format: date } }
responses:
'200':
description: The entries
content:
application/json:
schema:
type: object
required: [entries]
properties:
entries: { type: array, items: { $ref: '#/components/schemas/LeaveCalendarEntry' } }
'401': { $ref: '#/components/responses/Unauthenticated' }
'403': { $ref: '#/components/responses/LeaveForbidden' }
'422': { $ref: '#/components/responses/LeaveInvalid' }
/leave/routing-moves:
get:
operationId: listLeaveRoutingMoves
tags: [Leave]
summary: Approvals routed around leave
description: >
HD-2: each pending step that passed over an approver on leave, as the
daily job logged it; no_alternate when nobody above was available
and it stayed with them. Requires hr.leave.manage.
parameters:
- { name: from, in: query, required: true, schema: { type: string, format: date } }
- { name: to, in: query, required: true, schema: { type: string, format: date } }
responses:
'200':
description: The moves
content:
application/json:
schema:
type: object
required: [moves]
properties:
moves: { type: array, items: { $ref: '#/components/schemas/LeaveRoutingMove' } }
'401': { $ref: '#/components/responses/Unauthenticated' }
'403': { $ref: '#/components/responses/LeaveForbidden' }
'422': { $ref: '#/components/responses/LeaveInvalid' }

Under components: responses:, insert after H1’s HRInvalid response:

LeaveForbidden:
description: >
The caller does not hold the permission this operation requires
(read_all_required for scope=all), or the workflow's grants name
someone else for this transition.
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
LeaveNotFound:
description: No such leave type, holiday, request or employee in this organization — or none the caller may see.
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
LeaveConflict:
description: >
The same request would succeed against the records in another state.
code: duplicate_leave_type, rule_set_exists, duplicate_holiday,
not_an_employee, employee_status, overlapping_leave, attachment_bound,
leave_started, below_statutory_floor, insufficient_balance (with
available and requested), revision_mismatch.
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }
LeaveInvalid:
description: >
The request is well-formed but refused (field=… rule=…; sole_owner_reason_required;
owner_leave_approver_missing, owner_leave_approver_vacant).
content:
application/problem+json:
schema: { $ref: '#/components/schemas/Problem' }

Under components: headers:, after H1’s HRRevision:

LeaveRevision:
description: The leave type's revision, quoted.
schema: { type: string }

In the OrgModule schema, add leave to the key enum after H1’s hr.

Append to the end of the file (the schemas section):

LeaveKind:
type: string
enum: [annual, sick, hospitalisation, maternity, paternity, unpaid, other]
LeaveDays:
type: string
pattern: '^-?[0-9]{1,4}(\.[05])?$'
description: A number of days in whole or half days, as "1" or "1.5".
LeaveNames:
type: object
required: [en, ms]
additionalProperties: false
properties:
en: { type: string, maxLength: 200 }
ms: { type: string, maxLength: 200 }
zh: { type: string, maxLength: 200 }
LeaveType:
type: object
required: [id, code, names, kind, paid, half_days, attachment_required, active, revision, created_at, updated_at]
properties:
id: { type: string, format: uuid }
code: { type: string }
names: { $ref: '#/components/schemas/LeaveNames' }
kind: { $ref: '#/components/schemas/LeaveKind' }
paid: { type: boolean }
half_days: { type: boolean }
attachment_required: { type: boolean }
active: { type: boolean }
revision: { type: integer }
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
LeaveTypeInput:
type: object
required: [code, names, kind, paid, half_days, attachment_required]
additionalProperties: false
properties:
code: { type: string, maxLength: 20 }
names: { $ref: '#/components/schemas/LeaveNames' }
kind: { $ref: '#/components/schemas/LeaveKind' }
paid: { type: boolean, description: Ignored for statutory kinds (always paid) and unpaid (never). }
half_days: { type: boolean }
attachment_required: { type: boolean, description: Always true for sick and hospitalisation. }
LeaveTypeUpdate:
type: object
required: [names, half_days, attachment_required, active]
additionalProperties: false
properties:
names: { $ref: '#/components/schemas/LeaveNames' }
half_days: { type: boolean }
attachment_required: { type: boolean }
active: { type: boolean }
LeaveBand:
type: object
required: [from_month, to_month, days]
additionalProperties: false
properties:
from_month: { type: integer, minimum: 0 }
to_month: { type: integer, nullable: true, description: Exclusive; null is open-ended. }
days: { $ref: '#/components/schemas/LeaveDays' }
LeaveRule:
type: object
required: [id, type_id, effective_from, from_month, to_month, days, created_at]
properties:
id: { type: string, format: uuid }
type_id: { type: string, format: uuid }
effective_from: { type: string, format: date }
from_month: { type: integer }
to_month: { type: integer, nullable: true }
days: { $ref: '#/components/schemas/LeaveDays' }
created_at: { type: string, format: date-time }
LeaveRuleSetInput:
type: object
required: [effective_from, bands]
additionalProperties: false
properties:
effective_from: { type: string, format: date }
bands: { type: array, minItems: 1, items: { $ref: '#/components/schemas/LeaveBand' } }
LeaveFloor:
type: object
required: [kind, from_month, to_month, days]
properties:
kind: { $ref: '#/components/schemas/LeaveKind' }
from_month: { type: integer }
to_month: { type: integer, nullable: true }
days: { $ref: '#/components/schemas/LeaveDays' }
LeaveSettings:
type: object
required: [weekdays, state, carry_forward_max, carry_forward_expiry_months]
additionalProperties: false
properties:
weekdays: { type: array, minItems: 1, maxItems: 7, items: { type: integer, minimum: 1, maximum: 7 }, description: ISO weekdays, 1 Monday to 7 Sunday. }
state: { type: string, nullable: true, pattern: '^MY-(0[1-9]|1[0-6])$', description: The organization's state, for state public holidays. }
carry_forward_max: { $ref: '#/components/schemas/LeaveDays' }
carry_forward_expiry_months: { type: integer, minimum: 1, maximum: 12, nullable: true }
PublicHoliday:
type: object
required: [id, date, state, names, active, created_at]
properties:
id: { type: string, format: uuid }
date: { type: string, format: date }
state: { type: string, nullable: true }
names: { $ref: '#/components/schemas/LeaveNames' }
active: { type: boolean }
created_at: { type: string, format: date-time }
PublicHolidayInput:
type: object
required: [date, state, names]
additionalProperties: false
properties:
date: { type: string, format: date }
state: { type: string, nullable: true }
names: { $ref: '#/components/schemas/LeaveNames' }
LeaveRequest:
type: object
required: [id, employee_id, employee_name, type_id, type_kind, from_date, to_date, from_half, to_half, days, year,
reason, attachment_id, attachment_hidden, state, semantic, created_at]
properties:
id: { type: string, format: uuid }
employee_id: { type: string, format: uuid }
employee_name: { type: string }
type_id: { type: string, format: uuid }
type_kind: { $ref: '#/components/schemas/LeaveKind' }
from_date: { type: string, format: date }
to_date: { type: string, format: date }
from_half: { type: boolean, description: The first day is an afternoon only. }
to_half: { type: boolean, description: The last day is a morning only. }
days: { $ref: '#/components/schemas/LeaveDays' }
year: { type: integer }
reason: { type: string }
attachment_id: { type: string, format: uuid, nullable: true }
attachment_hidden: { type: boolean }
state: { type: string }
semantic: { $ref: '#/components/schemas/WorkflowSemantic' }
created_at: { type: string, format: date-time }
LeaveRequestView:
allOf:
- $ref: '#/components/schemas/LeaveRequest'
- type: object
required: [available, warnings]
properties:
available: { type: array, items: { $ref: '#/components/schemas/WorkflowAvailableTransition' } }
warnings: { type: array, items: { type: string, enum: [no_public_holidays] } }
LeaveRequestInput:
type: object
required: [type_id, from_date, to_date, from_half, to_half, reason]
additionalProperties: false
properties:
type_id: { type: string, format: uuid }
from_date: { type: string, format: date }
to_date: { type: string, format: date }
from_half: { type: boolean }
to_half: { type: boolean }
reason: { type: string, maxLength: 500, description: English or Bahasa Malaysia. }
attachment_id: { type: string, format: uuid, nullable: true, description: A verified document you uploaded (a medical certificate). }
LeaveBalance:
type: object
required: [employee_id, employee_name, employee_no, type_id, year, granted, carried_forward, taken, reversed,
expired, adjusted, balance, pending]
properties:
employee_id: { type: string, format: uuid }
employee_name: { type: string }
employee_no: { type: string }
type_id: { type: string, format: uuid }
year: { type: integer }
granted: { $ref: '#/components/schemas/LeaveDays' }
carried_forward: { $ref: '#/components/schemas/LeaveDays' }
taken: { $ref: '#/components/schemas/LeaveDays' }
reversed: { $ref: '#/components/schemas/LeaveDays' }
expired: { $ref: '#/components/schemas/LeaveDays' }
adjusted: { $ref: '#/components/schemas/LeaveDays' }
balance: { $ref: '#/components/schemas/LeaveDays' }
pending: { $ref: '#/components/schemas/LeaveDays' }
LeaveMe:
type: object
required: [employee_id, year, balances, requests, warnings]
properties:
employee_id: { type: string, format: uuid, nullable: true }
year: { type: integer }
balances: { type: array, items: { $ref: '#/components/schemas/LeaveBalance' } }
requests: { type: array, items: { $ref: '#/components/schemas/LeaveRequest' } }
warnings: { type: array, items: { type: string, enum: [no_public_holidays] } }
LeaveAdjustmentInput:
type: object
required: [employee_id, type_id, year, days, reason]
additionalProperties: false
properties:
employee_id: { type: string, format: uuid }
type_id: { type: string, format: uuid }
year: { type: integer }
days: { $ref: '#/components/schemas/LeaveDays' }
reason: { type: string, maxLength: 500 }
LeaveEntry:
type: object
required: [id, employee_id, type_id, year, kind, days, reason, recorded_at]
properties:
id: { type: string, format: uuid }
employee_id: { type: string, format: uuid }
type_id: { type: string, format: uuid }
year: { type: integer }
kind: { type: string, enum: [grant, take, reverse, carry_forward, expire, adjust] }
days: { $ref: '#/components/schemas/LeaveDays' }
reason: { type: string }
recorded_at: { type: string, format: date-time }
LeaveTakenRow:
type: object
required: [type_id, requests, days]
properties:
type_id: { type: string, format: uuid }
requests: { type: integer }
days: { $ref: '#/components/schemas/LeaveDays' }
LeaveCalendarEntry:
type: object
required: [request_id, employee_id, employee_name, type_id, from_date, to_date, from_half, to_half, days]
properties:
request_id: { type: string, format: uuid }
employee_id: { type: string, format: uuid }
employee_name: { type: string }
type_id: { type: string, format: uuid, nullable: true, description: Null unless the caller holds hr.leave.read_all. }
from_date: { type: string, format: date }
to_date: { type: string, format: date }
from_half: { type: boolean }
to_half: { type: boolean }
days: { $ref: '#/components/schemas/LeaveDays' }
LeaveRoutingMove:
type: object
required: [id, instance_id, kind, state, grant_id, on_day, away_member_ids, moved_to_member_ids, no_alternate, logged_at]
properties:
id: { type: string, format: uuid }
instance_id: { type: string, format: uuid }
kind: { type: string, description: The workflow kind (any kind, not only leave). }
state: { type: string }
grant_id: { type: string, format: uuid }
on_day: { type: string, format: date }
away_member_ids: { type: array, items: { type: string, format: uuid } }
moved_to_member_ids: { type: array, items: { type: string, format: uuid } }
no_alternate: { type: boolean }
logged_at: { type: string, format: date-time }

Run: make generate Expected: internal/gen/api/api.gen.go and web/src/lib/api/schema.d.ts gain the leave operations and schemas.

  • Step 8: Wire the module

In internal/app/app.go: add the import "github.com/mcom/erp/internal/modules/leave"; add the field Leave leave.Deps after H1’s HR hr.Deps; add after hr.Register(optional("hr"), deps.HR):

leave.Register(optional("leave"), deps.Leave)

In internal/app/routes_test.go: add the import "github.com/mcom/erp/internal/modules/leave" and to composedModules:

"leave": {leave.PermissionRequest, leave.PermissionManage, leave.PermissionReadAll, leave.PermissionCalendar,
leave.PermissionAdjust, leave.PermissionMedicalRead},

In cmd/api/main.go: add the import "github.com/mcom/erp/internal/modules/leave" and, after H1’s HR: hr.Deps{…}, block:

Leave: leave.Deps{
DB: database,
Clock: clock.System(),
},

In cmd/orgadmin/main.go, cmd/migrate/main.go and cmd/worker/main.go, add _ "github.com/mcom/erp/internal/modules/leave" to the blank-import block (the worker’s Dispatcher provisions Owners from member.RegisteredPermissionSet, and its role grants come from this package’s init()). In cmd/orgadmin/wiring_test.go and cmd/migrate/wiring_test.go, add "github.com/mcom/erp/internal/modules/leave", to wantModules and after H1’s keys in wantPermissions:

"hr.leave.request", // modules/leave
"hr.leave.medical.read", // modules/leave

Append to cmd/worker/wiring_test.go:

// Phase 5 H3: the worker provisions organizations (the operator console's
// Dispatcher) with member.RegisteredPermissionSet, so it must link
// internal/modules/leave for the leave keys and their role grants.
func TestWorkerLinksTheLeaveModule(t *testing.T) {
require.Contains(t, member.RegisteredPermissionSet().Keys(), "hr.leave.request",
"cmd/worker must blank-import internal/modules/leave")
}

In web/src/lib/i18n/locales/en/modules.json add "leave": "Leave" to "names" and "leave": "Leave types, requests, balances and the team calendar." to "descriptions"; in ms/modules.json "leave": "Cuti" and "leave": "Jenis cuti, permohonan, baki dan kalendar pasukan."; in zh/modules.json "leave": "休假" and "leave": "休假类型、申请、余额和团队日历。".

  • Step 9: Run the tests

Run: go test ./internal/modules/leave/ ./internal/app/ ./cmd/... ./internal/gen/ ./internal/kernel/vocab/ ./internal/kernel/layering/ -count=1 && pnpm -C web typecheck && pnpm -C web test -- modules i18n schema Expected: PASS (TestEveryRouteIsAuthorized, TestEveryModuleContributesRoutesAndPermissions, TestTheOpenAPIDocumentSaysWhatTheRouterEnforces, TestModulesNeverReferenceRLSExemptTables and TestANestedPermissionBelongsToAModuleThatRequiresItsParent included).

  • Step 10: Watch the privacy guards fail once

In calendar, change if readAll { to if true {. Run go test ./internal/modules/leave/ -run TestTheCalendarWithholdsTheLeaveType -count=1. Expected: FAIL — ABSENCE: the calendar alone does not. Restore. In toRequestDTO, change if !medical && r.AttachmentID != nil { to if false {. Expected: FAIL — read_all sees the request, not the certificate. Restore. In internal/app/modulekeys_test.go’s subject, temporarily register a stray key by adding member.RegisterPermission("reports.leave.probe", "probe") to internal/modules/leave/permissions.go’s init(); run go test ./internal/app/ -run TestANestedPermission -count=1. Expected: FAIL — reports.leave.probe's second segment names the optional module leave, which does not require reports. Remove it; PASS.

  • Step 11: Commit
Terminal window
git add api/openapi.yaml internal/gen internal/modules/leave internal/app cmd web/src/lib/i18n/locales web/src/lib/api
git commit -m "Phase 5 H3: leave routes, contract and wiring
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Task 11: The screens — my leave, the team calendar, leave administration (en/ms/zh)

Section titled “Task 11: The screens — my leave, the team calendar, leave administration (en/ms/zh)”

Files:

  • Create: web/src/features/leave/{api.ts,MyLeavePage.tsx,RequestDialog.tsx,RequestDrawer.tsx,ReasonDialog.tsx,TeamCalendarPage.tsx,LeaveAdminPage.tsx,TypeDialog.tsx,RulesDialog.tsx,AdjustDialog.tsx,routes.tsx}
  • Create: web/src/lib/i18n/locales/{en,ms,zh}/leave.json
  • Modify: web/src/app/routes.ts, web/src/app/shell/nav.ts
  • Test: web/src/features/leave/__tests__/leave.test.tsx

Interfaces:

  • Consumes: Task 10’s routes and generated Schemas['LeaveType' | 'LeaveTypeInput' | 'LeaveTypeUpdate' | 'LeaveBand' | 'LeaveRule' | 'LeaveSettings' | 'PublicHoliday' | 'PublicHolidayInput' | 'LeaveRequest' | 'LeaveRequestView' | 'LeaveRequestInput' | 'LeaveBalance' | 'LeaveMe' | 'LeaveAdjustmentInput' | 'LeaveTakenRow' | 'LeaveCalendarEntry' | 'LeaveRoutingMove' | 'WorkflowAvailableTransition']; useCan, RequirePermission (@/features/auth/session); unwrap, ProblemError, problemMessage (@/lib/api/problem); formatDate, todayISO (@/lib/date); useLocale (@/lib/i18n); pickLabel (@/lib/i18n/pickLabel); DocumentUploader (@/components/upload/DocumentUploader); the UI kit.

  • Produces: routes /leave (search request), /leave/calendar, /leave/admin (search tab); hooks useMyLeave, useLeaveTypes, useLeaveRequests, useLeaveRequest, useCreateLeaveRequest, useLeaveTransition, useHolidays, useAddHoliday, useRetireHoliday, useLeaveSettings, usePutLeaveSettings, useCreateLeaveType, useUpdateLeaveType, useLeaveRules, useSetLeaveRules, useLeaveBalances, useLeaveTaken, useLeaveCalendar, useRoutingMoves, useAdjustLeave; leaveMessage; the leave i18n namespace.

  • Step 1: Write the failing screen tests

Create web/src/features/leave/__tests__/leave.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';
const annual = {
id: 't1',
code: 'AL',
names: { en: 'Annual leave', ms: 'Cuti tahunan' },
kind: 'annual',
paid: true,
half_days: true,
attachment_required: false,
active: true,
revision: 1,
created_at: '2026-01-01T01:00:00Z',
updated_at: '2026-01-01T01:00:00Z',
};
const request = {
id: 'r1',
employee_id: 'e1',
employee_name: 'Siti binti Ahmad',
type_id: 't1',
type_kind: 'annual',
from_date: '2026-10-05',
to_date: '2026-10-06',
from_half: false,
to_half: false,
days: '2',
year: 2026,
reason: 'Family matters',
attachment_id: null,
attachment_hidden: true,
state: 'pending',
semantic: 'pending',
created_at: '2026-09-21T01:00:00Z',
};
const balance = {
employee_id: 'e1',
employee_name: 'Siti binti Ahmad',
employee_no: 'E-100',
type_id: 't1',
year: 2026,
granted: '12',
carried_forward: '0',
taken: '-2',
reversed: '0',
expired: '0',
adjusted: '0',
balance: '10',
pending: '2',
};
function mockMine(employee: string | null) {
server.use(
http.get('*/v1/leave/types', () => HttpResponse.json({ types: [annual] })),
http.get('*/v1/leave/me', () =>
HttpResponse.json({
employee_id: employee,
year: 2026,
balances: employee ? [balance] : [],
requests: employee ? [request] : [],
warnings: ['no_public_holidays'],
}),
),
http.get('*/v1/leave/requests', () => HttpResponse.json({ requests: [] })),
);
}
describe('MyLeavePage', () => {
it('shows balances, requests and the holiday warning, and offers a request (presence first)', async () => {
mockMine('e1');
await renderWithApp({ path: '/leave', permissions: ['hr.leave.request'] });
expect(await screen.findAllByText('Annual leave')).toHaveLength(2); // the balance and the request
expect(screen.getByText('10')).toBeInTheDocument();
expect(screen.getByText(/No public holidays are entered for 2026/)).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Request leave' })).toBeInTheDocument();
});
it('offers no request to a member with no employee record', async () => {
mockMine(null);
await renderWithApp({ path: '/leave', permissions: ['hr.leave.request'] });
expect(await screen.findByText('You have no employee record here, so no leave to request.')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Request leave' })).not.toBeInTheDocument();
});
});
describe('RequestDrawer', () => {
it('offers the approver the transitions the server allows, and hides the certificate', async () => {
mockMine(null);
server.use(
http.get('*/v1/leave/requests/r1', () =>
HttpResponse.json({
...request,
warnings: [],
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' } },
{ to: 'cancelled', action_key: 'withdraw', gate: 'reporter', requires_reason: true, allowed: false,
labels: { en: 'Withdraw' } },
],
}),
),
);
const user = userEvent.setup();
await renderWithApp({ path: '/leave?request=r1', permissions: ['hr.leave.request'] });
expect(await screen.findByRole('button', { name: 'Approve' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Withdraw' })).not.toBeInTheDocument();
expect(screen.getByText('A medical certificate is attached; you may not open it.')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Reject' }));
expect(await screen.findByLabelText('Reason')).toBeInTheDocument();
});
});
describe('TeamCalendarPage', () => {
it('shows who is away, and the type only when the server sends it', async () => {
server.use(
http.get('*/v1/leave/types', () => HttpResponse.json({ types: [annual] })),
http.get('*/v1/leave/calendar', () =>
HttpResponse.json({
entries: [
{ request_id: 'r1', employee_id: 'e1', employee_name: 'Siti binti Ahmad', type_id: null,
from_date: '2026-10-05', to_date: '2026-10-06', from_half: false, to_half: false, days: '2' },
{ request_id: 'r2', employee_id: 'e2', employee_name: 'Tan Wei Ming', type_id: 't1',
from_date: '2026-10-07', to_date: '2026-10-07', from_half: false, to_half: false, days: '1' },
],
}),
),
);
await renderWithApp({ path: '/leave/calendar', permissions: ['hr.leave.calendar'] });
expect(await screen.findByText('Siti binti Ahmad')).toBeInTheDocument();
expect(screen.getByText('On leave')).toBeInTheDocument();
expect(screen.getByText('Annual leave')).toBeInTheDocument();
});
});
describe('LeaveAdminPage', () => {
it('lists the types and offers a new one to hr.leave.manage', async () => {
server.use(http.get('*/v1/leave/types', () => HttpResponse.json({ types: [annual] })));
await renderWithApp({ path: '/leave/admin', permissions: ['hr.leave.manage'] });
expect(await screen.findByText('Annual leave')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'New leave type' })).toBeInTheDocument();
});
});
  • Step 2: Run them to verify they fail

Run: pnpm -C web test -- features/leave Expected: FAIL — /leave renders the not-found page.

  • Step 3: Write the API hooks

Create web/src/features/leave/api.ts:

// Phase 5 H3's leave: types, rules, holidays, settings, requests, reports.
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import i18n, { 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 LeaveType = Schemas['LeaveType'];
export type LeaveKind = LeaveType['kind'];
export type LeaveBand = Schemas['LeaveBand'];
export type LeaveRule = Schemas['LeaveRule'];
export type LeaveSettings = Schemas['LeaveSettings'];
export type PublicHoliday = Schemas['PublicHoliday'];
export type LeaveRequest = Schemas['LeaveRequest'];
export type LeaveRequestView = Schemas['LeaveRequestView'];
export type LeaveBalance = Schemas['LeaveBalance'];
export type LeaveCalendarEntry = Schemas['LeaveCalendarEntry'];
export type LeaveRoutingMove = Schemas['LeaveRoutingMove'];
export type Scope = 'mine' | 'awaiting' | 'all';
export const LEAVE_KINDS: LeaveKind[] = [
'annual',
'sick',
'hospitalisation',
'maternity',
'paternity',
'unpaid',
'other',
];
const etag = (rev: number) => `"${rev}"`;
export const leaveKeys = {
all: ['leave'] as const,
me: (year: number) => ['leave', 'me', year] as const,
types: (all: boolean) => ['leave', 'types', all] as const,
rules: (id: string) => ['leave', 'rules', id] as const,
requests: (scope: Scope) => ['leave', 'requests', scope] as const,
request: (id: string) => ['leave', 'request', id] as const,
holidays: (year: number) => ['leave', 'holidays', year] as const,
settings: ['leave', 'settings'] as const,
balances: (year: number) => ['leave', 'balances', year] as const,
taken: (from: string, to: string) => ['leave', 'taken', from, to] as const,
calendar: (from: string, to: string) => ['leave', 'calendar', from, to] as const,
moves: (from: string, to: string) => ['leave', 'moves', from, to] as const,
};
function useInvalidateLeave() {
const qc = useQueryClient();
return () => void qc.invalidateQueries({ queryKey: leaveKeys.all });
}
export function useMyLeave(year: number) {
return useQuery({
queryKey: leaveKeys.me(year),
queryFn: async ({ signal }) => unwrap(await api.GET('/leave/me', { params: { query: { year } }, signal })),
});
}
export function useLeaveTypes(all = false) {
return useQuery({
queryKey: leaveKeys.types(all),
queryFn: async ({ signal }) =>
unwrap(await api.GET('/leave/types', { params: { query: { all } }, signal })).types,
});
}
export function useLeaveRequests(scope: Scope, enabled = true) {
return useQuery({
queryKey: leaveKeys.requests(scope),
enabled,
queryFn: async ({ signal }) =>
unwrap(await api.GET('/leave/requests', { params: { query: { scope } }, signal })).requests,
});
}
export function useLeaveRequest(id: string) {
return useQuery({
queryKey: leaveKeys.request(id),
queryFn: async ({ signal }) =>
unwrap(await api.GET('/leave/requests/{id}', { params: { path: { id } }, signal })),
});
}
export function useCreateLeaveRequest() {
const invalidate = useInvalidateLeave();
return useMutation({
// Created as a draft, then submitted: one action for the requester.
mutationFn: async (body: Schemas['LeaveRequestInput']) => {
const draft = unwrap(await api.POST('/leave/requests', { body }));
return unwrap(
await api.POST('/leave/requests/{id}/transitions', {
params: { path: { id: draft.id } },
body: { from: 'draft', to: 'pending' },
}),
);
},
onSettled: invalidate,
});
}
export function useLeaveTransition(id: string) {
const invalidate = useInvalidateLeave();
return useMutation({
mutationFn: async (body: { from: string; to: string; reason?: string }) =>
unwrap(await api.POST('/leave/requests/{id}/transitions', { params: { path: { id } }, body })),
onSuccess: invalidate,
});
}
export function useHolidays(year: number) {
return useQuery({
queryKey: leaveKeys.holidays(year),
queryFn: async ({ signal }) =>
unwrap(await api.GET('/leave/holidays', { params: { query: { year } }, signal })),
});
}
export function useAddHoliday() {
const invalidate = useInvalidateLeave();
return useMutation({
mutationFn: async (body: Schemas['PublicHolidayInput']) => unwrap(await api.POST('/leave/holidays', { body })),
onSuccess: invalidate,
});
}
export function useRetireHoliday() {
const invalidate = useInvalidateLeave();
return useMutation({
mutationFn: async (id: string) =>
unwrap(await api.POST('/leave/holidays/{id}/retire', { params: { path: { id } } })),
onSuccess: invalidate,
});
}
export function useLeaveSettings() {
return useQuery({
queryKey: leaveKeys.settings,
queryFn: async ({ signal }) => unwrap(await api.GET('/leave/settings', { signal })),
});
}
export function usePutLeaveSettings() {
const invalidate = useInvalidateLeave();
return useMutation({
mutationFn: async (body: LeaveSettings) => unwrap(await api.PUT('/leave/settings', { body })),
onSuccess: invalidate,
});
}
export function useCreateLeaveType() {
const invalidate = useInvalidateLeave();
return useMutation({
mutationFn: async (body: Schemas['LeaveTypeInput']) => unwrap(await api.POST('/leave/types', { body })),
onSuccess: invalidate,
});
}
export function useUpdateLeaveType(id: string) {
const invalidate = useInvalidateLeave();
return useMutation({
mutationFn: async (v: { revision: number; body: Schemas['LeaveTypeUpdate'] }) =>
unwrap(
await api.PUT('/leave/types/{id}', {
params: { path: { id }, header: { 'If-Match': etag(v.revision) } },
body: v.body,
}),
),
onSuccess: invalidate,
});
}
export function useLeaveRules(id: string) {
return useQuery({
queryKey: leaveKeys.rules(id),
queryFn: async ({ signal }) =>
unwrap(await api.GET('/leave/types/{id}/rules', { params: { path: { id } }, signal })).rules,
});
}
export function useSetLeaveRules(id: string) {
const invalidate = useInvalidateLeave();
return useMutation({
mutationFn: async (body: Schemas['LeaveRuleSetInput']) =>
unwrap(await api.POST('/leave/types/{id}/rules', { params: { path: { id } }, body })),
onSuccess: invalidate,
});
}
export function useLeaveBalances(year: number, enabled = true) {
return useQuery({
queryKey: leaveKeys.balances(year),
enabled,
queryFn: async ({ signal }) =>
unwrap(await api.GET('/leave/reports/balances', { params: { query: { year } }, signal })).balances,
});
}
export function useLeaveTaken(from: string, to: string, enabled = true) {
return useQuery({
queryKey: leaveKeys.taken(from, to),
enabled,
queryFn: async ({ signal }) =>
unwrap(await api.GET('/leave/reports/taken', { params: { query: { from, to } }, signal })).rows,
});
}
export function useLeaveCalendar(from: string, to: string) {
return useQuery({
queryKey: leaveKeys.calendar(from, to),
queryFn: async ({ signal }) =>
unwrap(await api.GET('/leave/calendar', { params: { query: { from, to } }, signal })).entries,
});
}
export function useRoutingMoves(from: string, to: string, enabled = true) {
return useQuery({
queryKey: leaveKeys.moves(from, to),
enabled,
queryFn: async ({ signal }) =>
unwrap(await api.GET('/leave/routing-moves', { params: { query: { from, to } }, signal })).moves,
});
}
export function useAdjustLeave() {
const invalidate = useInvalidateLeave();
return useMutation({
mutationFn: async (body: Schemas['LeaveAdjustmentInput']) =>
unwrap(await api.POST('/leave/adjustments', { body })),
onSuccess: invalidate,
});
}
/** A refusal in leave's own words when its code is one this screen knows, else the common text. */
export function leaveMessage(e: unknown, t: TFunction, tc: TFunction): string {
if (e instanceof ProblemError && e.code && i18n.exists(`leave:refusals.${e.code}`)) {
return t(`refusals.${e.code}`);
}
return problemMessage(e, tc);
}
/** The first day of this month and the last of the next, as ISO dates. */
export function twoMonths(today: string): { from: string; to: string } {
const [y, m] = today.split('-').map(Number);
const from = new Date(Date.UTC(y, m - 1, 1));
const to = new Date(Date.UTC(y, m + 1, 0));
return { from: from.toISOString().slice(0, 10), to: to.toISOString().slice(0, 10) };
}
  • Step 4: Write my leave, the request dialog and drawer

Create web/src/features/leave/ReasonDialog.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 { Textarea } from '@/components/ui/Textarea';
export function ReasonDialog({
title,
busy,
error,
onClose,
onConfirm,
}: {
title: string;
busy: boolean;
error: string | null;
onClose(): void;
onConfirm(reason: string): void;
}) {
const { t } = useTranslation('leave');
const [reason, setReason] = useState('');
return (
<Dialog
open
onOpenChange={(o) => !o && onClose()}
title={title}
footer={
<>
<Button variant="outline" onClick={onClose}>
{t('cancel')}
</Button>
<Button onClick={() => onConfirm(reason.trim())} disabled={!reason.trim()} loading={busy}>
{t('confirm')}
</Button>
</>
}
>
<Field label={t('fields.reason')} hint={t('recordLanguage')} error={error ?? undefined}>
<Textarea value={reason} onChange={(e) => setReason(e.target.value)} maxLength={500} />
</Field>
</Dialog>
);
}

Create web/src/features/leave/RequestDrawer.tsx:

import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Drawer } from '@/components/ui/Drawer';
import { toast } from '@/components/ui/Toaster';
import { formatDate } from '@/lib/date';
import { useLocale } from '@/lib/i18n';
import { pickLabel } from '@/lib/i18n/pickLabel';
import { ReasonDialog } from './ReasonDialog';
import { leaveMessage, useLeaveRequest, useLeaveTransition, useLeaveTypes } from './api';
type Edge = { to: string; requires_reason: boolean; labels: Record<string, string> };
export function RequestDrawer({ id, onClose }: { id: string; onClose(): void }) {
const { t } = useTranslation('leave');
const { t: tc } = useTranslation();
const [locale] = useLocale();
const view = useLeaveRequest(id);
const types = useLeaveTypes(true);
const move = useLeaveTransition(id);
const [asking, setAsking] = useState<Edge | null>(null);
const [error, setError] = useState<string | null>(null);
const r = view.data;
const type = types.data?.find((x) => x.id === r?.type_id);
async function take(edge: Edge, reason?: string) {
if (!r) return;
setError(null);
try {
await move.mutateAsync({ from: r.state, to: edge.to, reason });
toast.success(t('moved'));
setAsking(null);
} catch (e) {
setError(leaveMessage(e, t, tc));
}
}
return (
<Drawer
open
onOpenChange={(o) => !o && onClose()}
title={r ? r.employee_name : t('loading')}
footer={
r && (
<div className="flex flex-wrap gap-2">
{r.available
.filter((a) => a.allowed)
.map((a) => (
<Button
key={a.to}
variant={a.to === 'approved' ? 'primary' : 'outline'}
loading={move.isPending && !asking}
onClick={() => (a.requires_reason ? setAsking(a) : void take(a))}
>
{pickLabel(a.labels, locale)}
</Button>
))}
</div>
)
}
>
{r && (
<dl className="grid grid-cols-[max-content_1fr] gap-x-6 gap-y-2 text-body">
<dt className="text-fg-muted">{t('fields.type')}</dt>
<dd>{type ? pickLabel(type.names, locale) : t(`kinds.${r.type_kind}`)}</dd>
<dt className="text-fg-muted">{t('fields.dates')}</dt>
<dd>
{formatDate(r.from_date, locale)} – {formatDate(r.to_date, locale)}
{(r.from_half || r.to_half) && ` (${t('halfDay')})`}
</dd>
<dt className="text-fg-muted">{t('fields.days')}</dt>
<dd>{r.days}</dd>
<dt className="text-fg-muted">{t('fields.state')}</dt>
<dd>{t(`semantic.${r.semantic}`, { defaultValue: r.state })}</dd>
{r.reason && (
<>
<dt className="text-fg-muted">{t('fields.reason')}</dt>
<dd>{r.reason}</dd>
</>
)}
{(r.attachment_id || r.attachment_hidden) && (
<>
<dt className="text-fg-muted">{t('fields.attachment')}</dt>
<dd>
{r.attachment_hidden ? (
t('attachmentHidden')
) : (
<a className="text-link underline" href={`/documents/${r.attachment_id}`}>
{t('openAttachment')}
</a>
)}
</dd>
</>
)}
</dl>
)}
{error && !asking && <p className="mt-4 text-caption text-tone-red-fg">{error}</p>}
{asking && (
<ReasonDialog
title={pickLabel(asking.labels, locale)}
busy={move.isPending}
error={error}
onClose={() => setAsking(null)}
onConfirm={(reason) => void take(asking, reason)}
/>
)}
</Drawer>
);
}

Create web/src/features/leave/RequestDialog.tsx:

import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Checkbox } from '@/components/ui/Checkbox';
import { DatePicker } from '@/components/ui/DatePicker';
import { Dialog } from '@/components/ui/Dialog';
import { Field } from '@/components/ui/Field';
import { Select } from '@/components/ui/Select';
import { Textarea } from '@/components/ui/Textarea';
import { toast } from '@/components/ui/Toaster';
import { DocumentUploader } from '@/components/upload/DocumentUploader';
import type { UploadedDoc } from '@/components/upload/uploadDocument';
import { useLocale } from '@/lib/i18n';
import { pickLabel } from '@/lib/i18n/pickLabel';
import { leaveMessage, useCreateLeaveRequest, useLeaveTypes } from './api';
export function RequestDialog({ onClose }: { onClose(): void }) {
const { t } = useTranslation('leave');
const { t: tc } = useTranslation();
const [locale] = useLocale();
const types = useLeaveTypes();
const create = useCreateLeaveRequest();
const [typeId, setTypeId] = useState<string | undefined>();
const [from, setFrom] = useState<string | undefined>();
const [to, setTo] = useState<string | undefined>();
const [fromHalf, setFromHalf] = useState(false);
const [toHalf, setToHalf] = useState(false);
const [reason, setReason] = useState('');
const [doc, setDoc] = useState<UploadedDoc | undefined>();
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const type = types.data?.find((x) => x.id === typeId);
const ready = !!type && !!from && !!to && (!type.attachment_required || !!doc) && !uploading;
async function onSubmit() {
if (!type || !from || !to) return;
setError(null);
try {
await create.mutateAsync({
type_id: type.id,
from_date: from,
to_date: to,
from_half: type.half_days && fromHalf,
to_half: type.half_days && toHalf,
reason: reason.trim(),
attachment_id: doc?.id ?? null,
});
toast.success(t('submitted'));
onClose();
} catch (e) {
setError(leaveMessage(e, t, tc));
}
}
return (
<Dialog
open
onOpenChange={(o) => !o && onClose()}
title={t('request')}
footer={
<>
<Button variant="outline" onClick={onClose}>
{t('cancel')}
</Button>
<Button onClick={() => void onSubmit()} disabled={!ready} loading={create.isPending}>
{t('submit')}
</Button>
</>
}
>
<div className="grid gap-4">
<Field label={t('fields.type')}>
<Select
aria-label={t('fields.type')}
value={typeId}
onValueChange={setTypeId}
placeholder={t('chooseType')}
options={(types.data ?? []).map((x) => ({ value: x.id, label: pickLabel(x.names, locale) }))}
/>
</Field>
<div className="grid grid-cols-2 gap-4">
<Field label={t('fields.from')}>
<DatePicker value={from} onChange={setFrom} aria-label={t('fields.from')} />
</Field>
<Field label={t('fields.to')}>
<DatePicker value={to} onChange={setTo} min={from} aria-label={t('fields.to')} />
</Field>
</div>
{type?.half_days && (
<div className="flex gap-6">
<Checkbox checked={fromHalf} onCheckedChange={setFromHalf} label={t('fromHalf')} />
<Checkbox checked={toHalf} onCheckedChange={setToHalf} label={t('toHalf')} />
</div>
)}
<Field label={t('fields.reason')} hint={t('recordLanguage')} optional>
<Textarea value={reason} onChange={(e) => setReason(e.target.value)} maxLength={500} />
</Field>
{type?.attachment_required && (
<Field label={t('fields.attachment')} hint={t('attachmentHint')}>
<DocumentUploader
value={doc ? [doc] : []}
onUploaded={setDoc}
onRemoved={() => setDoc(undefined)}
onBusyChange={setUploading}
/>
</Field>
)}
{error && <p className="text-caption text-tone-red-fg">{error}</p>}
</div>
</Dialog>
);
}

Create web/src/features/leave/MyLeavePage.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 { EmptyState } from '@/components/ui/EmptyState';
import { PageHeader } from '@/components/ui/PageHeader';
import { Tag, type TagTone } from '@/components/ui/Tag';
import { formatDate, todayISO } from '@/lib/date';
import { useLocale } from '@/lib/i18n';
import { pickLabel } from '@/lib/i18n/pickLabel';
import { RequestDialog } from './RequestDialog';
import {
useLeaveRequests,
useLeaveTypes,
useMyLeave,
type LeaveBalance,
type LeaveRequest,
} from './api';
const TONE: Record<string, TagTone> = {
draft: 'neutral',
pending: 'amber',
committed: 'green',
rejected: 'red',
cancelled: 'muted',
};
export function SemanticTag({ semantic }: { semantic: string }) {
const { t } = useTranslation('leave');
return <Tag tone={TONE[semantic] ?? 'neutral'}>{t(`semantic.${semantic}`, { defaultValue: semantic })}</Tag>;
}
export function MyLeavePage({ onOpen }: { onOpen(id: string): void }) {
const { t } = useTranslation('leave');
const [locale] = useLocale();
const year = Number(todayISO().slice(0, 4));
const mine = useMyLeave(year);
const types = useLeaveTypes(true);
const awaiting = useLeaveRequests('awaiting');
const [requesting, setRequesting] = useState(false);
const typeName = (id: string) => {
const x = types.data?.find((y) => y.id === id);
return x ? pickLabel(x.names, locale) : '—';
};
const balanceColumns = useMemo<ColumnDef<LeaveBalance>[]>(
() => [
{ id: 'type', meta: { required: true }, header: t('fields.type'), cell: ({ row }) => typeName(row.original.type_id) },
{ id: 'balance', header: t('fields.balance'), cell: ({ row }) => row.original.balance },
{ id: 'pending', header: t('fields.pending'), cell: ({ row }) => row.original.pending },
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[t, locale, types.data],
);
const requestColumns = useMemo<ColumnDef<LeaveRequest>[]>(
() => [
{ id: 'name', header: t('fields.employee'), cell: ({ row }) => row.original.employee_name },
{ id: 'type', meta: { required: true }, header: t('fields.type'), cell: ({ row }) => typeName(row.original.type_id) },
{
id: 'dates',
header: t('fields.dates'),
cell: ({ row }) =>
`${formatDate(row.original.from_date, locale)} – ${formatDate(row.original.to_date, locale)}`,
},
{ id: 'days', header: t('fields.days'), cell: ({ row }) => row.original.days },
{ id: 'state', header: t('fields.state'), cell: ({ row }) => <SemanticTag semantic={row.original.semantic} /> },
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[t, locale, types.data],
);
const hasRecord = !!mine.data?.employee_id;
return (
<div className="flex flex-col gap-6">
<PageHeader
title={t('mine.title')}
subtitle={t('mine.subtitle')}
actions={
hasRecord && (
<Button onClick={() => setRequesting(true)}>
<Plus size={16} aria-hidden />
{t('request')}
</Button>
)
}
/>
{mine.data?.warnings.includes('no_public_holidays') && (
<p className="rounded-md bg-tone-amber-soft px-4 py-3 text-caption text-tone-amber-fg">
{t('warnings.no_public_holidays', { year })}
</p>
)}
{mine.data && !hasRecord && <EmptyState title={t('mine.noRecord')} />}
{hasRecord && (
<>
<DataTable
tableId="leave.balances.mine"
columns={balanceColumns}
data={mine.data?.balances ?? []}
loading={mine.isLoading}
getRowId={(b: LeaveBalance) => b.type_id}
empty={<EmptyState title={t('mine.noBalances')} />}
/>
<h2 className="text-title">{t('mine.requests')}</h2>
<DataTable
tableId="leave.requests.mine"
columns={requestColumns}
data={mine.data?.requests ?? []}
getRowId={(r: LeaveRequest) => r.id}
onRowClick={(r: LeaveRequest) => onOpen(r.id)}
empty={<EmptyState title={t('mine.noRequests')} />}
/>
</>
)}
{(awaiting.data?.length ?? 0) > 0 && (
<>
<h2 className="text-title">{t('mine.awaiting')}</h2>
<DataTable
tableId="leave.requests.awaiting"
columns={requestColumns}
data={awaiting.data ?? []}
getRowId={(r: LeaveRequest) => r.id}
onRowClick={(r: LeaveRequest) => onOpen(r.id)}
/>
</>
)}
{requesting && <RequestDialog onClose={() => setRequesting(false)} />}
</div>
);
}
  • Step 5: Write the team calendar

Create web/src/features/leave/TeamCalendarPage.tsx:

import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { DataTable, type ColumnDef } from '@/components/ui/DataTable';
import { DatePicker } from '@/components/ui/DatePicker';
import { EmptyState } from '@/components/ui/EmptyState';
import { PageHeader } from '@/components/ui/PageHeader';
import { formatDate, todayISO } from '@/lib/date';
import { useLocale } from '@/lib/i18n';
import { pickLabel } from '@/lib/i18n/pickLabel';
import { twoMonths, useLeaveCalendar, useLeaveTypes, type LeaveCalendarEntry } from './api';
export function TeamCalendarPage() {
const { t } = useTranslation('leave');
const [locale] = useLocale();
const initial = twoMonths(todayISO());
const [from, setFrom] = useState(initial.from);
const [to, setTo] = useState(initial.to);
const entries = useLeaveCalendar(from, to);
const types = useLeaveTypes(true);
const columns = useMemo<ColumnDef<LeaveCalendarEntry>[]>(
() => [
{ id: 'name', meta: { required: true }, header: t('fields.employee'), cell: ({ row }) => row.original.employee_name },
{
id: 'dates',
header: t('fields.dates'),
cell: ({ row }) =>
`${formatDate(row.original.from_date, locale)} – ${formatDate(row.original.to_date, locale)}`,
},
{ id: 'days', header: t('fields.days'), cell: ({ row }) => row.original.days },
{
id: 'type',
header: t('fields.type'),
cell: ({ row }) => {
const x = types.data?.find((y) => y.id === row.original.type_id);
return x ? pickLabel(x.names, locale) : t('calendar.onLeave');
},
},
],
[t, locale, types.data],
);
return (
<div className="flex flex-col gap-4">
<PageHeader title={t('calendar.title')} subtitle={t('calendar.subtitle')} />
<div className="flex flex-wrap gap-3">
<DatePicker value={from} onChange={(v) => v && setFrom(v)} aria-label={t('fields.from')} />
<DatePicker value={to} onChange={(v) => v && setTo(v)} min={from} aria-label={t('fields.to')} />
</div>
<DataTable
tableId="leave.calendar"
columns={columns}
data={entries.data ?? []}
loading={entries.isLoading}
getRowId={(e: LeaveCalendarEntry) => e.request_id}
empty={<EmptyState title={t('calendar.empty')} />}
/>
</div>
);
}
  • Step 6: Write the administration page and its dialogs

Create web/src/features/leave/TypeDialog.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 { toast } from '@/components/ui/Toaster';
import { LEAVE_KINDS, leaveMessage, useCreateLeaveType, useUpdateLeaveType, type LeaveKind, type LeaveType } from './api';
/** Create a type, or (with existing) rename, reconfigure or retire it. */
export function TypeDialog({ existing, onClose }: { existing?: LeaveType; onClose(): void }) {
const { t } = useTranslation('leave');
const { t: tc } = useTranslation();
const create = useCreateLeaveType();
const update = useUpdateLeaveType(existing?.id ?? '');
const [code, setCode] = useState(existing?.code ?? '');
const [kind, setKind] = useState<LeaveKind>(existing?.kind ?? 'annual');
const [en, setEn] = useState(existing?.names.en ?? '');
const [ms, setMs] = useState(existing?.names.ms ?? '');
const [zh, setZh] = useState(existing?.names.zh ?? '');
const [paid, setPaid] = useState(existing?.paid ?? true);
const [halfDays, setHalfDays] = useState(existing?.half_days ?? false);
const [attachment, setAttachment] = useState(existing?.attachment_required ?? false);
const [active, setActive] = useState(existing?.active ?? true);
const [error, setError] = useState<string | null>(null);
const names = { en: en.trim(), ms: ms.trim(), ...(zh.trim() ? { zh: zh.trim() } : {}) };
async function onSave() {
setError(null);
try {
if (existing) {
await update.mutateAsync({
revision: existing.revision,
body: { names, half_days: halfDays, attachment_required: attachment, active },
});
} else {
await create.mutateAsync({
code: code.trim(),
names,
kind,
paid,
half_days: halfDays,
attachment_required: attachment,
});
}
toast.success(t('saved'));
onClose();
} catch (e) {
setError(leaveMessage(e, t, tc));
}
}
return (
<Dialog
open
onOpenChange={(o) => !o && onClose()}
title={existing ? t('admin.editType') : t('admin.newType')}
footer={
<>
<Button variant="outline" onClick={onClose}>
{t('cancel')}
</Button>
<Button onClick={() => void onSave()} loading={create.isPending || update.isPending}>
{t('save')}
</Button>
</>
}
>
<div className="grid gap-4">
{!existing && (
<div className="grid grid-cols-2 gap-4">
<Field label={t('fields.code')}>
<Input value={code} onChange={(e) => setCode(e.target.value.toUpperCase())} maxLength={20} />
</Field>
<Field label={t('fields.kind')}>
<Select
aria-label={t('fields.kind')}
value={kind}
onValueChange={(v) => setKind(v as LeaveKind)}
options={LEAVE_KINDS.map((k) => ({ value: k, label: t(`kinds.${k}`) }))}
/>
</Field>
</div>
)}
<Field label={t('fields.nameEn')}>
<Input value={en} onChange={(e) => setEn(e.target.value)} maxLength={200} />
</Field>
<Field label={t('fields.nameMs')}>
<Input value={ms} onChange={(e) => setMs(e.target.value)} maxLength={200} />
</Field>
<Field label={t('fields.nameZh')} optional>
<Input value={zh} onChange={(e) => setZh(e.target.value)} maxLength={200} />
</Field>
<div className="flex flex-wrap gap-6">
{!existing && kind === 'other' && <Checkbox checked={paid} onCheckedChange={setPaid} label={t('fields.paid')} />}
<Checkbox checked={halfDays} onCheckedChange={setHalfDays} label={t('fields.halfDays')} />
<Checkbox checked={attachment} onCheckedChange={setAttachment} label={t('fields.attachmentRequired')} />
{existing && <Checkbox checked={active} onCheckedChange={setActive} label={t('fields.active')} />}
</div>
{error && <p className="text-caption text-tone-red-fg">{error}</p>}
</div>
</Dialog>
);
}

Create web/src/features/leave/RulesDialog.tsx:

import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { DatePicker } from '@/components/ui/DatePicker';
import { Dialog } from '@/components/ui/Dialog';
import { Field } from '@/components/ui/Field';
import { Input } from '@/components/ui/Input';
import { toast } from '@/components/ui/Toaster';
import { formatDate, todayISO } from '@/lib/date';
import { useLocale } from '@/lib/i18n';
import { pickLabel } from '@/lib/i18n/pickLabel';
import { leaveMessage, useLeaveRules, useSetLeaveRules, type LeaveBand, type LeaveType } from './api';
const STATUTORY: LeaveBand[] = [
{ from_month: 0, to_month: 24, days: '' },
{ from_month: 24, to_month: 60, days: '' },
{ from_month: 60, to_month: null, days: '' },
];
/** A type's rule sets (newest first) and a new set from a date (HD-8). */
export function RulesDialog({ type, onClose }: { type: LeaveType; onClose(): void }) {
const { t } = useTranslation('leave');
const { t: tc } = useTranslation();
const [locale] = useLocale();
const rules = useLeaveRules(type.id);
const save = useSetLeaveRules(type.id);
const [from, setFrom] = useState<string | undefined>(todayISO());
const [bands, setBands] = useState<LeaveBand[]>(STATUTORY);
const [error, setError] = useState<string | null>(null);
async function onSave() {
if (!from) return;
setError(null);
try {
await save.mutateAsync({ effective_from: from, bands });
toast.success(t('saved'));
onClose();
} catch (e) {
setError(leaveMessage(e, t, tc));
}
}
return (
<Dialog
open
onOpenChange={(o) => !o && onClose()}
title={t('admin.rulesFor', { name: pickLabel(type.names, locale) })}
footer={
<>
<Button variant="outline" onClick={onClose}>
{t('cancel')}
</Button>
<Button onClick={() => void onSave()} disabled={!from || bands.some((b) => !b.days)} loading={save.isPending}>
{t('save')}
</Button>
</>
}
>
<div className="grid gap-4">
<ul className="grid gap-1 text-caption text-fg-muted">
{(rules.data ?? []).map((r) => (
<li key={r.id}>
{t('admin.ruleLine', {
from: formatDate(r.effective_from, locale),
band: r.to_month === null ? `${r.from_month}+` : `${r.from_month}–${r.to_month}`,
days: r.days,
})}
</li>
))}
</ul>
<Field label={t('fields.effectiveFrom')} hint={t('admin.effectiveHint')}>
<DatePicker value={from} onChange={setFrom} aria-label={t('fields.effectiveFrom')} />
</Field>
{bands.map((b, i) => (
<Field
key={b.from_month}
label={b.to_month === null ? t('admin.bandOpen', { from: b.from_month }) : t('admin.band', { from: b.from_month, to: b.to_month })}
>
<Input
inputMode="decimal"
value={b.days}
onChange={(e) => setBands(bands.map((x, j) => (j === i ? { ...x, days: e.target.value.trim() } : x)))}
/>
</Field>
))}
<p className="text-caption text-fg-muted">{t('admin.floorHint')}</p>
{error && <p className="text-caption text-tone-red-fg">{error}</p>}
</div>
</Dialog>
);
}

Create web/src/features/leave/AdjustDialog.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 { Textarea } from '@/components/ui/Textarea';
import { toast } from '@/components/ui/Toaster';
import { leaveMessage, useAdjustLeave, type LeaveBalance } from './api';
/** HD-7: a signed number of half days with a reason; never below the floor. */
export function AdjustDialog({ balance, onClose }: { balance: LeaveBalance; onClose(): void }) {
const { t } = useTranslation('leave');
const { t: tc } = useTranslation();
const adjust = useAdjustLeave();
const [days, setDays] = useState('');
const [reason, setReason] = useState('');
const [error, setError] = useState<string | null>(null);
async function onSave() {
setError(null);
try {
await adjust.mutateAsync({
employee_id: balance.employee_id,
type_id: balance.type_id,
year: balance.year,
days: days.trim(),
reason: reason.trim(),
});
toast.success(t('saved'));
onClose();
} catch (e) {
setError(leaveMessage(e, t, tc));
}
}
return (
<Dialog
open
onOpenChange={(o) => !o && onClose()}
title={t('admin.adjustFor', { name: balance.employee_name })}
footer={
<>
<Button variant="outline" onClick={onClose}>
{t('cancel')}
</Button>
<Button onClick={() => void onSave()} disabled={!days.trim() || !reason.trim()} loading={adjust.isPending}>
{t('save')}
</Button>
</>
}
>
<div className="grid gap-4">
<Field label={t('fields.days')} hint={t('admin.adjustHint')}>
<Input inputMode="decimal" value={days} onChange={(e) => setDays(e.target.value)} />
</Field>
<Field label={t('fields.reason')} hint={t('recordLanguage')}>
<Textarea value={reason} onChange={(e) => setReason(e.target.value)} maxLength={500} />
</Field>
{error && <p className="text-caption text-tone-red-fg">{error}</p>}
</div>
</Dialog>
);
}

Create web/src/features/leave/LeaveAdminPage.tsx:

import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus } from 'lucide-react';
import { Button } from '@/components/ui/Button';
import { Checkbox } from '@/components/ui/Checkbox';
import { DataTable, type ColumnDef } from '@/components/ui/DataTable';
import { DatePicker } from '@/components/ui/DatePicker';
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 { Tabs } from '@/components/ui/Tabs';
import { Tag } from '@/components/ui/Tag';
import { toast } from '@/components/ui/Toaster';
import { useCan } from '@/features/auth/session';
import { formatDate, todayISO } from '@/lib/date';
import { useLocale } from '@/lib/i18n';
import { pickLabel } from '@/lib/i18n/pickLabel';
import { AdjustDialog } from './AdjustDialog';
import { RulesDialog } from './RulesDialog';
import { TypeDialog } from './TypeDialog';
import {
leaveMessage,
twoMonths,
useAddHoliday,
useHolidays,
useLeaveBalances,
useLeaveSettings,
useLeaveTaken,
useLeaveTypes,
usePutLeaveSettings,
useRetireHoliday,
useRoutingMoves,
type LeaveBalance,
type LeaveRoutingMove,
type LeaveType,
type PublicHoliday,
} from './api';
export type AdminTab = 'types' | 'holidays' | 'settings' | 'balances' | 'taken' | 'routing';
export function LeaveAdminPage({ tab, onTab }: { tab: AdminTab; onTab(t: AdminTab): void }) {
const { t } = useTranslation('leave');
const manage = useCan('hr.leave.manage');
const readAll = useCan('hr.leave.read_all');
const items = [
...(manage
? [
{ value: 'types', label: t('admin.tabs.types') },
{ value: 'holidays', label: t('admin.tabs.holidays') },
{ value: 'settings', label: t('admin.tabs.settings') },
]
: []),
...(readAll
? [
{ value: 'balances', label: t('admin.tabs.balances') },
{ value: 'taken', label: t('admin.tabs.taken') },
]
: []),
...(manage ? [{ value: 'routing', label: t('admin.tabs.routing') }] : []),
];
const current = items.some((i) => i.value === tab) ? tab : ((items[0]?.value ?? 'types') as AdminTab);
return (
<div className="flex flex-col gap-4">
<PageHeader title={t('admin.title')} subtitle={t('admin.subtitle')} />
<Tabs value={current} onValueChange={(v) => onTab(v as AdminTab)} items={items} aria-label={t('admin.title')}>
<div className="pt-4">
{current === 'types' && <TypesTab />}
{current === 'holidays' && <HolidaysTab />}
{current === 'settings' && <SettingsTab />}
{current === 'balances' && <BalancesTab />}
{current === 'taken' && <TakenTab />}
{current === 'routing' && <RoutingTab />}
</div>
</Tabs>
</div>
);
}
function TypesTab() {
const { t } = useTranslation('leave');
const [locale] = useLocale();
const types = useLeaveTypes(true);
const [editing, setEditing] = useState<LeaveType | 'new' | null>(null);
const [rulesFor, setRulesFor] = useState<LeaveType | null>(null);
const columns = useMemo<ColumnDef<LeaveType>[]>(
() => [
{ id: 'code', header: t('fields.code'), cell: ({ row }) => <span className="font-mono">{row.original.code}</span> },
{ id: 'name', meta: { required: true }, header: t('fields.name'), cell: ({ row }) => pickLabel(row.original.names, locale) },
{ id: 'kind', header: t('fields.kind'), cell: ({ row }) => t(`kinds.${row.original.kind}`) },
{
id: 'active',
header: t('fields.active'),
cell: ({ row }) => <Tag tone={row.original.active ? 'green' : 'muted'}>{t(row.original.active ? 'yes' : 'no')}</Tag>,
},
{
id: 'rules',
header: '',
cell: ({ row }) =>
row.original.paid && (
<Button
size="sm"
variant="outline"
onClick={(e) => {
e.stopPropagation();
setRulesFor(row.original);
}}
>
{t('admin.rules')}
</Button>
),
},
],
[t, locale],
);
return (
<div className="flex flex-col gap-4">
<div>
<Button onClick={() => setEditing('new')}>
<Plus size={16} aria-hidden />
{t('admin.newType')}
</Button>
</div>
<DataTable
tableId="leave.types"
columns={columns}
data={types.data ?? []}
loading={types.isLoading}
getRowId={(x: LeaveType) => x.id}
onRowClick={(x: LeaveType) => setEditing(x)}
empty={<EmptyState title={t('admin.noTypes')} />}
/>
{editing && <TypeDialog existing={editing === 'new' ? undefined : editing} onClose={() => setEditing(null)} />}
{rulesFor && <RulesDialog type={rulesFor} onClose={() => setRulesFor(null)} />}
</div>
);
}
function HolidaysTab() {
const { t } = useTranslation('leave');
const { t: tc } = useTranslation();
const [locale] = useLocale();
const [year, setYear] = useState(Number(todayISO().slice(0, 4)));
const list = useHolidays(year);
const add = useAddHoliday();
const retire = useRetireHoliday();
const [date, setDate] = useState<string | undefined>();
const [state, setState] = useState('');
const [en, setEn] = useState('');
const [ms, setMs] = useState('');
const [error, setError] = useState<string | null>(null);
async function onAdd() {
if (!date) return;
setError(null);
try {
await add.mutateAsync({ date, state: state.trim() || null, names: { en: en.trim(), ms: ms.trim() } });
setDate(undefined);
setEn('');
setMs('');
toast.success(t('saved'));
} catch (e) {
setError(leaveMessage(e, t, tc));
}
}
const columns: ColumnDef<PublicHoliday>[] = [
{ id: 'date', header: t('fields.date'), cell: ({ row }) => formatDate(row.original.date, locale) },
{ id: 'name', meta: { required: true }, header: t('fields.name'), cell: ({ row }) => pickLabel(row.original.names, locale) },
{ id: 'state', header: t('fields.state'), cell: ({ row }) => row.original.state ?? t('national') },
{
id: 'retire',
header: '',
cell: ({ row }) => (
<Button size="sm" variant="outline" onClick={() => void retire.mutateAsync(row.original.id)}>
{t('admin.retire')}
</Button>
),
},
];
return (
<div className="flex flex-col gap-4">
<Field label={t('fields.year')}>
<Input type="number" value={year} onChange={(e) => setYear(Number(e.target.value))} className="max-w-32" />
</Field>
{list.data?.warnings.includes('no_public_holidays') && (
<p className="rounded-md bg-tone-amber-soft px-4 py-3 text-caption text-tone-amber-fg">
{t('warnings.no_public_holidays', { year })}
</p>
)}
<DataTable
tableId="leave.holidays"
columns={columns}
data={list.data?.holidays ?? []}
loading={list.isLoading}
getRowId={(h: PublicHoliday) => h.id}
empty={<EmptyState title={t('admin.noHolidays')} />}
/>
<div className="grid gap-3 sm:grid-cols-4">
<DatePicker value={date} onChange={setDate} aria-label={t('fields.date')} />
<Input placeholder={t('fields.stateHint')} value={state} onChange={(e) => setState(e.target.value.toUpperCase())} />
<Input placeholder={t('fields.nameEn')} value={en} onChange={(e) => setEn(e.target.value)} />
<Input placeholder={t('fields.nameMs')} value={ms} onChange={(e) => setMs(e.target.value)} />
</div>
<div>
<Button onClick={() => void onAdd()} disabled={!date || !en.trim() || !ms.trim()} loading={add.isPending}>
{t('admin.addHoliday')}
</Button>
</div>
{error && <p className="text-caption text-tone-red-fg">{error}</p>}
</div>
);
}
const WEEKDAYS = [1, 2, 3, 4, 5, 6, 7];
function SettingsTab() {
const { t } = useTranslation('leave');
const { t: tc } = useTranslation();
const settings = useLeaveSettings();
const put = usePutLeaveSettings();
const [draft, setDraft] = useState<typeof settings.data>();
const s = draft ?? settings.data;
const [error, setError] = useState<string | null>(null);
if (!s) return null;
async function onSave() {
if (!s) return;
setError(null);
try {
await put.mutateAsync(s);
setDraft(undefined);
toast.success(t('saved'));
} catch (e) {
setError(leaveMessage(e, t, tc));
}
}
return (
<div className="grid max-w-xl gap-4">
<Field label={t('fields.weekdays')}>
<div className="flex flex-wrap gap-4">
{WEEKDAYS.map((d) => (
<Checkbox
key={d}
checked={s.weekdays.includes(d)}
onCheckedChange={(on) =>
setDraft({ ...s, weekdays: on ? [...s.weekdays, d].sort() : s.weekdays.filter((x) => x !== d) })
}
label={t(`weekdays.${d}`)}
/>
))}
</div>
</Field>
<Field label={t('fields.state')} hint={t('fields.stateHint')} optional>
<Input value={s.state ?? ''} onChange={(e) => setDraft({ ...s, state: e.target.value.toUpperCase() || null })} />
</Field>
<Field label={t('fields.carryForwardMax')} hint={t('admin.carryHint')}>
<Input inputMode="decimal" value={s.carry_forward_max} onChange={(e) => setDraft({ ...s, carry_forward_max: e.target.value })} />
</Field>
<Field label={t('fields.carryForwardExpiry')} optional>
<Input
type="number"
value={s.carry_forward_expiry_months ?? ''}
onChange={(e) => setDraft({ ...s, carry_forward_expiry_months: e.target.value ? Number(e.target.value) : null })}
/>
</Field>
<div>
<Button onClick={() => void onSave()} disabled={!draft} loading={put.isPending}>
{t('save')}
</Button>
</div>
{error && <p className="text-caption text-tone-red-fg">{error}</p>}
</div>
);
}
function BalancesTab() {
const { t } = useTranslation('leave');
const [locale] = useLocale();
const canAdjust = useCan('hr.leave.adjust');
const [year, setYear] = useState(Number(todayISO().slice(0, 4)));
const balances = useLeaveBalances(year);
const types = useLeaveTypes(true);
const [adjusting, setAdjusting] = useState<LeaveBalance | null>(null);
const columns: ColumnDef<LeaveBalance>[] = [
{ id: 'no', header: t('fields.employeeNo'), cell: ({ row }) => row.original.employee_no },
{ id: 'name', meta: { required: true }, header: t('fields.employee'), cell: ({ row }) => row.original.employee_name },
{
id: 'type',
header: t('fields.type'),
cell: ({ row }) => {
const x = types.data?.find((y) => y.id === row.original.type_id);
return x ? pickLabel(x.names, locale) : '—';
},
},
{ id: 'granted', header: t('fields.granted'), cell: ({ row }) => row.original.granted },
{ id: 'taken', header: t('fields.taken'), cell: ({ row }) => row.original.taken },
{ id: 'balance', header: t('fields.balance'), cell: ({ row }) => row.original.balance },
{ id: 'pending', header: t('fields.pending'), cell: ({ row }) => row.original.pending },
];
return (
<div className="flex flex-col gap-4">
<Field label={t('fields.year')}>
<Input type="number" value={year} onChange={(e) => setYear(Number(e.target.value))} className="max-w-32" />
</Field>
<DataTable
tableId="leave.balances"
columns={columns}
data={balances.data ?? []}
loading={balances.isLoading}
getRowId={(b: LeaveBalance) => `${b.employee_id}:${b.type_id}`}
onRowClick={canAdjust ? (b: LeaveBalance) => setAdjusting(b) : undefined}
empty={<EmptyState title={t('admin.noBalances')} />}
/>
{adjusting && <AdjustDialog balance={adjusting} onClose={() => setAdjusting(null)} />}
</div>
);
}
function TakenTab() {
const { t } = useTranslation('leave');
const [locale] = useLocale();
const initial = twoMonths(todayISO());
const [from, setFrom] = useState(initial.from);
const [to, setTo] = useState(initial.to);
const rows = useLeaveTaken(from, to);
const types = useLeaveTypes(true);
return (
<div className="flex flex-col gap-4">
<div className="flex gap-3">
<DatePicker value={from} onChange={(v) => v && setFrom(v)} aria-label={t('fields.from')} />
<DatePicker value={to} onChange={(v) => v && setTo(v)} min={from} aria-label={t('fields.to')} />
</div>
<ul className="grid gap-2">
{(rows.data ?? []).map((r) => {
const x = types.data?.find((y) => y.id === r.type_id);
return (
<li key={r.type_id} className="flex justify-between border-b border-border py-2">
<span>{x ? pickLabel(x.names, locale) : '—'}</span>
<span>{t('admin.takenLine', { days: r.days, requests: r.requests })}</span>
</li>
);
})}
</ul>
{rows.data?.length === 0 && <EmptyState title={t('admin.noneTaken')} />}
</div>
);
}
function RoutingTab() {
const { t } = useTranslation('leave');
const [locale] = useLocale();
const initial = twoMonths(todayISO());
const [from, setFrom] = useState(initial.from);
const [to, setTo] = useState(initial.to);
const moves = useRoutingMoves(from, to);
const columns: ColumnDef<LeaveRoutingMove>[] = [
{ id: 'day', header: t('fields.date'), cell: ({ row }) => formatDate(row.original.on_day, locale) },
{ id: 'kind', meta: { required: true }, header: t('fields.document'), cell: ({ row }) => row.original.kind },
{ id: 'state', header: t('fields.step'), cell: ({ row }) => row.original.state },
{
id: 'outcome',
header: t('fields.outcome'),
cell: ({ row }) =>
row.original.no_alternate ? (
<Tag tone="red">{t('admin.noAlternate')}</Tag>
) : (
<Tag tone="amber">{t('admin.movedUp')}</Tag>
),
},
];
return (
<div className="flex flex-col gap-4">
<p className="text-caption text-fg-muted">{t('admin.routingHint')}</p>
<div className="flex gap-3">
<DatePicker value={from} onChange={(v) => v && setFrom(v)} aria-label={t('fields.from')} />
<DatePicker value={to} onChange={(v) => v && setTo(v)} min={from} aria-label={t('fields.to')} />
</div>
<DataTable
tableId="leave.routing"
columns={columns}
data={moves.data ?? []}
loading={moves.isLoading}
getRowId={(m: LeaveRoutingMove) => m.id}
empty={<EmptyState title={t('admin.noMoves')} />}
/>
</div>
);
}
  • Step 7: Route and navigate

Create web/src/features/leave/routes.tsx:

import { createRoute } from '@tanstack/react-router';
import { z } from 'zod';
import { appRoute } from '@/app/layouts';
import { RequirePermission } from '@/features/auth/session';
import { LeaveAdminPage, type AdminTab } from './LeaveAdminPage';
import { MyLeavePage } from './MyLeavePage';
import { RequestDrawer } from './RequestDrawer';
import { TeamCalendarPage } from './TeamCalendarPage';
const mineSearch = z.object({ request: z.string().optional().catch(undefined) });
type MineSearch = z.infer<typeof mineSearch>;
const mineRoute = createRoute({
getParentRoute: () => appRoute,
path: 'leave',
validateSearch: (s: Record<string, unknown>) => mineSearch.parse(s),
component: function R() {
const s = mineRoute.useSearch();
const navigate = mineRoute.useNavigate();
return (
<RequirePermission permission="hr.leave.request">
<MyLeavePage onOpen={(id) => void navigate({ search: (p: MineSearch) => ({ ...p, request: id }) })} />
{s.request && (
<RequestDrawer
id={s.request}
onClose={() => void navigate({ search: (p: MineSearch) => ({ ...p, request: undefined }) })}
/>
)}
</RequirePermission>
);
},
});
const calendarRoute = createRoute({
getParentRoute: () => appRoute,
path: 'leave/calendar',
component: () => (
<RequirePermission permission="hr.leave.calendar">
<TeamCalendarPage />
</RequirePermission>
),
});
const adminSearch = z.object({ tab: z.string().optional().catch(undefined) });
type AdminSearch = z.infer<typeof adminSearch>;
const adminRoute = createRoute({
getParentRoute: () => appRoute,
path: 'leave/admin',
validateSearch: (s: Record<string, unknown>) => adminSearch.parse(s),
component: function R() {
const s = adminRoute.useSearch();
const navigate = adminRoute.useNavigate();
return (
<RequirePermission permission={['hr.leave.manage', 'hr.leave.read_all']}>
<LeaveAdminPage
tab={(s.tab ?? 'types') as AdminTab}
onTab={(tab) => void navigate({ search: (p: AdminSearch) => ({ ...p, tab }) })}
/>
</RequirePermission>
);
},
});
export const routes = [mineRoute, calendarRoute, adminRoute] as const;

In web/src/app/routes.ts, add import { routes as leaveRoutes } from '@/features/leave/routes'; after H1’s hrRoutes import, and ...leaveRoutes, after ...hrRoutes, in appRoutes.

In web/src/app/shell/nav.ts, add CalendarDays and Plane to the lucide-react import, then add after the H1 /hr/employees item:

{
group: 'requests',
to: '/leave',
labelKey: 'leave:nav.mine',
icon: Plane,
permission: 'hr.leave.request',
},
{
group: 'workspace',
to: '/leave/calendar',
labelKey: 'leave:nav.calendar',
icon: CalendarDays,
permission: 'hr.leave.calendar',
},
{
group: 'manage',
to: '/leave/admin',
labelKey: 'leave:nav.admin',
icon: CalendarCheck,
permission: ['hr.leave.manage', 'hr.leave.read_all'],
},
  • Step 8: Write the copy

Create web/src/lib/i18n/locales/en/leave.json:

{
"nav": { "mine": "My leave", "calendar": "Team calendar", "admin": "Leave setup" },
"loading": "Loading…",
"cancel": "Cancel",
"confirm": "Confirm",
"save": "Save",
"saved": "Saved.",
"submit": "Submit",
"submitted": "Leave requested.",
"moved": "Done.",
"request": "Request leave",
"chooseType": "Choose a leave type",
"fromHalf": "First day: afternoon only",
"toHalf": "Last day: morning only",
"halfDay": "half day",
"recordLanguage": "Write it in English or Bahasa Malaysia.",
"attachmentHint": "A medical certificate, as a PDF or a photo.",
"attachmentHidden": "A medical certificate is attached; you may not open it.",
"openAttachment": "Open the medical certificate",
"national": "National",
"yes": "Yes",
"no": "No",
"mine": {
"title": "My leave",
"subtitle": "Your balances for this year, your requests, and the leave waiting for your decision.",
"noRecord": "You have no employee record here, so no leave to request.",
"noBalances": "No leave granted yet this year.",
"requests": "My requests",
"noRequests": "No requests yet.",
"awaiting": "Awaiting my approval"
},
"calendar": {
"title": "Team calendar",
"subtitle": "Approved leave, by who is away.",
"onLeave": "On leave",
"empty": "Nobody is on leave in this period."
},
"admin": {
"title": "Leave setup",
"subtitle": "Leave types and entitlements, public holidays, the working week, balances and reports.",
"tabs": {
"types": "Types",
"holidays": "Public holidays",
"settings": "Working week",
"balances": "Balances",
"taken": "Leave taken",
"routing": "Approvals and leave"
},
"newType": "New leave type",
"editType": "Edit leave type",
"noTypes": "No leave types yet.",
"rules": "Entitlement",
"rulesFor": "Entitlement: {{name}}",
"ruleLine": "From {{from}}, months {{band}}: {{days}} days",
"band": "Service months {{from}} to {{to}}",
"bandOpen": "Service months {{from}} onwards",
"effectiveHint": "Applies to grants made on or after this day; past grants never change.",
"floorHint": "A statutory type cannot be set below the Employment Act minimum for any month of a band.",
"retire": "Retire",
"addHoliday": "Add holiday",
"noHolidays": "No public holidays entered for this year.",
"carryHint": "The most unused annual days carried into the next year. 0 means unused leave lapses.",
"adjustFor": "Adjust {{name}}'s leave",
"adjustHint": "Positive adds, negative takes away, in whole or half days.",
"noBalances": "No balances for this year.",
"takenLine": "{{days}} days over {{requests}} requests",
"noneTaken": "No leave taken in this period.",
"routingHint": "Approvals that passed over an approver on leave. Flagged ones stayed with them: nobody above was available.",
"noAlternate": "No alternate",
"movedUp": "Moved up",
"noMoves": "No approvals were routed around leave in this period."
},
"fields": {
"type": "Leave type",
"kind": "Kind",
"code": "Code",
"name": "Name",
"nameEn": "Name (English)",
"nameMs": "Name (Bahasa Malaysia)",
"nameZh": "Name (Chinese)",
"paid": "Paid",
"halfDays": "Half days allowed",
"attachmentRequired": "Needs a medical certificate",
"active": "Offered",
"from": "From",
"to": "To",
"dates": "Dates",
"days": "Days",
"reason": "Reason",
"attachment": "Medical certificate",
"state": "State",
"stateHint": "State code, e.g. MY-10 (blank: national)",
"employee": "Employee",
"employeeNo": "Employee no.",
"balance": "Balance",
"pending": "Pending",
"granted": "Granted",
"taken": "Taken",
"year": "Year",
"date": "Date",
"effectiveFrom": "Effective from",
"weekdays": "Working days",
"carryForwardMax": "Carry forward at most (days)",
"carryForwardExpiry": "Carried days expire after (months)",
"document": "Document",
"step": "Step",
"outcome": "Outcome"
},
"kinds": {
"annual": "Annual",
"sick": "Sick",
"hospitalisation": "Hospitalisation",
"maternity": "Maternity",
"paternity": "Paternity",
"unpaid": "Unpaid",
"other": "Other"
},
"semantic": {
"draft": "Draft",
"pending": "Awaiting approval",
"committed": "Approved",
"rejected": "Rejected",
"cancelled": "Cancelled"
},
"weekdays": { "1": "Mon", "2": "Tue", "3": "Wed", "4": "Thu", "5": "Fri", "6": "Sat", "7": "Sun" },
"warnings": {
"no_public_holidays": "No public holidays are entered for {{year}}; every working weekday counts as a leave day."
},
"refusals": {
"insufficient_balance": "Not enough of this leave left for these days.",
"overlapping_leave": "You already have leave on some of these days.",
"not_an_employee": "You have no employee record here, so no leave.",
"attachment_bound": "That document already supports another record.",
"leave_started": "The leave has started; it can no longer be cancelled.",
"below_statutory_floor": "That would take the leave below its statutory minimum for the year.",
"duplicate_leave_type": "Another leave type has this code.",
"rule_set_exists": "This type already has entitlement from that date.",
"duplicate_holiday": "That day is already a public holiday.",
"sole_owner_reason_required": "As the organization's only member you may approve this yourself, with a reason.",
"owner_leave_approver_missing": "Nobody is set up to approve the Owner's leave. An administrator adds an approver for Owner leave in Workflows → Leave request.",
"owner_leave_approver_vacant": "The approver set up for the Owner's leave cannot decide it now. An administrator re-points it in Workflows → Leave request."
}
}

Create web/src/lib/i18n/locales/ms/leave.json:

{
"nav": { "mine": "Cuti saya", "calendar": "Kalendar pasukan", "admin": "Tetapan cuti" },
"loading": "Memuatkan…",
"cancel": "Batal",
"confirm": "Sahkan",
"save": "Simpan",
"saved": "Disimpan.",
"submit": "Hantar",
"submitted": "Cuti telah dipohon.",
"moved": "Selesai.",
"request": "Mohon cuti",
"chooseType": "Pilih jenis cuti",
"fromHalf": "Hari pertama: petang sahaja",
"toHalf": "Hari terakhir: pagi sahaja",
"halfDay": "separuh hari",
"recordLanguage": "Tulis dalam Bahasa Inggeris atau Bahasa Malaysia.",
"attachmentHint": "Sijil cuti sakit, sebagai PDF atau gambar.",
"attachmentHidden": "Sijil cuti sakit dilampirkan; anda tidak boleh membukanya.",
"openAttachment": "Buka sijil cuti sakit",
"national": "Kebangsaan",
"yes": "Ya",
"no": "Tidak",
"mine": {
"title": "Cuti saya",
"subtitle": "Baki cuti anda tahun ini, permohonan anda, dan cuti yang menunggu keputusan anda.",
"noRecord": "Anda tiada rekod pekerja di sini, jadi tiada cuti untuk dipohon.",
"noBalances": "Belum ada cuti diberikan tahun ini.",
"requests": "Permohonan saya",
"noRequests": "Belum ada permohonan.",
"awaiting": "Menunggu kelulusan saya"
},
"calendar": {
"title": "Kalendar pasukan",
"subtitle": "Cuti yang diluluskan, mengikut siapa yang tiada.",
"onLeave": "Bercuti",
"empty": "Tiada sesiapa bercuti dalam tempoh ini."
},
"admin": {
"title": "Tetapan cuti",
"subtitle": "Jenis dan kelayakan cuti, cuti umum, minggu bekerja, baki dan laporan.",
"tabs": {
"types": "Jenis",
"holidays": "Cuti umum",
"settings": "Minggu bekerja",
"balances": "Baki",
"taken": "Cuti diambil",
"routing": "Kelulusan dan cuti"
},
"newType": "Jenis cuti baharu",
"editType": "Ubah jenis cuti",
"noTypes": "Belum ada jenis cuti.",
"rules": "Kelayakan",
"rulesFor": "Kelayakan: {{name}}",
"ruleLine": "Dari {{from}}, bulan {{band}}: {{days}} hari",
"band": "Bulan perkhidmatan {{from}} hingga {{to}}",
"bandOpen": "Bulan perkhidmatan {{from}} ke atas",
"effectiveHint": "Terpakai kepada pemberian pada atau selepas hari ini; pemberian lalu tidak berubah.",
"floorHint": "Jenis berkanun tidak boleh ditetapkan di bawah minimum Akta Kerja bagi mana-mana bulan dalam jalurnya.",
"retire": "Tamatkan",
"addHoliday": "Tambah cuti umum",
"noHolidays": "Tiada cuti umum dimasukkan untuk tahun ini.",
"carryHint": "Hari cuti tahunan maksimum yang dibawa ke tahun berikutnya. 0 bermaksud cuti yang tidak digunakan luput.",
"adjustFor": "Laraskan cuti {{name}}",
"adjustHint": "Positif menambah, negatif mengurangkan, dalam hari penuh atau separuh hari.",
"noBalances": "Tiada baki untuk tahun ini.",
"takenLine": "{{days}} hari dalam {{requests}} permohonan",
"noneTaken": "Tiada cuti diambil dalam tempoh ini.",
"routingHint": "Kelulusan yang melangkaui pelulus yang bercuti. Yang ditanda kekal dengan mereka: tiada sesiapa di atas yang ada.",
"noAlternate": "Tiada pengganti",
"movedUp": "Dinaikkan",
"noMoves": "Tiada kelulusan dialihkan kerana cuti dalam tempoh ini."
},
"fields": {
"type": "Jenis cuti",
"kind": "Kategori",
"code": "Kod",
"name": "Nama",
"nameEn": "Nama (Bahasa Inggeris)",
"nameMs": "Nama (Bahasa Malaysia)",
"nameZh": "Nama (Bahasa Cina)",
"paid": "Bergaji",
"halfDays": "Separuh hari dibenarkan",
"attachmentRequired": "Perlu sijil cuti sakit",
"active": "Ditawarkan",
"from": "Dari",
"to": "Hingga",
"dates": "Tarikh",
"days": "Hari",
"reason": "Sebab",
"attachment": "Sijil cuti sakit",
"state": "Negeri",
"stateHint": "Kod negeri, cth. MY-10 (kosong: kebangsaan)",
"employee": "Pekerja",
"employeeNo": "No. pekerja",
"balance": "Baki",
"pending": "Menunggu",
"granted": "Diberikan",
"taken": "Diambil",
"year": "Tahun",
"date": "Tarikh",
"effectiveFrom": "Berkuat kuasa dari",
"weekdays": "Hari bekerja",
"carryForwardMax": "Bawa ke hadapan maksimum (hari)",
"carryForwardExpiry": "Hari dibawa luput selepas (bulan)",
"document": "Dokumen",
"step": "Langkah",
"outcome": "Hasil"
},
"kinds": {
"annual": "Tahunan",
"sick": "Sakit",
"hospitalisation": "Kemasukan hospital",
"maternity": "Bersalin",
"paternity": "Paterniti",
"unpaid": "Tanpa gaji",
"other": "Lain-lain"
},
"semantic": {
"draft": "Draf",
"pending": "Menunggu kelulusan",
"committed": "Diluluskan",
"rejected": "Ditolak",
"cancelled": "Dibatalkan"
},
"weekdays": { "1": "Isn", "2": "Sel", "3": "Rab", "4": "Kha", "5": "Jum", "6": "Sab", "7": "Ahd" },
"warnings": {
"no_public_holidays": "Tiada cuti umum dimasukkan untuk {{year}}; setiap hari bekerja dikira sebagai hari cuti."
},
"refusals": {
"insufficient_balance": "Baki cuti ini tidak mencukupi untuk hari-hari ini.",
"overlapping_leave": "Anda sudah bercuti pada sebahagian hari ini.",
"not_an_employee": "Anda tiada rekod pekerja di sini, jadi tiada cuti.",
"attachment_bound": "Dokumen itu sudah menyokong rekod lain.",
"leave_started": "Cuti telah bermula; ia tidak boleh dibatalkan lagi.",
"below_statutory_floor": "Itu akan menurunkan cuti di bawah minimum berkanun bagi tahun itu.",
"duplicate_leave_type": "Jenis cuti lain menggunakan kod ini.",
"rule_set_exists": "Jenis ini sudah mempunyai kelayakan dari tarikh itu.",
"duplicate_holiday": "Hari itu sudah cuti umum.",
"sole_owner_reason_required": "Sebagai satu-satunya ahli organisasi, anda boleh meluluskannya sendiri dengan sebab.",
"owner_leave_approver_missing": "Tiada sesiapa ditetapkan untuk meluluskan cuti Pemilik. Pentadbir menambah pelulus cuti Pemilik dalam Aliran kerja → Permohonan cuti.",
"owner_leave_approver_vacant": "Pelulus yang ditetapkan untuk cuti Pemilik tidak dapat membuat keputusan sekarang. Pentadbir menukarnya dalam Aliran kerja → Permohonan cuti."
}
}

Create web/src/lib/i18n/locales/zh/leave.json:

{
"nav": { "mine": "我的休假", "calendar": "团队日历", "admin": "休假设置" },
"loading": "加载中…",
"cancel": "取消",
"confirm": "确认",
"save": "保存",
"saved": "已保存。",
"submit": "提交",
"submitted": "已申请休假。",
"moved": "完成。",
"request": "申请休假",
"chooseType": "选择休假类型",
"fromHalf": "第一天:仅下午",
"toHalf": "最后一天:仅上午",
"halfDay": "半天",
"recordLanguage": "请用英文或马来文填写。",
"attachmentHint": "病假单,PDF 或照片。",
"attachmentHidden": "已附病假单;您无权打开。",
"openAttachment": "打开病假单",
"national": "全国",
"yes": "是",
"no": "否",
"mine": {
"title": "我的休假",
"subtitle": "您今年的余额、您的申请,以及等待您决定的休假。",
"noRecord": "您在此没有员工档案,因此无法申请休假。",
"noBalances": "今年尚未发放休假。",
"requests": "我的申请",
"noRequests": "尚无申请。",
"awaiting": "等待我审批"
},
"calendar": {
"title": "团队日历",
"subtitle": "已批准的休假,按人员显示。",
"onLeave": "休假中",
"empty": "此期间无人休假。"
},
"admin": {
"title": "休假设置",
"subtitle": "休假类型与额度、公共假期、工作周、余额与报表。",
"tabs": {
"types": "类型",
"holidays": "公共假期",
"settings": "工作周",
"balances": "余额",
"taken": "已休假",
"routing": "审批与休假"
},
"newType": "新建休假类型",
"editType": "编辑休假类型",
"noTypes": "尚无休假类型。",
"rules": "额度",
"rulesFor": "额度:{{name}}",
"ruleLine": "自 {{from}},服务月数 {{band}}:{{days}} 天",
"band": "服务月数 {{from}} 至 {{to}}",
"bandOpen": "服务月数 {{from}} 及以上",
"effectiveHint": "适用于此日及之后的发放;过去的发放不会改变。",
"floorHint": "法定类型在任何服务月份都不能低于《雇佣法》的最低标准。",
"retire": "停用",
"addHoliday": "添加假期",
"noHolidays": "今年尚未录入公共假期。",
"carryHint": "未用年假最多结转到下一年的天数。0 表示未用假期作废。",
"adjustFor": "调整 {{name}} 的休假",
"adjustHint": "正数增加,负数减少,按整天或半天。",
"noBalances": "今年没有余额。",
"takenLine": "{{requests}} 份申请共 {{days}} 天",
"noneTaken": "此期间没有休假。",
"routingHint": "因审批人休假而跳过的审批。标记的仍由其处理:上级无人可用。",
"noAlternate": "无替代",
"movedUp": "已上移",
"noMoves": "此期间没有因休假而转移的审批。"
},
"fields": {
"type": "休假类型",
"kind": "类别",
"code": "代码",
"name": "名称",
"nameEn": "名称(英文)",
"nameMs": "名称(马来文)",
"nameZh": "名称(中文)",
"paid": "带薪",
"halfDays": "允许半天",
"attachmentRequired": "需要病假单",
"active": "提供中",
"from": "从",
"to": "至",
"dates": "日期",
"days": "天数",
"reason": "原因",
"attachment": "病假单",
"state": "州属",
"stateHint": "州属代码,如 MY-10(留空:全国)",
"employee": "员工",
"employeeNo": "员工编号",
"balance": "余额",
"pending": "待审批",
"granted": "已发放",
"taken": "已休",
"year": "年份",
"date": "日期",
"effectiveFrom": "生效日期",
"weekdays": "工作日",
"carryForwardMax": "最多结转(天)",
"carryForwardExpiry": "结转天数过期(月)",
"document": "单据",
"step": "步骤",
"outcome": "结果"
},
"kinds": {
"annual": "年假",
"sick": "病假",
"hospitalisation": "住院假",
"maternity": "产假",
"paternity": "陪产假",
"unpaid": "无薪假",
"other": "其他"
},
"semantic": {
"draft": "草稿",
"pending": "待批准",
"committed": "已批准",
"rejected": "已拒绝",
"cancelled": "已取消"
},
"weekdays": { "1": "一", "2": "二", "3": "三", "4": "四", "5": "五", "6": "六", "7": "日" },
"warnings": {
"no_public_holidays": "{{year}} 年尚未录入公共假期;每个工作日都计为休假日。"
},
"refusals": {
"insufficient_balance": "此类休假余额不足以覆盖这些天。",
"overlapping_leave": "您在其中某些日子已有休假。",
"not_an_employee": "您在此没有员工档案,因此没有休假。",
"attachment_bound": "该文件已用于另一条记录。",
"leave_started": "休假已开始,不能再取消。",
"below_statutory_floor": "这会使该年的休假低于法定最低标准。",
"duplicate_leave_type": "另一个休假类型已使用此代码。",
"rule_set_exists": "此类型在该日期已有额度。",
"duplicate_holiday": "该日已是公共假期。",
"sole_owner_reason_required": "作为组织唯一成员,您可以自行批准,但须说明原因。",
"owner_leave_approver_missing": "尚未设置所有者休假的审批人。管理员可在 工作流 → 休假申请 中添加。",
"owner_leave_approver_vacant": "所有者休假的审批人目前无法审批。管理员可在 工作流 → 休假申请 中更改。"
}
}
  • Step 9: Run the web checks

Run: pnpm -C web typecheck && pnpm -C web lint && pnpm -C web test -- features/leave i18n Expected: PASS (the i18n parity test sees the same keys in en, ms and zh).

  • Step 10: Watch the screen guard fail once

In MyLeavePage, change hasRecord && ( in the actions prop to true && (. Run pnpm -C web test -- features/leave. Expected: FAIL — offers no request to a member with no employee record: the Request leave button is in the document. Restore. In RequestDrawer, drop .filter((a) => a.allowed). Expected: FAIL — the Withdraw button is found. Restore; PASS.

  • Step 11: Commit
Terminal window
git add web/src
git commit -m "Phase 5 H3: leave screens — my leave, team calendar, leave setup (en/ms/zh)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Files:

  • Modify: docs/ROADMAP.md (Phase 5; “Needs an outside authority”), docs/DEPLOY_NOTES.md (new entry, newest first), docs/LOCK_ORDER.md (per-module table)

Interfaces:

  • Consumes: everything above.

  • Produces: the roadmap tick and follow-ups, the operator notes, the outside-authority item, the lock-order row.

  • Step 1: Tick the roadmap

In docs/ROADMAP.md, under ## Phase 5 — People, replace H1’s line

- [ ] H3 leave (`00840`–`00859`)

with:

- [x] H3 leave (`platform/hr` `leave_*.go`, `modules/leave`, `00840`–`00842`)
Plan: [`2026-09-30-phase-5-h3-leave.md`](superpowers/plans/2026-09-30-phase-5-h3-leave.md) (12 tasks, Rulings H3-1–H3-19)
Deploy: [`DEPLOY_NOTES.md`](DEPLOY_NOTES.md) — re-provision for the `leave_request` workflow; no types or holidays seeded; the two daily jobs
- Leave types (names as translation rows) over a seeded statutory floor the database enforces against the
highest floor inside each band (HD-3); effective-dated rule sets that never rewrite a past grant (HD-8)
- Append-only `leave_ledger` (grant, take, reverse, carry_forward, expire, adjust); a balance is the sum
- Daily `hr_leave_grants`: idempotent per employee, type and year; first-year annual prorated by whole months;
year-end lapse and optional carry-forward with expiry
- Adjustments with a reason, audited, never below the year's floor on a statutory type (HD-7)
- `leave_request` workflow (seat above the requester, or the OWNER seat) ending in `committed`: approval takes,
cancelling before the first day reverses; paid types never go negative; an OWNER-seat holder's own leave
goes to another OWNER-seat holder, the sole-owner exception, or an administrator-configured grant (owner
decision 2026-09-30, Ruling H3-20), never to the requester (database-enforced)
- Working days from the organization's week and the public holidays it enters (not seeded; warned when a year
has none); half days; maternity and paternity in calendar days
- Medical certificates for the requester, the approver, HR and `hr.leave.medical.read` only
- HD-2: `workflow.Unavailable`, registered by `hr` — an approver on approved leave is passed over by the
`on_vacancy: escalate` walk, recorded in the decision log; nobody above available → stays, flagged, never
refused; daily `hr_leave_reroute` logs each move once a day
- Reports: balances per employee, leave taken by type and period, the team calendar
- Drift tests: floor anywhere in a band (incl. spanning two floor bands); grant job twice grants once; a rule
change does not alter a past grant; beyond the balance refused, cancelling restores it; approver on leave
escalates and is logged, nobody above stays flagged; balance = sum of the ledger; nested module keys
- [ ] H3 follow-ups: HR recording leave on someone's behalf; a starter set of leave types; notifying the
approver a step was routed to while someone is on leave; account-approver sign-off steps and leave
(Ruling H3-14); a leave year other than the calendar year
  • Step 2: Add the outside-authority item

In docs/ROADMAP.md, append to the numbered list under ### Needs an outside authority, as its next number N:

N. **Employment Act leave figures (Phase 5 H3)** — an HR or legal adviser confirms the seeded
`leave_statutory_floor` figures (annual 8/12/16, sick 14/18/22 by under 2 / 2–5 / over 5 years;
hospitalisation 60; maternity 98 consecutive days; paternity 7 consecutive days — recalled from the
2022 amendments, UNVERIFIED), which employees they cover (some provisions apply only below a wage
threshold), the first-year proration and its rounding (s.60E(1A)), whether the service band moves
mid-year, the retention period for employment and leave records, and whether unused annual leave
carried over or lost on leaving has statutory treatment. Until then: the figures bind as seeded,
and a correction is a migration.
  • Step 3: Write the deploy notes

In docs/DEPLOY_NOTES.md, insert after the first --- line (before the newest existing entry):

## Phase 5 H3 — leave
Migrations `00841` (and `00840` only if this branch carried the `committed` semantic before Phase 4 O1
or H2 did), `00842`; permission keys `hr.leave.request`, `hr.leave.manage`, `hr.leave.read_all`,
`hr.leave.calendar`, `hr.leave.adjust`, `hr.leave.medical.read` (`00842`: the Owner all six; Employee,
Approver, Finance and Admin `hr.leave.request`; Approver `hr.leave.calendar`; `perm_version` bumped);
optional module `leave` (requires `hr`), on by default; River periodic jobs `hr_leave_grants` and
`hr_leave_reroute` (daily and on start, `maintenance` queue). No environment variable.
- **Re-run provisioning for every existing organization** (`POST /v1/org/provision`, or
`migrate provision --org … --owner-member …`): the `leave_request` workflow is a seeded graph, and
until it is published a leave request fails `no published definition`. Readiness shows the
`workflow_definitions` step incomplete until then.
- **Nothing is seeded per organization.** HR creates the leave types and their entitlement rules
(Leave setup → Types), and enters the public holidays (Leave setup → Public holidays). Until a
year has a holiday, every working weekday counts as a leave day and the screens say so.
- **The statutory floors are UNVERIFIED** (`leave_statutory_floor`, recalled from the 2022
Employment Act amendments). They bind every organization's rules as seeded; a correction is a
migration. See the ROADMAP's "Needs an outside authority".
- **No leave is granted to a pending employee record** — the members `00801` copied have no hire
date until HR records the hire (H1).
- **The worker must run both jobs.** `hr_leave_grants` is what grants each year's leave (its first
run in January is the year's grant, and it rolls last year's annual leave); `hr_leave_reroute`
logs approvals routed around leave. A worker that is down grants late, never twice.
- **Approvers on leave are passed over everywhere**, not only on leave requests: every workflow
resolves `workflow.Unavailable`, which `platform/hr` registers from `init()`. A binary that does
not link `platform/hr` would not know who is on leave — `cmd/api` and `cmd/worker` wiring tests
pin it.
- **The OWNER seat may approve anyone's leave** (a second grant beside the seat above the
requester; Ruling H3-11).
- **Who approves the Owner's own leave** (owner decision 2026-09-30, Ruling H3-20): another holder
of the OWNER seat if there is one; in a one-member organization the Owner, under the sole-owner
exception with a reason. In any other organization **an administrator must configure it** before
the Owner can submit leave: in Workflows → Leave request, add a grant on the Approved step naming
a member or a position, with a minimum amount of **1.00** (the OWNER-seat floor), and publish.
Until then the Owner's submission is refused `owner_leave_approver_missing`. That grant decides
OWNER-seat leave only. The requester never approves their own leave; the database refuses it
(`leave_take_by_requester`).
- **Down-migrations refuse once leave has been used:** `00841`'s tables are append-only and its
Down drops them with every balance — roll the code back, not this migration. `00840`'s Down
fails while a published state is `committed`.
  • Step 4: Record the lock order

In docs/LOCK_ORDER.md, add as the last row of the “Per module” table:

| Leave (Phase 5 H3) | `organization` `FOR UPDATE` → `employee` `FOR UPDATE` (H1's `lockEmployee`: every request, transition, accrual and adjustment) → an attachment's `document` `FOR UPDATE` (`documents.Lock`) → `workflow_instance` (inside `workflow.Transition`; the sole-owner exception's organization lock is already held) → `leave_request` / `leave_ledger` inserts → audit. The reroute job takes no row lock: it reads and inserts `leave_routing_move` with `ON CONFLICT DO NOTHING` | `hr/leave_request.go` `RequestLeave`, `TransitionLeave`; `hr/leave_ledger.go` `AccrueYear`, `Adjust`; `hr/leave_routing.go` `RecordRoutingMoves` |
  • Step 5: Run the full suite once

Run: make generate && git status --porcelain && go vet ./... && go test ./... -count=1 && pnpm -C web typecheck && pnpm -C web lint && pnpm -C web test Expected: make generate leaves nothing to commit beyond this task’s docs; every Go and web test passes, including TestEveryTableIsTenantIsolated, TestEveryTenantQueryCarriesAnExplicitOrgPredicate, TestEveryTenantForeignKeyIsCompositeOnOrgID, TestEveryConstraintIsNamed, TestEveryFunctionPinsItsSearchPath, TestOnlyOperationalTablesGrantDeleteToTheAppRole, TestModulesNeverReferenceRLSExemptTables, TestEveryRouteIsAuthorized, TestTheOpenAPIDocumentSaysWhatTheRouterEnforces, TestEverySeededGraphPassesTheValidator, TestANestedPermissionBelongsToAModuleThatRequiresItsParent, the H1 drift tests (unchanged by the Unavailable seam) and every H3 drift test.

  • Step 6: Commit
Terminal window
git add docs/ROADMAP.md docs/DEPLOY_NOTES.md docs/LOCK_ORDER.md
git commit -m "Phase 5 H3: roadmap, deploy notes, outside-authority item and lock order
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Self-review (done while writing; re-run before executing)

Section titled “Self-review (done while writing; re-run before executing)”

Spec coverage (§H3.1–H3.5, HD-2, HD-3, HD-7, HD-8, the H3 drift bullets):

Spec Where
leave_type: code, names as translation rows, kind, paid, half days, attachment required (sick, hospitalisation) Task 3 (leave_type, leave_type_attachment_for_medical), Task 4 (CreateLeaveType, names)
leave_rule: days per service band, effective-dated, grants on or after (HD-8) Task 3, Task 4 (SetLeaveRules, ruleFor), Task 5 test
leave_statutory_floor seeded, global, migration-only; figures marked unverified Task 3 (00841, rls_exempt.txt), Task 12 (DEPLOY_NOTES, ROADMAP outside authority)
Floor enforced in the database against the highest floor inside the band (HD-3) Task 3 (leave_rule_floor_guard), drift test watched failing
Append-only ledger of six kinds; balance = sum, never stored Task 3 (leave_ledger, leave_balance), Task 5, Task 6 drift test
Grants: River job at year start, idempotent per employee/type/year; first-year proration Task 5 (hr_leave_grants, leave_ledger_grant_once), Rulings H3-6
Carry-forward setting (maximum, expiry), default none Task 3 (leave_setting), Task 4, Task 5 (rollYear)
Manual adjustment: reason, hr.leave.adjust, audited, not below the floor (HD-7) Task 5 (Adjust, leave_adjust_below_floor), Task 10 route
leave_request: employee, type, dates, half days, days, reason (en/ms), attachment; workflow leave_request; approver resolved by the system; committed → take; cancel → reverse Task 1, Task 6, Ruling H3-11
Working days: work_calendar (Mon–Fri default), public_holiday (not seeded, state, names), warning with none Task 3, Task 4, Task 10, Task 11
Balance check: paid annual never negative; unpaid unlimited Task 6 (and every paid type, Ruling H3-9)
Sole-owner exception with a reason, audited Task 1 Step 3, Task 6 test
Owner decision 2026-09-30: an OWNER-seat holder’s own leave — another holder (requester never, DB-enforced), sole owner, or an administrator-configured grant with a named refusal until configured Ruling H3-20; Task 3 (leave_take_by_requester), Task 6 (OwnerLeaveFloor, OwnerLeaveApproverError, three tests), Task 10 (codes), Task 11 (copy), Task 12 (DEPLOY_NOTES)
HD-2 unavailable seat → on-vacancy walk; decision log “approver on leave”; no delegation Task 2, Task 7
Daily job re-checks pending steps and logs each move Task 7 (hr_leave_reroute, leave_routing_move)
Nobody above → stays, flagged, never refused Task 2, Task 7
Layering: workflow defines Unavailable, hr registers it at boot Task 2, Task 7, wiring tests
Medical certificates: approver, HR, hr.leave.medical.read; type visible to approver Task 6 (MayReadMedical, binder), Task 10 (DTO, calendar)
Leave tables retention-bearing (no DELETE) Task 3 (retention lists)
Reports: balances per employee, leave taken by type and period, team calendar Task 5 (Balances), Task 8, Task 10
Permissions: manage, read_all, calendar, adjust, medical.read, self-service Task 9, Task 10
Module install leave depends on hr Task 9
Drift bullets (floor incl. spanning band; grant twice; rule change; beyond balance / cancel restores; approver on leave escalates/logged and stays/flagged; balance = sum) Tasks 3, 5, 6, 7 — each with its sabotage

Placeholder scan: no TBD/TODO; every code step carries its code; every “watch it fail” step names the edit and the expected failure. Task 1 executes O1 Task 1 by reference, as Phase 4 O4’s Task 1 does, with a substitution table; its own new step (the sole-owner half) is written out.

Type consistency: hr.Days (half-day int) throughout; LeaveRequest, Entry, Balance, RoutingMove, TakenRow, CalendarEntry defined once and used with the same field names by the module’s DTOs. workflow.Resolved.Away/Note, workflow.Reroute and orgstruct.SeatHolders defined in Tasks 2 and 7. hrtest helpers: M, Grant, LeaveType, Rules, Annual (Task 4), Accrue, Balance (Task 5), Request, LeaveMove (Task 6), ApprovedLeave (Task 7) — each appended by the task that first needs it. H1’s names are used exactly: GetByMember, lockEmployee, checkReason, ServiceMonths, Employee.HireDate/LastDay/Status, StatusActive/StatusNotice, employee_spell, hrtest.New/Day/At/Hired/DeactivateByAdmin.

Known limits, deliberately: Rulings H3-3 (calendar year only), H3-10 (self-service only), H3-11 (OWNER seat in every band), H3-14 (sign-off steps not consulted), H3-15 (no notification), H3-16 (nothing seeded) are ROADMAP follow-ups, not gaps.