Universal Payroll Schema & Data-Flow Spec
The Smartta canonical resource is the universal payroll schema and API surface; each engine — myaccountant (AU, REST current-state) and the Payroll Engine (DE/AU-on-PE, effective-dated cases) — is an adapter that maps its native schema onto it. This doc is the consolidated reference: the canonical shapes, the endpoints, each engine's real schema, the field mappings, and the data-flow learnings gathered building it.
Overview
One integration, any jurisdiction. The SDK and partner API speak one canonical shape; engines differ structurally underneath and the adapter absorbs the difference. The boundary is API-only and governed — every read and write produces tamper-evident evidence.
contract/canon.js) before anything reaches a caller. A violating adapter output is a 500 contract_violation, never a silent inconsistency.myaccountant.js, payroll-engine.js, payroll-engine-au.js, mock.js — each implements the same method surface against its connector pack. Differences surface as capabilities, not divergent shapes.Architecture & data flow
A request crosses five layers. The SDK never touches an engine or a data store directly — it speaks the partner API, which speaks the governed connector rail.
| Layer | Speaks | Responsibility |
|---|---|---|
| SDK component | canonical JSON | UI + intent events; reads getCapabilities() to pre-gate actions |
| Ledra Pay API | /v1/tenants/{t}/… | tenant resolution, adapter dispatch, canonical contract enforcement, governance reason |
| Governance provider | /v1/external/connectors/{cin}/… | PAT→JWT, embed tokens, x25519 DH for PII reads, evidence-ref extraction, read coalescing + cache |
| Smartta cell | pack op id + params | runs the op, applies the mapping, writes cev_/inb_ evidence, DH-encrypts read payloads |
| Connector pack | vendor HTTP | operations, resource/write compositions, JSONata canonical↔vendor mappings |
Canonical schema universal
The Smartta resource shapes every engine maps onto, enforced by contract/canon.js. Identity is a deterministic public id — emp_<16 hex>, a one-way hash of the vendor id (safe to store/share; the vendor id never leaves the rail). Raw PII (TFN, bank) is never in a canonical shape — it is attested.
Employee
| Field | Type | Notes |
|---|---|---|
| id | emp_<16hex> | deterministic hash of the vendor id; reversed via a per-binding index (warmed by list reads) else a scan |
| first_name · last_name | string | write requires one; reads are lenient (an incomplete vendor record still surfaces) |
| string|null | ||
| employment | object | { start_date, annual_salary, hours_per_week, pay_frequency, type, status } |
| tax_profile | object | { treatment_code, income_type, residency, tfn_attested } — raw TFN is rejected by the contract |
| retirement | object | { sg_rate } (AU super) / pension equivalents |
| bank_attested | boolean | bank details are write-only/attested, never read back raw |
| status | active | terminated | canonical lifecycle state (see lifecycle) |
Pay run · totals · entry
| Shape | Fields |
|---|---|
| totals | { employee_count:int, gross, tax, net, super?, currency:ISO-4217, provenance } |
| entry | { id, employee_id:emp_…, employee_name, gross, tax, net, … } |
| calc | { pack, gross:{amount,currency}, net:{amount,currency}, provenance } |
provenance is mandatory on every computed number — each figure names the engine/pack that produced it. Currency is the jurisdiction's (ISO-4217), independent of UI language.Leave
| Shape | Fields / values |
|---|---|
| leave_type | { code, name, category, paid, accrues_super?, unit:hours|days, source:engine|statutory } |
| leave_balance | { employee_id, employee_name, leave_type, accrued, taken, balance, unit } |
| leave_booking | { id, employee_id, employee_name, leave_type:category, unit, quantity:2dp, start_date, end_date, status, evidence_id } |
| leave_record | { id, employee_id, employee_name, leave_type, unit, quantity, start/end_date, status:booked|cancelled } |
| category | annual · personal · sick · compassionate · long_service · family_domestic_violence · community · parental · maternity · short_time · special · unpaid · cash_out · other |
Universal API endpoints
The partner surface, identical in every jurisdiction. Tenant in the path; the JWT/embed token carries scope. Reads are advisory + cached; writes are EXECUTION and require a governance_reason.
| Resource | Method · path | Adapter method |
|---|---|---|
| Employees | GET /v1/tenants/{t}/employees | listEmployees |
| GET …/employees/{id} | getEmployee | |
| POST …/employees | createEmployee | |
| PATCH …/employees/{id} | updateEmployee | |
| POST …/employees/{id}/end-employment · /reactivate | endEmployment · reactivateEmployee | |
| DELETE …/employees/{id} | terminateEmployee (hard) | |
| Pay runs | GET …/pay-runs · /{id} · /{id}/entries · /payslips · /stp | listPayRuns · getPayRun · getPayRunEntries · listPayslips · getSTPSubmissions |
| POST …/pay-runs | createPayRun | |
| POST …/{id}/preview · /approve · /finalise | previewPayRun · approvePayRun · finalisePayRun | |
| POST …/{id}/entries | addEntry | |
| DELETE …/pay-runs/{id} | voidPayRun / deletePayRun | |
| Leave | GET …/reference/leave-types · /leave-balances · /leave | getLeaveTypes · getLeaveBalances · getLeaveTaken |
| POST …/leave/book · /{id}/cancel · /{id}/amend · /configure | bookLeave · cancelLeave · amendLeave | |
| GETPOST …/leave/requests · /{id}/approve · /reject · /cancel | listLeaveRequests · apply/approve/reject/cancelLeaveRequest | |
| Requests are a Ledra Pay-owned control-plane lifecycle; approval books to the engine. | ||
| Reference | GET …/company · /pay-item-types · /reports/summary | getCompany · getPayItemTypes · getReportSummary |
| GET …/packs | getCapabilities | |
| Governance | GET …/governance/audit-trail · /evidence/{id} | getAuditTrail · getEvidence |
| POST …/governance/verify | verifyChain | |
| Calc / tenant | POST /v1/calculate · …/embed/token · GET /v1/partners/tenants | calculate · mint embed token · list tenants |
Capability matrix
What each engine supports. The SDK reads this (GET …/packs) to gate UI; an unsupported call returns a 501 CAPABILITY_NOT_SUPPORTED with an _alternatives hint. native unsupported
| Capability | myaccountant (AU) | Payroll Engine (DE) | PE-AU |
|---|---|---|---|
| stateless_calculate | no | native | inherits PE |
| forecast_runs | no | native | — |
| effective_dating | no (current-state) | native | native |
| statutory_lodgement | native (STP P2) | no | — |
| payment_file · payslip_pdf · retirement_batch · eoy | native | no | — |
| leave_management · _book · _review · _amend | native | native | native |
| leave_cancel | native | no (effective-dated) | no |
| employee_terminate (hard delete) | native | no | no |
| employee_offboard (end-date, reversible) | native | no (wireable) | no |
| employee_reactivate | native | no (wireable) | no |
| payslip_compute | native | native | no (AU.AU clusterSet) |
Employee lifecycle
The single most divergent area between engines — and a recurring source of confusion. Three distinct operations, modelled very differently underneath.
| Action | myaccountant | Payroll Engine | Reversible? |
|---|---|---|---|
| Hard terminate (delete) | DELETE /employees/{id} → isDeleted; 404 on read & write | n/a — no destructive delete | no |
| End employment (offboard) | PUT employment-details · set endDate (+ endDateCode); stays readable | effective-dated end-of-employment case (wireable) | yes |
| Reactivate | clear endDate (Nullable<DateTime> null) | superseding effective-dated case change | yes |
endDate and isDeleted are separate fields. DELETE sets isDeleted (the record 404s on read and write — irreversible). endDate ends employment while keeping the record readable, and clears to null to reactivate. The canon maps endDate ? 'terminated' : 'active'. So the list offers the reversible actions; the detail keeps the hard delete for true removal.Engine: myaccountant AU
A REST, current-state payroll API (no effective-dating). Granular per-tab employee endpoints; soft-delete; STP Phase 2 lodgement. Connector pack: myaccountant_partner.
Native employee schema (read.employee)
| Field | Field | Field |
|---|---|---|
| id · firstName · lastName | email · mobile · dateOfBirth · gender | streetAddress · city · stateCode · zipCode · countryCode |
| startDate | endDate · endDateCode | employementStatusTypeId (FT/PT/Casual) |
| jobTitle · departmentType | hoursPerWeek · annualSalary · perDayOrPerHrRate | paymentModeType · payFrequencyType |
| incomeTypeCode (SAW…) | residencyType · employeeType · taxTreatmentCode | superGuaranteeRate · annual/personalLeaveEntitlement |
| isDeleted | isPortalAccessEnabled · portalInvitedAt | — bank/TFN are write-only (separate PII endpoints) |
Key operations
| Op | HTTP | Purpose |
|---|---|---|
| read.employees · read.employee | GET /api/payroll/employees[/{id}] | list / single (active-only; deleted 404) |
| create.employee | POST /api/payroll/employees | create shell; per-tab PUTs fill the rest |
| update.employee_{profile,tax,super,bank,employment,leave} | PUT …/{id}/{tab}-details | granular per-tab updates (each a full-replace command) |
| delete.employee | DELETE …/{id} | soft-delete (isDeleted) |
| read.pay_item_types · read.report_* | GET /api/payroll/… | leave types (Leave group), activity/leave-balance reports |
| enable/disable.employee_portal | POST …/{id}/portal/{enable,disable} | self-service login toggle (distinct from termination) |
Canonical ↔ vendor mapping & learnings
Write compositions consume the flat canonical hr:employee shape; JSONata reverse-maps it to the vendor command.
| Canonical | → myaccountant |
|---|---|
| first_name / last_name / email | firstName / lastName / email |
| employment.start_date / annual_salary / hours_per_week | startDate / annualSalary / hoursPerWeek |
| employment.end_date (+ metadata.end_date_code) | endDate (+ endDateCode) |
| tax_profile.* (attested) | tax-details PUT (TFN write-only) |
- Hollow records.
create_full_employeecreates a shell then PUTs each tab. The create-step body must mapfirstName ← first_name— a wrapped{profile:{firstName}}shape the mapping doesn't read produced null-name employees. - $sift strips null. The employment mapping drops null/undefined keys (to dodge "field must be set" rejections). Clearing
endDate(aNullable<DateTime>) needs an explicitmetadata.clear_end_datepath that mergesendDate:nullback after the strip. - Full-replace validation. the employment-details PUT re-validates the whole command: requires
employementStatusTypeId+incomeTypeCode(defaultSAW) and — ifpaymentModeType=Bank— bank details. Don't forceBank; an already-Bank record without bank details can't be updated (data-completeness constraint → hard delete only). - Read lag. a freshly-created employee isn't immediately in
read.employees(vendor eventual consistency). - Income type non-uniqueness for leave (typeCode "O" shared by several) → key bookings on the master pay-item-type
id, not the code.
Engine: Payroll Engine DE AU-on-PE
An effective-dated, case-based regulation engine (payrollengine.ch via the Verso gateway). Employees are bundles of GlobalCase values; every value carries start/end/created — so history is native and nothing is destructively deleted. Packs: payroll_engine (DE), payroll_engine_au (AU.Payroll).
Case model (DE regulation)
| Case (caseName) | caseFieldNames | Canonical |
|---|---|---|
| DE.Arbeitnehmer | SvNummer · SteuerIdNummer · GeburtsDatum | identity / national_id / tax_id / dob |
| DE.Beschaeftigung | EintrittsDatum · BeschaeftigungsArt · WochenArbeitsstunden | employment.start_date / type / hours_per_week |
| DE.Gehalt | GehaltsArt · Monatsgehalt | salary.monthly |
| DE.Steuerdaten | SteuerKlasse · BundeslandWohnort · Konfession · KinderFreibetragAnzahl | tax_profile.tax_class / region / religion / child_allowances |
| DE.SvDaten | IsKvPflichtig · IsPvPflichtig · IsRvPflichtig · IsAvPflichtig · KasseTyp · KinderAnzahlPv | social.{kv,pv,rv,av} / kasse_typ / children |
| DE.Bankverbindung | Iban | bank.iban |
| DE.EFZGDaten | EfzgKrankheitsTage (Period) | leave/absence (sick pay) — pay-affecting |
Key operations
| Op | HTTP | Purpose |
|---|---|---|
| read.employees · read.employee | GET /tenants/{t}/employees[/{id}] | raw employee records |
| read.employee_cases | GET …/employees/{id}/cases/changes/values | all case-change values (salary etc.) — per-employee (no bulk filter) |
| read.cases_values | GET …/cases/values?evaluationDate={as_of} | effective-dated read — values in force at a date |
| read.regulation_cases | GET …/regulations/{r}/cases?caseType=Employee | the case catalogue (absence-relevant for leave) |
| create.employee · write.case.bulk | POST …/employees · cases | create + one atomic CaseChangeSetup (parent + relatedCases) |
| start.preview · start.payrun | POST …/payruns/jobs[/preview] | non-persistent preview / committed run (Release→Process→Complete) |
Mapping & learnings
- Cases, not fields. a write is a
CaseChangeSetup— top-leveluserId/employeeId/divisionId/created/reason+ a parent case withrelatedCases[];createdmust be strictly beforeevaluationDate; one bad field fails all domains (atomic). - Effective-dating is the model. end-of-employment, corrections, future-dated changes are all just dated case values — inherently reversible by a superseding change. There is no delete.
- Leave ≠ Urlaub. PE models pay-affecting absences as cases (DE.EFZGDaten sick pay, Kurzarbeit). Annual leave (Urlaub) is labour-law, tracked in HR — surfaced as a statutory catalogue, not an engine concept.
- Discovery quirks.
read.cases_availableneeds a bare{clusterSetName}token (not{params.…}); RegulationShares block consumer tenants from reading the regulation, so the catalogue is discovered against the provider tenant. AU-on-PE payslip compute is blocked on theAU.AUpayrun clusterSet.
Evidence & governance
Every governed call is tamper-evident. The rail authenticates partner→tenant, encrypts PII reads, and produces a referenceable evidence id.
Auth chain
Partner PAT → JWT (10-min, minted on demand) → per-component embed token (15-min, tenant-pinned, component-scoped). PII/raw reads add a per-connection x25519 DH handshake (single-flight registration); the cell DH-encrypts inbound payloads, decrypted client-side.
Evidence ids
| Call shape | Evidence ref |
|---|---|
| plain operation read (read.employees) | cev_… (one per execution) |
| write / composition step | cev_out_… (the booking/update step) |
| resource / read-composition | no top-level id → surface execution_id (exe_…); evidence is per-record across inbound_docs |
| no governed call (static catalogue, honest-empty stub) | null (correct — nothing happened) |
Data-flow learnings (consolidated)
The non-obvious things gathered building this — the back-and-forth, distilled.
- Two read shapes. plain operation reads carry one
cev_; resource/read-composition reads spread evidence acrossinbound_docswith no single id — surface theexecution_idso reads stay traceable. - Deterministic, one-way ids.
emp_<16hex>is a hash of the vendor id; getEmployee resolves it via a per-binding index (warmed by list reads) to avoid a 200-row scan per drill-in. - No raw PII in the canon. TFN/bank are attested (
tfn_attested,bank_attested); the contract rejects a raw TFN — they flow gateway→engine only. - Reads are lenient, writes are strict. one incomplete vendor record must not 500 a whole list, but you can't create a nameless employee. The name guard is write-only.
- Lifecycle is engine-shaped. myaccountant has hard-delete (isDeleted) and end-date (reversible); PE has only effective-dated cases. Capabilities, not shape, expose the difference.
- Language ≠ jurisdiction ≠ currency. UI text (i18n catalogue) and number/date format follow the chosen language; currency + statutory fields follow
country. A German UI on an AU tenant is still AUD. - Errors must be legible. the API returns
{ error: { code, message } }— surface the message string, never[object Object]. - Host orchestrates, components emit. no component reaches into another;
ledrapay-employee-terminated/-reactivated,ledrapay-leave-approvedetc. bubble + compose, and the host wires the reaction.
Source of truth: services/api/src/contract/canon.js · routes/* · adapters/* · connector-packs/myaccountant_partner · scripts/build-pe-pack.mjs. Live component/endpoint reference: the SDK playground →