Field validation
Business rules
The rules that span more than one field or more than one record. Enforce them alongside the field rules.
View as MarkdownField pages cover what a single value must look like. This page covers everything else. Every rule here is mandatory.
Who to send#
PayCaptain processes UK PAYE payroll. Send a person only if they are paid through your PAYE payroll.
| Send | Do not send |
|---|---|
| Full-time PAYE employees | Contractors and freelancers |
| Part-time PAYE employees | Self-employed individuals |
| PAYE casual workers | Agency workers paid by the agency |
Identity and payroll codes#
hrEmployeeId identifies the person. payrollCode identifies one employment. They are different things.
Person hrEmployeeId "P001" never changes
Employment 1 payrollCode "EMP-101" 2022-03-01 to 2023-06-30 ended
Employment 2 payrollCode "EMP-205" 2024-01-15 to (none) active
- One
hrEmployeeIdper person, the same across all of their employments. - Every employment has its own
payrollCode. No two records share one. - A
payrollCodenever changes once assigned and is never reused. - A person who leaves and returns gets a new
payrollCode. - Employment periods for the same
hrEmployeeIdmust never overlap.
Dates#
All dates are ISO 8601 calendar dates, YYYY-MM-DD.
| Rule | Check |
|---|---|
| Leave date is not before start date | leaveDate >= startDate, when leaveDate is set |
| Pay effective date is inside the employment | startDate <= payEffectiveDate <= leaveDate |
| New employees start pay on day one | payEffectiveDate === startDate on first creation |
| Employments do not overlap | No two records with the same hrEmployeeId have overlapping date ranges |
function validateEmployeeDates({ startDate, leaveDate, payEffectiveDate, isNew }) {
// ISO dates compare correctly as strings
if (leaveDate && leaveDate < startDate) throw new Error("leaveDate is before startDate");
if (payEffectiveDate < startDate) throw new Error("payEffectiveDate is before startDate");
if (leaveDate && payEffectiveDate > leaveDate) throw new Error("payEffectiveDate is after leaveDate");
if (isNew && payEffectiveDate !== startDate) throw new Error("new employee: payEffectiveDate must equal startDate");
}
Pay changes#
payBasis decides which pay field is required: salary (annual) for "Salaried", rateOfPay (hourly) for "Hourly".
Must Whenever
payBasis,salaryorrateOfPaychanges, setpayEffectiveDateto the date of that change.
Approval#
Must Only approved shifts may be sent. Your application needs an approval step, and a shift must not reach
POST /shiftuntil an authorised person has approved it.
Archiving#
Records are never deleted. To remove a shift or payment from payroll, resubmit it with the same shiftId or paymentId and archived: true.
Change detection#
Must Only send a record when at least one field value has changed since the last successful submission.
Keep the last successfully submitted version of each record and compare before every call.
function hasChanged(current, lastSent) {
if (!lastSent) return true; // never sent
return Object.keys(current).some((field) => current[field] !== lastSent[field]);
}
if (hasChanged(employee, lastSentEmployee)) {
await submitToPayCaptain(employee);
saveLastSent(employee);
}
If a check fails#
Do not call the API. Log the failure, tell the user what to correct, and submit only once every field rule and every rule on this page passes.