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.

Universal contract. A single canonical Employee / Pay Run / Leave shape, validated at the adapter seam (contract/canon.js) before anything reaches a caller. A violating adapter output is a 500 contract_violation, never a silent inconsistency.
Per-engine adapters. 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.

SDK web component ──(canonical JSON, embed token)──▶ Ledra Pay API /v1/tenants/{t}/… │ │ adapterFor(binding) → enforceContract(canon) │ ▼ │ Governance provider (smartta.js) │ PAT→JWT · DH handshake · read coalesce+cache │ ▼ │ Smartta cell /v1/external/connectors/{cin}/{operations|resources|compositions} │ executes pack ops · writes cev_/inb_ evidence │ ▼ │ Connector pack (operations + JSONata mappings) │ ▼ ▼ Engine (myaccountant REST · Payroll Engine cases) canonical response ◀──────────────────────────────────────────┘
LayerSpeaksResponsibility
SDK componentcanonical JSONUI + 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 cellpack op id + paramsruns the op, applies the mapping, writes cev_/inb_ evidence, DH-encrypts read payloads
Connector packvendor HTTPoperations, 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 idemp_<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

FieldTypeNotes
idemp_<16hex>deterministic hash of the vendor id; reversed via a per-binding index (warmed by list reads) else a scan
first_name · last_namestringwrite requires one; reads are lenient (an incomplete vendor record still surfaces)
emailstring|null
employmentobject{ start_date, annual_salary, hours_per_week, pay_frequency, type, status }
tax_profileobject{ treatment_code, income_type, residency, tfn_attested }raw TFN is rejected by the contract
retirementobject{ sg_rate } (AU super) / pension equivalents
bank_attestedbooleanbank details are write-only/attested, never read back raw
statusactive | terminatedcanonical lifecycle state (see lifecycle)

Pay run · totals · entry

ShapeFields
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

ShapeFields / 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 }
categoryannual · 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.

ResourceMethod · pathAdapter method
EmployeesGET /v1/tenants/{t}/employeeslistEmployees
GET …/employees/{id}getEmployee
POST …/employeescreateEmployee
PATCH …/employees/{id}updateEmployee
POST …/employees/{id}/end-employment · /reactivateendEmployment · reactivateEmployee
DELETE …/employees/{id}terminateEmployee (hard)
Pay runsGET …/pay-runs · /{id} · /{id}/entries · /payslips · /stplistPayRuns · getPayRun · getPayRunEntries · listPayslips · getSTPSubmissions
POST …/pay-runscreatePayRun
POST …/{id}/preview · /approve · /finalisepreviewPayRun · approvePayRun · finalisePayRun
POST …/{id}/entriesaddEntry
DELETE …/pay-runs/{id}voidPayRun / deletePayRun
LeaveGET …/reference/leave-types · /leave-balances · /leavegetLeaveTypes · getLeaveBalances · getLeaveTaken
POST …/leave/book · /{id}/cancel · /{id}/amend · /configurebookLeave · cancelLeave · amendLeave
GETPOST …/leave/requests · /{id}/approve · /reject · /cancellistLeaveRequests · apply/approve/reject/cancelLeaveRequest
Requests are a Ledra Pay-owned control-plane lifecycle; approval books to the engine.
ReferenceGET …/company · /pay-item-types · /reports/summarygetCompany · getPayItemTypes · getReportSummary
GET …/packsgetCapabilities
GovernanceGET …/governance/audit-trail · /evidence/{id}getAuditTrail · getEvidence
POST …/governance/verifyverifyChain
Calc / tenantPOST /v1/calculate · …/embed/token · GET /v1/partners/tenantscalculate · 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

Capabilitymyaccountant (AU)Payroll Engine (DE)PE-AU
stateless_calculatenonativeinherits PE
forecast_runsnonative
effective_datingno (current-state)nativenative
statutory_lodgementnative (STP P2)no
payment_file · payslip_pdf · retirement_batch · eoynativeno
leave_management · _book · _review · _amendnativenativenative
leave_cancelnativeno (effective-dated)no
employee_terminate (hard delete)nativenono
employee_offboard (end-date, reversible)nativeno (wireable)no
employee_reactivatenativeno (wireable)no
payslip_computenativenativeno (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.

ActionmyaccountantPayroll EngineReversible?
Hard terminate (delete)DELETE /employees/{id} → isDeleted; 404 on read & writen/a — no destructive deleteno
End employment (offboard)PUT employment-details · set endDate (+ endDateCode); stays readableeffective-dated end-of-employment case (wireable)yes
Reactivateclear endDate (Nullable<DateTime> null)superseding effective-dated case changeyes
Verified. On myaccountant, 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)

