08 · HR — staff and payroll
Updated
Solution layer. Depends on Organization, Accounting, and Common. The model covers staff, time sheets, payroll fund expense, and social insurance contributions. Payroll fund expense participates in cost.
Purpose#
HR covers employees and positions, time sheets, payroll accrual and payment, and social insurance accrual and payment: five documents, three registers, and five dictionaries. Two registers split the payroll expense (Payroll) and the liability to employees (PayrollLiability). The third register is the cumulative social insurance contour (SocialInsurance). The model owns the Employee and ContributionType analytics.
Payroll accrual (AccruePayroll from an approved time sheet) is synchronous. In the same call it creates and posts SocialInsuranceAccrual using rates from HRSettings and from every active ContributionType card: one line for statutory social insurance plus one line per extra contribution. The sum of employee contributions reduces PayrollLiability to net. Both sides of the contribution and the payroll expense/liability are posted to GL by HR's own transaction scripts (PayrollAccrualGLTx, SocialInsuranceAccrualGLTx, …), so voiding a document takes its ledger leg back. The Ukraine localization keeps its taxes directly from PayrollLiability through a dedicated Withheld resource. See Business logic and invariants and Links with other modules.
What is created#
| Kind | Name | Purpose |
|---|---|---|
| Model | HR |
Module model on the Solution layer |
| NumberSequence | EmployeeSeq, PositionSeq, TimeOffSeq, TimeSheetSeq, HRSettingsSeq, PayrollAccrualSeq, PayrollPaymentSeq, SocialInsuranceAccrualSeq, SocialInsurancePaymentSeq, ContributionTypeSeq |
Numbering |
| EDT | RefEmployee, RefPosition, RefContributionType, AttendanceDayKindEdt |
References and day kind |
| Enum | AttendanceDayKind |
Work/Vacation/Sick/Absence |
| Dictionary | Position, Employee, HRSettings, TimeOff, ContributionType |
Positions, employees, social insurance settings (singleton), absence, payroll contributions beyond statutory social insurance |
| TablePart | TimeSheetDays, TimeSheetLines, PayrollAccrualLines, PayrollPaymentLines, SocialInsuranceAccrualLines, SocialInsurancePaymentLines |
Lines |
| Document | TimeSheet, PayrollAccrual, PayrollPayment, SocialInsuranceAccrual, SocialInsurancePayment |
Time sheet, payroll accrual, payroll payment, social insurance accrual, social insurance payment |
| Register | Payroll, PayrollLiability, SocialInsurance |
Payroll expense, employee liability, social insurance contributions |
| Analytic | Employee, ContributionType |
Sections |
| Service | IPayrollCalculationService, ITimeSheetService, ISocialInsuranceService, IPayrollVoidService |
Accrual formula, time sheet autofill, contribution calculation and accrual, cancellation with balance checks |
| Report | PayrollLiabilityByEmployee, SocialInsuranceByEmployee |
Turnover reports |
| Script | tx PayrollAccrualTx/PayrollPaymentTx/SocialInsuranceAccrualTx/SocialInsurancePaymentTx, 11 commands; see Commands |
Postings |
| Menu | menu.json |
Module menu with 4 subgroups |
| Reused | Division (Organization), Money (Accounting), Country (Common) |
Do not create them here |
Total: 1 model, 10 number sequences, 4 EDTs, 1 enum, 5 dictionaries, 6 table parts, 5 documents, 3 registers, 2 analytics, 4 services, 2 reports, scripts, and menu entries.
model.json#
Solution(2) layer, modelVersion 1.14.0. Dependencies: Accounting (minVersion 1.13.0), Common (minVersion 1.10.0), and Organization (minVersion 1.0.0).
Number sequences#
EmployeeSeq (start 1000), PositionSeq (1000), TimeOffSeq (1000), TimeSheetSeq (1), HRSettingsSeq (1, singleton), PayrollAccrualSeq (1000), PayrollPaymentSeq (1000), SocialInsuranceAccrualSeq (1000), SocialInsurancePaymentSeq (1000), ContributionTypeSeq (1000).
EDT#
Money comes from Accounting. Country and Division come from Common and Organization. RefEmployee, RefPosition, RefContributionType, and AttendanceDayKindEdt are local. RefContributionType is not required: an empty contribution type is a meaningful value, it means statutory social insurance. AttendanceDayKind values: Work = 0 by default, Vacation = 1, Sick = 2, Absence = 3. An empty day remains a working day, not an absence.
RefTimeSheet does not exist. A reference to the source time sheet is not stored as a field. It is stored as a document link through AddLinkAsync / pedigree; see PayrollAccrual.
Dictionaries#
Position#
Name String(128), required · HourlyRate Money, required · MonthlySalary Money, optional. The payment model is chosen on the position itself. MonthlySalary > 0 means a salary scheme, proportional to hours; see IPayrollCalculationService. 0 or empty means hourly pay through HourlyRate. There is no separate salary/hourly flag. Zero in MonthlySalary is the switch. The handler rejects negative HourlyRate or MonthlySalary.
Employee#
Name String(160), required · Division → Division, required · Position → Position, required · HireDate DateTime, required · IsActive Boolean, optional · Nationality → Country, optional. Nationality is not required. Empty means domestic; see Business logic and invariants. The handler only sets the default HireDate on create.
HRSettings#
Singleton dictionary (isSingleton:true, isCached:true). One record per installation. It is the key switch for the social insurance contour and time sheet parameters. All fields are optional, and the record may be absent: HomeCountry → Country (employer registration country; the standard domestic/foreign split), PayrollRunDay Integer, WorkHoursPerDay Decimal(18,4), WeeklyHours Decimal(18,4) (if set, it has priority over WorkHoursPerDay; see ITimeSheetService), SocialInsuranceEmployeeRate / SocialInsuranceEmployerRate / SocialInsuranceForeignEmployerRate Decimal(9,6), SocialInsuranceWageCeiling Money (contribution base ceiling).
Rates are plain decimals, not the Percent EDT and not code constants, because each country has its own rates and they change by law. 0 or empty ceiling means no ceiling. If this dictionary has no record, SocialInsuranceAccrual is not created at all, and the time sheet uses 8 hours per day. See Business logic and invariants.
HRSettings carries only statutory social insurance. Every further payroll contribution is a ContributionType card, because there can be many of them and the settings singleton would need a field pair per contribution.
ContributionType#
Payroll contributions beyond statutory social insurance: health insurance, union dues, voluntary funds. isCached:true, not hierarchical, not versioned, displayFormat {Name}, numbered by ContributionTypeSeq.
Name String(128), required · Code String(20), required, unique, indexed · EmployeeRate / EmployerRate / ForeignEmployeeRate / ForeignEmployerRate Decimal(9,6), optional · WageCeiling Money, optional · IsActive Boolean, required · ExpenseAccountCode / PayableAccountCode String(32), optional.
Four rates, not two: unlike HRSettings, which gives foreign staff only an employer rate, a contribution type can withhold from foreign staff too, at its own rate. 0 in a foreign rate means foreign staff are not charged for this contribution — it is not a fallback to the local rate.
WageCeiling is this type's own ceiling and has nothing to do with SocialInsuranceWageCeiling. 0 or empty means no ceiling, not a zero base.
IsActive is non-nullable so that an unset value reads as false; OnBeforeCreateAsync ticks it on a new card. Only active types are accrued. Deactivating is the supported way to stop a contribution: deleting the card would orphan the fund balance already posted against its analytic.
ExpenseAccountCode and PayableAccountCode are account codes, matching how GeneralLedgerSettings holds accounts in this workspace. Empty falls back to SocialInsuranceExpenseAccountCode / SocialInsurancePayableAccountCode of the accounting settings; see SocialInsuranceAccrualGLTx.
The statutory contribution is not a card here. On an accrual or payment line it is the row whose ContributionType is Guid.Empty.
OnBeforeSaveAsync rejects a negative rate, a negative WageCeiling, and a rate greater than 1 ("a rate is a fraction, not a percentage: 1.25% is 0.0125"). The last guard is the one that matters in practice: typing 1.25 instead of 0.0125 would otherwise withhold 125% of pay.
TimeOff#
Vacation, sick leave, and other absences are exceptions that autofill treats as deviations from the standard week. DateFrom/DateTo DateTime, required · Employee → Employee, required, indexed · Kind → AttendanceDayKindEdt, required · Name String(160), required and generated automatically as "{Kind} {DateFrom:yyyy-MM-dd}–{DateTo:yyyy-MM-dd}" · Note String(256), optional. The handler rejects Kind=Work ("choose a non-working kind"), requires DateFrom <= DateTo and filled Employee, and checks date intersections with existing absences for the same employee. It loads all absences through GetRecordsAsync("1 = 1") and filters with LINQ, not with a database predicate. A second absence for the same dates is rejected.
Documents#
TimeSheet#
A time sheet is a grid of attendance for the period. It posts nowhere and creates no register movements. Posting exists only to close lines for editing. Header: Division → Division, required · PeriodFrom/PeriodTo DateTime, defaulting to the current calendar month. There is no Employee header field; employees are only in lines. Two table parts:
Days— one day per employee:WorkDateDateTime, required ·Employee, required ·HoursDecimal(18,2), required ·DayKind→AttendanceDayKindEdt, optional ·IsAbsentBoolean, optional ·IsManualBoolean, optional.IsManualmarks manually adjusted lines and separates them from autofill.- Lines — total hours per employee for the period:
Employee, required ·HoursDecimal(18,2), required. It is recalculated automatically fromDayson each save whenDaysis not empty.
Subtypes: Draft (initial) → Approved. There is no Voided subtype for TimeSheet; this is the only module document without cancellation. Commands: FillTimeSheet (Draft, calls ITimeSheetService.FillStandardAsync), ApproveTimeSheet (Draft → Approved, guard: non-empty Lines), and AccruePayroll (on Approved; see PayrollAccrual). AccruePayroll creates and posts the accrual in one command.
PayrollAccrual#
Payroll fund accrual. Header: only Division, required. There are no period fields in the header. The accrual period is taken from the source time sheet through ancestry and is not stored again. Table part Lines: Employee, required · Amount Money, required. There is no reference to TimeSheet and no copy of hours. The amount is calculated once by AccruePayroll, and the connection to the source time sheet is a document link, not a field.
Subtypes: Draft (initial) → Posted (read-only) → Voided (read-only), explicitly declared. PayrollAccrualTx on Posted posts +Payroll by Division+Employee and +PayrollLiability by Employee for the full Amount.
Commands: AccruePayroll runs on TimeSheet.Approved. It reads the Lines table, calculates an amount for each line through IPayrollCalculationService.AmountOfAsync, creates a draft PayrollAccrual, and immediately posts it. The calculation lives in the command, not in the tx script, because position selection is asynchronous and tx scripts are synchronous. The command links the accrual with the source time sheet. It is idempotent: if the time sheet already has a linked accrual that is not Voided, it rejects the command. PostPayrollAccrual (Draft → Posted) is the manual alternative when the accrual is not created by AccruePayroll. VoidPayrollAccrual (Posted, requiresConfirmation + beginGroup) delegates to IPayrollVoidService.
OnAfterPostAsync on Posted re-reads the document because the event header has no lines. It sums Amount by employee across all lines at once to protect the contribution ceiling from line splitting. If this accrual does not yet have an outgoing pedigree link to SocialInsuranceAccrual, it calls ISocialInsuranceService.CreateAccrualAsync(division, gross, accrual.DocumentDate). When HRSettings is configured, that creates and immediately posts SocialInsuranceAccrual. The outgoing-link check is an idempotency guard: posting can restart the event chain, and without the guard the contribution would be assessed twice.
PayrollPayment#
Payroll payment. Header: only ID. Unlike accruals, payment has no header Division. One payment can close employees from different legal entities; see Links with other modules. Table part Lines: Employee, required · Amount Money, required.
Subtypes: Draft (initial) → Paid (read-only) → Voided (read-only). Here they are not declared declaratively; the command sets the subtype directly. PayrollPaymentTx on Paid posts −PayrollLiability by Employee for Amount.
Overpayment is rejected by PayrollPaymentEventHandler.OnBeforePostAsync, not by the engine. PayrollLiability.allowNegativeBalance:false cannot do this job: the register has no dimensions, Employee is an analytic, and the engine guards the balance over dimensions only. At engine level the whole register is therefore a single bucket, and as long as the company's total payroll debt is positive, paying one employee more than they are owed would pass silently. The handler re-reads the document (the event header carries no lines), sums Amount by employee so splitting one employee across several lines cannot dodge the check, reads PayrollLiability.Amount per employee through ITotalsManager.GetBalanceAsync, and cancels with "payment exceeds the accrued amount" when the payout is larger.
Commands: PayPayroll (Draft → Paid, guard: non-empty Lines; the earlier name PayEmployees is wrong) and VoidPayrollPayment (Paid, requiresConfirmation + beginGroup), which delegates to IPayrollVoidService.VoidPaymentAsync without a balance check. It is just cancellation.
SocialInsuranceAccrual#
Social insurance contributions. Header: Division, required. Table part Lines: Employee, required · ContributionType → ContributionType, optional · ContributoryBase Money, required · EmployeeContribution Money, required · EmployerContribution Money, required.
One document carries all contributions from one pay: the statutory line (ContributionType empty) and one line per active ContributionType. They are not split across documents, because the base is one, the date is one, and the fund payment is assembled by employee anyway. One employee therefore has as many lines as there are contributions.
Subtypes: Draft (initial) → Posted (read-only) → Voided (read-only). SocialInsuranceAccrualTx on Posted posts, per line, one set of movements: +SocialInsurance by Employee+Division+ContributionType with resources EmployeeContribution and EmployerContribution, and −PayrollLiability by Employee with Amount = -EmployeeContribution. The withheld employee share of every line reduces the debt from gross to net. The GL legs are a separate transaction script on the same subtype, SocialInsuranceAccrualGLTx; it groups legs by the resolved (expense account, payable account) pair rather than summing over the document, so a type with its own accounts gets its own entries and a type that leaves them empty merges with the statutory lines. See Links with other modules.
Usually this document is created and posted automatically by PayrollAccrual.OnAfterPostAsync. It is saved as Draft, its lines are filled, then it moves to Posted, where the subtype is read-only. You cannot write lines after that. There is also a manual path: PostSocialInsuranceAccrual (Draft → Posted, guard: non-empty Lines). It also recalculates amounts through ISocialInsuranceService.CalculateForTypeAsync(Employee, ContributoryBase, ContributionType) and rejects manually assembled rows when the saved calculation differs — the line is checked against its own type's rates, not against the statutory ones. VoidSocialInsuranceAccrual (Posted, requiresConfirmation + beginGroup) delegates to IPayrollVoidService.VoidSocialInsuranceAccrualAsync; a repeated call on Voided is not an error.
SocialInsurancePayment#
Payment of contributions to the fund is a separate document, not a subtype of accrual. Accrued contributions and employee withholding stay in place; only payment to the fund changes. This mirrors PayrollPayment for wages. Header: Division, required. Table part Lines: Employee, required · ContributionType → ContributionType, optional · EmployeeContribution Money, optional · EmployerContribution Money, optional. Both amount fields are optional at schema level; code checks amounts and non-negative values.
Subtypes: Draft (initial) → Paid (read-only) → Voided (read-only), explicitly declared. SocialInsurancePaymentTx on Paid posts minus both resources in SocialInsurance, in the line's own ContributionType slice: a health insurance payment does not close a statutory social insurance debt. It does not touch PayrollLiability, because withholding already happened at accrual. OnBeforePostAsync runs only when moving to Paid; it re-reads the document because the event header has no lines. It rejects empty lines, negative shares, and zero total. It intentionally allows overpayment: advance payments and extra payments after recalculation are legal, and a hard ceiling would make them impossible. This is the opposite of PayrollPayment, where overpaying an employee is blocked.
Commands: PaySocialInsurance (Draft → Paid, guard: non-empty Lines) and VoidSocialInsurancePayment (Paid, requiresConfirmation + beginGroup), which delegates to IPayrollVoidService.VoidSocialInsurancePaymentAsync. It is simple cancellation without a balance check.
Registers#
Payroll#
resources: Amount. analytics: Division + Employee. allowNegativeBalance:true.
PayrollLiability#
resources: required Amount and Withheld. analytics: Employee only. allowNegativeBalance:false. That flag does not stop an employee from being overpaid: the register has no dimensions, and the engine applies the flag over dimensions, so the whole register is one bucket. The real per-employee guard is PayrollPaymentEventHandler.OnBeforePostAsync; see PayrollPayment. Withheld stores the part of accrual withheld for a third party. It separates "debt reduced by withholding" from "debt repaid by payment". No HR tx script writes to Withheld; it is intended for other models. See Business logic and invariants and Links with other modules. PayrollLiabilityByEmployee reports Opening/Income/Outcome/Closing for both resources.
SocialInsurance#
resources: EmployeeContribution + EmployerContribution. analytics: Employee + Division + ContributionType. allowNegativeBalance:true. This is a purely cumulative contour: how much is due to the fund and how much has already been paid. Balance is the difference between accruals and SocialInsurancePayment within one contribution type: the empty ContributionType slice is statutory social insurance, and each card has its own. SocialInsuranceByEmployee reports the same column set for both resources.
Analytics#
Employee (RefEmployee) is the shared section reused by all three module registers. ContributionType (RefContributionType) splits the SocialInsurance register per fund; an empty value is statutory social insurance, not "unassigned".
Reports#
PayrollLiabilityByEmployee is a ReportView on PayrollLiability with 8 columns: Opening/Income/Outcome/Closing × Amount/Withheld. SocialInsuranceByEmployee is a ReportView on SocialInsurance with the same contour for EmployeeContribution/EmployerContribution.
Commands#
| Command | Subtype owner | Guard | Effect |
|---|---|---|---|
FillTimeSheet |
TimeSheet.Draft |
— | Calls ITimeSheetService.FillStandardAsync |
ApproveTimeSheet |
TimeSheet.Draft→Approved |
Empty Lines | Approves |
AccruePayroll |
TimeSheet.Approved |
Non-empty card, not already accrued | Calculates amounts and creates and posts PayrollAccrual |
PostPayrollAccrual |
PayrollAccrual.Draft→Posted |
Empty Lines | Manual alternative |
VoidPayrollAccrual |
PayrollAccrual.Posted→Voided |
Service check | IPayrollVoidService.VoidAccrualAsync |
PayPayroll |
PayrollPayment.Draft→Paid |
Empty Lines; payout per employee above the PayrollLiability balance (in OnBeforePostAsync) |
Pays |
VoidPayrollPayment |
PayrollPayment.Paid→Voided |
— | IPayrollVoidService.VoidPaymentAsync |
PostSocialInsuranceAccrual |
SocialInsuranceAccrual.Draft→Posted |
Non-empty Lines, sums verified | Posts |
VoidSocialInsuranceAccrual |
SocialInsuranceAccrual.Posted→Voided |
Idempotent | IPayrollVoidService.VoidSocialInsuranceAccrualAsync |
PaySocialInsurance |
SocialInsurancePayment.Draft→Paid |
Empty Lines | Pays |
VoidSocialInsurancePayment |
SocialInsurancePayment.Paid→Voided |
— | IPayrollVoidService.VoidSocialInsurancePaymentAsync |
All four Void* commands use requiresConfirmation + beginGroup.
Services#
IPayrollCalculationService#
AmountOfAsync(positionId, hours, periodFrom, periodTo) → decimal: returns 0 when the position is empty or missing, or when hours <= 0. If MonthlySalary > 0, the formula is Round(MonthlySalary * hours / StandardHoursAsync(from, to), 2, AwayFromZero). Otherwise it is hourly pay: Round(hours * HourlyRate, 2, AwayFromZero). StandardHoursAsync(periodFrom, periodTo) → decimal: number of weekdays in the period × ITimeSheetService.HoursPerDayAsync().
Examples from tests: hourly rate 50/hour × 10 hours = 500. On one time sheet, a senior employee (50/hour × 10 h) and a junior employee (25/hour × 8 h) produce Payroll for the period = 700. A monthly salary of 10,000 for a full standard month is exactly 10,000. Half of the standard hours is exactly half the salary: exact proportion, not floor rounding.
ITimeSheetService#
HoursPerDayAsync() → decimal: no HRSettings → 8; if WeeklyHours > 0, returns Round(WeeklyHours / 5, 2, AwayFromZero); otherwise uses WorkHoursPerDay if > 0; otherwise 8.
FillStandardAsync(sheetId) → int (number of added lines): if the sheet is missing, Division is empty, the period is missing, or from > to, returns 0. It removes and rebuilds only autogenerated Days lines (IsManual != true), so manually adjusted lines are kept. For each employee in the division and each weekday in the period, it skips the day if the employee was not hired yet (HireDate > day) or the day is already marked manually. Otherwise it checks intersecting TimeOff records. If one exists, it writes its Kind with Hours=0. If none exists, it writes Kind=Work with standard hours.
Example from a test: Mon-Fri 14-18.09.2026, 2 employees, 8 hours/day → filling creates 10 Days lines (5×2), and the Lines summary is 40 hours per employee. One employee has TimeOff that reduces the summary to 24 hours (3 working days × 8). A manual Friday absence (IsManual=true) survives refill and gives 32 hours (4×8) instead of 40.
ISocialInsuranceService#
CalculateAsync(employee, grossAmount) → (Employee, Employer): returns (0,0) when grossAmount <= 0 or HRSettings is not configured. Base is grossAmount, limited by SocialInsuranceWageCeiling if set. A domestic employee, meaning empty nationality or nationality equal to HomeCountry, pays both sides (SocialInsuranceEmployeeRate and SocialInsuranceEmployerRate). A foreign employee uses only the employer side, by SocialInsuranceForeignEmployerRate; Employee=0. Empty nationality is intentionally domestic; it is the safe default for withholding. ContributoryBaseAsync(grossAmount) returns the same base after the ceiling as a separate decimal for display.
CalculateForTypeAsync(employee, grossAmount, contributionType) → (Employee, Employer): the same for one specific contribution. Guid.Empty is statutory social insurance and takes the HRSettings rates and ceiling — CalculateAsync is exactly this overload with Guid.Empty. A filled type takes the rates and the ceiling from its card, using ForeignEmployeeRate/ForeignEmployerRate for foreign staff instead of falling back to the local pair. A type that is missing or inactive returns (0,0), not the statutory rates: silently accruing health insurance at the GOSI rate is worse than not accruing it. Rounding is AwayFromZero to the AmountScale global constant, default 2.
ContributoryBaseForTypeAsync(grossAmount, contributionType) → decimal: the base after that type's ceiling. A health insurance line must not display the social insurance base.
ActiveTypesAsync() → List<ContributionType>: active cards ordered by Code (ordinal). The order is fixed so accrual lines come out in a stable sequence rather than in dictionary insertion order.
CreateAccrualAsync(division, gross, documentDate) → Guid?: for an Employee→gross dictionary, it calculates each line, skips zero lines, creates a draft SocialInsuranceAccrual with the source date, saves lines, then moves it to Posted. It returns null if the contour is not configured or all lines are zero; no document is created. The source date matters. Otherwise a March contribution would not appear in the March declaration.
Per employee it writes the statutory line plus one line per active contribution type, skipping any whose both shares come out zero — a zero line would be a movement with no amount in its own analytics combination. All of them land in one document: the base is one, the date is one, and the fund payment is assembled by employee anyway.
Examples: rates 9.75%/11.75%/2% and ceiling 45,000 are demo stand data, not Saudi specifics. Domestic employee, gross 10,000 → Employee 975, Employer 1175, net employee balance 9025 (10,000 - 975). Foreign employee, gross 10,000 → Employee 0, Employer 200. Ceiling: two lines of 30,000 for one employee collapse into one gross of 60,000, ContributoryBase=45000, EmployeeContribution=4387.5. You cannot split accrual lines to bypass the ceiling: before the service call, PayrollAccrual sums gross by employee across all lines at once. If there is no HRSettings record, PayrollAccrual usually does not create SocialInsuranceAccrual at all, and PayrollLiability equals full gross.
Contribution type examples from tests, on the same settings. A health insurance type at 1.25%/2.5% on gross 10,000 produces a second line of 125/250 beside the statutory 975/1175, and the two sit in separate SocialInsurance slices. A health insurance ceiling of 20,000 on gross 30,000 gives ContributoryBase=20000 and 200/400 at 1%/2%, while the statutory line keeps the full 30,000 base under its own 45,000 ceiling. A foreign employee with ForeignEmployeeRate=0, ForeignEmployerRate=0.04 gives 0/400 on gross 10,000, not the 125/250 of the local rates. An inactive type produces no line at all. Withholdings add up in net pay: 10,000 − 975 statutory − 125 health − 50 union dues (0.5%) = 8,850 in PayrollLiability.
IPayrollVoidService#
Four methods return string?, where null means success:
VoidAccrualAsync: requiresPosted. It counts, for each line, the remainingPayrollLiability.Amountand already withheld amounts (SocialInsuranceAccrualchildren +Withheldbalance). It refuses with "already repaid" when the balance is less than accrued minus withheld, with epsilon0.0001mfor rounding. Otherwise it recursively voids linkedSocialInsuranceAccrualand then voids the accrual itself. Two different things reduce debt: payment to the employee, after which the accrual cannot be voided, and third-party withholding, which is reversible and can be voided with the accrual.VoidPaymentAsync: requiresPaid; voids without a balance check.VoidSocialInsuranceAccrualAsync: idempotent (Voidedreturnsnullimmediately). RequiresPosted. It checks remainingSocialInsurancefor both resources and refuses with "already paid to the fund" if more has already been paid than can be reversed.VoidSocialInsurancePaymentAsync: requiresPaid; voids without a balance check.
Example: accrued 100 → voided: Payroll and PayrollLiability return to 0. Voiding an accrual that was already paid is refused; the document stays Posted and the liability is unchanged. First void the payment, then void the accrual. The liability is then fully extinguished to 0.
Business logic and invariants#
A time sheet does not move registers. TimeSheet has no register movements at all. Posting only closes lines for editing. Register accrual starts with PayrollAccrual.
The calculation is in the command, not in the tx script. AccruePayroll calculates money amounts itself, not through PayrollAccrualTx, because employee position selection is asynchronous and tx scripts are synchronous.
Triple idempotency protects event-chain restarts. A document can restart its event chain; for example, recalculating totals during its own posting can call OnAfterPostAsync again. Without guards, contributions would be assessed twice. Guards exist in three places: PayrollAccrualEventHandler.OnAfterPostAsync checks for an outgoing pedigree link to SocialInsuranceAccrual before creating a new one; AccruePayrollCommand checks that the time sheet has no non-Voided linked accrual before creating a new one; and, outside HR, the Ukraine extensions of PayrollAccrual and PayrollPayment check existing movements by DocumentMetaId before writing.
The base ceiling is protected from line splitting. PayrollAccrual sums Amount by employee across all accrual lines before contribution calculation. You cannot split an accrual into several lines to bypass the ceiling.
Withholding makes the balance net, not gross. PayrollAccrualTx recognizes the full accrued gross in PayrollLiability. If social insurance is configured, SocialInsuranceAccrualTx immediately withholds the employee share with a separate movement (Amount = -EmployeeContribution). The final employee balance equals gross minus withheld. The employer contribution does not enter PayrollLiability; it is not a debt to the employee.
Social insurance is an optional contour. If there is no HRSettings record, or gross <= 0, SocialInsuranceAccrual is not created. PayrollLiability remains exactly gross. The switch is data, not code: enabling or disabling the contour means adding or removing settings.
Draft, then Posted. SocialInsuranceAccrual is saved as Draft, lines are written, then the document moves to Posted. The Posted subtype is read-only. If lines are written directly into Posted, the guard rejects it.
Document date comes from the source, not from now. CreateAccrualAsync receives the document date from the payroll accrual that generated it, not from DateTime.UtcNow. Otherwise a March contribution would be invisible in the March declaration. SocialInsurance movements use the accrual date.
You cannot overpay employees, but you may overpay the fund. Overpaying accrued payroll is rejected by PayrollPaymentEventHandler.OnBeforePostAsync, which compares the payout per employee against the PayrollLiability balance of that employee. It is not rejected by allowNegativeBalance:false: the register keeps Employee as an analytic and has no dimensions, and the engine enforces that flag over dimensions, so at engine level the register is one global bucket. Contribution payment to the fund intentionally allows overpayment: advance transfers and extra payments after recalculation are legal.
A register flag cannot guard what the register does not key by. This is the general form of the rule above. allowNegativeBalance is an engine check over dimensions. A register whose keys live in analytics gets no per-key protection from it, and the guard has to be written explicitly in OnBeforePost. Every other overdraw guard in this workspace is written that way.
Cancellation distinguishes payment from withholding. Employee debt is reduced by two different things: payment, which is irreversible and blocks accrual cancellation, and third-party withholding, which is reversible and allows cancellation. See IPayrollVoidService.
PayrollLiability.Withheld is a contract for other models. No HR tx script writes to Withheld. The resource exists so other models, confirmed by LocalizationUkraine, can record their own withholdings, such as personal income tax and military levy, without HR knowing those taxes. Then IPayrollVoidService can distinguish "debt withheld" from "debt paid". Directly deleting all movements by DocumentMetaId on PayrollLiability is dangerous: the native HR tx script also writes under the same document ID.
A foreign employee does not pay the contribution personally. Employee nationality is compared with HRSettings.HomeCountry. Domestic employees, including empty nationality, pay both parts. Foreign employees pay only through the employer rate, with EmployeeContribution=0.
Autofill is selectively destructive. FillStandardAsync removes and rebuilds autogenerated Days lines each time, but leaves manually adjusted lines (IsManual=true) untouched. Lines covered by TimeOff are recalculated on each fill, so a changed TimeOff record is picked up by the next fill. Manual corrections are not overwritten. Employee.HireDate limits filling: days before hire date are not created.
Payroll fund participates in cost. Payroll is allocated to release cost in Costing by period and division, not by a specific production order.
Scenario. Time sheet filled with FillTimeSheet and adjusted manually → approved with ApproveTimeSheet → AccruePayroll creates and posts PayrollAccrual (+Payroll + Liability) → if HRSettings is configured, SocialInsuranceAccrual is created (+SocialInsurance, −Liability to net) → PayrollPayment pays liability, and SocialInsurancePayment pays social insurance separately on its own schedule.
Menu#
Group HR (displayOrder 9) → Dictionaries (Employee, Position, TimeOff) · Documents (TimeSheet, PayrollAccrual, PayrollPayment, SocialInsuranceAccrual, SocialInsurancePayment) · Registers (Payroll, PayrollLiability, SocialInsurance) · Settings (HRSettings, singleton).
Links with other modules#
- Outgoing: Organization (
Division), Accounting (Money, GL), and Common (Country). - Incoming: Costing reads
Payrollfor labor cost allocation. Production posts absorbed labour againstLaborAbsorbedAccountCode. Each of the four HR documents (PayrollAccrual,PayrollPayment,SocialInsuranceAccrual,SocialInsurancePayment) has its own GL transaction script (see Who writes the general ledger); they read accounts fromAccountingSettings(PayrollExpenseAccountCode,PayrollLiabilityAccountCode,SocialInsurancePayableAccountCode,SocialInsuranceExpenseAccountCode,CashAccountCode). AContributionTypecard may override the expense and payable pair for its own contribution; empty falls back to the accounting settings. Payroll payment is grouped by legal entity throughEmployee→Division→LegalEntity, becausePayrollPaymentitself does not store a legal entity in the header.LocalizationUkraineextendsPayrollAccrualandPayrollPayment: it records personal income tax and military levy in its ownUaPayrollLevyregister and writes directly toPayrollLiability.Withheld; on cancellation, those movements are written off manually, not by the native HR tx script.LocalizationSaudiArabiadoes not extend HR at all. The 9.75%/11.75%/2%/45000 rates found in tests are demo data in the generalHRSettings, not Saudi specifics.
Objects#
Generated from workspace metadata by node tools/gen-reference.mjs; edit the metadata, not this block.
Dictionaries#
ContributionType — Contribution types
| Field | Caption | Type | Required |
|---|---|---|---|
Code |
Code | String(20) | Yes |
EmployeeRate |
Rate, employee | Decimal(9,6) | No |
EmployerRate |
Rate, employer | Decimal(9,6) | No |
ExpenseAccountCode |
Expense account | String(32) | No |
ForeignEmployeeRate |
Rate, employee (foreign staff) | Decimal(9,6) | No |
ForeignEmployerRate |
Rate, employer (foreign staff) | Decimal(9,6) | No |
ID |
Code | String(50) | Yes |
IsActive |
Active | Boolean | Yes |
Name |
Name | String(128) | Yes |
PayableAccountCode |
Payable account | String(32) | No |
WageCeiling |
Contributory wage ceiling | Money |
No |
Employee — Employees
| Field | Caption | Type | Required |
|---|---|---|---|
Division |
Division | → Division |
Yes |
HireDate |
Hire date | DateTime | Yes |
ID |
Code | String(50) | Yes |
IsActive |
Active | Boolean | No |
Name |
Name | String(160) | Yes |
Nationality |
Nationality | → Country |
No |
Position |
Position | → Position |
Yes |
HRSettings — HR settings
| Field | Caption | Type | Required |
|---|---|---|---|
HomeCountry |
Home country | → Country |
No |
ID |
Code | String(50) | Yes |
PayrollRunDay |
Payroll run day of month | Integer | No |
SocialInsuranceEmployeeRate |
Social insurance rate, employee | Decimal(9,6) | No |
SocialInsuranceEmployerRate |
Social insurance rate, employer | Decimal(9,6) | No |
SocialInsuranceForeignEmployerRate |
Social insurance rate, employer (foreign staff) | Decimal(9,6) | No |
SocialInsuranceWageCeiling |
Contributory wage ceiling | Money |
No |
WeeklyHours |
Work hours per week | Decimal(18,4) | No |
WorkHoursPerDay |
Work hours per day | Decimal(18,4) | No |
Position — Positions
| Field | Caption | Type | Required |
|---|---|---|---|
HourlyRate |
Hourly rate | Money |
Yes |
ID |
Code | String(50) | Yes |
MonthlySalary |
Monthly salary | Money |
No |
Name |
Name | String(128) | Yes |
TimeOff — Time off
Vacation, sick leave and other absences. Fill timesheet treats these as exceptions to the standard week.
| Field | Caption | Type | Required |
|---|---|---|---|
DateFrom |
From | DateTime | Yes |
DateTo |
To | DateTime | Yes |
Employee |
Employee | → Employee |
Yes |
ID |
Code | String(50) | Yes |
Kind |
Kind | AttendanceDayKindEdt |
Yes |
Name |
Name | String(160) | Yes |
Note |
Note | String(256) | No |
Documents#
PayrollAccrual — Payroll accrual
Header fields:
| Field | Caption | Type | Required |
|---|---|---|---|
Division |
Division | → Division |
Yes |
ID |
Number | String(50) | Yes |
Subtypes:
| Subtype | Caption |
|---|---|
Draft |
Draft |
Posted |
Posted |
Voided |
Voided |
Commands:
| Command | Caption | Target subtype |
|---|---|---|
PostPayrollAccrual |
Post payroll accrual | — |
VoidPayrollAccrual |
Void payroll accrual | — |
PayrollPayment — Payroll payment
Header fields:
| Field | Caption | Type | Required |
|---|---|---|---|
ID |
Number | String(50) | Yes |
Subtypes:
| Subtype | Caption |
|---|---|
Draft |
Draft |
Paid |
Paid |
Voided |
Voided |
Commands:
| Command | Caption | Target subtype |
|---|---|---|
PayPayroll |
Pay payroll | — |
VoidPayrollPayment |
Void payroll payment | — |
SocialInsuranceAccrual — Social insurance accrual
Header fields:
| Field | Caption | Type | Required |
|---|---|---|---|
Division |
Division | → Division |
Yes |
ID |
Number | String(50) | Yes |
Subtypes:
| Subtype | Caption |
|---|---|
Draft |
Draft |
Posted |
Posted |
Voided |
Voided |
Commands:
| Command | Caption | Target subtype |
|---|---|---|
PostSocialInsuranceAccrual |
Post social insurance | — |
VoidSocialInsuranceAccrual |
Void social insurance accrual | — |
SocialInsurancePayment — Social insurance payment
Header fields:
| Field | Caption | Type | Required |
|---|---|---|---|
Division |
Division | → Division |
Yes |
ID |
Number | String(50) | Yes |
Subtypes:
| Subtype | Caption |
|---|---|
Draft |
Draft |
Paid |
Paid |
Voided |
Voided |
Commands:
| Command | Caption | Target subtype |
|---|---|---|
PaySocialInsurance |
Pay social insurance | — |
VoidSocialInsurancePayment |
Void social insurance payment | — |
TimeSheet — Time sheet
Header fields:
| Field | Caption | Type | Required |
|---|---|---|---|
Division |
Division | → Division |
Yes |
ID |
Number | String(50) | Yes |
PeriodFrom |
Period from | DateTime | No |
PeriodTo |
Period to | DateTime | No |
Subtypes:
| Subtype | Caption |
|---|---|
Draft |
Draft |
Approved |
Approved |
Commands:
| Command | Caption | Target subtype |
|---|---|---|
FillTimeSheet |
Fill standard week | — |
AccruePayroll |
Accrue payroll | — |
ApproveTimeSheet |
Approve timesheet | — |
Registers#
Payroll — Payroll
Resources:
| Resource | Caption | Type | Required |
|---|---|---|---|
Amount |
Amount | Money |
— |
Analytics:
| Analytic | Caption | Type | Required |
|---|---|---|---|
Payroll-Division |
Payroll-Division | — | Yes |
Payroll-Employee |
Payroll-Employee | — | Yes |
PayrollLiability — Payroll liability
Resources:
| Resource | Caption | Type | Required |
|---|---|---|---|
Amount |
Amount | Money |
— |
Withheld |
Withheld | Money |
— |
Analytics:
| Analytic | Caption | Type | Required |
|---|---|---|---|
PayrollLiability-Employee |
PayrollLiability-Employee | — | Yes |
SocialInsurance — Social insurance
Resources:
| Resource | Caption | Type | Required |
|---|---|---|---|
EmployeeContribution |
Employee contribution | Money |
— |
EmployerContribution |
Employer contribution | Money |
— |
Analytics:
| Analytic | Caption | Type | Required |
|---|---|---|---|
SocialInsurance-ContributionType |
SocialInsurance-ContributionType | — | No |
SocialInsurance-Division |
SocialInsurance-Division | — | Yes |
SocialInsurance-Employee |
SocialInsurance-Employee | — | Yes |
Table parts#
PayrollAccrualLines — Accrual lines
Used by: PayrollAccrual.
| Field | Caption | Type | Required |
|---|---|---|---|
Amount |
Amount | Money |
Yes |
Employee |
Employee | → Employee |
Yes |
PayrollPaymentLines — Payment lines
Used by: PayrollPayment.
| Field | Caption | Type | Required |
|---|---|---|---|
Amount |
Amount | Money |
Yes |
Employee |
Employee | → Employee |
Yes |
SocialInsuranceAccrualLines — Lines
Used by: SocialInsuranceAccrual.
| Field | Caption | Type | Required |
|---|---|---|---|
ContributionType |
Contribution type | → ContributionType |
No |
ContributoryBase |
Contributory base | Money |
Yes |
Employee |
Employee | → Employee |
Yes |
EmployeeContribution |
Employee contribution | Money |
Yes |
EmployerContribution |
Employer contribution | Money |
Yes |
SocialInsurancePaymentLines — Lines
Used by: SocialInsurancePayment.
| Field | Caption | Type | Required |
|---|---|---|---|
ContributionType |
Contribution type | → ContributionType |
No |
Employee |
Employee | → Employee |
Yes |
EmployeeContribution |
Employee contribution | Money |
No |
EmployerContribution |
Employer contribution | Money |
No |
TimeSheetDays — Days
Used by: TimeSheet.
| Field | Caption | Type | Required |
|---|---|---|---|
DayKind |
Kind | AttendanceDayKindEdt |
No |
Employee |
Employee | → Employee |
Yes |
Hours |
Hours | Decimal(18,2) | Yes |
IsAbsent |
Absent | Boolean | No |
IsManual |
Manual | Boolean | No |
WorkDate |
Date | DateTime | Yes |
TimeSheetLines — Lines
Used by: TimeSheet.
| Field | Caption | Type | Required |
|---|---|---|---|
Employee |
Employee | → Employee |
Yes |
Hours |
Hours | Decimal(18,2) | Yes |