Runbook — Enabling PAYROLL_LEDGER_ENABLED in staging / production
Status: Operational.
Owner: Treasury + Backend on-call.
Trigger: Going live with T1-B (PayrollRun → Ledger postings on APPROVED).
Effect when on: Every successful POST /api/payroll/:id/approve posts a balanced six-line transaction to the ledger. ADR-006 invariant satisfied (ledger = source of truth for employer-books payroll liabilities).
Until this is on, the approve flow ships the row + the outbox events + the per-entry payroll.deductions_taken.v1 events, but it does NOT post to the ledger — the run is therefore "approved" without an accounting entry. Acceptable for shadow / dry-run, NOT for production.
0. Pre-flight (one-time per environment)
- Confirm the ledger gRPC client is live. EWA + lending postings should already be working — check
demozpay_ledger_grpc_duration_secondshas recent samples. - Confirm the migration is applied. Run
psql "$DATABASE_URL" -c "\dt payroll_employee_transfer"— must return one row (T1-C migration20260601100000_payroll_employee_transferlands the table). The ledger toggle does not depend on this table but the per-employee settlement layer does and they ship together. - Confirm the API role can post. The ledger gRPC's tenant model expects
tenantIdon the request; the payroll adapter pulls it fromPostPayrollApprovalInput.tenantIdwhich the use case derives from the run aggregate. No special grant.
1. Author the six chart-of-accounts in the ledger
For each tenant going live, the ledger must contain a row for each of these six accounts. The semantics:
| Logical name | Account type | Purpose |
|---|---|---|
payroll_expense | EXPENSE | DEBIT side of the approval entry — gross + employer pension |
salary_payable | LIABILITY | CREDIT — owed to employees |
tax_payable | LIABILITY | CREDIT — owed to MoR until remitted |
employee_pension_payable | LIABILITY | CREDIT — withheld from net pay; remitted with the pension filing |
employer_pension_payable | LIABILITY | CREDIT — employer's own contribution; remitted with the pension filing |
other_deductions_payable | LIABILITY | CREDIT — court orders, EWA, loan, OTHER (aggregate; the consumers settle the underlying rows themselves) |
Authoring is done via the ledger's own admin tool (gRPC CreateAccount or whatever the operational equivalent is). Capture the returned account_id for each — they are opaque strings.
If a tenant operates multiple ledger tenants (e.g. a holding co + a subsidiary), do this once per tenant. The current adapter binds account ids process-wide via env vars, so the single-tenant path is straightforward; multi-tenant runs need a per-tenant overlay (follow-up; not blocking pilot).
2. Set the env vars
Append the following to the API process's environment (helm values, .env, K8s ConfigMap — whatever the deploy uses):
# Top-level toggle.
PAYROLL_LEDGER_ENABLED=true
# Account ids from step 1.
PAYROLL_LEDGER_EXPENSE_ACCOUNT_ID=acct_xxxxx_payroll_expense
PAYROLL_LEDGER_SALARY_PAYABLE_ACCOUNT_ID=acct_xxxxx_salary_payable
PAYROLL_LEDGER_TAX_PAYABLE_ACCOUNT_ID=acct_xxxxx_tax_payable
PAYROLL_LEDGER_EMPLOYEE_PENSION_PAYABLE_ACCOUNT_ID=acct_xxxxx_employee_pension
PAYROLL_LEDGER_EMPLOYER_PENSION_PAYABLE_ACCOUNT_ID=acct_xxxxx_employer_pension
PAYROLL_LEDGER_OTHER_DEDUCTIONS_PAYABLE_ACCOUNT_ID=acct_xxxxx_other_deductions
All six are required when PAYROLL_LEDGER_ENABLED=true. The adapter throws at first use if any is missing (see payroll-ledger.adapter.ts:resolveAccounts).
3. Roll out
- Staging first. Set the env vars in staging, roll the API.
- Approve a test run (
POST /api/payroll/:id/approve). The response body now includes aledgerfield:"ledger": {"transactionId": "ldg_xxx","status": "POSTED","alreadyPosted": false,"totals": { "grossSantim": "...", "netSantim": "...", ... }}alreadyPosted: falseon first approve. On a retry,alreadyPosted: true(B5 sharpens the signal — see below). - Verify ledger-side. Query the ledger by tenant + idempotency key
payroll-approval:<tenantId>:<runId>and confirm the six entries sum to balanced debits == credits. - Bake for 24h, run a second period. Once two periods are clean, promote the env to production.
4. Failure modes
| Symptom | Likely cause | Action |
|---|---|---|
LedgerUnbalancedError: ... debits=X credits=Y thrown from approve | Calculator math drifted; entries' deductions don't sum back to gross − net | Stop approving; investigate calculate-payroll-run.usecase.ts. The error refuses to post — no ledger contamination. |
PayrollLedgerAdapter is missing required account ids: ... | One or more env vars unset | Set them; restart API. The error names which ones are missing. |
Approve succeeds but ledger: null in response | PAYROLL_LEDGER_ENABLED is not true (or the boolean transform mis-fired) | Re-check PAYROLL_LEDGER_ENABLED is literally the string true (zod coerces). |
| Ledger gRPC returns FAILED | Partner ledger is mis-configured (account doesn't exist, currency mismatch) | Approve flow rolls back; PayrollRun stays CALCULATED. Fix the ledger account, retry approve. |
5. Disabling
Flip PAYROLL_LEDGER_ENABLED=false. Existing posted runs are unaffected (the ledger is the source of truth and stays authoritative). New approvals skip the posting step — the run still flows to APPROVED. Use only for incident response — disabling masks a real regulatory gap.
6. Related follow-ups
- B5 — Ledger proto
wasIdempotentHit: Until the proto carries an explicit "this was an idempotency replay" signal,alreadyPostedis alwaysfalse. Track the request id on the ledger side and compare on replay. Seepayroll-ledger.adapter.ts:wasIdempotentHit. - Per-tenant account overlay: When more than one tenant goes live, the env-var binding stops scaling. Move account-id resolution to a tenant-settings table or a
PayrollLedgerAccountsPortwith a tenant param. - Treasury dashboard: Once postings are flowing, the next reconciliation work is the
/api/wallet/balanceread view (planned in inventory §21) backed by ledger queries.