@kernhq/module-hr 0.18.1 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -12,6 +12,7 @@ import { ApprovalService } from './services/approvals.js';
12
12
  import { AttendanceService } from './services/attendance.js';
13
13
  import { accessLogSort, HrAuditService } from './services/audit.js';
14
14
  import { inForceOn, todayIso } from './services/db.js';
15
+ import { assembleExport, exportManifest, PayrollExportService, } from './services/exports.js';
15
16
  import { LedgerService, MINUTES_PER_DAY, yearOf } from './services/ledger.js';
16
17
  import { PeopleService } from './services/people.js';
17
18
  import { hashConfig, PolicyService } from './services/policies.js';
@@ -446,6 +447,7 @@ export function implement_(kernel) {
446
447
  const approvals = new ApprovalService(kernel, subjects.appliersFor(null));
447
448
  const privacy = new PrivacyService();
448
449
  const reports = new ReportsService(resolve);
450
+ const payroll = new PayrollExportService(reports);
449
451
  const audit = new HrAuditService(kernel, access);
450
452
  const db = kernel.database;
451
453
  const settingsOf = (workspaceId) => kernel.settings.module(workspaceId, MODULE_ID, HrSettings);
@@ -3538,6 +3540,92 @@ export function implement_(kernel) {
3538
3540
  };
3539
3541
  })),
3540
3542
  },
3543
+ // ================================================================= payroll export
3544
+ /**
3545
+ * The monthly handover to whoever runs payroll, for one legal entity, frozen at v1.
3546
+ *
3547
+ * **Three keys, and the middle one is new.** `hr.payroll.export` ships in this change because
3548
+ * this is the change that makes a writer exist — it is granted to nobody by default, like
3549
+ * `hr.person.view_sensitive` and `hr.privacy.manage`, so on a fresh workspace only an owner can
3550
+ * reach it. On top of it, the same second-check rule the reports follow:
3551
+ * `hr.attendance.view_team` for the hours file and `hr.leave.view_team` for the leave file,
3552
+ * because an export must not answer what the row-level procedure would refuse.
3553
+ *
3554
+ * **One capability gate, and it carries two more inside it.** `payroll_export` declares
3555
+ * `dependsOn: ['core', 'periods', 'attendance']`, and `kernel.capabilities` prunes a capability
3556
+ * whose dependencies are off — so a workspace with attendance off has no day sheet to hand over
3557
+ * and this answers 404, and a workspace with periods off would have every day open, the refusal
3558
+ * below would fire on every call, and a switch that only ever produces an error is worse than no
3559
+ * switch. Gating on the three separately would say the same thing three times and let them drift;
3560
+ * the dependency list is where that belongs.
3561
+ *
3562
+ * That pruning is measured rather than assumed, because the whole gate rests on it: against a real
3563
+ * kernel, `payroll.export.v1` answers `NOT_FOUND` with `attendance` off, with `periods` off, and
3564
+ * with `payroll_export` itself off, and reaches the handler only when all three are on. Worth
3565
+ * knowing when reading a support ticket: all three refusals say *`hr.payroll_export` is not
3566
+ * enabled*, so an administrator looking at a switch that is plainly on is looking at a dependency
3567
+ * that is off.
3568
+ *
3569
+ * **Nothing here writes**, including no `sensitive_access_log` row: this export reads no
3570
+ * sensitive field. Adding `iban` would make it a bulk decrypt of every employee's bank account
3571
+ * and would owe one audit row per person with `via: 'export'` — a different procedure with its
3572
+ * own key, not a column on this one.
3573
+ */
3574
+ payroll: {
3575
+ export: {
3576
+ /**
3577
+ * One entity, one period, two CSVs and a manifest.
3578
+ *
3579
+ * Synchronous, and that is checked rather than preferred: `core.files.createUpload` needs a
3580
+ * user principal and returns a presigned PUT for a browser, so a background job cannot mint a
3581
+ * `FileObject` at all, and writing bytes straight into `kernel.storage` would orphan an
3582
+ * object `core.files.*` cannot see and nothing will ever delete.
3583
+ *
3584
+ * It throws the first refusal `collect` found rather than emitting a row of zeros — an open
3585
+ * period without `draft`, an entity with nobody in it, or somebody with no employment record
3586
+ * covering their days here. The preview below returns all of them instead, so a screen can
3587
+ * show the reader every reason at once before anybody downloads anything.
3588
+ */
3589
+ v1: scoped.payroll.export.v1
3590
+ .use(cap('payroll_export'))
3591
+ .use(requires('hr.payroll.export'))
3592
+ .use(requires('hr.attendance.view_team'))
3593
+ .use(requires('hr.leave.view_team'))
3594
+ .handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
3595
+ const data = await payroll.collect(tx, input);
3596
+ const [first] = data.refusals;
3597
+ // `conflict` rather than `badRequest`: the request is well formed and the state is not
3598
+ // ready. `hr.period.not_locked` mirrors the spelling of `hr.period.locked`, which
3599
+ // `PolicyService.assertOpen` throws pointed the other way.
3600
+ if (first)
3601
+ throw KernError.conflict(first.message, first.code);
3602
+ return assembleExport(payrollAssembly(data, input.draft));
3603
+ })),
3604
+ /**
3605
+ * The same rows as JSON, with no file and no refusal thrown.
3606
+ *
3607
+ * The manifest it carries is the manifest the export *would* write, filenames included, so a
3608
+ * screen can name the files before they exist and a reader can see `DRAFT` in the name before
3609
+ * choosing to send it.
3610
+ */
3611
+ preview: scoped.payroll.export.preview
3612
+ .use(cap('payroll_export'))
3613
+ .use(requires('hr.payroll.export'))
3614
+ .use(requires('hr.attendance.view_team'))
3615
+ .use(requires('hr.leave.view_team'))
3616
+ .handler(({ input }) => db.withWorkspace(input.workspaceId, async (tx) => {
3617
+ const data = await payroll.collect(tx, input);
3618
+ return {
3619
+ manifest: exportManifest(payrollAssembly(data, input.draft)),
3620
+ refusals: data.refusals,
3621
+ exportable: data.refusals.length === 0,
3622
+ totals: data.totals,
3623
+ hours: data.hours,
3624
+ leave: data.leave,
3625
+ };
3626
+ })),
3627
+ },
3628
+ },
3541
3629
  // ================================================================= privacy
