@fabriktor/fx 0.0.122 → 0.0.123

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.
@@ -11,6 +11,7 @@ export type MultiCreateStatus = (typeof MultiCreateStatus)[keyof typeof MultiCre
11
11
  export type MultiCreateCheckpoint = {
12
12
  created_id?: string;
13
13
  on_created?: (id: string) => void;
14
+ on_rejected?: () => void;
14
15
  before_request?: () => void;
15
16
  };
16
17
  export type MultiCreateAttempt = MultiCreateCheckpoint & {
@@ -46,6 +47,7 @@ export declare class MultiCreatePool {
46
47
  export type MultiCreateOptions<Input, Result> = {
47
48
  run: (input: Input, attempt: MultiCreateAttempt) => Promise<Result>;
48
49
  pool?: MultiCreatePool;
50
+ retry_same_request?: boolean;
49
51
  before_request?: () => void;
50
52
  on_failed?: (entry: MultiCreateEntry<Input, Result>) => void;
51
53
  on_settled?: (outcome: MultiCreateOutcome<Input, Result>) => void;
@@ -115,10 +115,12 @@ export class MultiCreator {
115
115
  return this.completion;
116
116
  // Explicit user retry is a new creation attempt when no ID was acknowledged.
117
117
  // A known ID always resumes retrieval with the original request key.
118
+ if (this.opts.retry_same_request && entry.attempted && input !== undefined)
119
+ throw new Error("A submitted request cannot be edited while retrying.");
118
120
  const next = {
119
121
  ...entry,
120
122
  input: input === undefined ? entry.input : structuredClone(input),
121
- idempotency_key: entry.created_id
123
+ idempotency_key: entry.created_id || (this.opts.retry_same_request && entry.attempted)
122
124
  ? entry.idempotency_key
123
125
  : crypto.randomUUID(),
124
126
  status: MultiCreateStatus.Pending,
@@ -159,6 +161,10 @@ export class MultiCreator {
159
161
  current = { ...current, created_id: id };
160
162
  this.replace(current);
161
163
  },
164
+ on_rejected: () => {
165
+ current = { ...current, attempted: false };
166
+ this.replace(current);
167
+ },
162
168
  });
163
169
  this.replace({
164
170
  ...current,
@@ -51,6 +51,7 @@ export declare function runSearch<T>(opts: RunSearchOptions<T>): Promise<SearchP
51
51
  export declare function normalizeSearchPage(value?: number): number;
52
52
  export declare function normalizeSearchPerPage(value?: number): number;
53
53
  export declare function normalizeSearchQuery(value?: string): string | undefined;
54
+ export declare function emptySearchPage<T>(page?: number, per_page?: number): SearchPage<T>;
54
55
  export type SearchResultWithID = {
55
56
  readonly id: string;
56
57
  };
@@ -201,7 +201,7 @@ function hasMoreSearchResults(opts) {
201
201
  }
202
202
  return opts.result_count >= opts.per_page;
203
203
  }
204
- function emptySearchPage(page, per_page) {
204
+ export function emptySearchPage(page = firstSearchPage, per_page = defaultSearchPerPage) {
205
205
  return {
206
206
  results: [],
207
207
  page,
package/dist/src/fx.js CHANGED
@@ -82,6 +82,7 @@ export class FX {
82
82
  });
83
83
  this.payrolls = newPayrollsFX({
84
84
  payrolls: opts.payrolls,
85
+ billable: opts.billable,
85
86
  });
86
87
  this.preferences = newPreferencesFX({
87
88
  preferences: opts.preferences,
@@ -28,6 +28,7 @@ export * from "./memos/memos.js";
28
28
  export * from "./notifications/notifications.js";
29
29
  export * from "./payments/payments.js";
30
30
  export * from "./payrolls/payrolls.js";
31
+ export * from "./payrolls/workspace.js";
31
32
  export * from "./preferences/preferences.js";
32
33
  export * from "./privacy/privacy.js";
33
34
  export * from "./processes/processes.js";
package/dist/src/index.js CHANGED
@@ -28,6 +28,7 @@ export * from "./memos/memos.js";
28
28
  export * from "./notifications/notifications.js";
29
29
  export * from "./payments/payments.js";
30
30
  export * from "./payrolls/payrolls.js";
31
+ export * from "./payrolls/workspace.js";
31
32
  export * from "./preferences/preferences.js";
32
33
  export * from "./privacy/privacy.js";
33
34
  export * from "./processes/processes.js";
@@ -21,6 +21,13 @@ type PmtPublicSessionInsertOptions = Parameters<PaymentsOperator["insertOnePmtPu
21
21
  type PmtPublicSessionReplaceOptions = Parameters<PaymentsOperator["replaceOnePmtPublicSession"]>[0];
22
22
  type CreditLedgerEntryInsertOptions = Parameters<PaymentsOperator["insertOneCreditLedgerEntry"]>[0];
23
23
  type CreditLedgerEntryReplaceOptions = Parameters<PaymentsOperator["replaceOneCreditLedgerEntry"]>[0];
24
+ export type EntityPaymentHistoryOptions = {
25
+ owner_id: string;
26
+ entity_id: string;
27
+ type: Payment["type"];
28
+ recipient_id?: string;
29
+ signal?: AbortSignal;
30
+ };
24
31
  export type CreatePaymentOptions = PaymentInsertOptions;
25
32
  export type ReplacePaymentOptions = PaymentReplaceOptions;
26
33
  export type SearchPaymentsOptions = OwnerSearchOptions;
@@ -105,6 +112,7 @@ export type PaymentsFXOptions = {
105
112
  payments: PaymentsOperator;
106
113
  };
107
114
  export interface PaymentsFXOperator {
115
+ entityPaymentHistory(opts: EntityPaymentHistoryOptions): Promise<Payment[]>;
108
116
  createPayment(opts: CreatePaymentOptions): Promise<Payment>;
109
117
  replacePayment(opts: ReplacePaymentOptions): Promise<Payment>;
110
118
  searchPayments(opts: SearchPaymentsOptions): Promise<SearchPaymentsPage>;
@@ -143,6 +151,7 @@ export declare class PaymentsFX implements PaymentsFXOperator {
143
151
  private payments;
144
152
  private submittedPublicSessionIDs;
145
153
  constructor(opts: PaymentsFXOptions);
154
+ entityPaymentHistory(opts: EntityPaymentHistoryOptions): Promise<Payment[]>;
146
155
  createPayment(opts: CreatePaymentOptions): Promise<Payment>;
147
156
  replacePayment(opts: ReplacePaymentOptions): Promise<Payment>;
148
157
  searchPayments(opts: SearchPaymentsOptions): Promise<SearchPaymentsPage>;
@@ -3,7 +3,7 @@
3
3
  // Licensed under the Apache License, Version 2.0 (the "License"); you may
4
4
  // not use this file except in compliance with the License. You may obtain
5
5
  // a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
6
- import { CreditLedgerSourceAndroidIAP, CreditLedgerSourceAppleIAP, CreditLedgerSourceStripe, ZeroID, } from "@fabriktor/schema";
6
+ import { CreditLedgerSourceAndroidIAP, CreditLedgerSourceAppleIAP, CreditLedgerSourceStripe, ZeroID, PaymentKindMain, } from "@fabriktor/schema";
7
7
  import { queryParams } from "@fabriktor/client";
8
8
  import { errResolveFailed, errValidation } from "../core/err.js";
9
9
  import { requiredOperationID, resolvedID } from "../core/id.js";
@@ -101,6 +101,17 @@ export class PaymentsFX {
101
101
  this.payments = opts.payments;
102
102
  this.submittedPublicSessionIDs = new Set();
103
103
  }
104
+ async entityPaymentHistory(opts) {
105
+ if (!resolvedID(opts.owner_id) || !resolvedID(opts.entity_id) || !opts.type.trim()) {
106
+ throw errValidation({ field: "entity_id", msg: "Payment history requires an owner, entity, and type." });
107
+ }
108
+ const response = await this.payments.queryPayments({
109
+ signal: opts.signal,
110
+ query: queryParams({ owner_id: opts.owner_id, entity_id: opts.entity_id, type: opts.type, kind: PaymentKindMain, recipient_id: opts.recipient_id }),
111
+ });
112
+ // Preserve the backend's chronological order; do not infer settlement from PSP payment balance flags.
113
+ return response.value ?? [];
114
+ }
104
115
  async createPayment(opts) {
105
116
  const out = await this.payments.insertOnePayment(opts);
106
117
  const id = requiredOperationID(out, msgCreatedPaymentIDMissing);
@@ -1,4 +1,5 @@
1
- import type { PayrollsOperator } from "@fabriktor/client";
1
+ import { PayrollsWorkspace } from "./workspace.js";
2
+ import type { BillableOperator, PayrollsOperator } from "@fabriktor/client";
2
3
  import type { Payable, Payroll, PayConfig, HourBank, Adjustment, AdjustmentFolder, AdjustmentRecord, AdjustmentAggregate, EmployeePayConfig, PayrollProgram } from "@fabriktor/schema";
3
4
  import { type OwnerSearchOptions, type SearchPage } from "../core/search.js";
4
5
  type PayableInsertOptions = Parameters<PayrollsOperator["insertOnePayable"]>[0];
@@ -88,8 +89,10 @@ export type SetEmployeePayConfigArchivedResult = Awaited<ReturnType<PayrollsOper
88
89
  export type SetPayrollProgramEnabledResult = Awaited<ReturnType<PayrollsOperator["replaceOnePayrollProgram"]>>;
89
90
  export type PayrollsFXOptions = {
90
91
  payrolls: PayrollsOperator;
92
+ billable?: BillableOperator;
91
93
  };
92
94
  export interface PayrollsFXOperator {
95
+ readonly workspace: PayrollsWorkspace;
93
96
  createPayable(opts: CreatePayableOptions): Promise<Payable>;
94
97
  replacePayable(opts: ReplacePayableOptions): Promise<Payable>;
95
98
  searchPayables(opts: SearchPayablesOptions): Promise<SearchPayablesPage>;
@@ -127,6 +130,7 @@ export interface PayrollsFXOperator {
127
130
  setPayrollProgramEnabled(opts: SetPayrollProgramEnabledOptions): Promise<SetPayrollProgramEnabledResult>;
128
131
  }
129
132
  export declare class PayrollsFX implements PayrollsFXOperator {
133
+ readonly workspace: PayrollsWorkspace;
130
134
  private payrolls;
131
135
  constructor(opts: PayrollsFXOptions);
132
136
  createPayable(opts: CreatePayableOptions): Promise<Payable>;
@@ -3,6 +3,7 @@
3
3
  // Licensed under the Apache License, Version 2.0 (the "License"); you may
4
4
  // not use this file except in compliance with the License. You may obtain
5
5
  // a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ import { PayrollsWorkspace } from "./workspace.js";
6
7
  import { errResolveFailed } from "../core/err.js";
7
8
  import { requiredOperationID } from "../core/id.js";
8
9
  import { runOwnerSearch, } from "../core/search.js";
@@ -37,9 +38,11 @@ const resourcePayrollProgram = "payroll_program";
37
38
  const msgCreatedPayrollProgramIDMissing = "Created payroll program response did not contain an ID.";
38
39
  const msgPayrollProgramMissing = "Payroll program could not be resolved after the mutation.";
39
40
  export class PayrollsFX {
41
+ workspace;
40
42
  payrolls;
41
43
  constructor(opts) {
42
44
  this.payrolls = opts.payrolls;
45
+ this.workspace = new PayrollsWorkspace(opts.payrolls, opts.billable);
43
46
  }
44
47
  async createPayable(opts) {
45
48
  const out = await this.payrolls.insertOnePayable(opts);
@@ -0,0 +1,210 @@
1
+ import type { BillableOperator, PayrollsOperator, QueryOptions } from "@fabriktor/client";
2
+ import { ColPayrollsPayrolls, ColPayrollsHourBanks, ColPayrollsPayables, ColPayrollsAdjustments, ColPayrollsAdjustmentFolders, ColPayrollsPayConfigs, ColPayrollsEmployeePayConfigs, ColPayrollsAdjustmentRecords, ColPayrollsAdjustmentAggregates, ColPayrollsPayrollPrograms, type Payroll, type HourBank, type Payable, type Adjustment, type AdjustmentFolder, type PayConfig, type EmployeePayConfig, type AdjustmentRecord, type AdjustmentAggregate, type PayrollProgram, type InPayroll, type InAdjustment, type InAdjustmentFolder, type InPayConfig, type InEmployeePayConfig, type InPayrollProgram, type InAdjustmentAggregate, type PLPayrollsGeneratePayrollFromEntities, type InDate, type Period } from "@fabriktor/schema";
3
+ import type { EmploymentActor, EmploymentReadContext } from "../contacts/scope.js";
4
+ import { type SearchOptions, type SearchPage } from "../core/search.js";
5
+ import type { MultiCreateAttempt } from "../core/multi-create.js";
6
+ export declare const PayrollResource: {
7
+ readonly Payrolls: "payrolls";
8
+ readonly HourBanks: "hour_banks";
9
+ readonly Payables: "payables";
10
+ readonly Adjustments: "adjustments";
11
+ readonly Folders: "adjustment_folders";
12
+ readonly PayConfigs: "pay_configs";
13
+ readonly EmployeeConfigs: "employee_pay_configs";
14
+ readonly Records: "adjustment_records";
15
+ readonly Aggregates: "adjustment_aggregates";
16
+ readonly Programs: "payroll_programs";
17
+ };
18
+ export type PayrollResource = (typeof PayrollResource)[keyof typeof PayrollResource];
19
+ export type PayrollRecordMap = {
20
+ [ColPayrollsPayrolls]: Payroll;
21
+ [ColPayrollsHourBanks]: HourBank;
22
+ [ColPayrollsPayables]: Payable;
23
+ [ColPayrollsAdjustments]: Adjustment;
24
+ [ColPayrollsAdjustmentFolders]: AdjustmentFolder;
25
+ [ColPayrollsPayConfigs]: PayConfig;
26
+ [ColPayrollsEmployeePayConfigs]: EmployeePayConfig;
27
+ [ColPayrollsAdjustmentRecords]: AdjustmentRecord;
28
+ [ColPayrollsAdjustmentAggregates]: AdjustmentAggregate;
29
+ [ColPayrollsPayrollPrograms]: PayrollProgram;
30
+ };
31
+ export type PayrollRecord = PayrollRecordMap[PayrollResource];
32
+ export type PayrollFilters = {
33
+ employee_id?: string[];
34
+ employee_pay_config_id?: string[];
35
+ pay_config_id?: string[];
36
+ payroll_id?: string[];
37
+ hour_bank_id?: string[];
38
+ folder_id?: string[];
39
+ period_index?: number[];
40
+ period_indexes?: number[];
41
+ currency?: string;
42
+ timezone?: string;
43
+ is_archived?: boolean;
44
+ is_payrolled?: boolean;
45
+ is_banked?: boolean;
46
+ is_problematic?: boolean;
47
+ is_enabled?: boolean;
48
+ is_employer_contribution?: boolean;
49
+ type?: string[];
50
+ name?: string[];
51
+ };
52
+ export declare const payrollPolicies: {
53
+ payrolls: {
54
+ create: true;
55
+ remove: true;
56
+ report: true;
57
+ filters: ("timezone" | "currency" | "employee_id" | "employee_pay_config_id" | "hour_bank_id" | "period_indexes" | "is_banked" | "is_problematic")[];
58
+ employee_read: boolean;
59
+ searchable: boolean;
60
+ archive: boolean;
61
+ };
62
+ hour_banks: {
63
+ searchable: false;
64
+ filters: ("timezone" | "currency" | "employee_id" | "period_indexes" | "is_payrolled")[];
65
+ employee_read: boolean;
66
+ create: boolean;
67
+ archive: boolean;
68
+ remove: boolean;
69
+ report: boolean;
70
+ };
71
+ payables: {
72
+ filters: ("period_index" | "currency" | "employee_id" | "payroll_id" | "is_payrolled")[];
73
+ employee_read: boolean;
74
+ searchable: boolean;
75
+ create: boolean;
76
+ archive: boolean;
77
+ remove: boolean;
78
+ report: boolean;
79
+ };
80
+ adjustments: {
81
+ filters: ("is_archived" | "type" | "currency" | "folder_id" | "name" | "is_employer_contribution")[];
82
+ employee_read: boolean;
83
+ searchable: boolean;
84
+ create: boolean;
85
+ archive: boolean;
86
+ remove: boolean;
87
+ report: boolean;
88
+ };
89
+ adjustment_folders: {
90
+ filters: "is_archived"[];
91
+ employee_read: boolean;
92
+ searchable: boolean;
93
+ create: boolean;
94
+ archive: boolean;
95
+ remove: boolean;
96
+ report: boolean;
97
+ };
98
+ pay_configs: {
99
+ filters: ("is_archived" | "type" | "currency" | "name")[];
100
+ employee_read: boolean;
101
+ searchable: boolean;
102
+ create: boolean;
103
+ archive: boolean;
104
+ remove: boolean;
105
+ report: boolean;
106
+ };
107
+ employee_pay_configs: {
108
+ employee_read: true;
109
+ filters: ("is_archived" | "currency" | "name" | "employee_id" | "pay_config_id")[];
110
+ searchable: boolean;
111
+ create: boolean;
112
+ archive: boolean;
113
+ remove: boolean;
114
+ report: boolean;
115
+ };
116
+ adjustment_records: {
117
+ filters: ("period_index" | "currency" | "employee_id" | "payroll_id")[];
118
+ employee_read: boolean;
119
+ searchable: boolean;
120
+ create: boolean;
121
+ archive: boolean;
122
+ remove: boolean;
123
+ report: boolean;
124
+ };
125
+ adjustment_aggregates: {
126
+ employee_read: false;
127
+ create: true;
128
+ report: true;
129
+ filters: ("currency" | "period_indexes")[];
130
+ searchable: boolean;
131
+ archive: boolean;
132
+ remove: boolean;
133
+ };
134
+ payroll_programs: {
135
+ searchable: false;
136
+ create: true;
137
+ remove: true;
138
+ filters: ("timezone" | "currency" | "employee_id" | "employee_pay_config_id" | "is_enabled")[];
139
+ employee_read: boolean;
140
+ archive: boolean;
141
+ report: boolean;
142
+ };
143
+ };
144
+ export declare const payrollPayTypes: readonly ["per_hour", "fixed"];
145
+ export declare const payrollAdjustmentTypes: readonly ["rate", "per_period", "per_hour"];
146
+ export declare const payrollRecurrenceTypes: readonly ["daily", "weekly", "monthly", "yearly"];
147
+ export declare const payrollPercentageScale = 1000;
148
+ export type PayrollCommand = {
149
+ resource: typeof PayrollResource.Payrolls;
150
+ input: InPayroll;
151
+ } | {
152
+ resource: typeof PayrollResource.HourBanks | typeof PayrollResource.Payables;
153
+ input: PLPayrollsGeneratePayrollFromEntities;
154
+ } | {
155
+ resource: typeof PayrollResource.Adjustments;
156
+ input: InAdjustment;
157
+ } | {
158
+ resource: typeof PayrollResource.Folders;
159
+ input: InAdjustmentFolder;
160
+ } | {
161
+ resource: typeof PayrollResource.PayConfigs;
162
+ input: InPayConfig;
163
+ } | {
164
+ resource: typeof PayrollResource.EmployeeConfigs;
165
+ input: InEmployeePayConfig;
166
+ } | {
167
+ resource: typeof PayrollResource.Aggregates;
168
+ input: InAdjustmentAggregate;
169
+ } | {
170
+ resource: typeof PayrollResource.Programs;
171
+ input: InPayrollProgram;
172
+ };
173
+ export type PayrollBrowseOptions = SearchOptions & {
174
+ resource: PayrollResource;
175
+ context: EmploymentReadContext;
176
+ employer_ids?: string[];
177
+ filters?: PayrollFilters;
178
+ };
179
+ export declare class PayrollWorkflowError extends Error {
180
+ readonly reason: "access" | "reference" | "source" | "invalid" | "changed" | "pending_source";
181
+ constructor(reason: "access" | "reference" | "source" | "invalid" | "changed" | "pending_source");
182
+ }
183
+ export declare function payrollCanManage(actor: EmploymentActor, record?: PayrollRecord | null): boolean;
184
+ export declare function payrollCanRead(actor: EmploymentActor, resource: PayrollResource): boolean;
185
+ export declare function payrollFilterQuery(resource: PayrollResource, filters?: PayrollFilters): QueryOptions;
186
+ export declare class PayrollsWorkspace {
187
+ private readonly payrolls;
188
+ private readonly billable?;
189
+ private readonly ports;
190
+ private readonly submitted;
191
+ private consuming;
192
+ private readonly sourceRequests;
193
+ constructor(payrolls: PayrollsOperator, billable?: BillableOperator | undefined);
194
+ browse(opts: PayrollBrowseOptions): Promise<SearchPage<PayrollRecord>>;
195
+ find<K extends PayrollResource>(resource: K, id: string, signal?: AbortSignal): Promise<PayrollRecordMap[K]>;
196
+ periods(owner_id: string, ranges: readonly {
197
+ start: InDate;
198
+ end: InDate;
199
+ }[]): Promise<Period[]>;
200
+ create(actor: EmploymentActor, command: PayrollCommand, attempt: MultiCreateAttempt): Promise<PayrollRecord>;
201
+ private createChecked;
202
+ private owned;
203
+ private active;
204
+ private adjustments;
205
+ private config;
206
+ private validateCommand;
207
+ setArchived(actor: EmploymentActor, resource: PayrollResource, record: PayrollRecord, value: boolean): Promise<void>;
208
+ setEnabled(actor: EmploymentActor, record: PayrollProgram, is_enabled: boolean): Promise<void>;
209
+ remove(actor: EmploymentActor, resource: PayrollResource, record: PayrollRecord): Promise<void>;
210
+ }
@@ -0,0 +1,518 @@
1
+ import { isHTTPClientErr } from "@fabriktor/client";
2
+ import { ColPayrollsPayrolls, ColPayrollsHourBanks, ColPayrollsPayables, ColPayrollsAdjustments, ColPayrollsAdjustmentFolders, ColPayrollsPayConfigs, ColPayrollsEmployeePayConfigs, ColPayrollsAdjustmentRecords, ColPayrollsAdjustmentAggregates, ColPayrollsPayrollPrograms, UserTypeEmployer, UserTypeEmployee, AdjustmentTypeRate, AdjustmentTypePerHour, AdjustmentTypePerPeriod, PayTypeFixed, PayTypePerHour, RecurrenceTypeDaily, RecurrenceTypeWeekly, RecurrenceTypeMonthly, RecurrenceTypeYearly, } from "@fabriktor/schema";
3
+ import { employmentCanRead, employmentCatalogOwnerIDs, } from "../contacts/scope.js";
4
+ import { normalizeIDs, requiredOperationID } from "../core/id.js";
5
+ import { runOwnerScopeQuery } from "../core/query.js";
6
+ import { runOwnerScopeSearch, emptySearchPage, } from "../core/search.js";
7
+ export const PayrollResource = {
8
+ Payrolls: ColPayrollsPayrolls,
9
+ HourBanks: ColPayrollsHourBanks,
10
+ Payables: ColPayrollsPayables,
11
+ Adjustments: ColPayrollsAdjustments,
12
+ Folders: ColPayrollsAdjustmentFolders,
13
+ PayConfigs: ColPayrollsPayConfigs,
14
+ EmployeeConfigs: ColPayrollsEmployeePayConfigs,
15
+ Records: ColPayrollsAdjustmentRecords,
16
+ Aggregates: ColPayrollsAdjustmentAggregates,
17
+ Programs: ColPayrollsPayrollPrograms,
18
+ };
19
+ const generated = {
20
+ employee_read: true,
21
+ searchable: true,
22
+ create: false,
23
+ archive: false,
24
+ remove: false,
25
+ report: false,
26
+ filters: [],
27
+ };
28
+ const catalog = {
29
+ ...generated,
30
+ employee_read: false,
31
+ create: true,
32
+ archive: true,
33
+ };
34
+ // The service README and record Rules() define this capability/filter table.
35
+ // A generic CRUD method in the client is not permission to edit an immutable record.
36
+ export const payrollPolicies = {
37
+ [PayrollResource.Payrolls]: {
38
+ ...generated,
39
+ create: true,
40
+ remove: true,
41
+ report: true,
42
+ filters: [
43
+ "employee_id",
44
+ "employee_pay_config_id",
45
+ "hour_bank_id",
46
+ "is_banked",
47
+ "is_problematic",
48
+ "period_indexes",
49
+ "currency",
50
+ "timezone",
51
+ ],
52
+ },
53
+ [PayrollResource.HourBanks]: {
54
+ ...generated,
55
+ searchable: false,
56
+ filters: [
57
+ "employee_id",
58
+ "is_payrolled",
59
+ "period_indexes",
60
+ "currency",
61
+ "timezone",
62
+ ],
63
+ },
64
+ [PayrollResource.Payables]: {
65
+ ...generated,
66
+ filters: [
67
+ "employee_id",
68
+ "payroll_id",
69
+ "is_payrolled",
70
+ "period_index",
71
+ "currency",
72
+ ],
73
+ },
74
+ [PayrollResource.Adjustments]: {
75
+ ...catalog,
76
+ filters: [
77
+ "name",
78
+ "type",
79
+ "is_employer_contribution",
80
+ "folder_id",
81
+ "currency",
82
+ "is_archived",
83
+ ],
84
+ },
85
+ [PayrollResource.Folders]: { ...catalog, filters: ["is_archived"] },
86
+ [PayrollResource.PayConfigs]: {
87
+ ...catalog,
88
+ filters: ["type", "name", "currency", "is_archived"],
89
+ },
90
+ [PayrollResource.EmployeeConfigs]: {
91
+ ...catalog,
92
+ employee_read: true,
93
+ filters: [
94
+ "employee_id",
95
+ "pay_config_id",
96
+ "name",
97
+ "currency",
98
+ "is_archived",
99
+ ],
100
+ },
101
+ [PayrollResource.Records]: {
102
+ ...generated,
103
+ filters: ["employee_id", "payroll_id", "currency", "period_index"],
104
+ },
105
+ [PayrollResource.Aggregates]: {
106
+ ...generated,
107
+ employee_read: false,
108
+ create: true,
109
+ report: true,
110
+ filters: ["currency", "period_indexes"],
111
+ },
112
+ [PayrollResource.Programs]: {
113
+ ...generated,
114
+ searchable: false,
115
+ create: true,
116
+ remove: true,
117
+ filters: [
118
+ "employee_id",
119
+ "employee_pay_config_id",
120
+ "is_enabled",
121
+ "currency",
122
+ "timezone",
123
+ ],
124
+ },
125
+ };
126
+ export const payrollPayTypes = [PayTypePerHour, PayTypeFixed];
127
+ export const payrollAdjustmentTypes = [
128
+ AdjustmentTypeRate,
129
+ AdjustmentTypePerPeriod,
130
+ AdjustmentTypePerHour,
131
+ ];
132
+ export const payrollRecurrenceTypes = [
133
+ RecurrenceTypeDaily,
134
+ RecurrenceTypeWeekly,
135
+ RecurrenceTypeMonthly,
136
+ RecurrenceTypeYearly,
137
+ ];
138
+ // record/config.go pctMoney uses basis.X100000. A displayed percentage is 100 times the fraction.
139
+ export const payrollPercentageScale = 1000;
140
+ export class PayrollWorkflowError extends Error {
141
+ reason;
142
+ constructor(reason) {
143
+ super(`Payroll workflow: ${reason}`);
144
+ this.reason = reason;
145
+ this.name = "PayrollWorkflowError";
146
+ }
147
+ }
148
+ function requirePayroll(value, reason) {
149
+ if (!value)
150
+ throw new PayrollWorkflowError(reason);
151
+ }
152
+ export function payrollCanManage(actor, record) {
153
+ return (employmentCanRead(actor) &&
154
+ actor.active_mode === UserTypeEmployer &&
155
+ (!record || record.owner_id === actor.owner_id));
156
+ }
157
+ export function payrollCanRead(actor, resource) {
158
+ return (employmentCanRead(actor) &&
159
+ (actor.active_mode === UserTypeEmployer ||
160
+ payrollPolicies[resource].employee_read));
161
+ }
162
+ export function payrollFilterQuery(resource, filters = {}) {
163
+ const allowed = payrollPolicies[resource].filters;
164
+ return Object.fromEntries(Object.entries(filters).filter(([key, value]) => allowed.includes(key) && value !== undefined));
165
+ }
166
+ export class PayrollsWorkspace {
167
+ payrolls;
168
+ billable;
169
+ ports;
170
+ // Only holds in-flight/ambiguous requests. Successful POSTs are checkpointed then removed.
171
+ submitted = new Map();
172
+ consuming = Promise.resolve();
173
+ sourceRequests = new Map();
174
+ constructor(payrolls, billable) {
175
+ this.payrolls = payrolls;
176
+ this.billable = billable;
177
+ this.ports = {
178
+ [PayrollResource.Payrolls]: {
179
+ query: (o) => payrolls.queryPayrolls(o),
180
+ search: (o) => payrolls.searchManyPayrolls(o),
181
+ find: (o) => payrolls.findOnePayroll(o),
182
+ },
183
+ [PayrollResource.HourBanks]: {
184
+ query: (o) => payrolls.queryHourBanks(o),
185
+ search: (o) => payrolls.searchManyHourBanks(o),
186
+ find: (o) => payrolls.findOneHourBank(o),
187
+ },
188
+ [PayrollResource.Payables]: {
189
+ query: (o) => payrolls.queryPayables(o),
190
+ search: (o) => payrolls.searchManyPayables(o),
191
+ find: (o) => payrolls.findOnePayable(o),
192
+ },
193
+ [PayrollResource.Adjustments]: {
194
+ query: (o) => payrolls.queryAdjustments(o),
195
+ search: (o) => payrolls.searchManyAdjustments(o),
196
+ find: (o) => payrolls.findOneAdjustment(o),
197
+ },
198
+ [PayrollResource.Folders]: {
199
+ query: (o) => payrolls.queryAdjustmentFolders(o),
200
+ search: (o) => payrolls.searchManyAdjustmentFolders(o),
201
+ find: (o) => payrolls.findOneAdjustmentFolder(o),
202
+ },
203
+ [PayrollResource.PayConfigs]: {
204
+ query: (o) => payrolls.queryPayConfigs(o),
205
+ search: (o) => payrolls.searchManyPayConfigs(o),
206
+ find: (o) => payrolls.findOnePayConfig(o),
207
+ },
208
+ [PayrollResource.EmployeeConfigs]: {
209
+ query: (o) => payrolls.queryEmployeePayConfigs(o),
210
+ search: (o) => payrolls.searchManyEmployeePayConfigs(o),
211
+ find: (o) => payrolls.findOneEmployeePayConfig(o),
212
+ },
213
+ [PayrollResource.Records]: {
214
+ query: (o) => payrolls.queryAdjustmentRecords(o),
215
+ search: (o) => payrolls.searchManyAdjustmentRecords(o),
216
+ find: (o) => payrolls.findOneAdjustmentRecord(o),
217
+ },
218
+ [PayrollResource.Aggregates]: {
219
+ query: (o) => payrolls.queryAdjustmentAggregates(o),
220
+ search: (o) => payrolls.searchManyAdjustmentAggregates(o),
221
+ find: (o) => payrolls.findOneAdjustmentAggregate(o),
222
+ },
223
+ [PayrollResource.Programs]: {
224
+ query: (o) => payrolls.queryPayrollPrograms(o),
225
+ search: (o) => payrolls.searchManyPayrollPrograms(o),
226
+ find: (o) => payrolls.findOnePayrollProgram(o),
227
+ },
228
+ };
229
+ }
230
+ async browse(opts) {
231
+ if (!payrollCanRead(opts.context, opts.resource))
232
+ return emptySearchPage(opts.page, opts.per_page);
233
+ const owner_ids = employmentCatalogOwnerIDs(opts.context, opts.employer_ids);
234
+ const query = payrollFilterQuery(opts.resource, opts.filters);
235
+ if (opts.context.active_mode === UserTypeEmployee)
236
+ query.employee_id = opts.context.owner_id;
237
+ const port = this.ports[opts.resource];
238
+ if (payrollPolicies[opts.resource].searchable) {
239
+ return runOwnerScopeSearch({
240
+ ...opts,
241
+ owner_ids,
242
+ query,
243
+ search: port.search,
244
+ });
245
+ }
246
+ requirePayroll(!opts.q?.trim(), "invalid");
247
+ const results = await runOwnerScopeQuery({
248
+ owner_ids,
249
+ query,
250
+ fetch: port.query,
251
+ signal: opts.signal,
252
+ });
253
+ return {
254
+ ...emptySearchPage(opts.page, opts.per_page),
255
+ results,
256
+ total_count: results.length,
257
+ };
258
+ }
259
+ async find(resource, id, signal) {
260
+ const value = (await this.ports[resource].find({ id, signal })).value;
261
+ requirePayroll(value, "reference");
262
+ return value;
263
+ }
264
+ async periods(owner_id, ranges) {
265
+ requirePayroll(owner_id && this.billable && ranges.length, "invalid");
266
+ const periods = new Map();
267
+ for (const range of ranges) {
268
+ const response = await this.billable.periodsFromDates({
269
+ employer_id: owner_id,
270
+ ...range,
271
+ });
272
+ const values = response.value?.periods?.periods;
273
+ requirePayroll(values?.length, "invalid");
274
+ for (const period of values)
275
+ periods.set(period.period_index, period);
276
+ }
277
+ return [...periods.values()].sort((a, b) => a.period_index - b.period_index);
278
+ }
279
+ async create(actor, command, attempt) {
280
+ if (command.resource !== PayrollResource.HourBanks &&
281
+ command.resource !== PayrollResource.Payables)
282
+ return this.createChecked(actor, command, attempt);
283
+ // Consumption transactions must re-read sources after the previous one settles.
284
+ // This also prevents two entries in the shared concurrent creator consuming the same hours.
285
+ const running = this.consuming.then(() => this.createChecked(actor, command, attempt));
286
+ this.consuming = running.catch(() => undefined);
287
+ return running;
288
+ }
289
+ async createChecked(actor, command, attempt) {
290
+ requirePayroll(payrollCanManage(actor), "access");
291
+ const resource = command.resource === PayrollResource.HourBanks ||
292
+ command.resource === PayrollResource.Payables
293
+ ? PayrollResource.Payrolls
294
+ : command.resource;
295
+ if (attempt.created_id)
296
+ return this.owned(actor, resource, attempt.created_id, attempt.signal);
297
+ const fingerprint = JSON.stringify({ owner_id: actor.owner_id, command });
298
+ const previous = this.submitted.get(attempt.idempotency_key);
299
+ requirePayroll(!previous || previous === fingerprint, "changed");
300
+ const sourceKeys = command.resource === PayrollResource.HourBanks ||
301
+ command.resource === PayrollResource.Payables
302
+ ? command.input.ids.map((id) => `${actor.owner_id}:${command.resource}:${id}`)
303
+ : [];
304
+ for (const key of sourceKeys)
305
+ requirePayroll(!this.sourceRequests.has(key) ||
306
+ this.sourceRequests.get(key) === attempt.idempotency_key, "pending_source");
307
+ if (!previous)
308
+ await this.validateCommand(actor, command, attempt.signal);
309
+ attempt.before_request?.();
310
+ attempt.signal.throwIfAborted();
311
+ this.submitted.set(attempt.idempotency_key, fingerprint);
312
+ for (const key of sourceKeys)
313
+ this.sourceRequests.set(key, attempt.idempotency_key);
314
+ const p = this.payrolls;
315
+ const request = {
316
+ signal: attempt.signal,
317
+ idempotency_key: attempt.idempotency_key,
318
+ };
319
+ const out = await (() => {
320
+ switch (command.resource) {
321
+ case PayrollResource.Payrolls:
322
+ return p.generatePayroll(command.input, request);
323
+ case PayrollResource.HourBanks:
324
+ return p.generatePayrollFromHourBanks(command.input, request);
325
+ case PayrollResource.Payables:
326
+ return p.generatePayrollFromPayables(command.input, request);
327
+ case PayrollResource.Aggregates:
328
+ return p.aggregateAdjustments(command.input, request);
329
+ case PayrollResource.Adjustments:
330
+ return p.insertOneAdjustment({ in: command.input, ...request });
331
+ case PayrollResource.Folders:
332
+ return p.insertOneAdjustmentFolder({ in: command.input, ...request });
333
+ case PayrollResource.PayConfigs:
334
+ return p.insertOnePayConfig({ in: command.input, ...request });
335
+ case PayrollResource.EmployeeConfigs:
336
+ return p.insertOneEmployeePayConfig({
337
+ in: command.input,
338
+ ...request,
339
+ });
340
+ case PayrollResource.Programs:
341
+ return p.insertOnePayrollProgram({ in: command.input, ...request });
342
+ }
343
+ })().catch((error) => {
344
+ // These three handlers roll back their transaction on validation failure.
345
+ // Other responses remain uncertain and retain request/source checkpoints.
346
+ const transactional = command.resource === PayrollResource.Payrolls ||
347
+ command.resource === PayrollResource.HourBanks ||
348
+ command.resource === PayrollResource.Payables;
349
+ if (transactional && isHTTPClientErr(error) && error.status === 400) {
350
+ this.submitted.delete(attempt.idempotency_key);
351
+ for (const key of sourceKeys)
352
+ this.sourceRequests.delete(key);
353
+ attempt.on_rejected?.();
354
+ }
355
+ throw error;
356
+ });
357
+ const id = requiredOperationID(out, "Generated payroll record response did not contain an ID.");
358
+ attempt.on_created?.(id);
359
+ this.submitted.delete(attempt.idempotency_key);
360
+ for (const key of sourceKeys)
361
+ this.sourceRequests.delete(key);
362
+ return this.owned(actor, resource, id, attempt.signal);
363
+ }
364
+ async owned(actor, resource, id, signal) {
365
+ const value = await this.find(resource, id, signal);
366
+ requirePayroll(payrollCanManage(actor, value), "access");
367
+ return value;
368
+ }
369
+ async active(actor, resource, id, currency, signal) {
370
+ const value = await this.owned(actor, resource, id, signal);
371
+ requirePayroll(!("is_archived" in value && value.is_archived) &&
372
+ (!currency || !("currency" in value) || value.currency === currency), "reference");
373
+ return value;
374
+ }
375
+ async adjustments(actor, ref, currency, signal) {
376
+ for (const [ids, contribution] of [
377
+ [ref.deduction_ids, false],
378
+ [ref.contribution_ids, true],
379
+ ]) {
380
+ requirePayroll(normalizeIDs(ids).length === ids.length, "invalid");
381
+ for (const id of ids) {
382
+ const value = await this.active(actor, PayrollResource.Adjustments, id, currency, signal);
383
+ requirePayroll(value.is_employer_contribution === contribution, "reference");
384
+ }
385
+ }
386
+ }
387
+ async config(actor, id, currency, signal) {
388
+ const value = await this.active(actor, PayrollResource.EmployeeConfigs, id, currency, signal);
389
+ const pay = await this.active(actor, PayrollResource.PayConfigs, value.pay_config_id, currency, signal);
390
+ await this.adjustments(actor, pay, currency, signal);
391
+ return value;
392
+ }
393
+ async validateCommand(actor, command, signal) {
394
+ const v = command.input;
395
+ const integerAmounts = [
396
+ "rate",
397
+ "unit_amount",
398
+ "work_normal_rate",
399
+ "work_overtime_rate",
400
+ "transport_normal_rate",
401
+ "transport_overtime_rate",
402
+ "default_fixed_hours",
403
+ "work_rate",
404
+ "transport_rate",
405
+ ];
406
+ for (const [key, value] of Object.entries(v))
407
+ if (integerAmounts.includes(key))
408
+ requirePayroll(typeof value === "number" &&
409
+ Number.isSafeInteger(value) &&
410
+ value >= 0, "invalid");
411
+ if ("sub_contributions" in v)
412
+ requirePayroll(v.sub_contributions.every((rule) => Number.isSafeInteger(rule.rate) && rule.rate >= 0), "invalid");
413
+ if ("periods" in v)
414
+ requirePayroll(v.periods.length &&
415
+ v.periods.every((period) => Number.isSafeInteger(period.period) &&
416
+ period.period > 0 &&
417
+ Number.isSafeInteger(period.period_year) &&
418
+ period.period_year > 0), "invalid");
419
+ if ("currency" in v)
420
+ requirePayroll(v.currency.trim(), "invalid");
421
+ switch (command.resource) {
422
+ case PayrollResource.Payrolls:
423
+ requirePayroll(command.input.periods.length &&
424
+ command.input.timezone === command.input.tz, "invalid");
425
+ await this.config(actor, command.input.employee_pay_config_id, command.input.currency, signal);
426
+ break;
427
+ case PayrollResource.Programs: {
428
+ const input = command.input;
429
+ const config = await this.config(actor, input.employee_pay_config_id, input.currency, signal);
430
+ requirePayroll(input.employee_id === config.employee_id &&
431
+ input.timezone === input.next_payroll_date.tz &&
432
+ Number.isSafeInteger(input.recurrence_rate) &&
433
+ input.recurrence_rate > 0 &&
434
+ payrollRecurrenceTypes.some((x) => x === input.recurrence_type), "invalid");
435
+ break;
436
+ }
437
+ case PayrollResource.HourBanks:
438
+ case PayrollResource.Payables: {
439
+ const input = command.input;
440
+ requirePayroll(input.employee_id &&
441
+ input.ids.length &&
442
+ normalizeIDs(input.ids).length === input.ids.length &&
443
+ Number.isSafeInteger(input.period_index), "invalid");
444
+ for (const id of input.ids) {
445
+ const source = await this.owned(actor, command.resource, id, signal);
446
+ requirePayroll(!source.is_payrolled &&
447
+ source.employee_id === input.employee_id &&
448
+ source.currency === input.currency, "source");
449
+ }
450
+ await this.adjustments(actor, input.AdjustmentRef, input.currency, signal);
451
+ break;
452
+ }
453
+ case PayrollResource.EmployeeConfigs:
454
+ requirePayroll(command.input.employee_id && command.input.name.trim(), "invalid");
455
+ await this.active(actor, PayrollResource.PayConfigs, command.input.pay_config_id, command.input.currency, signal);
456
+ break;
457
+ case PayrollResource.PayConfigs:
458
+ requirePayroll(command.input.name.trim() &&
459
+ payrollPayTypes.some((x) => x === command.input.type) &&
460
+ Number.isSafeInteger(command.input.default_fixed_hours), "invalid");
461
+ await this.adjustments(actor, command.input, command.input.currency, signal);
462
+ break;
463
+ case PayrollResource.Adjustments: {
464
+ const input = command.input;
465
+ requirePayroll(input.name.trim() &&
466
+ payrollAdjustmentTypes.some((x) => x === input.type), "invalid");
467
+ requirePayroll(!input.sub_contributions.length ||
468
+ (!input.is_employer_contribution &&
469
+ !input.is_accrued &&
470
+ input.sub_contributions.every((x) => x.type === AdjustmentTypeRate && !x.is_accrued)), "invalid");
471
+ await this.active(actor, PayrollResource.Folders, input.folder_id, undefined, signal);
472
+ break;
473
+ }
474
+ case PayrollResource.Folders:
475
+ requirePayroll(command.input.folder_name.trim(), "invalid");
476
+ break;
477
+ case PayrollResource.Aggregates:
478
+ requirePayroll(command.input.employee_ids.length && command.input.periods.length, "invalid");
479
+ break;
480
+ }
481
+ }
482
+ async setArchived(actor, resource, record, value) {
483
+ requirePayroll(payrollCanManage(actor, record) && payrollPolicies[resource].archive, "access");
484
+ await this.owned(actor, resource, record.id);
485
+ const opts = { id: record.id, in: { is_archived: value } };
486
+ switch (resource) {
487
+ case PayrollResource.Adjustments:
488
+ await this.payrolls.replaceOneAdjustment(opts);
489
+ break;
490
+ case PayrollResource.Folders:
491
+ await this.payrolls.replaceOneAdjustmentFolder(opts);
492
+ break;
493
+ case PayrollResource.PayConfigs:
494
+ await this.payrolls.replaceOnePayConfig(opts);
495
+ break;
496
+ case PayrollResource.EmployeeConfigs:
497
+ await this.payrolls.replaceOneEmployeePayConfig(opts);
498
+ break;
499
+ }
500
+ }
501
+ async setEnabled(actor, record, is_enabled) {
502
+ const latest = await this.owned(actor, PayrollResource.Programs, record.id);
503
+ if (is_enabled)
504
+ await this.config(actor, latest.employee_pay_config_id, latest.currency);
505
+ await this.payrolls.replaceOnePayrollProgram({
506
+ id: record.id,
507
+ in: { is_enabled },
508
+ });
509
+ }
510
+ async remove(actor, resource, record) {
511
+ requirePayroll(payrollCanManage(actor, record) && payrollPolicies[resource].remove, "access");
512
+ await this.owned(actor, resource, record.id);
513
+ if (resource === PayrollResource.Payrolls)
514
+ await this.payrolls.deleteOnePayroll({ id: record.id });
515
+ else if (resource === PayrollResource.Programs)
516
+ await this.payrolls.deleteOnePayrollProgram({ id: record.id });
517
+ }
518
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabriktor/fx",
3
- "version": "0.0.122",
3
+ "version": "0.0.123",
4
4
  "description": "![Fabriktor Logo](./img/fabriktor-character.gif)",
5
5
  "type": "module",
6
6
  "exports": {
@@ -17,7 +17,7 @@
17
17
  "typescript": "^6.0.3"
18
18
  },
19
19
  "dependencies": {
20
- "@fabriktor/client": "0.0.90",
20
+ "@fabriktor/client": "0.0.91",
21
21
  "@fabriktor/fx": "0.0.94",
22
22
  "@fabriktor/schema": "0.0.65"
23
23
  }