@fire-spark/payments 0.4.0 → 0.5.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 +54 -34
- package/dist/index.js +307 -260
- package/package.json +4 -2
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Big } from "big.js";
|
|
1
2
|
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
3
|
//#region src/allocation.d.ts
|
|
3
4
|
/** `metadata.fee_based_on` values that are a tip (always primary, D18). */
|
|
@@ -43,9 +44,9 @@ declare const isTipFee: (line: OrderLine) => boolean;
|
|
|
43
44
|
/** Delivery fee line (distance or coverage zone based). */
|
|
44
45
|
declare const isDeliveryFee: (line: OrderLine) => boolean;
|
|
45
46
|
/**
|
|
46
|
-
* Splits `amount`
|
|
47
|
-
*
|
|
48
|
-
*
|
|
47
|
+
* Splits `amount` by `units / quantity`. Parts are ordered by `sequence`.
|
|
48
|
+
* Every part but the last is `amount * units / quantity`. The last part is
|
|
49
|
+
* the remainder, so the parts sum to `amount`.
|
|
49
50
|
*/
|
|
50
51
|
declare const prorateMinor: (amount: number, quantity: number, parts: readonly {
|
|
51
52
|
units: number;
|
|
@@ -53,10 +54,12 @@ declare const prorateMinor: (amount: number, quantity: number, parts: readonly {
|
|
|
53
54
|
}[]) => number[];
|
|
54
55
|
/**
|
|
55
56
|
* Server-side money of every tender (§3.2): whole lines as stored, parts
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
57
|
+
* split exactly (last part is the remainder), fees to their tender, tip /
|
|
58
|
+
* delivery and any order-level difference to the primary, then REDEMPTION
|
|
59
|
+
* deducted from the primary first and by `sequence`, never below a tender's
|
|
60
|
+
* tip + delivery (D18). A tender publishes only what it pays
|
|
61
|
+
* (`amount.total`); `allocation.amounts` is the money of the lines it
|
|
62
|
+
* covers, before REDEMPTION. Amounts are not rounded.
|
|
60
63
|
*/
|
|
61
64
|
declare const allocateTenders: (input: AllocateTendersInput) => AllocateTendersResult;
|
|
62
65
|
/**
|
|
@@ -93,6 +96,27 @@ declare const validateTendersAgainstRules: (input: {
|
|
|
93
96
|
effect: MixedPaymentsEffect | null;
|
|
94
97
|
}) => ValidateTendersResult;
|
|
95
98
|
//#endregion
|
|
99
|
+
//#region src/money.d.ts
|
|
100
|
+
/** One monetary amount. Arithmetic stays on Big until a caller serializes. */
|
|
101
|
+
type Money = Big;
|
|
102
|
+
declare const money: (value: number | string | Money) => Money;
|
|
103
|
+
declare const moneyZero: () => Money;
|
|
104
|
+
declare const moneySum: (values: readonly Money[]) => Big;
|
|
105
|
+
declare const moneyMax: (left: Money, right: Money) => Big;
|
|
106
|
+
declare const moneyMin: (left: Money, right: Money) => Big;
|
|
107
|
+
declare const moneyNumber: (value: Money) => number;
|
|
108
|
+
/**
|
|
109
|
+
* Splits `whole` by `weights`. Every part but the last is
|
|
110
|
+
* `whole * weight / totalWeight`. The last part is what remains, so the
|
|
111
|
+
* parts add up to `whole`.
|
|
112
|
+
*/
|
|
113
|
+
declare const splitMoney: (whole: Money, weights: readonly Money[]) => Big[];
|
|
114
|
+
/**
|
|
115
|
+
* Serializes parts so a Big sum of the numbers equals `whole`. Earlier
|
|
116
|
+
* parts keep their value; the last part is `whole` minus those numbers.
|
|
117
|
+
*/
|
|
118
|
+
declare const moneyNumbers: (whole: Money, parts: readonly Money[]) => number[];
|
|
119
|
+
//#endregion
|
|
96
120
|
//#region src/errors.d.ts
|
|
97
121
|
type PaymentErrorReason = "timeout" | "network" | "declined" | "provider" | "ambiguous" | "user";
|
|
98
122
|
type ErrorDefaults = {
|
|
@@ -417,7 +441,8 @@ type PaymentAdapterCapabilities = {
|
|
|
417
441
|
/** ISO 4217 minor units; anything not listed uses 2. */
|
|
418
442
|
declare const CURRENCY_DECIMALS: Readonly<Record<string, number>>;
|
|
419
443
|
declare const currencyDecimals: (currency: string) => number;
|
|
420
|
-
|
|
444
|
+
/** Full precision. `decimals` is ignored so amounts are never truncated. */
|
|
445
|
+
declare const roundTo: (value: number, _decimals: number) => number;
|
|
421
446
|
declare const toPaymentAmount: (value: number, currency: string) => PaymentAmount;
|
|
422
447
|
type ChargeConversion = {
|
|
423
448
|
amount: PaymentAmount;
|
|
@@ -432,8 +457,6 @@ declare const toChargeAmount: (input: {
|
|
|
432
457
|
chargeCurrency?: string | null;
|
|
433
458
|
rate?: number | null;
|
|
434
459
|
}) => ChargeConversion;
|
|
435
|
-
/** Tolerance used when comparing amounts in a currency. */
|
|
436
|
-
declare const amountTolerance: (currency: string) => number;
|
|
437
460
|
//#endregion
|
|
438
461
|
//#region src/device.d.ts
|
|
439
462
|
/** Required, visible device fields that are still empty on this device. */
|
|
@@ -472,16 +495,17 @@ type RedemptionDetails = Extract<TenderDetails, {
|
|
|
472
495
|
/** One reward inside a grouped REDEMPTION tender (entry totals). */
|
|
473
496
|
type RedemptionReward = RedemptionDetails["rewards"][number];
|
|
474
497
|
/**
|
|
475
|
-
*
|
|
476
|
-
* the entry (every unit); `quantity` is informational.
|
|
498
|
+
* Redeem as stored in `orders.redeem` or a root line's `redeem`. `amount`
|
|
499
|
+
* is the total of the entry (every unit); `quantity` is informational.
|
|
477
500
|
*/
|
|
478
|
-
type
|
|
501
|
+
type RedeemLike = {
|
|
479
502
|
reward_id: string;
|
|
480
503
|
quantity?: number;
|
|
481
504
|
payment_id?: string | null;
|
|
482
505
|
amount?: number;
|
|
506
|
+
before_taxes?: boolean;
|
|
483
507
|
};
|
|
484
|
-
/**
|
|
508
|
+
/** Redeems of one `payment_id`, ready to seed its tender. */
|
|
485
509
|
type RedemptionGroup = {
|
|
486
510
|
payment_id: string;
|
|
487
511
|
/** Tender value: Σ `amount` of the grouped redeems. */
|
|
@@ -489,18 +513,14 @@ type RedemptionGroup = {
|
|
|
489
513
|
details: RedemptionDetails;
|
|
490
514
|
};
|
|
491
515
|
/**
|
|
492
|
-
*
|
|
493
|
-
*
|
|
516
|
+
* Every redeem has a `payment_id` whose REDEMPTION tender covers it.
|
|
517
|
+
* Legacy rows stored without one are ignored.
|
|
494
518
|
*/
|
|
495
|
-
declare const
|
|
519
|
+
declare const hasRedeemPayment: (redeem: {
|
|
496
520
|
payment_id?: string | null;
|
|
497
521
|
}) => boolean;
|
|
498
|
-
/**
|
|
499
|
-
|
|
500
|
-
payment_id?: string | null;
|
|
501
|
-
}>(redeem: readonly T[]) => T[];
|
|
502
|
-
/** Order fields payment redeems live in: the order and its root lines. */
|
|
503
|
-
type PaymentRedeemSource<T extends PaymentRedeemLike> = {
|
|
522
|
+
/** Order fields redeems live in: the order and its root lines. */
|
|
523
|
+
type RedeemSource<T extends RedeemLike> = {
|
|
504
524
|
redeem?: readonly T[] | null;
|
|
505
525
|
lines?: readonly {
|
|
506
526
|
parent_uid?: string | null;
|
|
@@ -508,24 +528,24 @@ type PaymentRedeemSource<T extends PaymentRedeemLike> = {
|
|
|
508
528
|
}[];
|
|
509
529
|
};
|
|
510
530
|
/**
|
|
511
|
-
* Every
|
|
512
|
-
*
|
|
513
|
-
*
|
|
531
|
+
* Every redeem of the order: order-level ones first, then the ones kept
|
|
532
|
+
* on a root line (a voucher stored with the product it pays for). Each is
|
|
533
|
+
* covered by a REDEMPTION tender; child-line redeems never apply.
|
|
514
534
|
*/
|
|
515
|
-
declare const
|
|
535
|
+
declare const orderRedeems: <T extends RedeemLike>(order: RedeemSource<T>) => T[];
|
|
516
536
|
/**
|
|
517
|
-
*
|
|
518
|
-
*
|
|
537
|
+
* Redeems pay the whole order, so no other tender is needed: the intent
|
|
538
|
+
* is only REDEMPTION tenders and the order advances on its own.
|
|
519
539
|
*/
|
|
520
|
-
declare const
|
|
540
|
+
declare const redeemsCoverTotal: (redeem: readonly RedeemLike[], totals: {
|
|
521
541
|
total: number;
|
|
522
542
|
currency: string;
|
|
523
543
|
}) => boolean;
|
|
524
544
|
/**
|
|
525
|
-
* Groups
|
|
526
|
-
*
|
|
545
|
+
* Groups redeems into one entry per `payment_id`, in first-appearance
|
|
546
|
+
* order. Legacy entries without `payment_id` are skipped.
|
|
527
547
|
*/
|
|
528
|
-
declare const
|
|
548
|
+
declare const groupRedeems: (redeem: readonly RedeemLike[]) => RedemptionGroup[];
|
|
529
549
|
/**
|
|
530
550
|
* REDEMPTION `details` in the current shape, or null for other methods.
|
|
531
551
|
* Details persisted earlier (one `reward_id`, or money as `points_value`)
|
|
@@ -700,4 +720,4 @@ declare const scopedPaymentStorage: (storage: PaymentStorage, paymentId: string)
|
|
|
700
720
|
/** For tests and web previews; apps provide AsyncStorage or localStorage. */
|
|
701
721
|
declare const memoryPaymentStorage: () => PaymentStorage;
|
|
702
722
|
//#endregion
|
|
703
|
-
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,
|
|
723
|
+
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, Money, 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, RedeemLike, RedeemSource, RedemptionDetails, RedemptionGroup, RedemptionReward, RulesTender, StandardPaymentError, TERMINAL_TENDER_STATUSES, TIP_FEE_BASED_ON, type TaxBreakdown, TaxLine, type TerminalContext, type TerminalOperation, type TerminalOperations, type TerminalResult, ValidateTendersResult, activeIntent, allocateTenders, batchCloseDay, countTenderItems, createAttemptReference, createPaymentError, currencyDecimals, currentIntent, defaultReversalFor, definePaymentErrors, derivePaymentStatus, formatPaymentError, groupRedeems, hasRedeemPayment, isBatchCloseDue, isDeliveryFee, isIntentCovered, isMixedTenders, isPaymentError, isRealPaymentMethod, isRedemptionReserved, isScopePaid, isTenderSettled, isTerminalLocked, isTipFee, liveRealTenders, liveTenders, memoryPaymentStorage, mergePaymentTender, missingDeviceFields, money, moneyMax, moneyMin, moneyNumber, moneyNumbers, moneySum, moneyZero, nextIntentStatus, orderRedeems, primaryTender, primaryTenderOf, prorateMinor, realTenders, redeemsCoverTotal, redemptionDetailsOf, redemptionRewardsOf, resolvePaymentPolicy, resolveProviderEnvironment, roundTo, scopedPaymentStorage, splitMoney, taxBreakdown, tendersCoverage, tendersInScope, toChargeAmount, toPaymentAmount, toPaymentError, validateTendersAgainstRules, withTerminalLock };
|
package/dist/index.js
CHANGED
|
@@ -1,57 +1,45 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
const
|
|
4
|
-
|
|
5
|
-
PYG: 0,
|
|
6
|
-
JPY: 0,
|
|
7
|
-
COP: 2,
|
|
8
|
-
USD: 2,
|
|
9
|
-
BRL: 2,
|
|
10
|
-
PEN: 2,
|
|
11
|
-
ARS: 2,
|
|
12
|
-
VES: 2,
|
|
13
|
-
MXN: 2,
|
|
14
|
-
EUR: 2
|
|
1
|
+
import { Big } from "big.js";
|
|
2
|
+
//#region src/money.ts
|
|
3
|
+
const money = (value) => {
|
|
4
|
+
return new Big(value);
|
|
15
5
|
};
|
|
16
|
-
const
|
|
17
|
-
|
|
6
|
+
const moneyZero = () => new Big(0);
|
|
7
|
+
const moneySum = (values) => {
|
|
8
|
+
return values.reduce((sum, value) => sum.plus(value), moneyZero());
|
|
18
9
|
};
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
return Math.round((value + Number.EPSILON) * factor) / factor;
|
|
10
|
+
const moneyMax = (left, right) => {
|
|
11
|
+
return left.gt(right) ? left : right;
|
|
22
12
|
};
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
const decimals = currencyDecimals(code);
|
|
26
|
-
const rounded = roundTo(value, decimals);
|
|
27
|
-
return {
|
|
28
|
-
value: rounded,
|
|
29
|
-
minor: Math.round(rounded * 10 ** decimals),
|
|
30
|
-
currency: code,
|
|
31
|
-
decimals
|
|
32
|
-
};
|
|
13
|
+
const moneyMin = (left, right) => {
|
|
14
|
+
return left.lt(right) ? left : right;
|
|
33
15
|
};
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
return {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
};
|
|
16
|
+
const moneyNumber = (value) => value.toNumber();
|
|
17
|
+
/**
|
|
18
|
+
* Splits `whole` by `weights`. Every part but the last is
|
|
19
|
+
* `whole * weight / totalWeight`. The last part is what remains, so the
|
|
20
|
+
* parts add up to `whole`.
|
|
21
|
+
*/
|
|
22
|
+
const splitMoney = (whole, weights) => {
|
|
23
|
+
if (weights.length === 0) return [];
|
|
24
|
+
const total = moneySum(weights);
|
|
25
|
+
if (total.eq(0)) return weights.map(() => moneyZero());
|
|
26
|
+
let rest = whole;
|
|
27
|
+
return weights.map((weight, index) => {
|
|
28
|
+
if (index === weights.length - 1) return rest;
|
|
29
|
+
const share = whole.times(weight).div(total);
|
|
30
|
+
rest = rest.minus(share);
|
|
31
|
+
return share;
|
|
32
|
+
});
|
|
51
33
|
};
|
|
52
|
-
/**
|
|
53
|
-
|
|
54
|
-
|
|
34
|
+
/**
|
|
35
|
+
* Serializes parts so a Big sum of the numbers equals `whole`. Earlier
|
|
36
|
+
* parts keep their value; the last part is `whole` minus those numbers.
|
|
37
|
+
*/
|
|
38
|
+
const moneyNumbers = (whole, parts) => {
|
|
39
|
+
if (parts.length === 0) return [];
|
|
40
|
+
const head = parts.slice(0, -1).map((part) => part.toNumber());
|
|
41
|
+
const used = head.reduce((sum, value) => sum.plus(value), moneyZero());
|
|
42
|
+
return [...head, whole.minus(used).toNumber()];
|
|
55
43
|
};
|
|
56
44
|
//#endregion
|
|
57
45
|
//#region src/tenders.ts
|
|
@@ -152,17 +140,20 @@ const coveringTenders = (tenders) => {
|
|
|
152
140
|
return tender.status !== "FAILED" && tender.status !== "CANCELLED";
|
|
153
141
|
});
|
|
154
142
|
};
|
|
143
|
+
const coverageOf = (tenders) => {
|
|
144
|
+
return coveringTenders(tenders).reduce((sum, tender) => {
|
|
145
|
+
return sum.plus(tender.amount.total);
|
|
146
|
+
}, moneyZero());
|
|
147
|
+
};
|
|
155
148
|
/** Σ `amount.total` of live, not failed/cancelled tenders. */
|
|
156
149
|
const tendersCoverage = (tenders) => {
|
|
157
|
-
return
|
|
158
|
-
return sum + tender.amount.total;
|
|
159
|
-
}, 0);
|
|
150
|
+
return moneyNumber(coverageOf(tenders));
|
|
160
151
|
};
|
|
161
152
|
/** Live tenders that are not failed/cancelled cover the order total. */
|
|
162
153
|
const isIntentCovered = (intent, orderTotal) => {
|
|
163
154
|
if (!intent) return false;
|
|
164
155
|
const total = orderTotal ?? intent.totals.total;
|
|
165
|
-
return
|
|
156
|
+
return coverageOf(liveTenders(intent)).gte(money(total));
|
|
166
157
|
};
|
|
167
158
|
/**
|
|
168
159
|
* Order `payment_status` from the live tenders of the current intent
|
|
@@ -219,8 +210,7 @@ const RESOLVED_STATUSES = [
|
|
|
219
210
|
const isPartialApproval = (tender, update) => {
|
|
220
211
|
if (update.status !== "PAID") return false;
|
|
221
212
|
if (update.approved_amount === void 0) return false;
|
|
222
|
-
|
|
223
|
-
return update.approved_amount + tolerance < tender.amount.total;
|
|
213
|
+
return money(update.approved_amount).lt(tender.amount.total);
|
|
224
214
|
};
|
|
225
215
|
const withAttempt = (tender, reference) => {
|
|
226
216
|
if (tender.attempt_reference === reference) return tender;
|
|
@@ -370,18 +360,20 @@ const DELIVERY_FEE_BASED_ON = [
|
|
|
370
360
|
"DISTANCE_STREET",
|
|
371
361
|
"COVERAGE_ZONE_TAG"
|
|
372
362
|
];
|
|
373
|
-
const zeroMinor = () =>
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
363
|
+
const zeroMinor = () => {
|
|
364
|
+
return {
|
|
365
|
+
subtotal: moneyZero(),
|
|
366
|
+
discount: moneyZero(),
|
|
367
|
+
tax: moneyZero(),
|
|
368
|
+
total: moneyZero()
|
|
369
|
+
};
|
|
370
|
+
};
|
|
379
371
|
const addMinor = (left, right) => {
|
|
380
372
|
return {
|
|
381
|
-
subtotal: left.subtotal
|
|
382
|
-
discount: left.discount
|
|
383
|
-
tax: left.tax
|
|
384
|
-
total: left.total
|
|
373
|
+
subtotal: left.subtotal.plus(right.subtotal),
|
|
374
|
+
discount: left.discount.plus(right.discount),
|
|
375
|
+
tax: left.tax.plus(right.tax),
|
|
376
|
+
total: left.total.plus(right.total)
|
|
385
377
|
};
|
|
386
378
|
};
|
|
387
379
|
const feeBasedOf = (line) => {
|
|
@@ -423,43 +415,30 @@ const descendantsOf = (lines, rootUid) => {
|
|
|
423
415
|
return out;
|
|
424
416
|
};
|
|
425
417
|
/**
|
|
426
|
-
* Splits `amount`
|
|
427
|
-
*
|
|
428
|
-
*
|
|
418
|
+
* Splits `amount` by `units / quantity`. Parts are ordered by `sequence`.
|
|
419
|
+
* Every part but the last is `amount * units / quantity`. The last part is
|
|
420
|
+
* the remainder, so the parts sum to `amount`.
|
|
429
421
|
*/
|
|
430
422
|
const prorateMinor = (amount, quantity, parts) => {
|
|
431
|
-
const
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
423
|
+
const ordered = parts.map((part, index) => ({
|
|
424
|
+
...part,
|
|
425
|
+
index
|
|
426
|
+
})).toSorted((left, right) => left.sequence - right.sequence);
|
|
427
|
+
const whole = money(amount);
|
|
428
|
+
const qty = money(quantity);
|
|
429
|
+
let rest = whole;
|
|
430
|
+
const numbers = moneyNumbers(whole, ordered.map((part, index) => {
|
|
431
|
+
if (index === ordered.length - 1) return rest;
|
|
432
|
+
const share = whole.times(part.units).div(qty);
|
|
433
|
+
rest = rest.minus(share);
|
|
434
|
+
return share;
|
|
435
|
+
}));
|
|
436
|
+
const out = parts.map(() => 0);
|
|
437
|
+
ordered.forEach((part, index) => {
|
|
438
|
+
out[part.index] = numbers[index] ?? 0;
|
|
445
439
|
});
|
|
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
440
|
return out;
|
|
453
441
|
};
|
|
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
442
|
const planAllocations = (input, real, roots) => {
|
|
464
443
|
const byUid = new Map(input.lines.map((line) => [line.uid, line]));
|
|
465
444
|
const plan = {
|
|
@@ -550,39 +529,39 @@ const planAllocations = (input, real, roots) => {
|
|
|
550
529
|
}
|
|
551
530
|
return plan;
|
|
552
531
|
};
|
|
553
|
-
const
|
|
532
|
+
const ownMoney = (line) => {
|
|
554
533
|
return {
|
|
555
|
-
subtotal:
|
|
556
|
-
discount:
|
|
557
|
-
tax:
|
|
558
|
-
total:
|
|
534
|
+
subtotal: money(line.subtotal),
|
|
535
|
+
discount: money(line.discount_total),
|
|
536
|
+
tax: money(line.tax_total),
|
|
537
|
+
total: money(line.total)
|
|
559
538
|
};
|
|
560
539
|
};
|
|
561
|
-
const
|
|
540
|
+
const groupMoney = (line) => {
|
|
562
541
|
return {
|
|
563
|
-
subtotal:
|
|
564
|
-
discount:
|
|
565
|
-
tax:
|
|
566
|
-
total:
|
|
542
|
+
subtotal: money(line.group_subtotal),
|
|
543
|
+
discount: money(line.group_discount),
|
|
544
|
+
tax: money(line.group_tax_total),
|
|
545
|
+
total: money(line.group_total)
|
|
567
546
|
};
|
|
568
547
|
};
|
|
569
548
|
const isConsistent = (value) => {
|
|
570
|
-
return
|
|
549
|
+
return value.subtotal.minus(value.discount).plus(value.tax).eq(value.total);
|
|
571
550
|
};
|
|
572
|
-
/** Split of one component per part
|
|
551
|
+
/** Split of one component per part. Each component sums to its source. */
|
|
573
552
|
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);
|
|
553
|
+
const subtotal = prorateMinor(moneyNumber(value.subtotal), quantity, parts);
|
|
554
|
+
const discount = prorateMinor(moneyNumber(value.discount), quantity, parts);
|
|
555
|
+
const total = prorateMinor(moneyNumber(value.total), quantity, parts);
|
|
577
556
|
return parts.map((_, index) => {
|
|
578
|
-
const
|
|
579
|
-
const
|
|
580
|
-
const
|
|
557
|
+
const partSubtotal = money(subtotal[index] ?? 0);
|
|
558
|
+
const partDiscount = money(discount[index] ?? 0);
|
|
559
|
+
const partTotal = money(total[index] ?? 0);
|
|
581
560
|
return {
|
|
582
|
-
subtotal:
|
|
583
|
-
discount:
|
|
584
|
-
tax:
|
|
585
|
-
total:
|
|
561
|
+
subtotal: partSubtotal,
|
|
562
|
+
discount: partDiscount,
|
|
563
|
+
tax: partTotal.minus(partSubtotal).plus(partDiscount),
|
|
564
|
+
total: partTotal
|
|
586
565
|
};
|
|
587
566
|
});
|
|
588
567
|
};
|
|
@@ -593,50 +572,51 @@ const base = (tender) => {
|
|
|
593
572
|
method: tender.method
|
|
594
573
|
};
|
|
595
574
|
};
|
|
575
|
+
const balanceAmounts = (amounts, gross) => {
|
|
576
|
+
if (amounts.length === 0) return amounts;
|
|
577
|
+
const head = amounts.slice(0, -1);
|
|
578
|
+
const used = head.reduce((sum, row) => sum.plus(row.total), moneyZero());
|
|
579
|
+
const lastTotal = gross.minus(used);
|
|
580
|
+
const last = amounts[amounts.length - 1];
|
|
581
|
+
return [...head, {
|
|
582
|
+
...last,
|
|
583
|
+
total: moneyNumber(lastTotal)
|
|
584
|
+
}];
|
|
585
|
+
};
|
|
596
586
|
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
587
|
return {
|
|
610
|
-
|
|
611
|
-
...b2,
|
|
588
|
+
currency,
|
|
612
589
|
total
|
|
613
590
|
};
|
|
614
591
|
};
|
|
615
592
|
/**
|
|
616
593
|
* Server-side money of every tender (§3.2): whole lines as stored, parts
|
|
617
|
-
*
|
|
618
|
-
*
|
|
619
|
-
*
|
|
620
|
-
*
|
|
594
|
+
* split exactly (last part is the remainder), fees to their tender, tip /
|
|
595
|
+
* delivery and any order-level difference to the primary, then REDEMPTION
|
|
596
|
+
* deducted from the primary first and by `sequence`, never below a tender's
|
|
597
|
+
* tip + delivery (D18). A tender publishes only what it pays
|
|
598
|
+
* (`amount.total`); `allocation.amounts` is the money of the lines it
|
|
599
|
+
* covers, before REDEMPTION. Amounts are not rounded.
|
|
621
600
|
*/
|
|
622
601
|
const allocateTenders = (input) => {
|
|
623
602
|
const currency = input.totals.currency;
|
|
624
|
-
const { toMinor, fromMinor } = createConverter(currency);
|
|
625
603
|
const sorted = input.tenders.toSorted((a, b) => a.sequence - b.sequence);
|
|
626
604
|
const real = sorted.filter((tender) => isRealPaymentMethod(tender.method));
|
|
627
605
|
const redemptions = sorted.filter((tender) => {
|
|
628
606
|
return !isRealPaymentMethod(tender.method);
|
|
629
607
|
});
|
|
630
608
|
const roots = input.lines.filter((line) => line.parent_uid === null);
|
|
631
|
-
const orderTotal =
|
|
609
|
+
const orderTotal = money(input.totals.total);
|
|
632
610
|
const tipDelivery = roots.reduce((sum, line) => {
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
611
|
+
if (!(isTipFee(line) || isDeliveryFee(line))) return sum;
|
|
612
|
+
return sum.plus(line.group_total);
|
|
613
|
+
}, moneyZero());
|
|
614
|
+
const redemptionValues = redemptions.map((tender) => {
|
|
615
|
+
return money(tender.redemption_amount ?? 0);
|
|
637
616
|
});
|
|
638
|
-
const redeemed =
|
|
639
|
-
|
|
617
|
+
const redeemed = moneySum(redemptionValues);
|
|
618
|
+
const cap = orderTotal.minus(tipDelivery);
|
|
619
|
+
if (redeemed.gt(cap)) return {
|
|
640
620
|
ok: false,
|
|
641
621
|
error: {
|
|
642
622
|
code: "PAYMENT_REDEMPTION_EXCEEDED",
|
|
@@ -653,7 +633,7 @@ const allocateTenders = (input) => {
|
|
|
653
633
|
}
|
|
654
634
|
};
|
|
655
635
|
const redemptionTenders = redemptions.map((tender, index) => {
|
|
656
|
-
const value =
|
|
636
|
+
const value = moneyNumber(redemptionValues[index] ?? moneyZero());
|
|
657
637
|
const amount = emptyAmount(currency, value);
|
|
658
638
|
return {
|
|
659
639
|
...base(tender),
|
|
@@ -662,10 +642,10 @@ const allocateTenders = (input) => {
|
|
|
662
642
|
};
|
|
663
643
|
});
|
|
664
644
|
if (real.length === 0) {
|
|
665
|
-
if (redeemed
|
|
645
|
+
if (!redeemed.eq(orderTotal)) {
|
|
666
646
|
const msg = "Redemptions must cover the order without a real tender";
|
|
667
647
|
let code = "PAYMENT_UNALLOCATED_LINES";
|
|
668
|
-
if (redeemed
|
|
648
|
+
if (redeemed.gt(orderTotal)) code = "PAYMENT_REDEMPTION_EXCEEDED";
|
|
669
649
|
return {
|
|
670
650
|
ok: false,
|
|
671
651
|
error: {
|
|
@@ -686,37 +666,35 @@ const allocateTenders = (input) => {
|
|
|
686
666
|
error: plan.fail
|
|
687
667
|
};
|
|
688
668
|
const builds = real.map(() => {
|
|
689
|
-
|
|
669
|
+
const b1 = {
|
|
690
670
|
products: zeroMinor(),
|
|
691
|
-
fees:
|
|
692
|
-
|
|
693
|
-
|
|
671
|
+
fees: moneyZero()
|
|
672
|
+
};
|
|
673
|
+
const b2 = {
|
|
674
|
+
tip: moneyZero(),
|
|
675
|
+
delivery: moneyZero(),
|
|
694
676
|
amounts: []
|
|
695
677
|
};
|
|
678
|
+
return {
|
|
679
|
+
...b1,
|
|
680
|
+
...b2
|
|
681
|
+
};
|
|
696
682
|
});
|
|
697
683
|
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
684
|
return {
|
|
705
685
|
line_uid: uid,
|
|
706
686
|
...units === void 0 ? {} : { units },
|
|
707
|
-
|
|
708
|
-
discount,
|
|
709
|
-
...b1
|
|
687
|
+
total: moneyNumber(value.total)
|
|
710
688
|
};
|
|
711
689
|
};
|
|
712
690
|
for (const root of roots) {
|
|
713
691
|
if (root.type === "FEE") {
|
|
714
692
|
const build = builds[plan.fees.get(root.uid) ?? 0];
|
|
715
|
-
const total =
|
|
716
|
-
if (isTipFee(root)) build.tip
|
|
717
|
-
else if (isDeliveryFee(root)) build.delivery
|
|
718
|
-
else build.fees
|
|
719
|
-
const amount = toAmount(root.uid,
|
|
693
|
+
const total = money(root.group_total);
|
|
694
|
+
if (isTipFee(root)) build.tip = build.tip.plus(total);
|
|
695
|
+
else if (isDeliveryFee(root)) build.delivery = build.delivery.plus(total);
|
|
696
|
+
else build.fees = build.fees.plus(total);
|
|
697
|
+
const amount = toAmount(root.uid, groupMoney(root));
|
|
720
698
|
build.amounts.push({
|
|
721
699
|
...amount,
|
|
722
700
|
children: []
|
|
@@ -727,10 +705,10 @@ const allocateTenders = (input) => {
|
|
|
727
705
|
const wholeIndex = plan.whole.get(root.uid);
|
|
728
706
|
if (wholeIndex !== void 0) {
|
|
729
707
|
const build = builds[wholeIndex];
|
|
730
|
-
const group =
|
|
708
|
+
const group = groupMoney(root);
|
|
731
709
|
build.products = addMinor(build.products, group);
|
|
732
710
|
const childAmounts = children.map((child) => {
|
|
733
|
-
const own =
|
|
711
|
+
const own = ownMoney(child);
|
|
734
712
|
return {
|
|
735
713
|
...toAmount(child.uid, own),
|
|
736
714
|
quantity: child.quantity
|
|
@@ -747,7 +725,7 @@ const allocateTenders = (input) => {
|
|
|
747
725
|
const components = [root, ...children];
|
|
748
726
|
const splits = [];
|
|
749
727
|
for (const component of components) {
|
|
750
|
-
const own =
|
|
728
|
+
const own = ownMoney(component);
|
|
751
729
|
if (!isConsistent(own)) return {
|
|
752
730
|
ok: false,
|
|
753
731
|
error: {
|
|
@@ -765,7 +743,7 @@ const allocateTenders = (input) => {
|
|
|
765
743
|
build.products = addMinor(build.products, value);
|
|
766
744
|
const childAmounts = children.map((child, childIndex) => {
|
|
767
745
|
const share = shares[childIndex + 1] ?? zeroMinor();
|
|
768
|
-
const quantity = child.quantity
|
|
746
|
+
const quantity = moneyNumber(money(child.quantity).times(part.units).div(root.quantity));
|
|
769
747
|
return {
|
|
770
748
|
...toAmount(child.uid, share),
|
|
771
749
|
quantity
|
|
@@ -779,23 +757,25 @@ const allocateTenders = (input) => {
|
|
|
779
757
|
}
|
|
780
758
|
}
|
|
781
759
|
const allocated = builds.reduce((sum, build) => {
|
|
782
|
-
|
|
783
|
-
|
|
760
|
+
const gross = build.products.total.plus(build.fees);
|
|
761
|
+
return sum.plus(gross).plus(build.tip).plus(build.delivery);
|
|
762
|
+
}, moneyZero());
|
|
784
763
|
const primary = builds[0];
|
|
785
|
-
const adjustment = orderTotal
|
|
786
|
-
if (adjustment
|
|
764
|
+
const adjustment = orderTotal.minus(allocated);
|
|
765
|
+
if (adjustment.lt(0)) {
|
|
787
766
|
const value = { ...primary.products };
|
|
788
|
-
value.discount
|
|
789
|
-
value.total
|
|
767
|
+
value.discount = value.discount.minus(adjustment);
|
|
768
|
+
value.total = value.total.plus(adjustment);
|
|
790
769
|
primary.products = value;
|
|
791
|
-
} else if (adjustment
|
|
770
|
+
} else if (adjustment.gt(0)) primary.products = {
|
|
792
771
|
...primary.products,
|
|
793
|
-
|
|
772
|
+
subtotal: primary.products.subtotal.plus(adjustment),
|
|
773
|
+
total: primary.products.total.plus(adjustment)
|
|
794
774
|
};
|
|
795
775
|
const totals = builds.map((build) => {
|
|
796
|
-
return build.products.total
|
|
776
|
+
return build.products.total.plus(build.fees).plus(build.tip).plus(build.delivery);
|
|
797
777
|
});
|
|
798
|
-
if ((totals[0] ??
|
|
778
|
+
if ((totals[0] ?? moneyZero()).lt(0)) return {
|
|
799
779
|
ok: false,
|
|
800
780
|
error: {
|
|
801
781
|
code: "PAYMENT_INVALID_PRIMARY",
|
|
@@ -803,67 +783,83 @@ const allocateTenders = (input) => {
|
|
|
803
783
|
tender_id: real[0]?.id
|
|
804
784
|
}
|
|
805
785
|
};
|
|
806
|
-
const applied = builds.map(() =>
|
|
786
|
+
const applied = builds.map(() => moneyZero());
|
|
807
787
|
let pending = redeemed;
|
|
808
788
|
for (const [index, build] of builds.entries()) {
|
|
809
|
-
if (pending
|
|
810
|
-
const floor = build.tip
|
|
811
|
-
const
|
|
812
|
-
const take =
|
|
789
|
+
if (pending.lte(0)) break;
|
|
790
|
+
const floor = build.tip.plus(build.delivery);
|
|
791
|
+
const gross = totals[index] ?? moneyZero();
|
|
792
|
+
const take = moneyMin(moneyMax(moneyZero(), gross.minus(floor)), pending);
|
|
813
793
|
applied[index] = take;
|
|
814
|
-
pending
|
|
794
|
+
pending = pending.minus(take);
|
|
815
795
|
}
|
|
816
|
-
if (pending
|
|
796
|
+
if (pending.gt(0)) return {
|
|
817
797
|
ok: false,
|
|
818
798
|
error: {
|
|
819
799
|
code: "PAYMENT_REDEMPTION_EXCEEDED",
|
|
820
800
|
message: "Redemptions exceed what real tenders can absorb"
|
|
821
801
|
}
|
|
822
802
|
};
|
|
803
|
+
const appliedById = /* @__PURE__ */ new Map();
|
|
804
|
+
const tenders = [...real.map((tender, index) => {
|
|
805
|
+
const build = builds[index];
|
|
806
|
+
const gross = totals[index] ?? moneyZero();
|
|
807
|
+
const redemptions = applied[index] ?? moneyZero();
|
|
808
|
+
appliedById.set(tender.id, redemptions);
|
|
809
|
+
const published = gross.minus(redemptions);
|
|
810
|
+
const amount = {
|
|
811
|
+
currency,
|
|
812
|
+
total: moneyNumber(published)
|
|
813
|
+
};
|
|
814
|
+
const open = tender.allocation === null;
|
|
815
|
+
const lines = open ? [] : tender.allocation?.lines ?? [];
|
|
816
|
+
const feesLines = open ? [] : tender.allocation?.fees ?? [];
|
|
817
|
+
let allocation = null;
|
|
818
|
+
if (real.length > 1 || !open) allocation = {
|
|
819
|
+
lines,
|
|
820
|
+
fees: feesLines,
|
|
821
|
+
amounts: balanceAmounts(build.amounts, gross)
|
|
822
|
+
};
|
|
823
|
+
return {
|
|
824
|
+
...base(tender),
|
|
825
|
+
allocation,
|
|
826
|
+
amount
|
|
827
|
+
};
|
|
828
|
+
}), ...redemptionTenders].toSorted((a, b) => {
|
|
829
|
+
return a.sequence - b.sequence;
|
|
830
|
+
});
|
|
831
|
+
const primaryId = real[0]?.id ?? null;
|
|
832
|
+
const used = tenders.filter((tender) => tender.id !== primaryId).reduce((sum, tender) => {
|
|
833
|
+
return sum.plus(tender.amount.total);
|
|
834
|
+
}, moneyZero());
|
|
835
|
+
const primaryTotal = orderTotal.minus(used);
|
|
823
836
|
return {
|
|
824
837
|
ok: true,
|
|
825
|
-
tenders:
|
|
826
|
-
|
|
827
|
-
|
|
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) };
|
|
838
|
+
tenders: tenders.map((tender) => {
|
|
839
|
+
if (tender.id !== primaryId) return tender;
|
|
840
|
+
if (primaryTotal.eq(tender.amount.total)) return tender;
|
|
840
841
|
const amount = {
|
|
841
|
-
...
|
|
842
|
-
|
|
843
|
-
...b3,
|
|
844
|
-
...b4,
|
|
845
|
-
...b5,
|
|
846
|
-
...b6,
|
|
847
|
-
total: fromMinor(total)
|
|
842
|
+
...tender.amount,
|
|
843
|
+
total: moneyNumber(primaryTotal)
|
|
848
844
|
};
|
|
849
|
-
const
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
if (real.length > 1 || !open) allocation = {
|
|
854
|
-
lines,
|
|
855
|
-
fees,
|
|
856
|
-
amounts: build.amounts
|
|
845
|
+
const allocation = tender.allocation;
|
|
846
|
+
if (!allocation) return {
|
|
847
|
+
...tender,
|
|
848
|
+
amount
|
|
857
849
|
};
|
|
850
|
+
const redemptions = appliedById.get(tender.id) ?? moneyZero();
|
|
851
|
+
const gross = primaryTotal.plus(redemptions);
|
|
852
|
+
const amounts = balanceAmounts(allocation.amounts, gross);
|
|
858
853
|
return {
|
|
859
|
-
...
|
|
860
|
-
|
|
861
|
-
|
|
854
|
+
...tender,
|
|
855
|
+
amount,
|
|
856
|
+
allocation: {
|
|
857
|
+
...allocation,
|
|
858
|
+
amounts
|
|
859
|
+
}
|
|
862
860
|
};
|
|
863
|
-
}), ...redemptionTenders].toSorted((a, b) => {
|
|
864
|
-
return a.sequence - b.sequence;
|
|
865
861
|
}),
|
|
866
|
-
primary_tender_id:
|
|
862
|
+
primary_tender_id: primaryId
|
|
867
863
|
};
|
|
868
864
|
};
|
|
869
865
|
/**
|
|
@@ -922,8 +918,7 @@ const validateTendersAgainstRules = (input) => {
|
|
|
922
918
|
}
|
|
923
919
|
};
|
|
924
920
|
const maxAmount = caps?.max_amount ?? effect.max_amount_per_method;
|
|
925
|
-
|
|
926
|
-
if (maxAmount !== null && tender.amount.total > maxAmount + tolerance) return {
|
|
921
|
+
if (maxAmount !== null && money(tender.amount.total).gt(maxAmount)) return {
|
|
927
922
|
ok: false,
|
|
928
923
|
error: {
|
|
929
924
|
code: "PAYMENT_AMOUNT_EXCEEDED",
|
|
@@ -938,6 +933,61 @@ const validateTendersAgainstRules = (input) => {
|
|
|
938
933
|
};
|
|
939
934
|
};
|
|
940
935
|
//#endregion
|
|
936
|
+
//#region src/amount.ts
|
|
937
|
+
/** ISO 4217 minor units; anything not listed uses 2. */
|
|
938
|
+
const CURRENCY_DECIMALS = {
|
|
939
|
+
CLP: 0,
|
|
940
|
+
PYG: 0,
|
|
941
|
+
JPY: 0,
|
|
942
|
+
COP: 2,
|
|
943
|
+
USD: 2,
|
|
944
|
+
BRL: 2,
|
|
945
|
+
PEN: 2,
|
|
946
|
+
ARS: 2,
|
|
947
|
+
VES: 2,
|
|
948
|
+
MXN: 2,
|
|
949
|
+
EUR: 2
|
|
950
|
+
};
|
|
951
|
+
const currencyDecimals = (currency) => {
|
|
952
|
+
return CURRENCY_DECIMALS[currency.toUpperCase()] ?? 2;
|
|
953
|
+
};
|
|
954
|
+
/** Full precision. `decimals` is ignored so amounts are never truncated. */
|
|
955
|
+
const roundTo = (value, _decimals) => {
|
|
956
|
+
return moneyNumber(money(value));
|
|
957
|
+
};
|
|
958
|
+
const toPaymentAmount = (value, currency) => {
|
|
959
|
+
const code = currency.toUpperCase();
|
|
960
|
+
const decimals = currencyDecimals(code);
|
|
961
|
+
const exact = money(value);
|
|
962
|
+
const scale = money(10).pow(decimals);
|
|
963
|
+
const minor = moneyNumber(exact.times(scale));
|
|
964
|
+
return {
|
|
965
|
+
value: moneyNumber(exact),
|
|
966
|
+
minor,
|
|
967
|
+
currency: code,
|
|
968
|
+
decimals
|
|
969
|
+
};
|
|
970
|
+
};
|
|
971
|
+
/** Converts an order-currency value to the charge currency at a fixed rate. */
|
|
972
|
+
const toChargeAmount = (input) => {
|
|
973
|
+
const orderCurrency = input.orderCurrency.toUpperCase();
|
|
974
|
+
const chargeCurrency = (input.chargeCurrency ?? orderCurrency).toUpperCase();
|
|
975
|
+
const same = chargeCurrency === orderCurrency;
|
|
976
|
+
if (!same && !(input.rate && input.rate > 0)) {
|
|
977
|
+
const message = `Missing exchange rate ${orderCurrency}->${chargeCurrency}`;
|
|
978
|
+
throw new Error(message);
|
|
979
|
+
}
|
|
980
|
+
const rate = same ? 1 : input.rate;
|
|
981
|
+
const charged = money(input.value).times(rate);
|
|
982
|
+
const amount = toPaymentAmount(moneyNumber(charged), chargeCurrency);
|
|
983
|
+
return {
|
|
984
|
+
amount,
|
|
985
|
+
charged_amount: amount.value,
|
|
986
|
+
charged_currency: chargeCurrency,
|
|
987
|
+
exchange_rate: rate
|
|
988
|
+
};
|
|
989
|
+
};
|
|
990
|
+
//#endregion
|
|
941
991
|
//#region src/device.ts
|
|
942
992
|
const isEmpty = (value) => {
|
|
943
993
|
return value == null || typeof value === "string" && value.trim() === "";
|
|
@@ -1171,39 +1221,36 @@ const isBatchCloseDue = (input) => {
|
|
|
1171
1221
|
const batchCloseDay = (now) => localDay(now);
|
|
1172
1222
|
//#endregion
|
|
1173
1223
|
//#region src/redemptions.ts
|
|
1174
|
-
const
|
|
1175
|
-
|
|
1224
|
+
const add = (left, right) => {
|
|
1225
|
+
return moneyNumber(money(left).plus(right));
|
|
1226
|
+
};
|
|
1176
1227
|
/**
|
|
1177
|
-
*
|
|
1178
|
-
*
|
|
1228
|
+
* Every redeem has a `payment_id` whose REDEMPTION tender covers it.
|
|
1229
|
+
* Legacy rows stored without one are ignored.
|
|
1179
1230
|
*/
|
|
1180
|
-
const
|
|
1231
|
+
const hasRedeemPayment = (redeem) => {
|
|
1181
1232
|
return Boolean(redeem.payment_id);
|
|
1182
1233
|
};
|
|
1183
|
-
/** Discount redeems: the ones replayed into `redemption_steps`. */
|
|
1184
|
-
const discountRedeems = (redeem) => {
|
|
1185
|
-
return redeem.filter((item) => !isPaymentRedeem(item));
|
|
1186
|
-
};
|
|
1187
1234
|
/**
|
|
1188
|
-
* Every
|
|
1189
|
-
*
|
|
1190
|
-
*
|
|
1235
|
+
* Every redeem of the order: order-level ones first, then the ones kept
|
|
1236
|
+
* on a root line (a voucher stored with the product it pays for). Each is
|
|
1237
|
+
* covered by a REDEMPTION tender; child-line redeems never apply.
|
|
1191
1238
|
*/
|
|
1192
|
-
const
|
|
1239
|
+
const orderRedeems = (order) => {
|
|
1193
1240
|
const onLines = (order.lines ?? []).filter((line) => line.parent_uid == null).flatMap((line) => line.redeem ?? []);
|
|
1194
|
-
return [...order.redeem ?? [], ...onLines].filter(
|
|
1241
|
+
return [...order.redeem ?? [], ...onLines].filter(hasRedeemPayment);
|
|
1195
1242
|
};
|
|
1196
1243
|
/**
|
|
1197
|
-
*
|
|
1198
|
-
*
|
|
1244
|
+
* Redeems pay the whole order, so no other tender is needed: the intent
|
|
1245
|
+
* is only REDEMPTION tenders and the order advances on its own.
|
|
1199
1246
|
*/
|
|
1200
|
-
const
|
|
1201
|
-
const decimals = currencyDecimals(totals.currency);
|
|
1247
|
+
const redeemsCoverTotal = (redeem, totals) => {
|
|
1202
1248
|
const paid = redeem.reduce((sum, item) => {
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1249
|
+
if (!hasRedeemPayment(item)) return sum;
|
|
1250
|
+
return sum.plus(item.amount ?? 0);
|
|
1251
|
+
}, moneyZero());
|
|
1252
|
+
if (paid.lte(0)) return false;
|
|
1253
|
+
return paid.eq(money(totals.total));
|
|
1207
1254
|
};
|
|
1208
1255
|
const rewardOf = (redeem) => {
|
|
1209
1256
|
return {
|
|
@@ -1225,13 +1272,13 @@ const detailsOf = (rewards, loyalty_transaction_id) => {
|
|
|
1225
1272
|
};
|
|
1226
1273
|
};
|
|
1227
1274
|
/**
|
|
1228
|
-
* Groups
|
|
1229
|
-
*
|
|
1275
|
+
* Groups redeems into one entry per `payment_id`, in first-appearance
|
|
1276
|
+
* order. Legacy entries without `payment_id` are skipped.
|
|
1230
1277
|
*/
|
|
1231
|
-
const
|
|
1278
|
+
const groupRedeems = (redeem) => {
|
|
1232
1279
|
const groups = /* @__PURE__ */ new Map();
|
|
1233
1280
|
for (const item of redeem) {
|
|
1234
|
-
if (!
|
|
1281
|
+
if (!hasRedeemPayment(item)) continue;
|
|
1235
1282
|
const payment_id = item.payment_id;
|
|
1236
1283
|
const list = groups.get(payment_id) ?? [];
|
|
1237
1284
|
list.push(item);
|
|
@@ -1325,24 +1372,24 @@ const createAttemptReference = (now = Date.now()) => {
|
|
|
1325
1372
|
* its children). `share` prorates it when the intent covers part of the order.
|
|
1326
1373
|
*/
|
|
1327
1374
|
const taxBreakdown = (lines, options = {}) => {
|
|
1328
|
-
const share = options.share ?? 1;
|
|
1329
|
-
const decimals = options.decimals ?? 2;
|
|
1375
|
+
const share = money(options.share ?? 1);
|
|
1330
1376
|
const byRate = /* @__PURE__ */ new Map();
|
|
1331
1377
|
for (const line of lines) {
|
|
1332
1378
|
const rate = line.tax_rate ?? 0;
|
|
1333
1379
|
const current = byRate.get(rate) ?? {
|
|
1334
|
-
base:
|
|
1335
|
-
tax:
|
|
1380
|
+
base: moneyZero(),
|
|
1381
|
+
tax: moneyZero()
|
|
1336
1382
|
};
|
|
1337
|
-
|
|
1338
|
-
current.
|
|
1383
|
+
const base = money(line.subtotal).minus(line.discount_total);
|
|
1384
|
+
current.base = current.base.plus(base);
|
|
1385
|
+
current.tax = current.tax.plus(line.tax_total);
|
|
1339
1386
|
byRate.set(rate, current);
|
|
1340
1387
|
}
|
|
1341
1388
|
return [...byRate.entries()].toSorted(([a], [b]) => a - b).map(([rate, sums]) => {
|
|
1342
1389
|
return {
|
|
1343
1390
|
rate,
|
|
1344
|
-
base:
|
|
1345
|
-
tax:
|
|
1391
|
+
base: moneyNumber(sums.base.times(share)),
|
|
1392
|
+
tax: moneyNumber(sums.tax.times(share))
|
|
1346
1393
|
};
|
|
1347
1394
|
});
|
|
1348
1395
|
};
|
|
@@ -1387,4 +1434,4 @@ const memoryPaymentStorage = () => {
|
|
|
1387
1434
|
};
|
|
1388
1435
|
};
|
|
1389
1436
|
//#endregion
|
|
1390
|
-
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,
|
|
1437
|
+
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, batchCloseDay, countTenderItems, createAttemptReference, createPaymentError, currencyDecimals, currentIntent, defaultReversalFor, definePaymentErrors, derivePaymentStatus, formatPaymentError, groupRedeems, hasRedeemPayment, isBatchCloseDue, isDeliveryFee, isIntentCovered, isMixedTenders, isPaymentError, isRealPaymentMethod, isRedemptionReserved, isScopePaid, isTenderSettled, isTerminalLocked, isTipFee, liveRealTenders, liveTenders, memoryPaymentStorage, mergePaymentTender, missingDeviceFields, money, moneyMax, moneyMin, moneyNumber, moneyNumbers, moneySum, moneyZero, nextIntentStatus, orderRedeems, primaryTender, primaryTenderOf, prorateMinor, realTenders, redeemsCoverTotal, redemptionDetailsOf, redemptionRewardsOf, resolvePaymentPolicy, resolveProviderEnvironment, roundTo, scopedPaymentStorage, splitMoney, 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.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Fire Spark payments contract: adapters, errors, amounts and policy",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -17,9 +17,11 @@
|
|
|
17
17
|
}
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"
|
|
20
|
+
"big.js": "6.2.1",
|
|
21
|
+
"@fire-spark/types": "^0.5.0"
|
|
21
22
|
},
|
|
22
23
|
"devDependencies": {
|
|
24
|
+
"@types/big.js": "6.2.2",
|
|
23
25
|
"tsdown": "^0.22.14",
|
|
24
26
|
"typescript": "^7.0.2",
|
|
25
27
|
"vitest": "^2.1.8"
|