Skip to content

Payroll

Backend domain: app/graphql/payroll/ Migration: add_payroll RBAC Path: PAYROLL

Overview

Five tables:

  • Employee — auto-numbered EMP-00001, base salary, pay frequency (WEEKLY | BIWEEKLY | MONTHLY), national ID, position, department, optional bank account.
  • PayrollPeriod — start/end + pay date. Statuses: DRAFT | CALCULATED | APPROVED | PAID | POSTED.
  • PayrollRun — one per period; aggregate totals (gross, net, employer cost), status, optional ledgerEntryId once posted.
  • PayrollItem — one per employee per run; stores gross, net, deductions JSONB, employer_contributions JSONB, optional hours/bonuses.
  • PayrollConcept — a code (e.g. wage_expense, css_employee, isr_payable, …) → GL account mapping used at posting time.

Panama-specific calculations live in app/graphql/payroll/strategies/panama_payroll.py: CSS 9.75% employee / 12.25% employer, Seguro Educativo 1.25% / 1.5%, Riesgo Profesional 0.98% (employer), and a 3-bracket annual ISR table (0% / 15% / 25%).

Setup prerequisites

Critical: before postPayrollRun works, the tenant must have PayrollConcept rows seeded for at least these codes (each mapping to an existing GL account):

Code Kind Typical account
wage_expense EARNING Wages & salaries expense
css_employee DEDUCTION (offsetting reference; not used for posting if you collapse to payable)
css_employee_payable DEDUCTION CSS payable
css_employer EMPLOYER_CONTRIBUTION CSS employer expense
css_employer_payable EMPLOYER_CONTRIBUTION CSS payable
seguro_educativo_employee_payable DEDUCTION Seguro Educativo payable
seguro_educativo_employer EMPLOYER_CONTRIBUTION SE employer expense
seguro_educativo_employer_payable EMPLOYER_CONTRIBUTION SE payable
isr_payable DEDUCTION ISR retention payable
riesgo_profesional_employer EMPLOYER_CONTRIBUTION Riesgo Profesional expense
riesgo_profesional_employer_payable EMPLOYER_CONTRIBUTION RP payable

The posting code raises a ValueError with the missing code when something is unconfigured — surface as an actionable error and link to the setup screen.

There is no createPayrollConcept mutation yet — seed via DB or add the mutation as a follow-up.

GraphQL surface

Queries

employees: [Employee!]!
employee(employeeId: UUID!): Employee!
payrollPeriods: [PayrollPeriod!]!
payrollRun(runId: UUID!): PayrollRun!
payrollRunsForPeriod(periodId: UUID!): [PayrollRun!]!

Employee: id, employeeNumber, firstName, lastName, nationalId, hireDate, terminationDate, position, department, baseSalary, payFrequency, bankAccountId, status, isActive.

PayrollPeriod: id, startDate, endDate, payDate, status.

PayrollRun: id, payrollPeriodId, totalGross, totalNet, totalEmployerCost, ledgerEntryId, status, plus nested items: [PayrollItem!]!.

PayrollItem: id, runId, employeeId, gross, net, workedHours, overtimeHours, bonuses, deductions (JSON), employerContributions (JSON).

Mutations

createEmployee(employee: EmployeeInput!): Employee!
createPayrollPeriod(payrollPeriod: PayrollPeriodInput!): PayrollPeriod!
calculatePayroll(periodId: UUID!): PayrollRun!
approvePayrollRun(runId: UUID!): PayrollRun!
postPayrollRun(runId: UUID!, cashAccountId: UUID!): PayrollRun!

EmployeeInput: firstName, lastName, hireDate, baseSalary, plus optional payFrequency (default BIWEEKLY), nationalId, position, department, bankAccountId.

PayrollPeriodInput: startDate, endDate, payDate.

Behaviour to handle in the UI

  • Employee directory — list, add, edit (no update mutation yet — see follow-up), terminate. Show employeeNumber prominently.
  • Payroll period workspace — one screen per period with three tabs: Setup, Calculate, Post.
  • Setup: period dates, pay date, list of active employees that will be included.
  • Calculate: runs calculatePayroll(periodId), shows the resulting PayrollRun with one row per employee, expandable to show the JSON deductions/contributions per item.
  • Post: approvePayrollRunpostPayrollRun(cashAccountId). Confirm the cash account.
  • Deductions/contributions JSON rendering: parse the JSON and display as a sub-table with code, label (from PayrollConcept.name), amount.
  • No payslip PDF yet — see follow-up. For now offer "Print" using the calculated table.

Error states

Behaviour When Frontend message
ValueError ("PayrollConcept '' is not configured") postPayrollRun "Configure '' in Payroll Settings before posting." Link to the setup page.
ValueError ("must be APPROVED") postPayrollRun on a non-approved run "Approve the run first."
PeriodClosedError (from underlying ledger service) postPayrollRun against a closed accounting period "The accounting period covering MM/YYYY is closed."

Suggested UX flow

  1. Employees page — directory + add/edit/terminate.
  2. Payroll SettingsPayrollConcept mapping table (code → account). Block payroll posting until all required codes exist.
  3. Payroll cycle — period list → workspace with the three tabs.
  4. History — list of all runs across periods with totals and posted JE links.

Payslip PDF + SIPE export

payslipPdfUrl(itemId: UUID!): String
sipeExportUrl(runId: UUID!): String

payslipPdfUrl returns a presigned URL to a per-employee PDF showing the period dates, gross/net, hours, bonuses, and an itemized breakdown of deductions and employer contributions parsed from the PayrollItem.deductions / employer_contributions JSONB blobs.

sipeExportUrl returns a presigned URL to a CSV file matching the Panama CSS/SIPE column set:

cedula, nombre_completo, salario_bruto, css_empleado,
seguro_educativo_empleado, isr, css_empleador,
seguro_educativo_empleador, riesgo_profesional

One row per PayrollItem in the run. Surface as a "Download for SIPE" button on the run detail page.

Open follow-ups (not yet implemented)

  • ISR brackets are annualized inside the calculator — biweekly/monthly periods are annualized; year-end true-up isn't implemented.
  • No bonuses / overtime input pathbonuses and overtimeHours fields exist on PayrollItem but calculatePayroll doesn't ingest them; add a "Per-employee adjustments" UI + mutation before relying on them.
  • No decimo tercer mes accrual — Panama 13th-month bonus isn't computed.
  • Tax-rate change: when Panama rates change, add a new strategy class with a valid_from rather than editing in place so historical runs stay reproducible.