@fabriktor/fx 0.0.127 → 0.0.129

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.
@@ -15,6 +15,7 @@ export type MultiCreateCheckpoint = {
15
15
  before_request?: () => void;
16
16
  };
17
17
  export type MultiCreateAttempt = MultiCreateCheckpoint & {
18
+ entry_id?: string;
18
19
  idempotency_key: string;
19
20
  signal: AbortSignal;
20
21
  };
@@ -148,6 +148,7 @@ export class MultiCreator {
148
148
  signal.throwIfAborted();
149
149
  this.opts.before_request?.();
150
150
  const result = await this.opts.run(structuredClone(current.input), {
151
+ entry_id: current.id,
151
152
  signal,
152
153
  idempotency_key: current.idempotency_key,
153
154
  created_id: current.created_id,
package/dist/src/fx.js CHANGED
@@ -79,6 +79,9 @@ export class FX {
79
79
  });
80
80
  this.payments = newPaymentsFX({
81
81
  payments: opts.payments,
82
+ billable: opts.billable,
83
+ payrolls: opts.payrolls,
84
+ events: this.events,
82
85
  });
83
86
  this.payrolls = newPayrollsFX({
84
87
  payrolls: opts.payrolls,
@@ -1,6 +1,8 @@
1
- import { type CreditLedgerEntry, type Payment, type PmtAccount, type PmtBankAccount, type PmtCard, type PmtCustomer, type PmtPublicSession, type PmtRefund } from "@fabriktor/schema";
2
- import { type PaymentsOperator } from "@fabriktor/client";
3
- import { type OwnerSearchOptions, type SearchPage } from "../core/search.js";
1
+ import { PaymentEntityTypeBill, PaymentEntityTypePayroll, type Bill, type Payroll, PSPPaymentStatusPending, PSPPaymentStatusPartial, PSPPaymentStatusFull, type CreditLedgerEntry, type Payment, type PmtAccount, type PmtBankAccount, type PmtCard, type PmtCustomer, type PmtPublicSession, type PmtRefund } from "@fabriktor/schema";
2
+ import { type PaymentsOperator, type BillableOperator, type PayrollsOperator } from "@fabriktor/client";
3
+ import { type SearchOptions, type OwnerSearchOptions, type SearchPage } from "../core/search.js";
4
+ import { EventBus } from "../core/events.js";
5
+ import type { MultiCreateAttempt, MultiCreateCheckpoint } from "../core/multi-create.js";
4
6
  type PurchaseCreditsPayload = Parameters<PaymentsOperator["purchaseCredits"]>[0];
5
7
  type OpenPublicSessionPayload = Parameters<PaymentsOperator["openPmtPublicSession"]>[0];
6
8
  type PaymentFromPublicSessionPayload = Parameters<PaymentsOperator["paymentFromPublicSession"]>[0];
@@ -21,16 +23,73 @@ type PmtPublicSessionInsertOptions = Parameters<PaymentsOperator["insertOnePmtPu
21
23
  type PmtPublicSessionReplaceOptions = Parameters<PaymentsOperator["replaceOnePmtPublicSession"]>[0];
22
24
  type CreditLedgerEntryInsertOptions = Parameters<PaymentsOperator["insertOneCreditLedgerEntry"]>[0];
23
25
  type CreditLedgerEntryReplaceOptions = Parameters<PaymentsOperator["replaceOneCreditLedgerEntry"]>[0];
24
- export type EntityPaymentHistoryOptions = {
26
+ export type EntityPaymentScope = {
25
27
  owner_id: string;
26
- entity_id: string;
27
- type: Payment["type"];
28
28
  recipient_id?: string;
29
+ } | {
30
+ owner_id?: string;
31
+ recipient_id: string;
32
+ };
33
+ export type EntityPaymentStatus = typeof PSPPaymentStatusPending | typeof PSPPaymentStatusPartial | typeof PSPPaymentStatusFull;
34
+ export type EntityPaymentHistoryOptions = EntityPaymentScope & {
35
+ entity_id: string | readonly string[];
36
+ type: Payment["type"];
37
+ psp_payment_status?: EntityPaymentStatus | readonly EntityPaymentStatus[];
29
38
  signal?: AbortSignal;
30
39
  };
31
- export type CreatePaymentOptions = PaymentInsertOptions;
40
+ export type PaymentEntity = {
41
+ id: string;
42
+ currency: string;
43
+ original_amount: number;
44
+ };
45
+ export type ResolveEntityPaymentsOptions = EntityPaymentScope & Pick<EntityPaymentHistoryOptions, "type" | "psp_payment_status" | "signal"> & {
46
+ entities: readonly PaymentEntity[];
47
+ };
48
+ export type EntityPayments = {
49
+ entity: PaymentEntity;
50
+ payments: Payment[];
51
+ matching_payments: Payment[];
52
+ paid_amount: number;
53
+ remaining_amount: number;
54
+ status: EntityPaymentStatus;
55
+ has_pending: boolean;
56
+ };
57
+ export declare const PaymentsEvents: {
58
+ Changed: import("../index.js").EventKey<Payment>;
59
+ };
60
+ export type PayableEntityType = typeof PaymentEntityTypeBill | typeof PaymentEntityTypePayroll;
61
+ export type PayableEntity = PaymentEntity & {
62
+ type: PayableEntityType;
63
+ recipient_id: string;
64
+ record: Bill | Payroll;
65
+ };
66
+ export type PayableEntityBalance = EntityPayments & {
67
+ entity: PayableEntity;
68
+ };
69
+ export type SearchPayableEntitiesOptions = SearchOptions & {
70
+ owner_id: string;
71
+ type: PayableEntityType;
72
+ };
73
+ export type ResolvePayableEntityOptions = {
74
+ owner_id: string;
75
+ entity_id: string;
76
+ type: PayableEntityType;
77
+ signal?: AbortSignal;
78
+ };
79
+ export type CreateEntityPaymentOptions = {
80
+ owner_id: string;
81
+ entity_id: string;
82
+ type: PayableEntityType;
83
+ remaining_amount: number;
84
+ payer_payment_method_psp_id: string;
85
+ return_url: string;
86
+ };
87
+ export type CreatePaymentOptions = PaymentInsertOptions & MultiCreateCheckpoint;
32
88
  export type ReplacePaymentOptions = PaymentReplaceOptions;
33
- export type SearchPaymentsOptions = OwnerSearchOptions;
89
+ export type SearchPaymentsOptions = OwnerSearchOptions & {
90
+ type?: Payment["type"] | Payment["type"][];
91
+ kind?: Payment["kind"];
92
+ };
34
93
  export type SearchPaymentsPage = SearchPage<Payment>;
35
94
  export type CreatePmtAccountOptions = PmtAccountInsertOptions;
36
95
  export type ContinuePmtAccountOnboardingOptions = PmtAccountOnboardingOptions;
@@ -110,9 +169,16 @@ export type OpenPublicPaymentSessionResult = {
110
169
  };
111
170
  export type PaymentsFXOptions = {
112
171
  payments: PaymentsOperator;
172
+ billable?: Pick<BillableOperator, "searchManyBills" | "findOneBill">;
173
+ payrolls?: Pick<PayrollsOperator, "searchManyPayrolls" | "findOnePayroll">;
174
+ events?: EventBus;
113
175
  };
114
176
  export interface PaymentsFXOperator {
177
+ resolvePayableEntity(opts: ResolvePayableEntityOptions): Promise<PayableEntityBalance>;
178
+ searchPayableEntities(opts: SearchPayableEntitiesOptions): Promise<SearchPage<PayableEntityBalance>>;
179
+ createEntityPayment(opts: CreateEntityPaymentOptions, attempt: MultiCreateAttempt): Promise<Payment>;
115
180
  entityPaymentHistory(opts: EntityPaymentHistoryOptions): Promise<Payment[]>;
181
+ resolveEntityPayments(opts: ResolveEntityPaymentsOptions): Promise<EntityPayments[]>;
116
182
  createPayment(opts: CreatePaymentOptions): Promise<Payment>;
117
183
  replacePayment(opts: ReplacePaymentOptions): Promise<Payment>;
118
184
  searchPayments(opts: SearchPaymentsOptions): Promise<SearchPaymentsPage>;
@@ -149,9 +215,16 @@ export interface PaymentsFXOperator {
149
215
  }
150
216
  export declare class PaymentsFX implements PaymentsFXOperator {
151
217
  private payments;
218
+ private billable;
219
+ private payrolls;
220
+ private readonly events;
152
221
  private submittedPublicSessionIDs;
153
222
  constructor(opts: PaymentsFXOptions);
154
223
  entityPaymentHistory(opts: EntityPaymentHistoryOptions): Promise<Payment[]>;
224
+ resolveEntityPayments(opts: ResolveEntityPaymentsOptions): Promise<EntityPayments[]>;
225
+ searchPayableEntities(opts: SearchPayableEntitiesOptions): Promise<SearchPage<PayableEntityBalance>>;
226
+ resolvePayableEntity(opts: ResolvePayableEntityOptions): Promise<PayableEntityBalance>;
227
+ createEntityPayment(opts: CreateEntityPaymentOptions, attempt: MultiCreateAttempt): Promise<Payment>;
155
228
  createPayment(opts: CreatePaymentOptions): Promise<Payment>;
156
229
  replacePayment(opts: ReplacePaymentOptions): Promise<Payment>;
157
230
  searchPayments(opts: SearchPaymentsOptions): Promise<SearchPaymentsPage>;
@@ -196,4 +269,5 @@ export declare class PaymentsFX implements PaymentsFXOperator {
196
269
  private openPublicPaymentSession;
197
270
  }
198
271
  export declare function newPaymentsFX(opts: PaymentsFXOptions): PaymentsFX;
272
+ export declare function isPaymentPending(payment: Payment): boolean;
199
273
  export {};
@@ -3,11 +3,13 @@
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, PaymentKindMain, } from "@fabriktor/schema";
7
- import { queryParams } from "@fabriktor/client";
6
+ import { CreditLedgerSourceAndroidIAP, CreditLedgerSourceAppleIAP, CreditLedgerSourceStripe, ZeroID, PaymentKindMain, PaymentEntityTypeBill, PaymentEntityTypePayroll, PSPActionableStatusSucceeded, PSPActionableStatusFailed, PSPActionableStatusCanceled, PSPPaymentStatusPending, PSPPaymentStatusPartial, PSPPaymentStatusFull, } from "@fabriktor/schema";
7
+ import { queryParams, } from "@fabriktor/client";
8
8
  import { errResolveFailed, errValidation } from "../core/err.js";
9
- import { requiredOperationID, resolvedID } from "../core/id.js";
10
- import { runOwnerSearch, } from "../core/search.js";
9
+ import { normalizeIDs, requiredOperationID, resolvedID } from "../core/id.js";
10
+ import { runOwnerSearch, runSearch, } from "../core/search.js";
11
+ import { createEvent, EventBus } from "../core/events.js";
12
+ const entityPaymentQuerySize = 30;
11
13
  const publicAmountPattern = /^\d+$/;
12
14
  const resourcePayment = "payment";
13
15
  const resourcePmtAccount = "pmt_account";
@@ -20,6 +22,17 @@ const resourcePmtPublicSession = "pmt_public_session";
20
22
  const resourceCreditLedgerEntry = "credit_ledger_entry";
21
23
  const resourceStripeCreditPayment = "stripe credit payment";
22
24
  const resourcePublicPaymentSession = "public payment session";
25
+ const msgPayableSourceMissing = "Payable entity source is unavailable.";
26
+ const msgPayableBalanceChanged = "Payment balance changed or a payment is still pending. Refresh before paying.";
27
+ const msgPaymentMethodRequired = "A payment method is required.";
28
+ const msgPayableUnavailable = "This entity is not available for payment.";
29
+ const msgPaymentScopeMissing = "Payment history requires a payer or recipient scope and an entity type.";
30
+ const msgPaymentHistoryIncomplete = "Payment history response is incomplete.";
31
+ const msgPaymentEntityInvalid = "Payment resolution requires a valid entity, currency, and amount.";
32
+ const msgPaymentEntityConflict = "Payment resolution received conflicting entity amounts.";
33
+ const msgPaymentCurrencyMismatch = "Payment currency differs from the entity currency.";
34
+ const msgPaymentAmountInvalid = "Payment amount is invalid.";
35
+ const msgPaymentStatusesInvalid = "Select at least one valid payment status, or omit the status filter.";
23
36
  const msgCreatedPaymentIDMissing = "Created payment response did not contain an ID.";
24
37
  const msgPaymentMissing = "Payment could not be resolved after the mutation.";
25
38
  const msgCreatedPmtAccountIDMissing = "Created payment account response did not contain an ID.";
@@ -47,6 +60,9 @@ const msgPublicSessionConsumed = "Public payment session has already been consum
47
60
  const msgPublicAmountInvalid = "Public payment amount must be a positive integer.";
48
61
  const msgPublicBalanceInvalid = "Public payment session balance is invalid.";
49
62
  const msgPublicAmountExceedsBalance = "Public payment amount cannot exceed the session balance.";
63
+ export const PaymentsEvents = {
64
+ Changed: createEvent(),
65
+ };
50
66
  export const PmtAccountAccessStatus = {
51
67
  Missing: "missing",
52
68
  OnboardingRequired: "onboarding_required",
@@ -71,8 +87,7 @@ export function pmtAccountAccess(account) {
71
87
  if (account === null) {
72
88
  return { account, status: PmtAccountAccessStatus.Missing, ready: false };
73
89
  }
74
- if (account.is_currently_due ||
75
- account.is_external_account_due) {
90
+ if (account.is_currently_due || account.is_external_account_due) {
76
91
  return {
77
92
  account,
78
93
  status: PmtAccountAccessStatus.RequirementsDue,
@@ -96,34 +111,289 @@ export const PublicPaymentSessionStatus = {
96
111
  };
97
112
  export class PaymentsFX {
98
113
  payments;
114
+ billable;
115
+ payrolls;
116
+ events;
99
117
  submittedPublicSessionIDs;
100
118
  constructor(opts) {
101
119
  this.payments = opts.payments;
120
+ this.billable = opts.billable;
121
+ this.payrolls = opts.payrolls;
122
+ this.events = opts.events ?? new EventBus();
102
123
  this.submittedPublicSessionIDs = new Set();
103
124
  }
104
125
  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." });
126
+ const owner_id = resolvedID(opts.owner_id);
127
+ const recipient_id = resolvedID(opts.recipient_id);
128
+ if ((!owner_id && !recipient_id) || !opts.type.trim()) {
129
+ throw errValidation({ msg: msgPaymentScopeMissing });
130
+ }
131
+ const ids = normalizeIDs(typeof opts.entity_id === "string" ? [opts.entity_id] : opts.entity_id);
132
+ const statuses = entityPaymentStatuses(opts.psp_payment_status);
133
+ const records = new Map();
134
+ for (let offset = 0; offset < ids.length; offset += entityPaymentQuerySize) {
135
+ opts.signal?.throwIfAborted();
136
+ const response = await this.payments.queryPayments({
137
+ signal: opts.signal,
138
+ query: queryParams({
139
+ owner_id,
140
+ recipient_id,
141
+ entity_id: ids.length === 1
142
+ ? ids[0]
143
+ : ids.slice(offset, offset + entityPaymentQuerySize),
144
+ type: opts.type,
145
+ kind: PaymentKindMain,
146
+ ...(statuses === undefined ? {} : { psp_payment_status: statuses }),
147
+ }),
148
+ });
149
+ opts.signal?.throwIfAborted();
150
+ if (!Array.isArray(response.value)) {
151
+ throw errResolveFailed({
152
+ resource: resourcePayment,
153
+ msg: msgPaymentHistoryIncomplete,
154
+ });
155
+ }
156
+ // Retain each entity's backend order and ignore duplicate documents.
157
+ for (const payment of response.value)
158
+ records.set(payment.id, payment);
159
+ }
160
+ return [...records.values()];
161
+ }
162
+ async resolveEntityPayments(opts) {
163
+ const { entities, psp_payment_status, ...scope } = opts;
164
+ const statuses = entityPaymentStatuses(psp_payment_status);
165
+ const unique = new Map();
166
+ for (const entity of entities) {
167
+ if (!resolvedID(entity.id) ||
168
+ !entity.currency.trim() ||
169
+ !Number.isSafeInteger(entity.original_amount) ||
170
+ entity.original_amount < 0) {
171
+ throw errValidation({ msg: msgPaymentEntityInvalid });
172
+ }
173
+ const previous = unique.get(entity.id);
174
+ if (previous &&
175
+ (previous.currency !== entity.currency ||
176
+ previous.original_amount !== entity.original_amount)) {
177
+ throw errValidation({ msg: msgPaymentEntityConflict });
178
+ }
179
+ unique.set(entity.id, entity);
180
+ }
181
+ // Never infer unpaid from a filtered history: resolve coverage from all statuses.
182
+ const payments = await this.entityPaymentHistory({
183
+ ...scope,
184
+ entity_id: [...unique.keys()],
185
+ });
186
+ const grouped = new Map();
187
+ for (const payment of payments) {
188
+ if (payment.kind !== PaymentKindMain ||
189
+ payment.type !== opts.type ||
190
+ !unique.has(payment.entity_id))
191
+ continue;
192
+ const group = grouped.get(payment.entity_id) ?? [];
193
+ group.push(payment);
194
+ grouped.set(payment.entity_id, group);
195
+ }
196
+ return [...unique.values()].map((entity) => {
197
+ const records = grouped.get(entity.id) ?? [];
198
+ let paid_amount = 0;
199
+ for (const payment of records) {
200
+ if (payment.currency.toUpperCase() !== entity.currency.toUpperCase()) {
201
+ throw errResolveFailed({
202
+ resource: resourcePayment,
203
+ msg: msgPaymentCurrencyMismatch,
204
+ });
205
+ }
206
+ if (payment.psp_actionable_status !== PSPActionableStatusSucceeded)
207
+ continue;
208
+ const amount = payment.gross_amount.raw;
209
+ if (!Number.isSafeInteger(amount) ||
210
+ amount < 0 ||
211
+ !Number.isSafeInteger(paid_amount + amount)) {
212
+ throw errResolveFailed({
213
+ resource: resourcePayment,
214
+ msg: msgPaymentAmountInvalid,
215
+ });
216
+ }
217
+ paid_amount += amount;
218
+ }
219
+ const remaining_amount = Math.max(0, entity.original_amount - paid_amount);
220
+ return {
221
+ entity,
222
+ payments: records,
223
+ matching_payments: statuses === undefined
224
+ ? records
225
+ : records.filter((payment) => statuses.includes(payment.psp_payment_status)),
226
+ paid_amount,
227
+ remaining_amount,
228
+ status: remaining_amount === 0
229
+ ? PSPPaymentStatusFull
230
+ : paid_amount > 0
231
+ ? PSPPaymentStatusPartial
232
+ : PSPPaymentStatusPending,
233
+ has_pending: records.some(isPaymentPending),
234
+ };
235
+ });
236
+ }
237
+ // Scan real source pages until there are payable results or the source is exhausted.
238
+ // Settlement is a cross-service resolution, not a fabricated backend unpaid filter.
239
+ async searchPayableEntities(opts) {
240
+ if (!resolvedID(opts.owner_id))
241
+ throw errValidation({ msg: msgPaymentScopeMissing });
242
+ let page = opts.page;
243
+ while (true) {
244
+ opts.signal?.throwIfAborted();
245
+ let source;
246
+ if (opts.type === PaymentEntityTypeBill && this.billable) {
247
+ source = await runSearch({
248
+ ...opts,
249
+ page,
250
+ query: queryParams({
251
+ client_id: opts.owner_id,
252
+ is_internal_client: true,
253
+ is_estimate: false,
254
+ is_canceled: false,
255
+ }),
256
+ search: (request) => this.billable.searchManyBills(request),
257
+ });
258
+ }
259
+ else if (opts.type === PaymentEntityTypePayroll && this.payrolls) {
260
+ source = await runOwnerSearch({
261
+ ...opts,
262
+ page,
263
+ query: queryParams({
264
+ is_banked: false,
265
+ is_problematic: false,
266
+ }),
267
+ search: (request) => this.payrolls.searchManyPayrolls(request),
268
+ });
269
+ }
270
+ else
271
+ throw errValidation({ msg: msgPayableSourceMissing });
272
+ const entities = source.results.map((record) => payableEntity(record, opts.type, opts.owner_id));
273
+ const balances = await this.resolveEntityPayments({
274
+ owner_id: opts.owner_id,
275
+ type: opts.type,
276
+ entities,
277
+ signal: opts.signal,
278
+ });
279
+ const results = balances.flatMap((balance, index) => balance.remaining_amount > 0 && !balance.has_pending
280
+ ? [{ ...balance, entity: entities[index] }]
281
+ : []);
282
+ if (results.length || source.done) {
283
+ // Source counts include paid records and cannot describe the filtered result.
284
+ return {
285
+ ...source,
286
+ results,
287
+ total_count: undefined,
288
+ last_page: undefined,
289
+ };
290
+ }
291
+ if (source.next_page <= source.page)
292
+ throw errResolveFailed({
293
+ resource: resourcePayment,
294
+ msg: msgPaymentHistoryIncomplete,
295
+ });
296
+ page = source.next_page;
107
297
  }
108
- const response = await this.payments.queryPayments({
298
+ }
299
+ async resolvePayableEntity(opts) {
300
+ opts.signal?.throwIfAborted();
301
+ const record = opts.type === PaymentEntityTypeBill
302
+ ? (await this.billable?.findOneBill({
303
+ id: opts.entity_id,
304
+ signal: opts.signal,
305
+ }))?.value
306
+ : opts.type === PaymentEntityTypePayroll
307
+ ? (await this.payrolls?.findOnePayroll({
308
+ id: opts.entity_id,
309
+ signal: opts.signal,
310
+ }))?.value
311
+ : undefined;
312
+ if (!record)
313
+ throw errResolveFailed({
314
+ resource: resourcePayment,
315
+ msg: msgPaymentMissing,
316
+ });
317
+ const entity = payableEntity(record, opts.type, opts.owner_id);
318
+ const [balance] = await this.resolveEntityPayments({
319
+ owner_id: opts.owner_id,
320
+ type: opts.type,
321
+ entities: [entity],
109
322
  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
323
  });
112
- // Preserve the backend's chronological order; do not infer settlement from PSP payment balance flags.
113
- return response.value ?? [];
324
+ if (!balance)
325
+ throw errResolveFailed({
326
+ resource: resourcePayment,
327
+ msg: msgPaymentHistoryIncomplete,
328
+ });
329
+ return { ...balance, entity };
330
+ }
331
+ async createEntityPayment(opts, attempt) {
332
+ if (attempt.created_id)
333
+ return this.findPayment(attempt.created_id, attempt.signal);
334
+ const balance = await this.resolvePayableEntity({
335
+ ...opts,
336
+ signal: attempt.signal,
337
+ });
338
+ const { entity } = balance;
339
+ if (!balance ||
340
+ balance.has_pending ||
341
+ balance.remaining_amount <= 0 ||
342
+ balance.remaining_amount !== opts.remaining_amount) {
343
+ throw errValidation({
344
+ msg: msgPayableBalanceChanged,
345
+ });
346
+ }
347
+ if (!resolvedID(opts.payer_payment_method_psp_id))
348
+ throw errValidation({ msg: msgPaymentMethodRequired });
349
+ const amount = {
350
+ raw: balance.remaining_amount,
351
+ text: String(balance.remaining_amount),
352
+ };
353
+ return this.createPayment({
354
+ ...attempt,
355
+ in: {
356
+ psp_confirm: true,
357
+ is_manual: false,
358
+ currency: entity.currency,
359
+ entity_id: entity.id,
360
+ type: entity.type,
361
+ recipient_id: entity.recipient_id,
362
+ payer_payment_method_psp_id: opts.payer_payment_method_psp_id,
363
+ return_url: opts.return_url,
364
+ use_psp_sdk: true,
365
+ original_amount: {
366
+ raw: entity.original_amount,
367
+ text: String(entity.original_amount),
368
+ },
369
+ original_balance_amount: amount,
370
+ gross_amount: amount,
371
+ is_off_session: false,
372
+ },
373
+ });
114
374
  }
115
375
  async createPayment(opts) {
376
+ if (opts.created_id)
377
+ return this.findPayment(opts.created_id, opts.signal);
378
+ opts.signal?.throwIfAborted();
379
+ opts.before_request?.();
116
380
  const out = await this.payments.insertOnePayment(opts);
117
381
  const id = requiredOperationID(out, msgCreatedPaymentIDMissing);
118
- return await this.findPayment(id);
382
+ opts.on_created?.(id);
383
+ const payment = await this.findPayment(id, opts.signal);
384
+ this.events.publish(PaymentsEvents.Changed, payment);
385
+ return payment;
119
386
  }
120
387
  async replacePayment(opts) {
121
388
  await this.payments.replaceOnePayment(opts);
122
- return await this.findPayment(opts.id);
389
+ const payment = await this.findPayment(opts.id);
390
+ this.events.publish(PaymentsEvents.Changed, payment);
391
+ return payment;
123
392
  }
124
393
  async searchPayments(opts) {
125
394
  return await runOwnerSearch({
126
395
  ...opts,
396
+ query: queryParams({ type: opts.type, kind: opts.kind }),
127
397
  search: (searchOpts) => this.payments.searchManyPayments(searchOpts),
128
398
  });
129
399
  }
@@ -388,9 +658,10 @@ export class PaymentsFX {
388
658
  throw err;
389
659
  }
390
660
  }
391
- async findPayment(id) {
661
+ async findPayment(id, signal) {
392
662
  const out = await this.payments.findOnePayment({
393
663
  id,
664
+ signal,
394
665
  });
395
666
  const payment = out.value;
396
667
  if (payment === undefined) {
@@ -597,3 +868,45 @@ function zeroGoogleIAP() {
597
868
  kind: "",
598
869
  };
599
870
  }
871
+ // A coverage flag alone does not establish the provider outcome.
872
+ export function isPaymentPending(payment) {
873
+ return (payment.psp_actionable_status !== PSPActionableStatusSucceeded &&
874
+ payment.psp_actionable_status !== PSPActionableStatusFailed &&
875
+ payment.psp_actionable_status !== PSPActionableStatusCanceled);
876
+ }
877
+ function entityPaymentStatuses(value) {
878
+ if (value === undefined)
879
+ return undefined;
880
+ const values = [...new Set(typeof value === "string" ? [value] : value)];
881
+ if (!values.length ||
882
+ values.some((status) => status !== PSPPaymentStatusPending &&
883
+ status !== PSPPaymentStatusPartial &&
884
+ status !== PSPPaymentStatusFull)) {
885
+ throw errValidation({ msg: msgPaymentStatusesInvalid });
886
+ }
887
+ return values;
888
+ }
889
+ function payableEntity(record, type, ownerID) {
890
+ const bill = "is_estimate" in record;
891
+ if (!resolvedID(ownerID) ||
892
+ (type === PaymentEntityTypeBill
893
+ ? !bill ||
894
+ !record.is_internal_client ||
895
+ record.client_id !== ownerID ||
896
+ record.is_estimate ||
897
+ record.is_canceled
898
+ : bill ||
899
+ record.owner_id !== ownerID ||
900
+ record.is_banked ||
901
+ record.is_problematic)) {
902
+ throw errValidation({ msg: msgPayableUnavailable });
903
+ }
904
+ return {
905
+ id: record.id,
906
+ type,
907
+ recipient_id: bill ? record.owner_id : record.employee_id,
908
+ currency: record.currency,
909
+ original_amount: bill ? record.total.total.raw : record.net_amount.raw,
910
+ record,
911
+ };
912
+ }
@@ -311,23 +311,28 @@ export class PayrollsWorkspace {
311
311
  : command.resource;
312
312
  if (attempt.created_id)
313
313
  return this.owned(actor, resource, attempt.created_id, attempt.signal);
314
+ const requestID = attempt.entry_id ?? attempt.idempotency_key;
314
315
  const fingerprint = JSON.stringify({ owner_id: actor.owner_id, command });
315
- const previous = this.submitted.get(attempt.idempotency_key);
316
- requirePayroll(!previous || previous === fingerprint, "changed");
316
+ const previous = this.submitted.get(requestID);
317
+ requirePayroll(!previous || previous.fingerprint === fingerprint, "changed");
317
318
  const sourceKeys = command.resource === PayrollResource.HourBanks ||
318
319
  command.resource === PayrollResource.Payables
319
320
  ? command.input.ids.map((id) => `${actor.owner_id}:${command.resource}:${id}`)
320
321
  : [];
321
322
  for (const key of sourceKeys)
322
323
  requirePayroll(!this.sourceRequests.has(key) ||
323
- this.sourceRequests.get(key) === attempt.idempotency_key, "pending_source");
324
- if (!previous)
324
+ this.sourceRequests.get(key) === requestID, "pending_source");
325
+ // A fresh HTTP attempt must re-read configuration and source consumption state.
326
+ if (!previous || previous.idempotency_key !== attempt.idempotency_key)
325
327
  await this.validateCommand(actor, command, attempt.signal);
326
328
  attempt.before_request?.();
327
329
  attempt.signal.throwIfAborted();
328
- this.submitted.set(attempt.idempotency_key, fingerprint);
330
+ this.submitted.set(requestID, {
331
+ fingerprint,
332
+ idempotency_key: attempt.idempotency_key,
333
+ });
329
334
  for (const key of sourceKeys)
330
- this.sourceRequests.set(key, attempt.idempotency_key);
335
+ this.sourceRequests.set(key, requestID);
331
336
  const p = this.payrolls;
332
337
  const request = {
333
338
  signal: attempt.signal,
@@ -365,7 +370,7 @@ export class PayrollsWorkspace {
365
370
  isHTTPClientErr(error) &&
366
371
  error.status !== undefined &&
367
372
  payrollGenerationRejectionStatuses.includes(error.status)) {
368
- this.submitted.delete(attempt.idempotency_key);
373
+ this.submitted.delete(requestID);
369
374
  for (const key of sourceKeys)
370
375
  this.sourceRequests.delete(key);
371
376
  attempt.on_rejected?.();
@@ -374,7 +379,7 @@ export class PayrollsWorkspace {
374
379
  });
375
380
  const id = requiredOperationID(out, "Generated payroll record response did not contain an ID.");
376
381
  attempt.on_created?.(id);
377
- this.submitted.delete(attempt.idempotency_key);
382
+ this.submitted.delete(requestID);
378
383
  for (const key of sourceKeys)
379
384
  this.sourceRequests.delete(key);
380
385
  return this.owned(actor, resource, id, attempt.signal);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fabriktor/fx",
3
- "version": "0.0.127",
3
+ "version": "0.0.129",
4
4
  "description": "![Fabriktor Logo](./img/fabriktor-character.gif)",
5
5
  "type": "module",
6
6
  "exports": {
@@ -17,8 +17,8 @@
17
17
  "typescript": "^6.0.3"
18
18
  },
19
19
  "dependencies": {
20
- "@fabriktor/client": "0.0.91",
20
+ "@fabriktor/client": "0.0.92",
21
21
  "@fabriktor/fx": "0.0.94",
22
- "@fabriktor/schema": "0.0.65"
22
+ "@fabriktor/schema": "0.0.66"
23
23
  }
24
24
  }