@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.
Files changed (3) hide show
  1. package/dist/index.d.ts +54 -34
  2. package/dist/index.js +307 -260
  3. 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` (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`.
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
- * 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).
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
- declare const roundTo: (value: number, decimals: number) => number;
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
- * Payment redeem as stored in `orders.redeem`. `amount` is the total of
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 PaymentRedeemLike = {
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
- /** Payment redeems of one `payment_id`, ready to seed its tender. */
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
- * Reward (voucher, balance…) paid with a REDEMPTION tender, not a discount (D16):
493
- * it never discounts lines nor has a `redemption_steps` entry.
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 isPaymentRedeem: (redeem: {
519
+ declare const hasRedeemPayment: (redeem: {
496
520
  payment_id?: string | null;
497
521
  }) => boolean;
498
- /** Discount redeems: the ones replayed into `redemption_steps`. */
499
- declare const discountRedeems: <T extends {
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 payment redeem of the order: order-level ones first, then the ones
512
- * kept on a root line (a voucher stored with the product it pays for).
513
- * Both are paid by REDEMPTION tenders; child-line redeems never apply.
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 orderPaymentRedeems: <T extends PaymentRedeemLike>(order: PaymentRedeemSource<T>) => T[];
535
+ declare const orderRedeems: <T extends RedeemLike>(order: RedeemSource<T>) => T[];
516
536
  /**
517
- * Payment redeems pay the whole order, so no real tender is needed: the
518
- * intent is only REDEMPTION tenders and the order advances on its own.
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 paymentRedeemsCoverTotal: (redeem: readonly PaymentRedeemLike[], totals: {
540
+ declare const redeemsCoverTotal: (redeem: readonly RedeemLike[], totals: {
521
541
  total: number;
522
542
  currency: string;
523
543
  }) => boolean;
524
544
  /**
525
- * Groups payment redeems (`payment_id` set) into one entry per
526
- * `payment_id`, in first-appearance order. Discount redeems are skipped.
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 groupPaymentRedeems: (redeem: readonly PaymentRedeemLike[]) => RedemptionGroup[];
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, PaymentRedeemLike, PaymentRedeemSource, type PaymentResult, type PaymentReversal, type PaymentServer, type PaymentStorage, PaymentTenderUpdate, type PaymentUI, REDEMPTION_METHOD, RawPaymentError, 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, amountTolerance, batchCloseDay, countTenderItems, createAttemptReference, createPaymentError, currencyDecimals, currentIntent, defaultReversalFor, definePaymentErrors, derivePaymentStatus, discountRedeems, formatPaymentError, groupPaymentRedeems, isBatchCloseDue, isDeliveryFee, isIntentCovered, isMixedTenders, isPaymentError, isPaymentRedeem, isRealPaymentMethod, isRedemptionReserved, isScopePaid, isTenderSettled, isTerminalLocked, isTipFee, liveRealTenders, liveTenders, memoryPaymentStorage, mergePaymentTender, missingDeviceFields, nextIntentStatus, orderPaymentRedeems, paymentRedeemsCoverTotal, primaryTender, primaryTenderOf, prorateMinor, realTenders, redemptionDetailsOf, redemptionRewardsOf, resolvePaymentPolicy, resolveProviderEnvironment, roundTo, scopedPaymentStorage, taxBreakdown, tendersCoverage, tendersInScope, toChargeAmount, toPaymentAmount, toPaymentError, validateTendersAgainstRules, withTerminalLock };
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
- //#region src/amount.ts
2
- /** ISO 4217 minor units; anything not listed uses 2. */
3
- const CURRENCY_DECIMALS = {
4
- CLP: 0,
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 currencyDecimals = (currency) => {
17
- return CURRENCY_DECIMALS[currency.toUpperCase()] ?? 2;
6
+ const moneyZero = () => new Big(0);
7
+ const moneySum = (values) => {
8
+ return values.reduce((sum, value) => sum.plus(value), moneyZero());
18
9
  };
19
- const roundTo = (value, decimals) => {
20
- const factor = 10 ** decimals;
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 toPaymentAmount = (value, currency) => {
24
- const code = currency.toUpperCase();
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
- /** Converts an order-currency value to the charge currency at a fixed rate. */
35
- const toChargeAmount = (input) => {
36
- const orderCurrency = input.orderCurrency.toUpperCase();
37
- const chargeCurrency = (input.chargeCurrency ?? orderCurrency).toUpperCase();
38
- const same = chargeCurrency === orderCurrency;
39
- if (!same && !(input.rate && input.rate > 0)) {
40
- const message = `Missing exchange rate ${orderCurrency}->${chargeCurrency}`;
41
- throw new Error(message);
42
- }
43
- const rate = same ? 1 : input.rate;
44
- const amount = toPaymentAmount(input.value * rate, chargeCurrency);
45
- return {
46
- amount,
47
- charged_amount: amount.value,
48
- charged_currency: chargeCurrency,
49
- exchange_rate: rate
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
- /** Tolerance used when comparing amounts in a currency. */
53
- const amountTolerance = (currency) => {
54
- return 10 ** -currencyDecimals(currency) / 2;
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 coveringTenders(tenders).reduce((sum, tender) => {
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 tendersCoverage(liveTenders(intent)) + amountTolerance(intent.totals.currency) >= total;
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
- const tolerance = amountTolerance(tender.amount.currency);
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
- subtotal: 0,
375
- discount: 0,
376
- tax: 0,
377
- total: 0
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 + right.subtotal,
382
- discount: left.discount + right.discount,
383
- tax: left.tax + right.tax,
384
- total: left.total + right.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` (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`.
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 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;
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 ownMinor = (line, toMinor) => {
532
+ const ownMoney = (line) => {
554
533
  return {
555
- subtotal: toMinor(line.subtotal),
556
- discount: toMinor(line.discount_total),
557
- tax: toMinor(line.tax_total),
558
- total: toMinor(line.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 groupMinor = (line, toMinor) => {
540
+ const groupMoney = (line) => {
562
541
  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)
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 Math.abs(value.subtotal - value.discount + value.tax - value.total) <= 1;
549
+ return value.subtotal.minus(value.discount).plus(value.tax).eq(value.total);
571
550
  };
572
- /** Split of one component per part; `tax = total − subtotal + discount`. */
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 s = subtotal[index] ?? 0;
579
- const d = discount[index] ?? 0;
580
- const t = total[index] ?? 0;
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: s,
583
- discount: d,
584
- tax: t - s + d,
585
- total: t
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
- ...b1,
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
- * 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).
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 = toMinor(input.totals.total);
609
+ const orderTotal = money(input.totals.total);
632
610
  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);
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 = redemptionMinor.reduce((sum, value) => sum + value, 0);
639
- if (redeemed > orderTotal - tipDelivery) return {
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 = fromMinor(redemptionMinor[index] ?? 0);
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 !== orderTotal) {
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 > orderTotal) code = "PAYMENT_REDEMPTION_EXCEEDED";
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
- return {
669
+ const b1 = {
690
670
  products: zeroMinor(),
691
- fees: 0,
692
- tip: 0,
693
- delivery: 0,
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
- subtotal,
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 = 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));
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 = groupMinor(root, toMinor);
708
+ const group = groupMoney(root);
731
709
  build.products = addMinor(build.products, group);
732
710
  const childAmounts = children.map((child) => {
733
- const own = ownMinor(child, toMinor);
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 = ownMinor(component, toMinor);
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 * part.units / root.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
- return sum + build.products.total + build.fees + build.tip + build.delivery;
783
- }, 0);
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 - allocated;
786
- if (adjustment < 0) {
764
+ const adjustment = orderTotal.minus(allocated);
765
+ if (adjustment.lt(0)) {
787
766
  const value = { ...primary.products };
788
- value.discount -= adjustment;
789
- value.total += adjustment;
767
+ value.discount = value.discount.minus(adjustment);
768
+ value.total = value.total.plus(adjustment);
790
769
  primary.products = value;
791
- } else if (adjustment > 0) primary.products = {
770
+ } else if (adjustment.gt(0)) primary.products = {
792
771
  ...primary.products,
793
- total: primary.products.total + adjustment
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 + build.fees + build.tip + build.delivery;
776
+ return build.products.total.plus(build.fees).plus(build.tip).plus(build.delivery);
797
777
  });
798
- if ((totals[0] ?? 0) < 0) return {
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(() => 0);
786
+ const applied = builds.map(() => moneyZero());
807
787
  let pending = redeemed;
808
788
  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);
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 -= take;
794
+ pending = pending.minus(take);
815
795
  }
816
- if (pending > 0) return {
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: [...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) };
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
- ...b1,
842
- ...b2,
843
- ...b3,
844
- ...b4,
845
- ...b5,
846
- ...b6,
847
- total: fromMinor(total)
842
+ ...tender.amount,
843
+ total: moneyNumber(primaryTotal)
848
844
  };
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
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
- ...base(tender),
860
- allocation,
861
- amount
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: real[0]?.id ?? null
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
- const tolerance = amountTolerance(tender.amount.currency);
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 SUM_DECIMALS = 6;
1175
- const add = (a, b) => roundTo(a + b, SUM_DECIMALS);
1224
+ const add = (left, right) => {
1225
+ return moneyNumber(money(left).plus(right));
1226
+ };
1176
1227
  /**
1177
- * Reward (voucher, balance…) paid with a REDEMPTION tender, not a discount (D16):
1178
- * it never discounts lines nor has a `redemption_steps` entry.
1228
+ * Every redeem has a `payment_id` whose REDEMPTION tender covers it.
1229
+ * Legacy rows stored without one are ignored.
1179
1230
  */
1180
- const isPaymentRedeem = (redeem) => {
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 payment redeem of the order: order-level ones first, then the ones
1189
- * kept on a root line (a voucher stored with the product it pays for).
1190
- * Both are paid by REDEMPTION tenders; child-line redeems never apply.
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 orderPaymentRedeems = (order) => {
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(isPaymentRedeem);
1241
+ return [...order.redeem ?? [], ...onLines].filter(hasRedeemPayment);
1195
1242
  };
1196
1243
  /**
1197
- * Payment redeems pay the whole order, so no real tender is needed: the
1198
- * intent is only REDEMPTION tenders and the order advances on its own.
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 paymentRedeemsCoverTotal = (redeem, totals) => {
1201
- const decimals = currencyDecimals(totals.currency);
1247
+ const redeemsCoverTotal = (redeem, totals) => {
1202
1248
  const paid = redeem.reduce((sum, item) => {
1203
- return isPaymentRedeem(item) ? add(sum, item.amount ?? 0) : sum;
1204
- }, 0);
1205
- if (paid <= 0) return false;
1206
- return roundTo(paid, decimals) >= roundTo(totals.total, decimals);
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 payment redeems (`payment_id` set) into one entry per
1229
- * `payment_id`, in first-appearance order. Discount redeems are skipped.
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 groupPaymentRedeems = (redeem) => {
1278
+ const groupRedeems = (redeem) => {
1232
1279
  const groups = /* @__PURE__ */ new Map();
1233
1280
  for (const item of redeem) {
1234
- if (!isPaymentRedeem(item)) continue;
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: 0,
1335
- tax: 0
1380
+ base: moneyZero(),
1381
+ tax: moneyZero()
1336
1382
  };
1337
- current.base += line.subtotal - line.discount_total;
1338
- current.tax += line.tax_total;
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: roundTo(sums.base * share, decimals),
1345
- tax: roundTo(sums.tax * share, decimals)
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, amountTolerance, batchCloseDay, countTenderItems, createAttemptReference, createPaymentError, currencyDecimals, currentIntent, defaultReversalFor, definePaymentErrors, derivePaymentStatus, discountRedeems, formatPaymentError, groupPaymentRedeems, isBatchCloseDue, isDeliveryFee, isIntentCovered, isMixedTenders, isPaymentError, isPaymentRedeem, isRealPaymentMethod, isRedemptionReserved, isScopePaid, isTenderSettled, isTerminalLocked, isTipFee, liveRealTenders, liveTenders, memoryPaymentStorage, mergePaymentTender, missingDeviceFields, nextIntentStatus, orderPaymentRedeems, paymentRedeemsCoverTotal, primaryTender, primaryTenderOf, prorateMinor, realTenders, redemptionDetailsOf, redemptionRewardsOf, resolvePaymentPolicy, resolveProviderEnvironment, roundTo, scopedPaymentStorage, taxBreakdown, tendersCoverage, tendersInScope, toChargeAmount, toPaymentAmount, toPaymentError, validateTendersAgainstRules, withTerminalLock };
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.4.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
- "@fire-spark/types": "^0.4.0"
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"