FieldFieldField
id · firstName · lastNameemail · mobile · dateOfBirth · genderstreetAddress · city · stateCode · zipCode · countryCode
startDateendDate · endDateCodeemployementStatusTypeId (FT/PT/Casual)
jobTitle · departmentTypehoursPerWeek · annualSalary · perDayOrPerHrRatepaymentModeType · payFrequencyType
incomeTypeCode (SAW…)residencyType · employeeType · taxTreatmentCodesuperGuaranteeRate · annual/personalLeaveEntitlement
isDeletedisPortalAccessEnabled · portalInvitedAt— bank/TFN are write-only (separate PII endpoints)

Key operations

OpHTTPPurpose
read.employees · read.employeeGET /api/payroll/employees[/{id}]list / single (active-only; deleted 404)
create.employeePOST /api/payroll/employeescreate shell; per-tab PUTs fill the rest
update.employee_{profile,tax,super,bank,employment,leave}PUT …/{id}/{tab}-detailsgranular per-tab updates (each a full-replace command)
delete.employeeDELETE …/{id}soft-delete (isDeleted)
read.pay_item_types · read.report_*GET /api/payroll/…leave types (Leave group), activity/leave-balance reports
enable/disable.employee_portalPOST …/{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 / emailfirstName / lastName / email
employment.start_date / annual_salary / hours_per_weekstartDate / annualSalary / hoursPerWeek
employment.end_date (+ metadata.end_date_code)endDate (+ endDateCode)
tax_profile.* (attested)tax-details PUT (TFN write-only)
Mapping gotchas (all verified the hard way):
  • Hollow records. create_full_employee creates a shell then PUTs each tab. The create-step body must map firstName ← 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 (a Nullable<DateTime>) needs an explicit metadata.clear_end_date path that merges endDate:null back after the strip.
  • Full-replace validation. the employment-details PUT re-validates the whole command: requires employementStatusTypeId + incomeTypeCode (default SAW) and — if paymentModeType=Bank — bank details. Don't force Bank; 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)caseFieldNamesCanonical
DE.ArbeitnehmerSvNummer · SteuerIdNummer · GeburtsDatumidentity / national_id / tax_id / dob
DE.BeschaeftigungEintrittsDatum · BeschaeftigungsArt · WochenArbeitsstundenemployment.start_date / type / hours_per_week
DE.GehaltGehaltsArt · Monatsgehaltsalary.monthly
DE.SteuerdatenSteuerKlasse · BundeslandWohnort · Konfession · KinderFreibetragAnzahltax_profile.tax_class / region / religion / child_allowances
DE.SvDatenIsKvPflichtig · IsPvPflichtig · IsRvPflichtig · IsAvPflichtig · KasseTyp · KinderAnzahlPvsocial.{kv,pv,rv,av} / kasse_typ / children
DE.BankverbindungIbanbank.iban
DE.EFZGDatenEfzgKrankheitsTage (Period)leave/absence (sick pay) — pay-affecting

Key operations

OpHTTPPurpose
read.employees · read.employeeGET /tenants/{t}/employees[/{id}]raw employee records
read.employee_casesGET …/employees/{id}/cases/changes/valuesall case-change values (salary etc.) — per-employee (no bulk filter)
read.cases_valuesGET …/cases/values?evaluationDate={as_of}effective-dated read — values in force at a date
read.regulation_casesGET …/regulations/{r}/cases?caseType=Employeethe case catalogue (absence-relevant for leave)
create.employee · write.case.bulkPOST …/employees · casescreate + one atomic CaseChangeSetup (parent + relatedCases)
start.preview · start.payrunPOST …/payruns/jobs[/preview]non-persistent preview / committed run (Release→Process→Complete)

Mapping & learnings

PE learnings:
  • Cases, not fields. a write is a CaseChangeSetup — top-level userId/employeeId/divisionId/created/reason + a parent case with relatedCases[]; created must be strictly before evaluationDate; 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_available needs 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 the AU.AU payrun 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 PATJWT (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 shapeEvidence ref
plain operation read (read.employees)cev_… (one per execution)
write / composition stepcev_out_… (the booking/update step)
resource / read-compositionno 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)
Rate limit. 60 requests/min per PAT. The SDK mounts many components that each read through one PAT, so the provider coalesces concurrent identical reads into one upstream call and caches reads ~8s (busted on writes); transient 429/503 retry with backoff.

Data-flow learnings (consolidated)

The non-obvious things gathered building this — the back-and-forth, distilled.

  1. Two read shapes. plain operation reads carry one cev_; resource/read-composition reads spread evidence across inbound_docs with no single id — surface the execution_id so reads stay traceable.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. Errors must be legible. the API returns { error: { code, message } } — surface the message string, never [object Object].
  8. Host orchestrates, components emit. no component reaches into another; ledrapay-employee-terminated/-reactivated, ledrapay-leave-approved etc. 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 →