@fire-spark/payments 0.2.0 → 0.3.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.
package/dist/index.d.ts CHANGED
@@ -1,4 +1,98 @@
1
- import { OrderTotals, PaymentMethod, PaymentProvider } from "@fire-spark/types";
1
+ import { MixedPaymentsEffect, OrderLine, OrderTotals, PaymentCollectAt, PaymentCollectAt as PaymentCollectAt$1, PaymentErrorCode as PaymentErrorCode$1, PaymentIntent, PaymentMethod, PaymentProvider, PaymentScope, PaymentStatus, PaymentTender, TenderAllocation, TenderAllocationInput, TenderAmount, TenderDetails } from "@fire-spark/types";
2
+ //#region src/allocation.d.ts
3
+ /** `metadata.fee_based_on` values that are a tip (always primary, D18). */
4
+ declare const TIP_FEE_BASED_ON: readonly string[];
5
+ /** `metadata.fee_based_on` values that are delivery (always primary, D18). */
6
+ declare const DELIVERY_FEE_BASED_ON: readonly string[];
7
+ type AllocateTenderInput = {
8
+ id: string;
9
+ sequence: number;
10
+ method: PaymentMethod;
11
+ allocation: TenderAllocationInput | null;
12
+ /** Value a REDEMPTION tender covers (`details.points_value`). */
13
+ redemption_amount?: number;
14
+ };
15
+ type AllocateTendersInput = {
16
+ lines: readonly OrderLine[];
17
+ totals: OrderTotals;
18
+ tenders: readonly AllocateTenderInput[];
19
+ };
20
+ type AllocatedTender = {
21
+ id: string;
22
+ sequence: number;
23
+ method: PaymentMethod;
24
+ allocation: TenderAllocation | null;
25
+ amount: TenderAmount;
26
+ };
27
+ type AllocationError = {
28
+ code: PaymentErrorCode$1;
29
+ message: string;
30
+ tender_id?: string;
31
+ line_uid?: string;
32
+ };
33
+ type AllocateTendersResult = {
34
+ ok: true;
35
+ tenders: AllocatedTender[];
36
+ primary_tender_id: string | null;
37
+ } | {
38
+ ok: false;
39
+ error: AllocationError;
40
+ };
41
+ /** Tip fee line (`metadata.fee_based_on = TIPS`). */
42
+ declare const isTipFee: (line: OrderLine) => boolean;
43
+ /** Delivery fee line (distance or coverage zone based). */
44
+ declare const isDeliveryFee: (line: OrderLine) => boolean;
45
+ /**
46
+ * Splits `amount` (minor units) by `units / quantity` per part. Each part
47
+ * gets the floor of its exact share; the residual goes one minor unit at a
48
+ * time to the largest discarded fraction, ties to the lowest `sequence`.
49
+ */
50
+ declare const prorateMinor: (amount: number, quantity: number, parts: readonly {
51
+ units: number;
52
+ sequence: number;
53
+ }[]) => number[];
54
+ /**
55
+ * Server-side money of every tender (§3.2): whole lines as stored, parts
56
+ * prorated per component with the residual minor unit to the largest
57
+ * discarded fraction, fees to their tender, tip / delivery and order-level
58
+ * adjustments to the primary, then REDEMPTION deducted from the primary
59
+ * first and by `sequence`, never below a tender's tip + delivery (D18).
60
+ */
61
+ declare const allocateTenders: (input: AllocateTendersInput) => AllocateTendersResult;
62
+ /**
63
+ * Items of one real tender (D8): whole `PRODUCT` parent lines plus each
64
+ * part (1 each). Children, fees and REDEMPTION never count; units do not
65
+ * add up. `null` allocation = every product parent line.
66
+ */
67
+ declare const countTenderItems: (allocation: Pick<TenderAllocationInput, "lines"> | null, lines: readonly OrderLine[]) => number;
68
+ type RulesTender = {
69
+ id: string;
70
+ method: PaymentMethod;
71
+ amount: {
72
+ total: number;
73
+ currency: string;
74
+ };
75
+ allocation: Pick<TenderAllocationInput, "lines"> | null;
76
+ };
77
+ type ValidateTendersResult = {
78
+ ok: true;
79
+ is_mixed: boolean;
80
+ } | {
81
+ ok: false;
82
+ error: AllocationError & {
83
+ reason?: string;
84
+ };
85
+ };
86
+ /**
87
+ * Mixed payments rules over real tenders only (D17): a REDEMPTION never
88
+ * makes an order mixed, counts as a method, an item or an amount.
89
+ */
90
+ declare const validateTendersAgainstRules: (input: {
91
+ tenders: readonly RulesTender[];
92
+ lines: readonly OrderLine[];
93
+ effect: MixedPaymentsEffect | null;
94
+ }) => ValidateTendersResult;
95
+ //#endregion
2
96
  //#region src/errors.d.ts
3
97
  type PaymentErrorReason = "timeout" | "network" | "declined" | "provider" | "ambiguous" | "user";
