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. Bank accounts are not a field on the employee; they attach the same way they do for clients/suppliers/company — a BankAccount row with sourceType: EMPLOYEES and sourceId: <employeeId> (see Employee bank accounts).
  • 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 CSS employee deduction (offsetting reference)
css_employee_payable DEDUCTION CSS payable
css_employer EMPLOYER_CONTRIBUTION CSS employer expense
css_employer_payable EMPLOYER_CONTRIBUTION CSS payable
seguro_educativo_employee DEDUCTION Seguro Educativo employee deduction (offsetting reference)
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 DEDUCTION ISR withholding (offsetting reference)
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.

Use createPayrollConcept (see Mutations below) to seed these rows from the UI.

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, status, isActive. (No bankAccountId — see Employee bank accounts.)

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!
updateEmployee(employee: EmployeeUpdateInput!): Employee!
terminateEmployee(employeeId: UUID!, terminationDate: Date!): Employee!
reactivateEmployee(employeeId: UUID!): 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.

EmployeeUpdateInput: employeeId, firstName, lastName, baseSalary, payFrequency, plus optional nationalId, position, department. (hireDate and employeeNumber are immutable; termination/reactivation are their own mutations.)

PayrollPeriodInput: startDate, endDate, payDate.

Employee bank accounts

Employees don't carry a bank-account field. An employee's bank accounts live on the BankAccount table via sourceType: EMPLOYEES + sourceId: <employeeId> — identical to how clients, suppliers, and the company hold theirs. The frontend manages them with the existing bank-account surface, scoped to the employee:

# list an employee's accounts
findBankAccountsBySourceId(sourceId: $employeeId, sourceType: EMPLOYEES): [BankAccount!]!

# create / update / delete (BankAccountInput.sourceType = EMPLOYEES, sourceId = employeeId)
createBankAccount(bankAccount: BankAccountInput!): BankAccount!
updateBankAccount(bankAccount: BankAccountInput!): BankAccount!
deleteBankAccount(bankAccountId: UUID!): Boolean!

Render them in their own tab/table on the employee detail page (mirrors the client/supplier bank-accounts tab). An employee can have zero, one, or many accounts.

Behaviour to handle in the UI

  • Employee directory — list, add (createEmployee), edit (updateEmployee), terminate (terminateEmployee) / reactivate (reactivateEmployee). Show employeeNumber prominently. Bank accounts get their own tab — see Employee bank accounts.
  • 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.