3542
3630
  /**
3543
3631
  * Subject access, erasure and retention.
@@ -4103,6 +4191,34 @@ export function implement_(kernel) {
4103
4191
  rows,
4104
4192
  };
4105
4193
  }
4194
+ /**
4195
+ * The provenance half of a payroll export's manifest — the part that is the router's to supply.
4196
+ *
4197
+ * `kernVersion` is the platform version this image was built as, recorded so a file can be traced
4198
+ * back to what wrote it. It is **not** the contract identity: `PAYROLL_EXPORT_CONTRACT` is a
4199
+ * literal that moves only when the column set does, and reading the module version into that field
4200
+ * would rename the format on every patch release.
4201
+ *
4202
+ * The three permissions are the ones the middlewares above actually asked for, written out rather
4203
+ * than inferred, for the reason `ReportScope` exists: two readers must never hold one file's
4204
+ * figures under one title without being told which grants produced them.
4205
+ */
4206
+ function payrollAssembly(data, draft) {
4207
+ return {
4208
+ entity: data.entity,
4209
+ period: data.period,
4210
+ draft,
4211
+ generatedAt: new Date().toISOString(),
4212
+ kernVersion: kernel.version,
4213
+ permissions: ['hr.payroll.export', 'hr.attendance.view_team', 'hr.leave.view_team'],
4214
+ dayLengthMinutes: MINUTES_PER_DAY,
4215
+ population: data.population,
4216
+ counted: data.counted,
4217
+ attendance: data.attendance,
4218
+ hours: data.hours,
4219
+ leave: data.leave,
4220
+ };
4221
+ }
4106
4222
  /** The request a retry is a retry *of*, or undefined the first time a key is seen. */
4107
4223
  async function byIdempotencyKey(tx, workspaceId, key) {
4108
4224
  const [row] = await tx