Skip to main content

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)

  1. Confirm the ledger gRPC client is live. EWA + lending postings should already be working — check demozpay_ledger_grpc_duration_seconds has recent samples.
  2. Confirm the migration is applied. Run psql "$DATABASE_URL" -c "\dt payroll_employee_transfer" — must return one row (T1-C migration 20260601100000_payroll_employee_transfer lands the table). The ledger toggle does not depend on this table but the per-employee settlement layer does and they ship together.
  3. Confirm the API role can post. The ledger gRPC's tenant model expects tenantId on the request; the payroll adapter pulls it from PostPayrollApprovalInput.tenantId which 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 nameAccount typePurpose
payroll_expenseEXPENSEDEBIT side of the approval entry — gross + employer pension
salary_payableLIABILITYCREDIT — owed to employees
tax_payableLIABILITYCREDIT — owed to MoR until remitted
employee_pension_payableLIABILITYCREDIT — withheld from net pay; remitted with the pension filing
employer_pension_payableLIABILITYCREDIT — employer's own contribution; remitted with the pension filing
other_deductions_payableLIABILITYCREDIT — 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

  1. Staging first. Set the env vars in staging, roll the API.
  2. Approve a test run (POST /api/payroll/:id/approve). The response body now includes a ledger field:
    "ledger": {
    "transactionId": "ldg_xxx",
    "status": "POSTED",
    "alreadyPosted": false,
    "totals": { "grossSantim": "...", "netSantim": "...", ... }
    }
    alreadyPosted: false on first approve. On a retry, alreadyPosted: true (B5 sharpens the signal — see below).
  3. Verify ledger-side. Query the ledger by tenant + idempotency key payroll-approval:<tenantId>:<runId> and confirm the six entries sum to balanced debits == credits.
  4. Bake for 24h, run a second period. Once two periods are clean, promote the env to production.

4. Failure modes

SymptomLikely causeAction
LedgerUnbalancedError: ... debits=X credits=Y thrown from approveCalculator math drifted; entries' deductions don't sum back to gross − netStop 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 unsetSet them; restart API. The error names which ones are missing.
Approve succeeds but ledger: null in responsePAYROLL_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 FAILEDPartner 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.


  • B5 — Ledger proto wasIdempotentHit: Until the proto carries an explicit "this was an idempotency replay" signal, alreadyPosted is always false. Track the request id on the ledger side and compare on replay. See payroll-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 PayrollLedgerAccountsPort with a tenant param.
  • Treasury dashboard: Once postings are flowing, the next reconciliation work is the /api/wallet/balance read view (planned in inventory §21) backed by ledger queries.