4
98
  type ErrorDefaults = {
@@ -106,8 +200,8 @@ declare const isPaymentError: (value: unknown) => value is PaymentError;
106
200
  declare const formatPaymentError: (error: StandardPaymentError, providerName?: string) => string;
107
201
  //#endregion
108
202
  //#region src/types.d.ts
109
- type PaymentCollectAt = "DEVICE" | "KIOSK" | "COUNTER" | "DOOR" | "OTHER";
110
- type PaymentIntentStatus = "IDLE" | "PENDING" | "PAID" | "FAILED" | "CANCELLED" | "REFUNDED";
203
+ /** Status of one tender attempt (same values as order `payment_status`). */
204
+ type PaymentIntentStatus = PaymentStatus;
111
205
  type PaymentEnvironment = "sandbox" | "production";
112
206
  /** Amount of one intent, already in the charge currency. */
113
207
  type PaymentAmount = {
@@ -213,7 +307,7 @@ type PaymentContext = {
213
307
  customer: PaymentCustomer;
214
308
  amount: PaymentAmount;
215
309
  taxes: TaxBreakdown[];
216
- collectAt: PaymentCollectAt;
310
+ collectAt: PaymentCollectAt$1;
217
311
  attemptReference: string;
218
312
  attempt: number;
219
313
  environment: PaymentEnvironment;
@@ -293,6 +387,8 @@ type PaymentResult = {
293
387
  amount: number;
294
388
  metadata: PaymentMetadata;
295
389
  error?: StandardPaymentError;
390
+ /** Non-sensitive tender data recorded with the attempt (§3.1). */
391
+ details?: TenderDetails | null;
296
392
  };
297
393
  type PaymentReversal = "cancel" | "refund" | "manual";
298
394
  type PaymentOperation = (ctx: PaymentContext, result: PaymentResult) => Promise<PaymentResult>;
@@ -307,8 +403,15 @@ type PaymentAdapter = {
307
403
  refund?: PaymentOperation;
308
404
  reversalFor?(result: PaymentResult, now: Date): PaymentReversal;
309
405
  terminal?: TerminalOperations;
406
+ capabilities?: PaymentAdapterCapabilities;
310
407
  errors: PaymentErrorDictionary;
311
408
  };
409
+ type PaymentAdapterCapabilities = {
410
+ /** Authorizes first and captures with `confirm` after inject (§6.1). */
411
+ deferredCapture?: boolean;
412
+ /** Collected by a person; the tender stays `IDLE` on the device. */
413
+ inPerson?: boolean;
414
+ };
312
415
  //#endregion
313
416
  //#region src/amount.d.ts
314
417
  /** ISO 4217 minor units; anything not listed uses 2. */
@@ -382,6 +485,134 @@ declare const taxBreakdown: (lines: TaxLine[], options?: {
382
485
  decimals?: number;
383
486
  }) => TaxBreakdown[];
384
487
  //#endregion
488
+ //#region src/tenders.d.ts
489
+ declare const REDEMPTION_METHOD = "REDEMPTION";
490
+ /** Methods that charge money (everything but `REDEMPTION`, D17). */
491
+ declare const isRealPaymentMethod: (method: string) => boolean;
492
+ declare const ACTIVE_INTENT_STATUSES: readonly ["DRAFT", "LOCKED"];
493
+ /** `collect_at` charged by the SDK on the device (`digital` scope). */
494
+ declare const DIGITAL_COLLECT_AT: readonly string[];
495
+ /** Tender statuses that are final for one tender. */
496
+ declare const TERMINAL_TENDER_STATUSES: readonly PaymentStatus[];
497
+ /** Allowed status changes of one tender; late notices never downgrade. */
498
+ declare const NEXT_STATUSES: Record<PaymentStatus, readonly PaymentStatus[]>;
499
+ /** `error_message` of a tender failed by a partial approval (D31). */
500
+ declare const PARTIAL_APPROVAL_ERROR = "PARTIAL_APPROVAL";
501
+ type IntentStatusLike = {
502
+ status: string;
503
+ };
504
+ type TenderLike = {
505
+ id: string;
506
+ method: string;
507
+ status: PaymentStatus;
508
+ collect_at: string;
509
+ amount: {
510
+ total: number;
511
+ };
512
+ replaces_tender_id?: string;
513
+ metadata?: Record<string, unknown>;
514
+ };
515
+ type IntentLike<T extends TenderLike = TenderLike> = {
516
+ status: string;
517
+ totals: {
518
+ currency: string;
519
+ total: number;
520
+ };
521
+ tenders: readonly T[];
522
+ };
523
+ /** The one `DRAFT` / `LOCKED` intent, by status (never by position). */
524
+ declare const activeIntent: <T extends IntentStatusLike>(intents: readonly T[] | null | undefined) => T | null;
525
+ /** The intent that pays the order: the active one, else the completed one. */
526
+ declare const currentIntent: <T extends IntentStatusLike>(intents: readonly T[] | null | undefined) => T | null;
527
+ /** Tenders not replaced by a retry or a method change (D12, D25). */
528
+ declare const liveTenders: <T extends TenderLike>(intent: {
529
+ tenders: readonly T[];
530
+ } | null | undefined) => T[];
531
+ declare const realTenders: <T extends {
532
+ method: string;
533
+ }>(tenders: readonly T[]) => T[];
534
+ /** Live real tenders of the current intent (facts for workflow edges). */
535
+ declare const liveRealTenders: <T extends TenderLike>(intents: readonly IntentLike<T>[] | null | undefined) => T[];
536
+ /** 2+ real tenders; `REDEMPTION` never makes an order mixed (D17). */
537
+ declare const isMixedTenders: (tenders: readonly {
538
+ method: string;
539
+ }[]) => boolean;
540
+ /** First real tender by `sequence`; null when every tender is REDEMPTION. */
541
+ declare const primaryTenderOf: <T extends {
542
+ method: string;
543
+ sequence: number;
544
+ }>(tenders: readonly T[]) => T | null;
545
+ /** The primary tender of an intent (by `primary_tender_id`). */
546
+ declare const primaryTender: <T extends TenderLike>(intent: {
547
+ primary_tender_id: string | null;
548
+ tenders: readonly T[];
549
+ } | null | undefined) => T | null;
550
+ /** A REDEMPTION tender whose points are held for the order (L2 / D30). */
551
+ declare const isRedemptionReserved: (tender: TenderLike) => boolean;
552
+ /** Paid, or a reserved REDEMPTION (confirmed later, §3.3). */
553
+ declare const isTenderSettled: (tender: TenderLike) => boolean;
554
+ /** Σ `amount.total` of live, not failed/cancelled tenders. */
555
+ declare const tendersCoverage: (tenders: readonly TenderLike[]) => number;
556
+ /** Live tenders that are not failed/cancelled cover the order total. */
557
+ declare const isIntentCovered: (intent: IntentLike | null | undefined, orderTotal?: number) => boolean;
558
+ /**
559
+ * Order `payment_status` from the live tenders of the current intent
560
+ * (D32). Reserved REDEMPTION tenders count as settled, so a redemption-only
561
+ * intent is `PAID` once its points are held (D30).
562
+ */
563
+ declare const derivePaymentStatus: (intent: IntentLike | null | undefined) => PaymentStatus;
564
+ /** Live tenders a `PAYING` node waits for (§7.3); no scope = all. */
565
+ declare const tendersInScope: <T extends TenderLike>(intent: {
566
+ tenders: readonly T[];
567
+ } | null | undefined, scope?: PaymentScope | null) => T[];
568
+ /**
569
+ * Every live tender in the node scope is settled. An empty scope is paid
570
+ * on entry; a scope with only REDEMPTION tenders is paid once they are
571
+ * reserved (D30).
572
+ */
573
+ declare const isScopePaid: (intent: IntentLike | null | undefined, scope?: PaymentScope | null) => boolean;
574
+ /** Intent status after its tenders change (`LOCKED` → `COMPLETED`). */
575
+ declare const nextIntentStatus: (intent: {
576
+ status: PaymentIntent["status"];
577
+ }, paymentStatus: PaymentStatus) => PaymentIntent["status"];
578
+ type PaymentTenderUpdate = {
579
+ tender_id: string;
580
+ attempt_reference: string;
581
+ status: PaymentStatus;
582
+ details?: TenderDetails | null;
583
+ /** Amount the provider approved; less than the tender ⇒ FAILED (D31). */
584
+ approved_amount?: number;
585
+ provider_reference?: string;
586
+ error_message?: string;
587
+ metadata?: Record<string, unknown>;
588
+ /** ISO timestamp of the update (defaults to now). */
589
+ at?: string;
590
+ };
591
+ type MergePaymentTenderOutcome =
592
+ /** The tender changed. */
593
+ "APPLIED" |
594
+ /** Same report again (idempotent by `attempt_reference`). */
595
+ "UNCHANGED" |
596
+ /** Not an allowed transition, or a stale attempt; nothing changed. */
597
+ "IGNORED" |
598
+ /** `PAID` after `CANCELLED`: money may be held; flagged for review. */
599
+ "NEEDS_REVIEW" | "NOT_FOUND";
600
+ type MergePaymentTenderResult<I> = {
601
+ outcome: MergePaymentTenderOutcome;
602
+ intent: I;
603
+ tender: PaymentTender | null;
604
+ /** The provider approved less than the tender (void it, D31). */
605
+ partialApproval: boolean;
606
+ };
607
+ /**
608
+ * Applies one tender result to its intent. Idempotent by the tender
609
+ * `attempt_reference`; a new reference is accepted only as a retry of an
610
+ * `IDLE` / `FAILED` tender (D25). A partial approval becomes `FAILED`
611
+ * (D31). A late `PAID` after `CANCELLED` keeps the tender cancelled and is
612
+ * flagged `NEEDS_REVIEW`.
613
+ */
614
+ declare const mergePaymentTender: <I extends PaymentIntent>(intent: I, update: PaymentTenderUpdate) => MergePaymentTenderResult<I>;
615
+ //#endregion
385
616
  //#region src/terminal.d.ts
386
617
  /**
387
618
  * Serializes everything that touches one terminal (charge, query, batch close)
@@ -394,4 +625,4 @@ declare const scopedPaymentStorage: (storage: PaymentStorage, paymentId: string)
394
625
  /** For tests and web previews; apps provide AsyncStorage or localStorage. */
395
626
  declare const memoryPaymentStorage: () => PaymentStorage;
396
627
  //#endregion
397
- export { CURRENCY_DECIMALS, ChargeConversion, DEFAULT_PAYMENT_POLICY, type DeviceField, PAYMENT_ERROR_CODES, PAYMENT_ERROR_DEFAULTS, type PaymentAction, type PaymentAdapter, type PaymentAmount, type PaymentCardType, type PaymentCollectAt, type PaymentContext, type PaymentCustomer, type PaymentEntryMode, type PaymentEnvironment, PaymentError, PaymentErrorCode, PaymentErrorDictionary, PaymentErrorEntry, PaymentErrorReason, type PaymentIntentStatus, type PaymentMetadata, type PaymentMetadataPayment, type PaymentOperation, type PaymentPolicy, type PaymentResult, type PaymentReversal, type PaymentServer, type PaymentStorage, type PaymentUI, RawPaymentError, StandardPaymentError, type TaxBreakdown, TaxLine, type TerminalContext, type TerminalOperation, type TerminalOperations, type TerminalResult, amountTolerance, batchCloseDay, createAttemptReference, createPaymentError, currencyDecimals, defaultReversalFor, definePaymentErrors, formatPaymentError, isBatchCloseDue, isPaymentError, isTerminalLocked, memoryPaymentStorage, missingDeviceFields, resolvePaymentPolicy, resolveProviderEnvironment, roundTo, scopedPaymentStorage, taxBreakdown, toChargeAmount, toPaymentAmount, toPaymentError, withTerminalLock };
628
+ export { ACTIVE_INTENT_STATUSES, AllocateTenderInput, AllocateTendersInput, AllocateTendersResult, AllocatedTender, AllocationError, CURRENCY_DECIMALS, ChargeConversion, DEFAULT_PAYMENT_POLICY, DELIVERY_FEE_BASED_ON, DIGITAL_COLLECT_AT, type DeviceField, MergePaymentTenderOutcome, MergePaymentTenderResult, NEXT_STATUSES, PARTIAL_APPROVAL_ERROR, PAYMENT_ERROR_CODES, PAYMENT_ERROR_DEFAULTS, type PaymentAction, type PaymentAdapter, type PaymentAdapterCapabilities, type PaymentAmount, type PaymentCardType, type PaymentCollectAt, type PaymentContext, type PaymentCustomer, type PaymentEntryMode, type PaymentEnvironment, PaymentError, PaymentErrorCode, PaymentErrorDictionary, PaymentErrorEntry, PaymentErrorReason, type PaymentIntentStatus, type PaymentMetadata, type PaymentMetadataPayment, type PaymentOperation, type PaymentPolicy, type PaymentResult, type PaymentReversal, type PaymentServer, type PaymentStorage, PaymentTenderUpdate, type PaymentUI, REDEMPTION_METHOD, RawPaymentError, RulesTender, StandardPaymentError, TERMINAL_TENDER_STATUSES, TIP_FEE_BASED_ON, type TaxBreakdown, TaxLine, type TerminalContext, type TerminalOperation, type TerminalOperations, type TerminalResult, ValidateTendersResult, activeIntent, allocateTenders, amountTolerance, batchCloseDay, countTenderItems, createAttemptReference, createPaymentError, currencyDecimals, currentIntent, defaultReversalFor, definePaymentErrors, derivePaymentStatus, formatPaymentError, isBatchCloseDue, isDeliveryFee, isIntentCovered, isMixedTenders, isPaymentError, isRealPaymentMethod, isRedemptionReserved, isScopePaid, isTenderSettled, isTerminalLocked, isTipFee, liveRealTenders, liveTenders, memoryPaymentStorage, mergePaymentTender, missingDeviceFields, nextIntentStatus, primaryTender, primaryTenderOf, prorateMinor, realTenders, resolvePaymentPolicy, resolveProviderEnvironment, roundTo, scopedPaymentStorage, taxBreakdown, tendersCoverage, tendersInScope, toChargeAmount, toPaymentAmount, toPaymentError, validateTendersAgainstRules, withTerminalLock };
package/dist/index.js CHANGED
@@ -54,6 +54,890 @@ const amountTolerance = (currency) => {
54
54
  return 10 ** -currencyDecimals(currency) / 2;
55
55
  };
56
56
  //#endregion
57
+ //#region src/tenders.ts
58
+ const REDEMPTION_METHOD = "REDEMPTION";
59
+ /** Methods that charge money (everything but `REDEMPTION`, D17). */
60
+ const isRealPaymentMethod = (method) => {
61
+ return method !== REDEMPTION_METHOD;
62
+ };
63
+ const ACTIVE_INTENT_STATUSES = ["DRAFT", "LOCKED"];
64
+ /** `collect_at` charged by the SDK on the device (`digital` scope). */
65
+ const DIGITAL_COLLECT_AT = ["DEVICE"];
66
+ /** Tender statuses that are final for one tender. */
67
+ const TERMINAL_TENDER_STATUSES = ["CANCELLED", "REFUNDED"];
68
+ /** Allowed status changes of one tender; late notices never downgrade. */
69
+ const NEXT_STATUSES = {
70
+ IDLE: [
71
+ "IDLE",
72
+ "PENDING",
73
+ "PAID",
74
+ "FAILED",
75
+ "CANCELLED"
76
+ ],
77
+ PENDING: [
78
+ "PENDING",
79
+ "PAID",
80
+ "FAILED",
81
+ "CANCELLED"
82
+ ],
83
+ FAILED: [
84
+ "FAILED",
85
+ "PENDING",
86
+ "PAID",
87
+ "CANCELLED"
88
+ ],
89
+ PAID: [
90
+ "PAID",
91
+ "CANCELLED",
92
+ "REFUNDED"
93
+ ],
94
+ CANCELLED: ["CANCELLED"],
95
+ REFUNDED: ["REFUNDED"]
96
+ };
97
+ /** `error_message` of a tender failed by a partial approval (D31). */
98
+ const PARTIAL_APPROVAL_ERROR = "PARTIAL_APPROVAL";
99
+ const isActiveStatus = (status) => {
100
+ return ACTIVE_INTENT_STATUSES.includes(status);
101
+ };
102
+ /** The one `DRAFT` / `LOCKED` intent, by status (never by position). */
103
+ const activeIntent = (intents) => {
104
+ return intents?.find((intent) => isActiveStatus(intent.status)) ?? null;
105
+ };
106
+ /** The intent that pays the order: the active one, else the completed one. */
107
+ const currentIntent = (intents) => {
108
+ const active = activeIntent(intents);
109
+ if (active) return active;
110
+ return intents?.find((intent) => intent.status === "COMPLETED") ?? null;
111
+ };
112
+ /** Tenders not replaced by a retry or a method change (D12, D25). */
113
+ const liveTenders = (intent) => {
114
+ if (!intent) return [];
115
+ const replaced = /* @__PURE__ */ new Set();
116
+ for (const tender of intent.tenders) if (tender.replaces_tender_id) replaced.add(tender.replaces_tender_id);
117
+ return intent.tenders.filter((tender) => !replaced.has(tender.id));
118
+ };
119
+ const realTenders = (tenders) => {
120
+ return tenders.filter((tender) => isRealPaymentMethod(tender.method));
121
+ };
122
+ /** Live real tenders of the current intent (facts for workflow edges). */
123
+ const liveRealTenders = (intents) => {
124
+ return realTenders(liveTenders(currentIntent(intents)));
125
+ };
126
+ /** 2+ real tenders; `REDEMPTION` never makes an order mixed (D17). */
127
+ const isMixedTenders = (tenders) => {
128
+ return realTenders(tenders).length >= 2;
129
+ };
130
+ /** First real tender by `sequence`; null when every tender is REDEMPTION. */
131
+ const primaryTenderOf = (tenders) => {
132
+ return realTenders(tenders).toSorted((a, b) => a.sequence - b.sequence)[0] ?? null;
133
+ };
134
+ /** The primary tender of an intent (by `primary_tender_id`). */
135
+ const primaryTender = (intent) => {
136
+ if (!intent?.primary_tender_id) return null;
137
+ const id = intent.primary_tender_id;
138
+ return intent.tenders.find((tender) => tender.id === id) ?? null;
139
+ };
140
+ /** A REDEMPTION tender whose points are held for the order (L2 / D30). */
141
+ const isRedemptionReserved = (tender) => {
142
+ if (isRealPaymentMethod(tender.method)) return false;
143
+ return typeof tender.metadata?.reserved_at === "string";
144
+ };
145
+ /** Paid, or a reserved REDEMPTION (confirmed later, §3.3). */
146
+ const isTenderSettled = (tender) => {
147
+ if (tender.status === "PAID") return true;
148
+ return tender.status === "PENDING" && isRedemptionReserved(tender);
149
+ };
150
+ const coveringTenders = (tenders) => {
151
+ return tenders.filter((tender) => {
152
+ return tender.status !== "FAILED" && tender.status !== "CANCELLED";
153
+ });
154
+ };
155
+ /** Σ `amount.total` of live, not failed/cancelled tenders. */
156
+ const tendersCoverage = (tenders) => {
157
+ return coveringTenders(tenders).reduce((sum, tender) => {
158
+ return sum + tender.amount.total;
159
+ }, 0);
160
+ };
161
+ /** Live tenders that are not failed/cancelled cover the order total. */
162
+ const isIntentCovered = (intent, orderTotal) => {
163
+ if (!intent) return false;
164
+ const total = orderTotal ?? intent.totals.total;
165
+ return tendersCoverage(liveTenders(intent)) + amountTolerance(intent.totals.currency) >= total;
166
+ };
167
+ /**
168
+ * Order `payment_status` from the live tenders of the current intent
169
+ * (D32). Reserved REDEMPTION tenders count as settled, so a redemption-only
170
+ * intent is `PAID` once its points are held (D30).
171
+ */
172
+ const derivePaymentStatus = (intent) => {
173
+ const tenders = liveTenders(intent);
174
+ if (tenders.length === 0) return "IDLE";
175
+ if (tenders.some((tender) => tender.status === "FAILED")) return "FAILED";
176
+ const statuses = tenders.map((tender) => tender.status);
177
+ if (statuses.every((status) => status === "CANCELLED")) return "CANCELLED";
178
+ const closed = (status) => {
179
+ return status === "REFUNDED" || status === "CANCELLED";
180
+ };
181
+ if (statuses.every(closed)) return "REFUNDED";
182
+ if (tenders.every((tender) => {
183
+ return tender.status === "REFUNDED" || isTenderSettled(tender);
184
+ })) return "PAID";
185
+ if (statuses.every((status) => status === "IDLE")) return "IDLE";
186
+ return "PENDING";
187
+ };
188
+ /** Live tenders a `PAYING` node waits for (§7.3); no scope = all. */
189
+ const tendersInScope = (intent, scope) => {
190
+ const tenders = liveTenders(intent);
191
+ if (!scope) return tenders;
192
+ return tenders.filter((tender) => {
193
+ if (!isRealPaymentMethod(tender.method)) return scope === "digital";
194
+ const digital = DIGITAL_COLLECT_AT.includes(tender.collect_at);
195
+ return scope === "digital" ? digital : !digital;
196
+ });
197
+ };
198
+ /**
199
+ * Every live tender in the node scope is settled. An empty scope is paid
200
+ * on entry; a scope with only REDEMPTION tenders is paid once they are
201
+ * reserved (D30).
202
+ */
203
+ const isScopePaid = (intent, scope) => {
204
+ if (!intent) return false;
205
+ return tendersInScope(intent, scope).every(isTenderSettled);
206
+ };
207
+ /** Intent status after its tenders change (`LOCKED` → `COMPLETED`). */
208
+ const nextIntentStatus = (intent, paymentStatus) => {
209
+ if (intent.status !== "LOCKED") return intent.status;
210
+ if (paymentStatus === "PAID" || paymentStatus === "REFUNDED") return "COMPLETED";
211
+ return intent.status;
212
+ };
213
+ const RESOLVED_STATUSES = [
214
+ "PAID",
215
+ "FAILED",
216
+ "CANCELLED",
217
+ "REFUNDED"
218
+ ];
219
+ const isPartialApproval = (tender, update) => {
220
+ if (update.status !== "PAID") return false;
221
+ if (update.approved_amount === void 0) return false;
222
+ const tolerance = amountTolerance(tender.amount.currency);
223
+ return update.approved_amount + tolerance < tender.amount.total;
224
+ };
225
+ const withAttempt = (tender, reference) => {
226
+ if (tender.attempt_reference === reference) return tender;
227
+ const previous = [...tender.previous_attempt_references, tender.attempt_reference];
228
+ return {
229
+ ...tender,
230
+ attempt_reference: reference,
231
+ previous_attempt_references: previous
232
+ };
233
+ };
234
+ const sameJson = (left, right) => {
235
+ return JSON.stringify(left) === JSON.stringify(right);
236
+ };
237
+ const replaceTender = (intent, tender) => {
238
+ const tenders = intent.tenders.map((item) => {
239
+ return item.id === tender.id ? tender : item;
240
+ });
241
+ return {
242
+ ...intent,
243
+ tenders
244
+ };
245
+ };
246
+ const flagReview = (intent, tender, update) => {
247
+ const at = update.at ?? (/* @__PURE__ */ new Date()).toISOString();
248
+ const late_report = {
249
+ status: update.status,
250
+ at,
251
+ attempt_reference: update.attempt_reference
252
+ };
253
+ const metadata = {
254
+ ...tender.metadata,
255
+ needs_review: true,
256
+ late_report
257
+ };
258
+ const next = {
259
+ ...tender,
260
+ metadata
261
+ };
262
+ const flagged = {
263
+ outcome: "NEEDS_REVIEW",
264
+ partialApproval: false
265
+ };
266
+ if (sameJson(tender, next)) return {
267
+ ...flagged,
268
+ intent,
269
+ tender
270
+ };
271
+ return {
272
+ ...flagged,
273
+ intent: replaceTender(intent, next),
274
+ tender: next
275
+ };
276
+ };
277
+ /**
278
+ * Applies one tender result to its intent. Idempotent by the tender
279
+ * `attempt_reference`; a new reference is accepted only as a retry of an
280
+ * `IDLE` / `FAILED` tender (D25). A partial approval becomes `FAILED`
281
+ * (D31). A late `PAID` after `CANCELLED` keeps the tender cancelled and is
282
+ * flagged `NEEDS_REVIEW`.
283
+ */
284
+ const mergePaymentTender = (intent, update) => {
285
+ const current = intent.tenders.find((item) => item.id === update.tender_id);
286
+ const none = {
287
+ intent,
288
+ tender: null,
289
+ partialApproval: false
290
+ };
291
+ if (!current) return {
292
+ outcome: "NOT_FOUND",
293
+ ...none
294
+ };
295
+ const reference = update.attempt_reference;
296
+ const keep = {
297
+ intent,
298
+ tender: current,
299
+ partialApproval: false
300
+ };
301
+ if (current.previous_attempt_references.includes(reference)) {
302
+ if (update.status !== "PAID") return {
303
+ outcome: "IGNORED",
304
+ ...keep
305
+ };
306
+ return flagReview(intent, current, update);
307
+ }
308
+ const isRetry = current.attempt_reference !== reference;
309
+ const retryable = current.status === "IDLE" || current.status === "FAILED";
310
+ if (isRetry && !retryable) return {
311
+ outcome: "IGNORED",
312
+ ...keep
313
+ };
314
+ const partialApproval = isPartialApproval(current, update);
315
+ const status = partialApproval ? "FAILED" : update.status;
316
+ if (!NEXT_STATUSES[current.status].includes(status)) {
317
+ if (current.status === "CANCELLED" && status === "PAID") return flagReview(intent, current, update);
318
+ return {
319
+ outcome: "IGNORED",
320
+ ...keep
321
+ };
322
+ }
323
+ const at = update.at ?? (/* @__PURE__ */ new Date()).toISOString();
324
+ const metadata = {
325
+ ...current.metadata,
326
+ ...update.metadata
327
+ };
328
+ if (update.provider_reference) metadata.provider_reference = update.provider_reference;
329
+ if (partialApproval) metadata.partial_approval = { approved_amount: update.approved_amount };
330
+ const base = withAttempt(current, reference);
331
+ const details = update.details === void 0 ? base.details : update.details;
332
+ let error_message = update.error_message ?? base.error_message;
333
+ if (partialApproval) error_message = PARTIAL_APPROVAL_ERROR;
334
+ const resolved_at = RESOLVED_STATUSES.includes(status) ? base.resolved_at ?? at : base.resolved_at;
335
+ const b1 = {
336
+ status,
337
+ details,
338
+ error_message,
339
+ metadata,
340
+ resolved_at
341
+ };
342
+ const next = {
343
+ ...base,
344
+ ...b1
345
+ };
346
+ if (sameJson(current, next)) return {
347
+ outcome: "UNCHANGED",
348
+ ...keep
349
+ };
350
+ const merged = replaceTender(intent, next);
351
+ const paymentStatus = derivePaymentStatus(merged);
352
+ const intentStatus = nextIntentStatus(merged, paymentStatus);
353
+ return {
354
+ outcome: "APPLIED",
355
+ intent: {
356
+ ...merged,
357
+ status: intentStatus
358
+ },
359
+ tender: next,
360
+ partialApproval
361
+ };
362
+ };
363
+ //#endregion
364
+ //#region src/allocation.ts
365
+ /** `metadata.fee_based_on` values that are a tip (always primary, D18). */
366
+ const TIP_FEE_BASED_ON = ["TIPS"];
367
+ /** `metadata.fee_based_on` values that are delivery (always primary, D18). */
368
+ const DELIVERY_FEE_BASED_ON = [
369
+ "DISTANCE_STRAIGHT",
370
+ "DISTANCE_STREET",
371
+ "COVERAGE_ZONE_TAG"
372
+ ];
373
+ const zeroMinor = () => ({
374
+ subtotal: 0,
375
+ discount: 0,
376
+ tax: 0,
377
+ total: 0
378
+ });
379
+ const addMinor = (left, right) => {
380
+ return {
381
+ subtotal: left.subtotal + right.subtotal,
382
+ discount: left.discount + right.discount,
383
+ tax: left.tax + right.tax,
384
+ total: left.total + right.total
385
+ };
386
+ };
387
+ const feeBasedOf = (line) => {
388
+ const value = line.metadata?.fee_based_on;
389
+ return typeof value === "string" ? value : null;
390
+ };
391
+ /** Tip fee line (`metadata.fee_based_on = TIPS`). */
392
+ const isTipFee = (line) => {
393
+ const based = feeBasedOf(line);
394
+ return line.type === "FEE" && based !== null && TIP_FEE_BASED_ON.includes(based);
395
+ };
396
+ /** Delivery fee line (distance or coverage zone based). */
397
+ const isDeliveryFee = (line) => {
398
+ const based = feeBasedOf(line);
399
+ if (line.type !== "FEE" || based === null) return false;
400
+ return DELIVERY_FEE_BASED_ON.includes(based);
401
+ };
402
+ const fail = (code, message, extra = {}) => {
403
+ return { fail: {
404
+ code,
405
+ message,
406
+ ...extra
407
+ } };
408
+ };
409
+ const isFail = (value) => {
410
+ return typeof value === "object" && value !== null && "fail" in value;
411
+ };
412
+ const descendantsOf = (lines, rootUid) => {
413
+ const out = [];
414
+ const queue = [rootUid];
415
+ while (queue.length > 0) {
416
+ const parent = queue.shift();
417
+ for (const line of lines) {
418
+ if (line.parent_uid !== parent) continue;
419
+ out.push(line);
420
+ queue.push(line.uid);
421
+ }
422
+ }
423
+ return out;
424
+ };
425
+ /**
426
+ * Splits `amount` (minor units) by `units / quantity` per part. Each part
427
+ * gets the floor of its exact share; the residual goes one minor unit at a
428
+ * time to the largest discarded fraction, ties to the lowest `sequence`.
429
+ */
430
+ const prorateMinor = (amount, quantity, parts) => {
431
+ const shares = parts.map((part, index) => {
432
+ const numerator = amount * part.units;
433
+ const base = Math.floor(numerator / quantity);
434
+ return {
435
+ index,
436
+ base,
437
+ remainder: numerator - base * quantity,
438
+ sequence: part.sequence
439
+ };
440
+ });
441
+ let residual = amount - shares.reduce((sum, share) => sum + share.base, 0);
442
+ const order = shares.toSorted((a, b) => {
443
+ if (b.remainder !== a.remainder) return b.remainder - a.remainder;
444
+ return a.sequence - b.sequence;
445
+ });
446
+ const out = shares.map((share) => share.base);
447
+ for (const share of order) {
448
+ if (residual <= 0) break;
449
+ out[share.index] = (out[share.index] ?? 0) + 1;
450
+ residual -= 1;
451
+ }
452
+ return out;
453
+ };
454
+ const createConverter = (currency) => {
455
+ const factor = 10 ** currencyDecimals(currency);
456
+ const toMinor = (value) => Math.round(value * factor);
457
+ const fromMinor = (value) => value / factor;
458
+ return {
459
+ toMinor,
460
+ fromMinor
461
+ };
462
+ };
463
+ const planAllocations = (input, real, roots) => {
464
+ const byUid = new Map(input.lines.map((line) => [line.uid, line]));
465
+ const plan = {
466
+ whole: /* @__PURE__ */ new Map(),
467
+ parts: /* @__PURE__ */ new Map(),
468
+ fees: /* @__PURE__ */ new Map()
469
+ };
470
+ const primaryIndex = 0;
471
+ for (const [tenderIndex, tender] of real.entries()) {
472
+ const allocation = tender.allocation;
473
+ if (!allocation) continue;
474
+ const tender_id = tender.id;
475
+ const seen = /* @__PURE__ */ new Set();
476
+ for (const item of allocation.lines ?? []) {
477
+ const line = byUid.get(item.line_uid);
478
+ const ref = {
479
+ tender_id,
480
+ line_uid: item.line_uid
481
+ };
482
+ if (!line) return fail("PAYMENT_INVALID_ALLOCATION", "Unknown line", ref);
483
+ if (line.parent_uid !== null) return fail("PAYMENT_INVALID_ALLOCATION", "Child lines are allocated with their parent", ref);
484
+ if (line.type === "FEE") return fail("PAYMENT_INVALID_ALLOCATION", "Fees are allocated in allocation.fees", ref);
485
+ if (seen.has(line.uid)) return fail("PAYMENT_INVALID_ALLOCATION", "A line appears twice in one tender", ref);
486
+ seen.add(line.uid);
487
+ if (item.units === void 0) {
488
+ if (plan.whole.has(line.uid) || plan.parts.has(line.uid)) return fail("PAYMENT_UNALLOCATED_LINES", "Line allocated more than once", ref);
489
+ plan.whole.set(line.uid, tenderIndex);
490
+ continue;
491
+ }
492
+ if (!(Number.isInteger(line.quantity) && line.quantity > 1)) return fail("PAYMENT_INVALID_ALLOCATION", "Only integer quantities above 1 can be split", ref);
493
+ if (plan.whole.has(line.uid)) return fail("PAYMENT_UNALLOCATED_LINES", "Line allocated more than once", ref);
494
+ const parts = plan.parts.get(line.uid) ?? [];
495
+ const sequence = tender.sequence;
496
+ parts.push({
497
+ tenderIndex,
498
+ units: item.units,
499
+ sequence
500
+ });
501
+ plan.parts.set(line.uid, parts);
502
+ }
503
+ for (const uid of allocation.fees ?? []) {
504
+ const line = byUid.get(uid);
505
+ const ref = {
506
+ tender_id,
507
+ line_uid: uid
508
+ };
509
+ const isFee = line?.type === "FEE" && line.parent_uid === null;
510
+ if (!line || !isFee) return fail("PAYMENT_INVALID_ALLOCATION", "Unknown fee", ref);
511
+ if ((isTipFee(line) || isDeliveryFee(line)) && tenderIndex !== primaryIndex) return fail("PAYMENT_INVALID_ALLOCATION", "Tip and delivery go to the primary tender", ref);
512
+ if (plan.fees.has(uid)) return fail("PAYMENT_UNALLOCATED_LINES", "Fee allocated more than once", ref);
513
+ plan.fees.set(uid, tenderIndex);
514
+ }
515
+ }
516
+ const openIndex = real.findIndex((tender) => tender.allocation === null);
517
+ for (const root of roots) {
518
+ const ref = { line_uid: root.uid };
519
+ if (root.type === "FEE") {
520
+ if (!plan.fees.has(root.uid)) {
521
+ const target = openIndex >= 0 ? openIndex : primaryIndex;
522
+ const primaryOnly = isTipFee(root) || isDeliveryFee(root);
523
+ plan.fees.set(root.uid, primaryOnly ? primaryIndex : target);
524
+ }
525
+ continue;
526
+ }
527
+ if (plan.whole.has(root.uid)) continue;
528
+ const parts = plan.parts.get(root.uid);
529
+ const assigned = (parts ?? []).reduce((sum, part) => sum + part.units, 0);
530
+ const missing = root.quantity - assigned;
531
+ if (missing === 0) continue;
532
+ const msg = "Line units do not add up to its quantity";
533
+ if (missing < 0) return fail("PAYMENT_UNALLOCATED_LINES", msg, ref);
534
+ if (openIndex < 0) return fail("PAYMENT_UNALLOCATED_LINES", msg, ref);
535
+ if (!parts) {
536
+ plan.whole.set(root.uid, openIndex);
537
+ continue;
538
+ }
539
+ if (parts.some((part) => part.tenderIndex === openIndex)) return fail("PAYMENT_UNALLOCATED_LINES", msg, ref);
540
+ const sequence = real[openIndex]?.sequence ?? 0;
541
+ parts.push({
542
+ tenderIndex: openIndex,
543
+ units: missing,
544
+ sequence
545
+ });
546
+ }
547
+ for (const [uid, parts] of plan.parts) {
548
+ if (parts.length >= 2) continue;
549
+ return fail("PAYMENT_INVALID_ALLOCATION", "A split line must be in two or more tenders", { line_uid: uid });
550
+ }
551
+ return plan;
552
+ };
553
+ const ownMinor = (line, toMinor) => {
554
+ return {
555
+ subtotal: toMinor(line.subtotal),
556
+ discount: toMinor(line.discount_total),
557
+ tax: toMinor(line.tax_total),
558
+ total: toMinor(line.total)
559
+ };
560
+ };
561
+ const groupMinor = (line, toMinor) => {
562
+ return {
563
+ subtotal: toMinor(line.group_subtotal),
564
+ discount: toMinor(line.group_discount),
565
+ tax: toMinor(line.group_tax_total),
566
+ total: toMinor(line.group_total)
567
+ };
568
+ };
569
+ const isConsistent = (value) => {
570
+ return Math.abs(value.subtotal - value.discount + value.tax - value.total) <= 1;
571
+ };
572
+ /** Split of one component per part; `tax = total − subtotal + discount`. */
573
+ const splitComponent = (value, quantity, parts) => {
574
+ const subtotal = prorateMinor(value.subtotal, quantity, parts);
575
+ const discount = prorateMinor(value.discount, quantity, parts);
576
+ const total = prorateMinor(value.total, quantity, parts);
577
+ return parts.map((_, index) => {
578
+ const s = subtotal[index] ?? 0;
579
+ const d = discount[index] ?? 0;
580
+ const t = total[index] ?? 0;
581
+ return {
582
+ subtotal: s,
583
+ discount: d,
584
+ tax: t - s + d,
585
+ total: t
586
+ };
587
+ });
588
+ };
589
+ const base = (tender) => {
590
+ return {
591
+ id: tender.id,
592
+ sequence: tender.sequence,
593
+ method: tender.method
594
+ };
595
+ };
596
+ const emptyAmount = (currency, total) => {
597
+ const b1 = {
598
+ currency,
599
+ subtotal: total,
600
+ discount_total: 0,
601
+ tax_total: 0
602
+ };
603
+ const b2 = {
604
+ fees: 0,
605
+ tip: 0,
606
+ delivery: 0,
607
+ redemptions_applied: 0
608
+ };
609
+ return {
610
+ ...b1,
611
+ ...b2,
612
+ total
613
+ };
614
+ };
615
+ /**
616
+ * Server-side money of every tender (§3.2): whole lines as stored, parts
617
+ * prorated per component with the residual minor unit to the largest
618
+ * discarded fraction, fees to their tender, tip / delivery and order-level
619
+ * adjustments to the primary, then REDEMPTION deducted from the primary
620
+ * first and by `sequence`, never below a tender's tip + delivery (D18).
621
+ */
622
+ const allocateTenders = (input) => {
623
+ const currency = input.totals.currency;
624
+ const { toMinor, fromMinor } = createConverter(currency);
625
+ const sorted = input.tenders.toSorted((a, b) => a.sequence - b.sequence);
626
+ const real = sorted.filter((tender) => isRealPaymentMethod(tender.method));
627
+ const redemptions = sorted.filter((tender) => {
628
+ return !isRealPaymentMethod(tender.method);
629
+ });
630
+ const roots = input.lines.filter((line) => line.parent_uid === null);
631
+ const orderTotal = toMinor(input.totals.total);
632
+ const tipDelivery = roots.reduce((sum, line) => {
633
+ return isTipFee(line) || isDeliveryFee(line) ? sum + toMinor(line.group_total) : sum;
634
+ }, 0);
635
+ const redemptionMinor = redemptions.map((tender) => {
636
+ return toMinor(tender.redemption_amount ?? 0);
637
+ });
638
+ const redeemed = redemptionMinor.reduce((sum, value) => sum + value, 0);
639
+ if (redeemed > orderTotal - tipDelivery) return {
640
+ ok: false,
641
+ error: {
642
+ code: "PAYMENT_REDEMPTION_EXCEEDED",
643
+ message: "Redemptions exceed the order total minus tip and delivery"
644
+ }
645
+ };
646
+ const openTenders = real.filter((tender) => tender.allocation === null);
647
+ if (openTenders.length > 1) return {
648
+ ok: false,
649
+ error: {
650
+ code: "PAYMENT_UNALLOCATED_LINES",
651
+ message: "Only one real tender can omit its allocation",
652
+ tender_id: openTenders[1]?.id
653
+ }
654
+ };
655
+ const redemptionTenders = redemptions.map((tender, index) => {
656
+ const value = fromMinor(redemptionMinor[index] ?? 0);
657
+ const amount = emptyAmount(currency, value);
658
+ return {
659
+ ...base(tender),
660
+ allocation: null,
661
+ amount
662
+ };
663
+ });
664
+ if (real.length === 0) {
665
+ if (redeemed !== orderTotal) {
666
+ const msg = "Redemptions must cover the order without a real tender";
667
+ let code = "PAYMENT_UNALLOCATED_LINES";
668
+ if (redeemed > orderTotal) code = "PAYMENT_REDEMPTION_EXCEEDED";
669
+ return {
670
+ ok: false,
671
+ error: {
672
+ code,
673
+ message: msg
674
+ }
675
+ };
676
+ }
677
+ return {
678
+ ok: true,
679
+ tenders: redemptionTenders,
680
+ primary_tender_id: null
681
+ };
682
+ }
683
+ const plan = planAllocations(input, real, roots);
684
+ if (isFail(plan)) return {
685
+ ok: false,
686
+ error: plan.fail
687
+ };
688
+ const builds = real.map(() => {
689
+ return {
690
+ products: zeroMinor(),
691
+ fees: 0,
692
+ tip: 0,
693
+ delivery: 0,
694
+ amounts: []
695
+ };
696
+ });
697
+ const toAmount = (uid, value, units) => {
698
+ const subtotal = fromMinor(value.subtotal);
699
+ const discount = fromMinor(value.discount);
700
+ const b1 = {
701
+ tax: fromMinor(value.tax),
702
+ total: fromMinor(value.total)
703
+ };
704
+ return {
705
+ line_uid: uid,
706
+ ...units === void 0 ? {} : { units },
707
+ subtotal,
708
+ discount,
709
+ ...b1
710
+ };
711
+ };
712
+ for (const root of roots) {
713
+ if (root.type === "FEE") {
714
+ const build = builds[plan.fees.get(root.uid) ?? 0];
715
+ const total = toMinor(root.group_total);
716
+ if (isTipFee(root)) build.tip += total;
717
+ else if (isDeliveryFee(root)) build.delivery += total;
718
+ else build.fees += total;
719
+ const amount = toAmount(root.uid, groupMinor(root, toMinor));
720
+ build.amounts.push({
721
+ ...amount,
722
+ children: []
723
+ });
724
+ continue;
725
+ }
726
+ const children = descendantsOf(input.lines, root.uid);
727
+ const wholeIndex = plan.whole.get(root.uid);
728
+ if (wholeIndex !== void 0) {
729
+ const build = builds[wholeIndex];
730
+ const group = groupMinor(root, toMinor);
731
+ build.products = addMinor(build.products, group);
732
+ const childAmounts = children.map((child) => {
733
+ const own = ownMinor(child, toMinor);
734
+ return {
735
+ ...toAmount(child.uid, own),
736
+ quantity: child.quantity
737
+ };
738
+ });
739
+ const amount = toAmount(root.uid, group);
740
+ build.amounts.push({
741
+ ...amount,
742
+ children: childAmounts
743
+ });
744
+ continue;
745
+ }
746
+ const parts = plan.parts.get(root.uid) ?? [];
747
+ const components = [root, ...children];
748
+ const splits = [];
749
+ for (const component of components) {
750
+ const own = ownMinor(component, toMinor);
751
+ if (!isConsistent(own)) return {
752
+ ok: false,
753
+ error: {
754
+ code: "PAYMENT_LINE_NOT_SPLITTABLE",
755
+ message: "Line amounts are not consistent; it cannot be split",
756
+ line_uid: root.uid
757
+ }
758
+ };
759
+ splits.push(splitComponent(own, root.quantity, parts));
760
+ }
761
+ for (const [partIndex, part] of parts.entries()) {
762
+ const build = builds[part.tenderIndex];
763
+ const shares = splits.map((split) => split[partIndex] ?? zeroMinor());
764
+ const value = shares.reduce(addMinor, zeroMinor());
765
+ build.products = addMinor(build.products, value);
766
+ const childAmounts = children.map((child, childIndex) => {
767
+ const share = shares[childIndex + 1] ?? zeroMinor();
768
+ const quantity = child.quantity * part.units / root.quantity;
769
+ return {
770
+ ...toAmount(child.uid, share),
771
+ quantity
772
+ };
773
+ });
774
+ const amount = toAmount(root.uid, value, part.units);
775
+ build.amounts.push({
776
+ ...amount,
777
+ children: childAmounts
778
+ });
779
+ }
780
+ }
781
+ const allocated = builds.reduce((sum, build) => {
782
+ return sum + build.products.total + build.fees + build.tip + build.delivery;
783
+ }, 0);
784
+ const primary = builds[0];
785
+ const adjustment = orderTotal - allocated;
786
+ if (adjustment < 0) {
787
+ const value = { ...primary.products };
788
+ value.discount -= adjustment;
789
+ value.total += adjustment;
790
+ primary.products = value;
791
+ } else if (adjustment > 0) primary.products = {
792
+ ...primary.products,
793
+ total: primary.products.total + adjustment
794
+ };
795
+ const totals = builds.map((build) => {
796
+ return build.products.total + build.fees + build.tip + build.delivery;
797
+ });
798
+ if ((totals[0] ?? 0) < 0) return {
799
+ ok: false,
800
+ error: {
801
+ code: "PAYMENT_INVALID_PRIMARY",
802
+ message: "Order discounts leave the primary tender below zero",
803
+ tender_id: real[0]?.id
804
+ }
805
+ };
806
+ const applied = builds.map(() => 0);
807
+ let pending = redeemed;
808
+ for (const [index, build] of builds.entries()) {
809
+ if (pending <= 0) break;
810
+ const floor = build.tip + build.delivery;
811
+ const room = Math.max(0, (totals[index] ?? 0) - floor);
812
+ const take = Math.min(room, pending);
813
+ applied[index] = take;
814
+ pending -= take;
815
+ }
816
+ if (pending > 0) return {
817
+ ok: false,
818
+ error: {
819
+ code: "PAYMENT_REDEMPTION_EXCEEDED",
820
+ message: "Redemptions exceed what real tenders can absorb"
821
+ }
822
+ };
823
+ return {
824
+ ok: true,
825
+ tenders: [...real.map((tender, index) => {
826
+ const build = builds[index];
827
+ const total = (totals[index] ?? 0) - (applied[index] ?? 0);
828
+ const b1 = {
829
+ currency,
830
+ subtotal: fromMinor(build.products.subtotal)
831
+ };
832
+ const b2 = { discount_total: fromMinor(build.products.discount) };
833
+ const b3 = { tax_total: fromMinor(build.products.tax) };
834
+ const b4 = {
835
+ fees: fromMinor(build.fees),
836
+ tip: fromMinor(build.tip)
837
+ };
838
+ const b5 = { delivery: fromMinor(build.delivery) };
839
+ const b6 = { redemptions_applied: fromMinor(applied[index] ?? 0) };
840
+ const amount = {
841
+ ...b1,
842
+ ...b2,
843
+ ...b3,
844
+ ...b4,
845
+ ...b5,
846
+ ...b6,
847
+ total: fromMinor(total)
848
+ };
849
+ const open = tender.allocation === null;
850
+ const lines = open ? [] : tender.allocation?.lines ?? [];
851
+ const fees = open ? [] : tender.allocation?.fees ?? [];
852
+ let allocation = null;
853
+ if (real.length > 1 || !open) allocation = {
854
+ lines,
855
+ fees,
856
+ amounts: build.amounts
857
+ };
858
+ return {
859
+ ...base(tender),
860
+ allocation,
861
+ amount
862
+ };
863
+ }), ...redemptionTenders].toSorted((a, b) => {
864
+ return a.sequence - b.sequence;
865
+ }),
866
+ primary_tender_id: real[0]?.id ?? null
867
+ };
868
+ };
869
+ /**
870
+ * Items of one real tender (D8): whole `PRODUCT` parent lines plus each
871
+ * part (1 each). Children, fees and REDEMPTION never count; units do not
872
+ * add up. `null` allocation = every product parent line.
873
+ */
874
+ const countTenderItems = (allocation, lines) => {
875
+ const products = lines.filter((line) => {
876
+ return line.parent_uid === null && line.type === "PRODUCT";
877
+ });
878
+ if (!allocation) return products.length;
879
+ const uids = new Set(products.map((line) => line.uid));
880
+ return (allocation.lines ?? []).filter((item) => uids.has(item.line_uid)).length;
881
+ };
882
+ /**
883
+ * Mixed payments rules over real tenders only (D17): a REDEMPTION never
884
+ * makes an order mixed, counts as a method, an item or an amount.
885
+ */
886
+ const validateTendersAgainstRules = (input) => {
887
+ const real = input.tenders.filter((tender) => {
888
+ return isRealPaymentMethod(tender.method);
889
+ });
890
+ const is_mixed = real.length >= 2;
891
+ if (!is_mixed) return {
892
+ ok: true,
893
+ is_mixed
894
+ };
895
+ const effect = input.effect;
896
+ if (!effect?.enabled) return {
897
+ ok: false,
898
+ error: {
899
+ code: "MIXED_PAYMENTS_DISABLED",
900
+ message: "Mixed payments are not enabled",
901
+ reason: "RULE_DISABLED"
902
+ }
903
+ };
904
+ if (new Set(real.map((tender) => tender.method)).size > effect.max_methods) return {
905
+ ok: false,
906
+ error: {
907
+ code: "PAYMENT_TOO_MANY_METHODS",
908
+ message: `At most ${effect.max_methods} payment methods`
909
+ }
910
+ };
911
+ for (const tender of real) {
912
+ const caps = effect.per_method[tender.method];
913
+ const maxItems = caps?.max_items ?? effect.max_items_per_method;
914
+ const items = countTenderItems(tender.allocation, input.lines);
915
+ const tender_id = tender.id;
916
+ if (maxItems !== null && items > maxItems) return {
917
+ ok: false,
918
+ error: {
919
+ code: "PAYMENT_TOO_MANY_ITEMS",
920
+ message: `At most ${maxItems} items per payment method`,
921
+ tender_id
922
+ }
923
+ };
924
+ const maxAmount = caps?.max_amount ?? effect.max_amount_per_method;
925
+ const tolerance = amountTolerance(tender.amount.currency);
926
+ if (maxAmount !== null && tender.amount.total > maxAmount + tolerance) return {
927
+ ok: false,
928
+ error: {
929
+ code: "PAYMENT_AMOUNT_EXCEEDED",
930
+ message: `At most ${maxAmount} per payment method`,
931
+ tender_id
932
+ }
933
+ };
934
+ }
935
+ return {
936
+ ok: true,
937
+ is_mixed
938
+ };
939
+ };
940
+ //#endregion
57
941
  //#region src/device.ts
58
942
  const isEmpty = (value) => {
59
943
  return value == null || typeof value === "string" && value.trim() === "";
@@ -383,4 +1267,4 @@ const memoryPaymentStorage = () => {
383
1267
  };
384
1268
  };
385
1269
  //#endregion
386
- export { CURRENCY_DECIMALS, DEFAULT_PAYMENT_POLICY, PAYMENT_ERROR_CODES, PAYMENT_ERROR_DEFAULTS, PaymentError, amountTolerance, batchCloseDay, createAttemptReference, createPaymentError, currencyDecimals, defaultReversalFor, definePaymentErrors, formatPaymentError, isBatchCloseDue, isPaymentError, isTerminalLocked, memoryPaymentStorage, missingDeviceFields, resolvePaymentPolicy, resolveProviderEnvironment, roundTo, scopedPaymentStorage, taxBreakdown, toChargeAmount, toPaymentAmount, toPaymentError, withTerminalLock };
1270
+ export { ACTIVE_INTENT_STATUSES, CURRENCY_DECIMALS, DEFAULT_PAYMENT_POLICY, DELIVERY_FEE_BASED_ON, DIGITAL_COLLECT_AT, NEXT_STATUSES, PARTIAL_APPROVAL_ERROR, PAYMENT_ERROR_CODES, PAYMENT_ERROR_DEFAULTS, PaymentError, REDEMPTION_METHOD, TERMINAL_TENDER_STATUSES, TIP_FEE_BASED_ON, activeIntent, allocateTenders, amountTolerance, batchCloseDay, countTenderItems, createAttemptReference, createPaymentError, currencyDecimals, currentIntent, defaultReversalFor, definePaymentErrors, derivePaymentStatus, formatPaymentError, isBatchCloseDue, isDeliveryFee, isIntentCovered, isMixedTenders, isPaymentError, isRealPaymentMethod, isRedemptionReserved, isScopePaid, isTenderSettled, isTerminalLocked, isTipFee, liveRealTenders, liveTenders, memoryPaymentStorage, mergePaymentTender, missingDeviceFields, nextIntentStatus, primaryTender, primaryTenderOf, prorateMinor, realTenders, resolvePaymentPolicy, resolveProviderEnvironment, roundTo, scopedPaymentStorage, taxBreakdown, tendersCoverage, tendersInScope, toChargeAmount, toPaymentAmount, toPaymentError, validateTendersAgainstRules, withTerminalLock };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fire-spark/payments",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Fire Spark payments contract: adapters, errors, amounts and policy",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -17,7 +17,7 @@
17
17
  }
18
18
  },
19
19
  "dependencies": {
20
- "@fire-spark/types": "^0.2.0"
20
+ "@fire-spark/types": "^0.3.0"
21
21
  },
22
22
  "devDependencies": {
23
23
  "tsdown": "^0.22.14",