@fire-spark/core 0.3.1 → 0.3.3
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 +33 -1
- package/dist/index.js +1224 -1164
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { DIGITAL_COLLECT_AT, PARTIAL_APPROVAL_ERROR, activeIntent, amountTolerance, createAttemptReference, createPaymentError, currencyDecimals, currentIntent, defaultReversalFor, groupPaymentRedeems, isDeliveryFee, isPaymentError, isRealPaymentMethod, isRealPaymentMethod as isRealPaymentMethod$1, isTenderSettled, isTipFee, liveTenders, memoryPaymentStorage, missingDeviceFields, resolvePaymentPolicy, resolveProviderEnvironment, roundTo, scopedPaymentStorage, taxBreakdown, toChargeAmount, withTerminalLock } from "@fire-spark/payments";
|
|
1
|
+
import { DIGITAL_COLLECT_AT, PARTIAL_APPROVAL_ERROR, activeIntent, amountTolerance, createAttemptReference, createPaymentError, currencyDecimals, currentIntent, defaultReversalFor, discountRedeems, groupPaymentRedeems, isDeliveryFee, isPaymentError, isPaymentRedeem, isRealPaymentMethod, isRealPaymentMethod as isRealPaymentMethod$1, isTenderSettled, isTipFee, liveTenders, memoryPaymentStorage, missingDeviceFields, paymentRedeemsCoverTotal, resolvePaymentPolicy, resolveProviderEnvironment, roundTo, scopedPaymentStorage, taxBreakdown, toChargeAmount, withTerminalLock } from "@fire-spark/payments";
|
|
2
2
|
import advancedFormat from "dayjs/plugin/advancedFormat.js";
|
|
3
3
|
import isBetween from "dayjs/plugin/isBetween.js";
|
|
4
4
|
import isSameOrAfter from "dayjs/plugin/isSameOrAfter.js";
|
|
@@ -2605,6 +2605,15 @@ const reorderOrder = (id, body = {}, options) => {
|
|
|
2605
2605
|
body: JSON.stringify(body)
|
|
2606
2606
|
}, options);
|
|
2607
2607
|
};
|
|
2608
|
+
/**
|
|
2609
|
+
* `POST /storefront/v1/orders/:id/reopen`: voids every payment of a placed,
|
|
2610
|
+
* unpaid order, cancels it (`VOIDED`) and returns the new `OPEN` order with
|
|
2611
|
+
* the same content. Rejects with `StorefrontHttpError` 400
|
|
2612
|
+
* (`details.code: "ORDER_PAYMENT_CAPTURED"`) when a payment was captured.
|
|
2613
|
+
*/
|
|
2614
|
+
const reopenOrder = (id, options) => {
|
|
2615
|
+
return storefrontJson(`${orderPath(id)}/reopen`, { method: "POST" }, options);
|
|
2616
|
+
};
|
|
2608
2617
|
const parseValidationDetails = (body) => {
|
|
2609
2618
|
if (!body || typeof body !== "object") return null;
|
|
2610
2619
|
const details = body.details;
|
|
@@ -2940,6 +2949,7 @@ const services = {
|
|
|
2940
2949
|
cancel: cancelOrder,
|
|
2941
2950
|
refund: refundOrder,
|
|
2942
2951
|
reorder: reorderOrder,
|
|
2952
|
+
reopen: reopenOrder,
|
|
2943
2953
|
validate: validateOrder,
|
|
2944
2954
|
place: placeOrder,
|
|
2945
2955
|
recordPayment: recordOrderPayment,
|
|
@@ -3441,1027 +3451,598 @@ const emptyLines = () => {
|
|
|
3441
3451
|
return [];
|
|
3442
3452
|
};
|
|
3443
3453
|
//#endregion
|
|
3444
|
-
//#region src/order/
|
|
3445
|
-
/**
|
|
3446
|
-
const
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
|
|
3453
|
-
|
|
3454
|
-
discountTotal = discountTotal.plus(line.group_discount);
|
|
3455
|
-
taxTotal = taxTotal.plus(line.group_tax_total);
|
|
3456
|
-
total = total.plus(line.group_total);
|
|
3457
|
-
}
|
|
3458
|
-
let taxRate = 0;
|
|
3459
|
-
let discountRate = 0;
|
|
3460
|
-
if (subtotal.gt(0)) {
|
|
3461
|
-
taxRate = taxTotal.div(subtotal).toNumber();
|
|
3462
|
-
discountRate = discountTotal.div(subtotal).toNumber();
|
|
3463
|
-
}
|
|
3464
|
-
const discount_total = discountTotal.toNumber();
|
|
3465
|
-
const tax_total = taxTotal.toNumber();
|
|
3466
|
-
const b1 = {
|
|
3467
|
-
currency,
|
|
3468
|
-
subtotal: subtotal.toNumber()
|
|
3469
|
-
};
|
|
3470
|
-
const b2 = {
|
|
3471
|
-
discount_rate: discountRate,
|
|
3472
|
-
discount_total
|
|
3473
|
-
};
|
|
3474
|
-
const b3 = {
|
|
3475
|
-
tax_rate: taxRate,
|
|
3476
|
-
tax_total,
|
|
3477
|
-
total: total.toNumber()
|
|
3478
|
-
};
|
|
3479
|
-
return {
|
|
3480
|
-
...b1,
|
|
3481
|
-
...b2,
|
|
3482
|
-
...b3
|
|
3483
|
-
};
|
|
3454
|
+
//#region src/order/tenders.ts
|
|
3455
|
+
/** Catalog provider of `REDEMPTION` payments (§3.3). */
|
|
3456
|
+
const DEFAULT_REDEMPTION_PROVIDER = "firespark";
|
|
3457
|
+
/**
|
|
3458
|
+
* Digital tender the SDK charges (`DEVICE`, §7.3). `KIOSK`, `COUNTER`,
|
|
3459
|
+
* `DOOR` and `OTHER` are in person: the POS, an operator or the courier
|
|
3460
|
+
* collects them (§12).
|
|
3461
|
+
*/
|
|
3462
|
+
const isDeviceCharged = (tender) => {
|
|
3463
|
+
return DIGITAL_COLLECT_AT.includes(tender.collect_at);
|
|
3484
3464
|
};
|
|
3485
|
-
|
|
3486
|
-
|
|
3487
|
-
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
|
|
3491
|
-
|
|
3492
|
-
const b2 = {
|
|
3493
|
-
tax_rate: 0,
|
|
3494
|
-
tax_total: 0,
|
|
3495
|
-
total: 0
|
|
3496
|
-
};
|
|
3497
|
-
return {
|
|
3498
|
-
...b1,
|
|
3499
|
-
...b2
|
|
3500
|
-
};
|
|
3465
|
+
/** Most a REDEMPTION can cover: order total − tip − delivery (D18). */
|
|
3466
|
+
const redemptionCap = (order) => {
|
|
3467
|
+
const primaryOnly = order.lines.filter((line) => line.parent_uid == null).reduce((sum, line) => {
|
|
3468
|
+
return isTipFee(line) || isDeliveryFee(line) ? sum + line.group_total : sum;
|
|
3469
|
+
}, 0);
|
|
3470
|
+
const decimals = currencyDecimals(order.totals.currency);
|
|
3471
|
+
return Math.max(roundTo(order.totals.total - primaryOnly, decimals), 0);
|
|
3501
3472
|
};
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
|
|
3505
|
-
|
|
3506
|
-
const
|
|
3507
|
-
|
|
3473
|
+
/**
|
|
3474
|
+
* Trims payment redeems in application order so their sum fits the cap;
|
|
3475
|
+
* points shrink in proportion. Discount redeems are left untouched.
|
|
3476
|
+
*/
|
|
3477
|
+
const clampPaymentRedeems = (redeem, cap, currency) => {
|
|
3478
|
+
const decimals = currencyDecimals(currency);
|
|
3479
|
+
let left = cap;
|
|
3480
|
+
return redeem.flatMap((item) => {
|
|
3481
|
+
if (!isPaymentRedeem(item)) return [item];
|
|
3482
|
+
const amount = item.amount ?? 0;
|
|
3483
|
+
const fits = Math.max(roundTo(Math.min(amount, left), decimals), 0);
|
|
3484
|
+
left = roundTo(left - fits, decimals);
|
|
3485
|
+
if (fits <= 0) return [];
|
|
3486
|
+
if (fits + amountTolerance(currency) >= amount) return [item];
|
|
3487
|
+
const ratio = amount > 0 ? fits / amount : 0;
|
|
3488
|
+
const points = roundTo((item.points ?? 0) * ratio, 0);
|
|
3489
|
+
return [{
|
|
3490
|
+
...item,
|
|
3491
|
+
amount: fits,
|
|
3492
|
+
points
|
|
3493
|
+
}];
|
|
3494
|
+
});
|
|
3508
3495
|
};
|
|
3509
|
-
|
|
3510
|
-
|
|
3511
|
-
|
|
3512
|
-
|
|
3513
|
-
|
|
3514
|
-
|
|
3515
|
-
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
3520
|
-
|
|
3521
|
-
|
|
3522
|
-
|
|
3496
|
+
/**
|
|
3497
|
+
* One REDEMPTION tender per `payment_id` of the payment redeems (D15,
|
|
3498
|
+
* D19); its `details.rewards[]` lists every grouped redeem.
|
|
3499
|
+
*/
|
|
3500
|
+
const redemptionTenderInputs = (redeem, providers = /* @__PURE__ */ new Map()) => {
|
|
3501
|
+
return groupPaymentRedeems(redeem).map(({ payment_id, details }) => {
|
|
3502
|
+
const fallback = DEFAULT_REDEMPTION_PROVIDER;
|
|
3503
|
+
const t1 = {
|
|
3504
|
+
method: "REDEMPTION",
|
|
3505
|
+
provider: providers.get(payment_id) ?? fallback,
|
|
3506
|
+
payment_id
|
|
3507
|
+
};
|
|
3508
|
+
const t2 = {
|
|
3509
|
+
collect_at: "OTHER",
|
|
3510
|
+
allocation: null,
|
|
3511
|
+
details
|
|
3512
|
+
};
|
|
3513
|
+
return {
|
|
3514
|
+
...t1,
|
|
3515
|
+
...t2
|
|
3516
|
+
};
|
|
3517
|
+
});
|
|
3523
3518
|
};
|
|
3524
|
-
/**
|
|
3525
|
-
const
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
3529
|
-
|
|
3530
|
-
|
|
3531
|
-
|
|
3532
|
-
if (discount_rate === void 0 && discount_amount === void 0) throw new Error("Redeem requires discount_rate or discount_amount");
|
|
3533
|
-
const rate = discount_rate ?? 0;
|
|
3534
|
-
if (!(rate >= 0 && rate <= 1)) throw new Error("Redeem discount_rate must be between 0 and 1");
|
|
3535
|
-
if (discount_amount !== void 0 && !(discount_amount > 0)) throw new Error("Redeem discount_amount must be > 0");
|
|
3536
|
-
const redeem = {
|
|
3537
|
-
reward_id,
|
|
3538
|
-
quantity
|
|
3519
|
+
/** The one real tender of the simple flow (`allocation: null`). */
|
|
3520
|
+
const simpleTenderInput = (payment) => {
|
|
3521
|
+
if (!payment.type || !payment.provider) return null;
|
|
3522
|
+
if (!isRealPaymentMethod(payment.type)) return null;
|
|
3523
|
+
const collect_at = payment.collect_at ?? "DEVICE";
|
|
3524
|
+
const t1 = {
|
|
3525
|
+
method: payment.type,
|
|
3526
|
+
provider: payment.provider
|
|
3539
3527
|
};
|
|
3540
|
-
|
|
3541
|
-
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
return redeem;
|
|
3545
|
-
};
|
|
3546
|
-
/** Payment redeems hold totals, so the kept units keep their share. */
|
|
3547
|
-
const keepUnits = (item, rest) => {
|
|
3548
|
-
if (!isPaymentRedeem(item) || rest === item.quantity) return {
|
|
3549
|
-
...item,
|
|
3550
|
-
quantity: rest
|
|
3528
|
+
const t2 = {
|
|
3529
|
+
payment_id: payment.id,
|
|
3530
|
+
collect_at,
|
|
3531
|
+
allocation: null
|
|
3551
3532
|
};
|
|
3552
|
-
const ratio = new Big(rest).div(item.quantity);
|
|
3553
|
-
const amount = new Big(item.amount ?? 0).times(ratio).round(6).toNumber();
|
|
3554
|
-
const points = new Big(item.points ?? 0).times(ratio).round(0).toNumber();
|
|
3555
3533
|
return {
|
|
3556
|
-
...
|
|
3557
|
-
|
|
3558
|
-
amount,
|
|
3559
|
-
points
|
|
3534
|
+
...t1,
|
|
3535
|
+
...t2
|
|
3560
3536
|
};
|
|
3561
3537
|
};
|
|
3562
3538
|
/**
|
|
3563
|
-
*
|
|
3564
|
-
*
|
|
3539
|
+
* Charge order of §6.1: deferred capture (authorize only), immediate
|
|
3540
|
+
* capture reversible with cancel/refund, manual reversal only, then cash /
|
|
3541
|
+
* IDLE last.
|
|
3565
3542
|
*/
|
|
3566
|
-
const
|
|
3567
|
-
if (
|
|
3568
|
-
|
|
3569
|
-
|
|
3570
|
-
|
|
3571
|
-
const item = items[i];
|
|
3572
|
-
if (pending === 0 || item.reward_id !== reward_id) {
|
|
3573
|
-
kept.unshift(item);
|
|
3574
|
-
continue;
|
|
3575
|
-
}
|
|
3576
|
-
const taken = Math.min(item.quantity, pending);
|
|
3577
|
-
pending -= taken;
|
|
3578
|
-
const rest = item.quantity - taken;
|
|
3579
|
-
if (rest > 0) kept.unshift(keepUnits(item, rest));
|
|
3580
|
-
}
|
|
3581
|
-
return kept;
|
|
3543
|
+
const chargeRank = (tender, adapter) => {
|
|
3544
|
+
if (tender.method === "CASH") return 3;
|
|
3545
|
+
if (adapter.capabilities?.deferredCapture) return 0;
|
|
3546
|
+
if (adapter.cancel || adapter.refund) return 1;
|
|
3547
|
+
return 2;
|
|
3582
3548
|
};
|
|
3583
|
-
|
|
3584
|
-
|
|
3585
|
-
|
|
3549
|
+
//#endregion
|
|
3550
|
+
//#region src/order/payments/orchestrator.ts
|
|
3551
|
+
const AMBIGUOUS_QUERIES = 3;
|
|
3552
|
+
const defaultSleep = (ms, signal) => {
|
|
3553
|
+
return new Promise((resolve) => {
|
|
3554
|
+
const timer = setTimeout(resolve, ms);
|
|
3555
|
+
signal?.addEventListener("abort", () => {
|
|
3556
|
+
clearTimeout(timer);
|
|
3557
|
+
resolve();
|
|
3558
|
+
});
|
|
3559
|
+
});
|
|
3586
3560
|
};
|
|
3587
|
-
|
|
3588
|
-
|
|
3589
|
-
|
|
3590
|
-
for (const line of lines) {
|
|
3591
|
-
const items = line.redeem ?? [];
|
|
3592
|
-
if (line.parent_uid != null || items.length === 0) continue;
|
|
3593
|
-
const target = {
|
|
3594
|
-
target: "LINE",
|
|
3595
|
-
target_id: line.uid
|
|
3596
|
-
};
|
|
3597
|
-
const pool = items.map((item) => ({
|
|
3598
|
-
...target,
|
|
3599
|
-
redeem: item
|
|
3600
|
-
}));
|
|
3601
|
-
pools.set(redemptionTargetKey(target), pool);
|
|
3602
|
-
}
|
|
3603
|
-
const order = { target: "ORDER" };
|
|
3604
|
-
const pool = redeem.filter((item) => !isPaymentRedeem(item)).map((item) => ({
|
|
3605
|
-
...order,
|
|
3606
|
-
redeem: item
|
|
3607
|
-
}));
|
|
3608
|
-
pools.set(ORDER_KEY, pool);
|
|
3609
|
-
return pools;
|
|
3561
|
+
const defaultClock = {
|
|
3562
|
+
now: () => Date.now(),
|
|
3563
|
+
sleep: defaultSleep
|
|
3610
3564
|
};
|
|
3565
|
+
var TimeoutSignal = class extends Error {};
|
|
3611
3566
|
/**
|
|
3612
|
-
*
|
|
3613
|
-
*
|
|
3614
|
-
* step are appended (lines first, then order).
|
|
3567
|
+
* Provider deadline that pauses while the customer is answering `ctx.ui`;
|
|
3568
|
+
* user waits run on `interaction_timeout_ms` instead.
|
|
3615
3569
|
*/
|
|
3616
|
-
const
|
|
3617
|
-
|
|
3618
|
-
|
|
3619
|
-
|
|
3620
|
-
|
|
3621
|
-
|
|
3622
|
-
|
|
3623
|
-
|
|
3624
|
-
|
|
3625
|
-
|
|
3626
|
-
|
|
3627
|
-
|
|
3628
|
-
|
|
3629
|
-
|
|
3570
|
+
const createDeadline = (timeoutMs, onExpire) => {
|
|
3571
|
+
let remaining = timeoutMs;
|
|
3572
|
+
let startedAt = 0;
|
|
3573
|
+
let timer = null;
|
|
3574
|
+
let paused = 0;
|
|
3575
|
+
const run = () => {
|
|
3576
|
+
startedAt = Date.now();
|
|
3577
|
+
timer = setTimeout(onExpire, Math.max(remaining, 0));
|
|
3578
|
+
};
|
|
3579
|
+
const stop = () => {
|
|
3580
|
+
if (!timer) return;
|
|
3581
|
+
clearTimeout(timer);
|
|
3582
|
+
timer = null;
|
|
3583
|
+
remaining -= Date.now() - startedAt;
|
|
3584
|
+
};
|
|
3585
|
+
return {
|
|
3586
|
+
start: run,
|
|
3587
|
+
pause: () => {
|
|
3588
|
+
if (paused === 0) stop();
|
|
3589
|
+
paused += 1;
|
|
3590
|
+
},
|
|
3591
|
+
resume: () => {
|
|
3592
|
+
paused -= 1;
|
|
3593
|
+
if (paused === 0) run();
|
|
3594
|
+
},
|
|
3595
|
+
clear: stop
|
|
3596
|
+
};
|
|
3630
3597
|
};
|
|
3631
|
-
const
|
|
3632
|
-
return
|
|
3598
|
+
const withUserTimeout = (promise, ms) => {
|
|
3599
|
+
return new Promise((resolve, reject) => {
|
|
3600
|
+
const timer = setTimeout(() => {
|
|
3601
|
+
reject(new PaymentUserTimeout());
|
|
3602
|
+
}, ms);
|
|
3603
|
+
promise.then((value) => {
|
|
3604
|
+
clearTimeout(timer);
|
|
3605
|
+
resolve(value);
|
|
3606
|
+
}, (error) => {
|
|
3607
|
+
clearTimeout(timer);
|
|
3608
|
+
reject(error);
|
|
3609
|
+
});
|
|
3610
|
+
});
|
|
3633
3611
|
};
|
|
3634
|
-
|
|
3635
|
-
|
|
3636
|
-
|
|
3612
|
+
var PaymentUserTimeout = class extends Error {};
|
|
3613
|
+
const timedUI = (ui, deadline, interactionMs) => {
|
|
3614
|
+
const wrap = (call) => {
|
|
3615
|
+
deadline.pause();
|
|
3616
|
+
return withUserTimeout(call(), interactionMs).finally(() => deadline.resume());
|
|
3617
|
+
};
|
|
3618
|
+
return {
|
|
3619
|
+
message: (input) => ui.message(input),
|
|
3620
|
+
action: (input) => ui.action(input),
|
|
3621
|
+
select: (input) => {
|
|
3622
|
+
return wrap(() => ui.select(input));
|
|
3623
|
+
},
|
|
3624
|
+
input: (input) => wrap(() => ui.input(input)),
|
|
3625
|
+
confirm: (input) => wrap(() => ui.confirm(input))
|
|
3626
|
+
};
|
|
3637
3627
|
};
|
|
3638
|
-
|
|
3639
|
-
|
|
3640
|
-
|
|
3641
|
-
|
|
3642
|
-
|
|
3643
|
-
|
|
3644
|
-
|
|
3645
|
-
|
|
3628
|
+
/** Runs one adapter call with the provider timeout and the user clock. */
|
|
3629
|
+
const runTimed = async (ctx, call) => {
|
|
3630
|
+
const controller = new AbortController();
|
|
3631
|
+
const parent = ctx.signal;
|
|
3632
|
+
const abort = () => controller.abort();
|
|
3633
|
+
parent.addEventListener("abort", abort);
|
|
3634
|
+
let expire = () => void 0;
|
|
3635
|
+
const expired = new Promise((_, reject) => {
|
|
3636
|
+
expire = () => {
|
|
3637
|
+
controller.abort();
|
|
3638
|
+
reject(new TimeoutSignal());
|
|
3639
|
+
};
|
|
3640
|
+
});
|
|
3641
|
+
const deadline = createDeadline(ctx.policy.timeout_ms, () => expire());
|
|
3642
|
+
const interaction = ctx.policy.interaction_timeout_ms;
|
|
3643
|
+
const ui = timedUI(ctx.ui, deadline, interaction);
|
|
3644
|
+
const scoped = {
|
|
3645
|
+
...ctx,
|
|
3646
|
+
ui,
|
|
3647
|
+
signal: controller.signal
|
|
3648
|
+
};
|
|
3649
|
+
deadline.start();
|
|
3650
|
+
try {
|
|
3651
|
+
return await Promise.race([call(scoped), expired]);
|
|
3652
|
+
} finally {
|
|
3653
|
+
deadline.clear();
|
|
3654
|
+
parent.removeEventListener("abort", abort);
|
|
3646
3655
|
}
|
|
3647
|
-
|
|
3648
|
-
|
|
3649
|
-
|
|
3656
|
+
};
|
|
3657
|
+
const failed = (ctx, error, extra = {}) => {
|
|
3658
|
+
const metadata = {
|
|
3659
|
+
attempt_reference: ctx.attemptReference,
|
|
3660
|
+
error,
|
|
3661
|
+
...extra
|
|
3650
3662
|
};
|
|
3651
|
-
const b2 = { discount_amount: discount.toNumber() };
|
|
3652
|
-
const b3 = { discount_rate: rateOf(discount, subtotal) };
|
|
3653
3663
|
return {
|
|
3654
|
-
|
|
3655
|
-
|
|
3656
|
-
|
|
3664
|
+
status: "FAILED",
|
|
3665
|
+
amount: ctx.amount.value,
|
|
3666
|
+
metadata,
|
|
3667
|
+
error
|
|
3657
3668
|
};
|
|
3658
3669
|
};
|
|
3659
|
-
const
|
|
3660
|
-
const
|
|
3661
|
-
|
|
3670
|
+
const errorOf = (ctx, error, startedAt, clock) => {
|
|
3671
|
+
const elapsed_ms = clock.now() - startedAt;
|
|
3672
|
+
const attempt = ctx.attempt;
|
|
3673
|
+
if (isPaymentError(error)) return {
|
|
3674
|
+
...error.error,
|
|
3675
|
+
attempt,
|
|
3676
|
+
elapsed_ms
|
|
3677
|
+
};
|
|
3678
|
+
let code = "UNKNOWN";
|
|
3679
|
+
if (error instanceof TimeoutSignal) code = "TIMEOUT";
|
|
3680
|
+
if (error instanceof PaymentUserTimeout) code = "USER_TIMEOUT";
|
|
3681
|
+
const extra = {
|
|
3682
|
+
attempt,
|
|
3683
|
+
elapsed_ms,
|
|
3684
|
+
provider_message: error instanceof Error ? error.message : null
|
|
3685
|
+
};
|
|
3686
|
+
return createPaymentError(code, extra);
|
|
3662
3687
|
};
|
|
3663
|
-
/**
|
|
3664
|
-
const
|
|
3665
|
-
|
|
3688
|
+
/** `start` that timed out after leaving the device is ambiguous. */
|
|
3689
|
+
const startError = (error) => {
|
|
3690
|
+
if (error.code !== "TIMEOUT") return error;
|
|
3691
|
+
return createPaymentError("AMBIGUOUS", {
|
|
3692
|
+
attempt: error.attempt,
|
|
3693
|
+
elapsed_ms: error.elapsed_ms
|
|
3694
|
+
});
|
|
3666
3695
|
};
|
|
3667
|
-
|
|
3668
|
-
const
|
|
3669
|
-
|
|
3670
|
-
|
|
3671
|
-
|
|
3672
|
-
|
|
3673
|
-
return;
|
|
3696
|
+
const isFinal = (result) => result.status !== "PENDING";
|
|
3697
|
+
const callOperation = async (ctx, operation, result, clock) => {
|
|
3698
|
+
const startedAt = clock.now();
|
|
3699
|
+
try {
|
|
3700
|
+
return await runTimed(ctx, (scoped) => operation(scoped, result));
|
|
3701
|
+
} catch (error) {
|
|
3702
|
+
return failed(ctx, errorOf(ctx, error, startedAt, clock));
|
|
3674
3703
|
}
|
|
3675
|
-
const net = netOf(root);
|
|
3676
|
-
const nextNet = net.minus(amount);
|
|
3677
|
-
root.tax = root.tax.times(nextNet).div(net);
|
|
3678
|
-
root.total = nextNet.plus(root.tax);
|
|
3679
3704
|
};
|
|
3680
|
-
/**
|
|
3681
|
-
const
|
|
3682
|
-
|
|
3683
|
-
|
|
3684
|
-
|
|
3685
|
-
|
|
3686
|
-
|
|
3687
|
-
if (
|
|
3688
|
-
}
|
|
3689
|
-
|
|
3690
|
-
|
|
3691
|
-
|
|
3692
|
-
|
|
3693
|
-
|
|
3694
|
-
|
|
3695
|
-
rest = rest.minus(part);
|
|
3696
|
-
discountRoot(root, part, beforeTaxes);
|
|
3697
|
-
});
|
|
3705
|
+
/** Query up to 3 times, cancel once, else FAILED + needs_review. */
|
|
3706
|
+
const resolveAmbiguous = async (adapter, ctx, pending, clock) => {
|
|
3707
|
+
if (adapter.query) for (let i = 0; i < AMBIGUOUS_QUERIES; i += 1) {
|
|
3708
|
+
await clock.sleep(ctx.policy.poll_interval_ms, ctx.signal);
|
|
3709
|
+
const next = await callOperation(ctx, adapter.query, pending, clock);
|
|
3710
|
+
if (next.status === "PAID") return next;
|
|
3711
|
+
const definitive = next.error?.code !== "TIMEOUT" && !next.error?.retryable;
|
|
3712
|
+
if (next.status === "FAILED" && definitive && next.error?.code !== "AMBIGUOUS") return next;
|
|
3713
|
+
}
|
|
3714
|
+
if (adapter.cancel) {
|
|
3715
|
+
const cancelled = await callOperation(ctx, adapter.cancel, pending, clock);
|
|
3716
|
+
if (cancelled.status === "CANCELLED") return cancelled;
|
|
3717
|
+
}
|
|
3718
|
+
const error = createPaymentError("AMBIGUOUS", { attempt: ctx.attempt });
|
|
3719
|
+
return failed(ctx, error, { needs_review: true });
|
|
3698
3720
|
};
|
|
3699
|
-
/**
|
|
3700
|
-
|
|
3701
|
-
|
|
3702
|
-
|
|
3703
|
-
const
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
|
|
3707
|
-
|
|
3708
|
-
|
|
3709
|
-
|
|
3710
|
-
|
|
3721
|
+
/** PENDING with an action: poll until paid, rejected or the QR expires. */
|
|
3722
|
+
const pollPending = async (adapter, ctx, pending, clock, options) => {
|
|
3723
|
+
if (pending.metadata.confirm_after_inject) return pending;
|
|
3724
|
+
if (!adapter.query) return pending;
|
|
3725
|
+
const expiresAt = clock.now() + ctx.policy.qr_ttl_ms;
|
|
3726
|
+
let current = pending;
|
|
3727
|
+
while (clock.now() < expiresAt) {
|
|
3728
|
+
if (ctx.signal.aborted) break;
|
|
3729
|
+
await clock.sleep(ctx.policy.poll_interval_ms, ctx.signal);
|
|
3730
|
+
if (ctx.signal.aborted) break;
|
|
3731
|
+
const next = await callOperation(ctx, adapter.query, current, clock);
|
|
3732
|
+
if (next.status === "FAILED" && next.error?.retryable) continue;
|
|
3733
|
+
if (isFinal(next)) return next;
|
|
3734
|
+
current = next;
|
|
3735
|
+
await options.onProgress?.(current);
|
|
3736
|
+
}
|
|
3737
|
+
if (adapter.cancel) await callOperation(ctx, adapter.cancel, current, clock);
|
|
3738
|
+
const code = ctx.signal.aborted ? "CANCELLED_BY_USER" : "WALLET_EXPIRED";
|
|
3739
|
+
return failed(ctx, createPaymentError(code, { attempt: ctx.attempt }));
|
|
3711
3740
|
};
|
|
3712
|
-
|
|
3713
|
-
|
|
3714
|
-
|
|
3715
|
-
|
|
3716
|
-
|
|
3717
|
-
|
|
3718
|
-
|
|
3719
|
-
if (line.parent_uid == null) continue;
|
|
3720
|
-
const list = children.get(line.parent_uid) ?? [];
|
|
3721
|
-
list.push(line);
|
|
3722
|
-
children.set(line.parent_uid, list);
|
|
3741
|
+
const runStart = async (adapter, ctx, clock) => {
|
|
3742
|
+
const startedAt = clock.now();
|
|
3743
|
+
try {
|
|
3744
|
+
return await runTimed(ctx, (scoped) => adapter.start(scoped));
|
|
3745
|
+
} catch (error) {
|
|
3746
|
+
const raw = errorOf(ctx, error, startedAt, clock);
|
|
3747
|
+
return failed(ctx, startError(raw));
|
|
3723
3748
|
}
|
|
3724
|
-
return lines.map((line) => {
|
|
3725
|
-
if (line.parent_uid != null) return line;
|
|
3726
|
-
const qty = new Big(line.quantity);
|
|
3727
|
-
let sub = new Big(0);
|
|
3728
|
-
let disc = new Big(0);
|
|
3729
|
-
let tax = new Big(0);
|
|
3730
|
-
let tot = new Big(0);
|
|
3731
|
-
for (const child of children.get(line.uid) ?? []) {
|
|
3732
|
-
sub = sub.plus(child.group_subtotal);
|
|
3733
|
-
disc = disc.plus(child.group_discount);
|
|
3734
|
-
tax = tax.plus(child.group_tax_total);
|
|
3735
|
-
tot = tot.plus(child.group_total);
|
|
3736
|
-
}
|
|
3737
|
-
const gSub = new Big(line.subtotal).plus(sub.times(qty));
|
|
3738
|
-
const gDisc = new Big(line.discount_total).plus(disc.times(qty));
|
|
3739
|
-
const gTax = new Big(line.tax_total).plus(tax.times(qty));
|
|
3740
|
-
const gTot = new Big(line.total).plus(tot.times(qty));
|
|
3741
|
-
const u1 = { unit_group_subtotal: gSub.div(qty).toNumber() };
|
|
3742
|
-
const u2 = { unit_group_discount: gDisc.div(qty).toNumber() };
|
|
3743
|
-
const u3 = { unit_group_tax_total: gTax.div(qty).toNumber() };
|
|
3744
|
-
const u4 = { unit_group_total: gTot.div(qty).toNumber() };
|
|
3745
|
-
const g1 = { group_subtotal: gSub.toNumber() };
|
|
3746
|
-
const g2 = { group_discount: gDisc.toNumber() };
|
|
3747
|
-
const g3 = { group_tax_total: gTax.toNumber() };
|
|
3748
|
-
const g4 = { group_total: gTot.toNumber() };
|
|
3749
|
-
const p1 = {
|
|
3750
|
-
...u1,
|
|
3751
|
-
...u2,
|
|
3752
|
-
...u3,
|
|
3753
|
-
...u4
|
|
3754
|
-
};
|
|
3755
|
-
return {
|
|
3756
|
-
...line,
|
|
3757
|
-
...p1,
|
|
3758
|
-
...g1,
|
|
3759
|
-
...g2,
|
|
3760
|
-
...g3,
|
|
3761
|
-
...g4
|
|
3762
|
-
};
|
|
3763
|
-
});
|
|
3764
3749
|
};
|
|
3765
|
-
const
|
|
3766
|
-
const
|
|
3767
|
-
|
|
3768
|
-
|
|
3769
|
-
|
|
3770
|
-
const
|
|
3771
|
-
const
|
|
3772
|
-
|
|
3773
|
-
|
|
3750
|
+
const toTerminalContext = (ctx) => {
|
|
3751
|
+
const b1 = {
|
|
3752
|
+
paymentId: ctx.paymentId,
|
|
3753
|
+
attempt: ctx.attempt
|
|
3754
|
+
};
|
|
3755
|
+
const b2 = { attemptReference: ctx.attemptReference };
|
|
3756
|
+
const b3 = {
|
|
3757
|
+
environment: ctx.environment,
|
|
3758
|
+
config: ctx.config
|
|
3759
|
+
};
|
|
3760
|
+
const b4 = {
|
|
3761
|
+
deviceSettings: ctx.deviceSettings,
|
|
3762
|
+
policy: ctx.policy
|
|
3763
|
+
};
|
|
3764
|
+
const b5 = {
|
|
3765
|
+
ui: ctx.ui,
|
|
3766
|
+
storage: ctx.storage,
|
|
3767
|
+
server: ctx.server
|
|
3768
|
+
};
|
|
3774
3769
|
return {
|
|
3775
|
-
...
|
|
3776
|
-
...
|
|
3777
|
-
...
|
|
3778
|
-
...
|
|
3779
|
-
...
|
|
3780
|
-
|
|
3781
|
-
...g3
|
|
3770
|
+
...b1,
|
|
3771
|
+
...b2,
|
|
3772
|
+
...b3,
|
|
3773
|
+
...b4,
|
|
3774
|
+
...b5,
|
|
3775
|
+
signal: ctx.signal
|
|
3782
3776
|
};
|
|
3783
3777
|
};
|
|
3778
|
+
const closeBatch = async (adapter, ctx) => {
|
|
3779
|
+
const close = adapter.terminal?.closeBatch;
|
|
3780
|
+
if (!close) return false;
|
|
3781
|
+
const done = await close(toTerminalContext(ctx)).catch(() => null);
|
|
3782
|
+
return Boolean(done?.ok);
|
|
3783
|
+
};
|
|
3784
3784
|
/**
|
|
3785
|
-
*
|
|
3786
|
-
*
|
|
3787
|
-
*
|
|
3785
|
+
* One payment attempt with the policy of section 7: timeout, retries on
|
|
3786
|
+
* retryable errors (same `attempt_reference`), batch close and retry,
|
|
3787
|
+
* polling for actions and resolution of ambiguous results.
|
|
3788
3788
|
*/
|
|
3789
|
-
const
|
|
3790
|
-
const
|
|
3791
|
-
|
|
3792
|
-
|
|
3793
|
-
|
|
3794
|
-
|
|
3795
|
-
|
|
3796
|
-
|
|
3797
|
-
|
|
3798
|
-
|
|
3799
|
-
|
|
3800
|
-
|
|
3801
|
-
|
|
3802
|
-
|
|
3803
|
-
|
|
3804
|
-
|
|
3805
|
-
|
|
3806
|
-
|
|
3807
|
-
|
|
3808
|
-
|
|
3809
|
-
|
|
3810
|
-
|
|
3811
|
-
|
|
3812
|
-
|
|
3813
|
-
|
|
3814
|
-
|
|
3815
|
-
|
|
3816
|
-
|
|
3817
|
-
|
|
3818
|
-
|
|
3819
|
-
|
|
3820
|
-
|
|
3821
|
-
|
|
3822
|
-
|
|
3823
|
-
|
|
3824
|
-
|
|
3825
|
-
|
|
3826
|
-
|
|
3827
|
-
|
|
3828
|
-
|
|
3829
|
-
|
|
3830
|
-
|
|
3831
|
-
const
|
|
3832
|
-
|
|
3833
|
-
|
|
3834
|
-
|
|
3835
|
-
|
|
3836
|
-
before,
|
|
3837
|
-
after: snapshot(roots, 1)
|
|
3838
|
-
};
|
|
3839
|
-
let b5 = {};
|
|
3840
|
-
if (root) b5 = {
|
|
3841
|
-
target_before: targetBefore,
|
|
3842
|
-
target_after: snapshot([root], root.quantity)
|
|
3789
|
+
const runPaymentAttempt = async (adapter, ctx, options = {}) => {
|
|
3790
|
+
const clock = options.clock ?? defaultClock;
|
|
3791
|
+
return withTerminalLock(ctx.paymentId, async () => {
|
|
3792
|
+
let batchClosed = false;
|
|
3793
|
+
let attempt = 1;
|
|
3794
|
+
while (true) {
|
|
3795
|
+
const current = {
|
|
3796
|
+
...ctx,
|
|
3797
|
+
attempt
|
|
3798
|
+
};
|
|
3799
|
+
const result = await runStart(adapter, current, clock);
|
|
3800
|
+
const code = result.error?.code;
|
|
3801
|
+
if (code === "BATCH_CLOSE_REQUIRED" && !batchClosed) {
|
|
3802
|
+
batchClosed = true;
|
|
3803
|
+
if (await closeBatch(adapter, current)) continue;
|
|
3804
|
+
}
|
|
3805
|
+
if (code === "AMBIGUOUS") {
|
|
3806
|
+
await options.onProgress?.({
|
|
3807
|
+
...result,
|
|
3808
|
+
status: "PENDING"
|
|
3809
|
+
});
|
|
3810
|
+
return resolveAmbiguous(adapter, current, result, clock);
|
|
3811
|
+
}
|
|
3812
|
+
const canRetry = attempt < ctx.policy.max_attempts;
|
|
3813
|
+
if (result.status === "FAILED" && result.error?.retryable && canRetry) {
|
|
3814
|
+
attempt += 1;
|
|
3815
|
+
continue;
|
|
3816
|
+
}
|
|
3817
|
+
if (result.status !== "PENDING") return result;
|
|
3818
|
+
await options.onProgress?.(result);
|
|
3819
|
+
return pollPending(adapter, current, result, clock, options);
|
|
3820
|
+
}
|
|
3821
|
+
});
|
|
3822
|
+
};
|
|
3823
|
+
/** Undo a charge after a failed injection or an incomplete split payment. */
|
|
3824
|
+
const reversePayment = async (adapter, ctx, result, options = {}) => {
|
|
3825
|
+
const clock = options.clock ?? defaultClock;
|
|
3826
|
+
const now = options.now ?? new Date(clock.now());
|
|
3827
|
+
let kind = (adapter.reversalFor ?? defaultReversalFor)(result, now);
|
|
3828
|
+
const operation = kind === "cancel" ? adapter.cancel : adapter.refund;
|
|
3829
|
+
if (kind !== "manual" && !operation) kind = "manual";
|
|
3830
|
+
if (kind === "manual" || !operation) {
|
|
3831
|
+
const error = createPaymentError("MANUAL_REVERSAL_REQUIRED");
|
|
3832
|
+
const m1 = {
|
|
3833
|
+
...result.metadata,
|
|
3834
|
+
error,
|
|
3835
|
+
needs_review: true
|
|
3843
3836
|
};
|
|
3844
3837
|
return {
|
|
3845
|
-
|
|
3846
|
-
|
|
3847
|
-
|
|
3848
|
-
|
|
3849
|
-
|
|
3838
|
+
kind: "manual",
|
|
3839
|
+
result: {
|
|
3840
|
+
...result,
|
|
3841
|
+
metadata: m1,
|
|
3842
|
+
error
|
|
3843
|
+
}
|
|
3850
3844
|
};
|
|
3851
|
-
}
|
|
3852
|
-
|
|
3853
|
-
|
|
3854
|
-
const money = moneyByUid.get(line.uid);
|
|
3855
|
-
if (!money || money.discount.eq(0)) return line;
|
|
3856
|
-
return withRootMoney(line, money);
|
|
3857
|
-
}),
|
|
3858
|
-
redemption_steps
|
|
3859
|
-
};
|
|
3860
|
-
};
|
|
3861
|
-
/** Discounted lines, steps and totals, keeping step order. */
|
|
3862
|
-
const recomputeRedemptions = (input) => {
|
|
3863
|
-
const { redeem, currency } = input;
|
|
3864
|
-
const steps = input.redemption_steps;
|
|
3865
|
-
const { lines, redemption_steps } = replayRedemptions(input.lines, redeem, steps);
|
|
3845
|
+
}
|
|
3846
|
+
const run = () => callOperation(ctx, operation, result, clock);
|
|
3847
|
+
const next = await withTerminalLock(ctx.paymentId, run);
|
|
3866
3848
|
return {
|
|
3867
|
-
|
|
3868
|
-
|
|
3869
|
-
totals: recomputeTotals(lines, currency)
|
|
3849
|
+
kind,
|
|
3850
|
+
result: next
|
|
3870
3851
|
};
|
|
3871
3852
|
};
|
|
3872
3853
|
//#endregion
|
|
3873
|
-
//#region src/order/
|
|
3874
|
-
|
|
3875
|
-
|
|
3876
|
-
|
|
3877
|
-
|
|
3878
|
-
|
|
3879
|
-
|
|
3880
|
-
*/
|
|
3881
|
-
const isDeviceCharged = (tender) => {
|
|
3882
|
-
return DIGITAL_COLLECT_AT.includes(tender.collect_at);
|
|
3854
|
+
//#region src/order/payments/session.ts
|
|
3855
|
+
const noopUI = {
|
|
3856
|
+
message: () => void 0,
|
|
3857
|
+
action: () => void 0,
|
|
3858
|
+
select: () => Promise.reject(/* @__PURE__ */ new Error("Payment UI not configured")),
|
|
3859
|
+
input: () => Promise.reject(/* @__PURE__ */ new Error("Payment UI not configured")),
|
|
3860
|
+
confirm: () => Promise.resolve(false)
|
|
3883
3861
|
};
|
|
3884
|
-
|
|
3885
|
-
|
|
3886
|
-
const primaryOnly = order.lines.filter((line) => line.parent_uid == null).reduce((sum, line) => {
|
|
3887
|
-
return isTipFee(line) || isDeliveryFee(line) ? sum + line.group_total : sum;
|
|
3888
|
-
}, 0);
|
|
3889
|
-
const decimals = currencyDecimals(order.totals.currency);
|
|
3890
|
-
return Math.max(roundTo(order.totals.total - primaryOnly, decimals), 0);
|
|
3862
|
+
const adapterKey = (provider, method) => {
|
|
3863
|
+
return `${provider}:${method}`;
|
|
3891
3864
|
};
|
|
3892
|
-
|
|
3893
|
-
|
|
3894
|
-
|
|
3895
|
-
*/
|
|
3896
|
-
const clampPaymentRedeems = (redeem, cap, currency) => {
|
|
3897
|
-
const decimals = currencyDecimals(currency);
|
|
3898
|
-
let left = cap;
|
|
3899
|
-
return redeem.flatMap((item) => {
|
|
3900
|
-
if (!isPaymentRedeem(item)) return [item];
|
|
3901
|
-
const amount = item.amount ?? 0;
|
|
3902
|
-
const fits = Math.max(roundTo(Math.min(amount, left), decimals), 0);
|
|
3903
|
-
left = roundTo(left - fits, decimals);
|
|
3904
|
-
if (fits <= 0) return [];
|
|
3905
|
-
if (fits + amountTolerance(currency) >= amount) return [item];
|
|
3906
|
-
const ratio = amount > 0 ? fits / amount : 0;
|
|
3907
|
-
const points = roundTo((item.points ?? 0) * ratio, 0);
|
|
3908
|
-
return [{
|
|
3909
|
-
...item,
|
|
3910
|
-
amount: fits,
|
|
3911
|
-
points
|
|
3912
|
-
}];
|
|
3913
|
-
});
|
|
3914
|
-
};
|
|
3915
|
-
/**
|
|
3916
|
-
* One REDEMPTION tender per `payment_id` of the payment redeems (D15,
|
|
3917
|
-
* D19); its `details.rewards[]` lists every grouped redeem.
|
|
3918
|
-
*/
|
|
3919
|
-
const redemptionTenderInputs = (redeem, providers = /* @__PURE__ */ new Map()) => {
|
|
3920
|
-
return groupPaymentRedeems(redeem).map(({ payment_id, details }) => {
|
|
3921
|
-
const fallback = DEFAULT_REDEMPTION_PROVIDER;
|
|
3922
|
-
const t1 = {
|
|
3923
|
-
method: "REDEMPTION",
|
|
3924
|
-
provider: providers.get(payment_id) ?? fallback,
|
|
3925
|
-
payment_id
|
|
3926
|
-
};
|
|
3927
|
-
const t2 = {
|
|
3928
|
-
collect_at: "OTHER",
|
|
3929
|
-
allocation: null,
|
|
3930
|
-
details
|
|
3931
|
-
};
|
|
3932
|
-
return {
|
|
3933
|
-
...t1,
|
|
3934
|
-
...t2
|
|
3935
|
-
};
|
|
3936
|
-
});
|
|
3937
|
-
};
|
|
3938
|
-
/** The one real tender of the simple flow (`allocation: null`). */
|
|
3939
|
-
const simpleTenderInput = (payment) => {
|
|
3940
|
-
if (!payment.type || !payment.provider) return null;
|
|
3941
|
-
if (!isRealPaymentMethod(payment.type)) return null;
|
|
3942
|
-
const collect_at = payment.collect_at ?? "DEVICE";
|
|
3943
|
-
const t1 = {
|
|
3944
|
-
method: payment.type,
|
|
3945
|
-
provider: payment.provider
|
|
3946
|
-
};
|
|
3947
|
-
const t2 = {
|
|
3948
|
-
payment_id: payment.id,
|
|
3949
|
-
collect_at,
|
|
3950
|
-
allocation: null
|
|
3951
|
-
};
|
|
3952
|
-
return {
|
|
3953
|
-
...t1,
|
|
3954
|
-
...t2
|
|
3955
|
-
};
|
|
3865
|
+
const record = (value) => {
|
|
3866
|
+
if (!value || typeof value !== "object") return {};
|
|
3867
|
+
return value;
|
|
3956
3868
|
};
|
|
3957
|
-
|
|
3958
|
-
|
|
3959
|
-
|
|
3960
|
-
* IDLE last.
|
|
3961
|
-
*/
|
|
3962
|
-
const chargeRank = (tender, adapter) => {
|
|
3963
|
-
if (tender.method === "CASH") return 3;
|
|
3964
|
-
if (adapter.capabilities?.deferredCapture) return 0;
|
|
3965
|
-
if (adapter.cancel || adapter.refund) return 1;
|
|
3966
|
-
return 2;
|
|
3869
|
+
const isPendingConfirm = (tender) => {
|
|
3870
|
+
const confirm = record(tender.metadata).confirm_after_inject === true;
|
|
3871
|
+
return tender.status === "PENDING" && confirm;
|
|
3967
3872
|
};
|
|
3968
|
-
|
|
3969
|
-
|
|
3970
|
-
|
|
3971
|
-
|
|
3972
|
-
return new Promise((resolve) => {
|
|
3973
|
-
const timer = setTimeout(resolve, ms);
|
|
3974
|
-
signal?.addEventListener("abort", () => {
|
|
3975
|
-
clearTimeout(timer);
|
|
3976
|
-
resolve();
|
|
3977
|
-
});
|
|
3873
|
+
/** Live tenders of the current intent that hold the customer's money. */
|
|
3874
|
+
const chargedTenders = (intents) => {
|
|
3875
|
+
return liveTenders(currentIntent(intents)).filter((tender) => {
|
|
3876
|
+
return isTenderSettled(tender) || isPendingConfirm(tender);
|
|
3978
3877
|
});
|
|
3979
3878
|
};
|
|
3980
|
-
|
|
3981
|
-
|
|
3982
|
-
|
|
3879
|
+
/** Settled tenders plus PENDING `confirm_after_inject`, order currency. */
|
|
3880
|
+
const coveredAmount = (intents) => {
|
|
3881
|
+
return chargedTenders(intents).reduce((sum, tender) => {
|
|
3882
|
+
return sum + tender.amount.total;
|
|
3883
|
+
}, 0);
|
|
3983
3884
|
};
|
|
3984
|
-
|
|
3985
|
-
|
|
3986
|
-
|
|
3987
|
-
|
|
3988
|
-
|
|
3989
|
-
const
|
|
3990
|
-
|
|
3991
|
-
|
|
3992
|
-
|
|
3993
|
-
let paused = 0;
|
|
3994
|
-
const run = () => {
|
|
3995
|
-
startedAt = Date.now();
|
|
3996
|
-
timer = setTimeout(onExpire, Math.max(remaining, 0));
|
|
3997
|
-
};
|
|
3998
|
-
const stop = () => {
|
|
3999
|
-
if (!timer) return;
|
|
4000
|
-
clearTimeout(timer);
|
|
4001
|
-
timer = null;
|
|
4002
|
-
remaining -= Date.now() - startedAt;
|
|
4003
|
-
};
|
|
3885
|
+
const remainingAmount = (order) => {
|
|
3886
|
+
const decimals = currencyDecimals(order.totals.currency);
|
|
3887
|
+
const left = order.totals.total - coveredAmount(order.payment_intents);
|
|
3888
|
+
return Math.max(roundTo(left, decimals), 0);
|
|
3889
|
+
};
|
|
3890
|
+
const isPlaced = (order) => order.status !== "OPEN";
|
|
3891
|
+
/** Orchestrator failures carry the charge-currency amount, not the order's. */
|
|
3892
|
+
const withOrderAmount = (result, amount) => {
|
|
3893
|
+
if (result.status !== "FAILED") return result;
|
|
4004
3894
|
return {
|
|
4005
|
-
|
|
4006
|
-
|
|
4007
|
-
if (paused === 0) stop();
|
|
4008
|
-
paused += 1;
|
|
4009
|
-
},
|
|
4010
|
-
resume: () => {
|
|
4011
|
-
paused -= 1;
|
|
4012
|
-
if (paused === 0) run();
|
|
4013
|
-
},
|
|
4014
|
-
clear: stop
|
|
3895
|
+
...result,
|
|
3896
|
+
amount
|
|
4015
3897
|
};
|
|
4016
3898
|
};
|
|
4017
|
-
const
|
|
4018
|
-
return
|
|
4019
|
-
const timer = setTimeout(() => {
|
|
4020
|
-
reject(new PaymentUserTimeout());
|
|
4021
|
-
}, ms);
|
|
4022
|
-
promise.then((value) => {
|
|
4023
|
-
clearTimeout(timer);
|
|
4024
|
-
resolve(value);
|
|
4025
|
-
}, (error) => {
|
|
4026
|
-
clearTimeout(timer);
|
|
4027
|
-
reject(error);
|
|
4028
|
-
});
|
|
4029
|
-
});
|
|
3899
|
+
const partialOf = (result) => {
|
|
3900
|
+
return record(result.metadata).partial_approval !== void 0;
|
|
4030
3901
|
};
|
|
4031
|
-
|
|
4032
|
-
const
|
|
4033
|
-
|
|
4034
|
-
|
|
4035
|
-
|
|
4036
|
-
|
|
3902
|
+
/** D31: PAID for less than the tender (same currency only). */
|
|
3903
|
+
const isPartialApproval = (result, tender, ctx) => {
|
|
3904
|
+
if (result.status !== "PAID") return false;
|
|
3905
|
+
if (ctx.amount.currency !== tender.amount.currency) return false;
|
|
3906
|
+
const tolerance = amountTolerance(tender.amount.currency);
|
|
3907
|
+
return result.amount + tolerance < tender.amount.total;
|
|
3908
|
+
};
|
|
3909
|
+
const toFailure = (error) => {
|
|
3910
|
+
const message = error instanceof Error ? error.message : "Terminal failed";
|
|
4037
3911
|
return {
|
|
4038
|
-
|
|
4039
|
-
|
|
4040
|
-
|
|
4041
|
-
return wrap(() => ui.select(input));
|
|
4042
|
-
},
|
|
4043
|
-
input: (input) => wrap(() => ui.input(input)),
|
|
4044
|
-
confirm: (input) => wrap(() => ui.confirm(input))
|
|
3912
|
+
ok: false,
|
|
3913
|
+
message,
|
|
3914
|
+
error: createPaymentError("UNKNOWN", { provider_message: message })
|
|
4045
3915
|
};
|
|
4046
3916
|
};
|
|
4047
|
-
/**
|
|
4048
|
-
|
|
4049
|
-
|
|
4050
|
-
|
|
4051
|
-
|
|
4052
|
-
|
|
4053
|
-
|
|
4054
|
-
|
|
4055
|
-
|
|
4056
|
-
|
|
4057
|
-
|
|
3917
|
+
/**
|
|
3918
|
+
* Payment state of one order: adapters, device settings and the attempt
|
|
3919
|
+
* orchestration. The app only provides adapters, UI and storage.
|
|
3920
|
+
*/
|
|
3921
|
+
var PaymentSession = class {
|
|
3922
|
+
deps;
|
|
3923
|
+
adapters = /* @__PURE__ */ new Map();
|
|
3924
|
+
payments = /* @__PURE__ */ new Map();
|
|
3925
|
+
ui = noopUI;
|
|
3926
|
+
storage = memoryPaymentStorage();
|
|
3927
|
+
options = {};
|
|
3928
|
+
constructor(deps) {
|
|
3929
|
+
this.deps = deps;
|
|
3930
|
+
}
|
|
3931
|
+
setAdapters(adapters) {
|
|
3932
|
+
this.adapters.clear();
|
|
3933
|
+
for (const adapter of adapters) this.adapters.set(adapterKey(adapter.provider, adapter.method), adapter);
|
|
3934
|
+
}
|
|
3935
|
+
setUI(ui) {
|
|
3936
|
+
this.ui = ui;
|
|
3937
|
+
}
|
|
3938
|
+
setStorage(storage) {
|
|
3939
|
+
this.storage = storage;
|
|
3940
|
+
}
|
|
3941
|
+
setOptions(options) {
|
|
3942
|
+
this.options = {
|
|
3943
|
+
...this.options,
|
|
3944
|
+
...options
|
|
4058
3945
|
};
|
|
4059
|
-
|
|
4060
|
-
const deadline = createDeadline(ctx.policy.timeout_ms, () => expire());
|
|
4061
|
-
const interaction = ctx.policy.interaction_timeout_ms;
|
|
4062
|
-
const ui = timedUI(ctx.ui, deadline, interaction);
|
|
4063
|
-
const scoped = {
|
|
4064
|
-
...ctx,
|
|
4065
|
-
ui,
|
|
4066
|
-
signal: controller.signal
|
|
4067
|
-
};
|
|
4068
|
-
deadline.start();
|
|
4069
|
-
try {
|
|
4070
|
-
return await Promise.race([call(scoped), expired]);
|
|
4071
|
-
} finally {
|
|
4072
|
-
deadline.clear();
|
|
4073
|
-
parent.removeEventListener("abort", abort);
|
|
3946
|
+
captureLogDevice(options.deviceId);
|
|
4074
3947
|
}
|
|
4075
|
-
|
|
4076
|
-
const
|
|
4077
|
-
|
|
4078
|
-
attempt_reference: ctx.attemptReference,
|
|
4079
|
-
error,
|
|
4080
|
-
...extra
|
|
4081
|
-
};
|
|
4082
|
-
return {
|
|
4083
|
-
status: "FAILED",
|
|
4084
|
-
amount: ctx.amount.value,
|
|
4085
|
-
metadata,
|
|
4086
|
-
error
|
|
4087
|
-
};
|
|
4088
|
-
};
|
|
4089
|
-
const errorOf = (ctx, error, startedAt, clock) => {
|
|
4090
|
-
const elapsed_ms = clock.now() - startedAt;
|
|
4091
|
-
const attempt = ctx.attempt;
|
|
4092
|
-
if (isPaymentError(error)) return {
|
|
4093
|
-
...error.error,
|
|
4094
|
-
attempt,
|
|
4095
|
-
elapsed_ms
|
|
4096
|
-
};
|
|
4097
|
-
let code = "UNKNOWN";
|
|
4098
|
-
if (error instanceof TimeoutSignal) code = "TIMEOUT";
|
|
4099
|
-
if (error instanceof PaymentUserTimeout) code = "USER_TIMEOUT";
|
|
4100
|
-
const extra = {
|
|
4101
|
-
attempt,
|
|
4102
|
-
elapsed_ms,
|
|
4103
|
-
provider_message: error instanceof Error ? error.message : null
|
|
4104
|
-
};
|
|
4105
|
-
return createPaymentError(code, extra);
|
|
4106
|
-
};
|
|
4107
|
-
/** `start` that timed out after leaving the device is ambiguous. */
|
|
4108
|
-
const startError = (error) => {
|
|
4109
|
-
if (error.code !== "TIMEOUT") return error;
|
|
4110
|
-
return createPaymentError("AMBIGUOUS", {
|
|
4111
|
-
attempt: error.attempt,
|
|
4112
|
-
elapsed_ms: error.elapsed_ms
|
|
4113
|
-
});
|
|
4114
|
-
};
|
|
4115
|
-
const isFinal = (result) => result.status !== "PENDING";
|
|
4116
|
-
const callOperation = async (ctx, operation, result, clock) => {
|
|
4117
|
-
const startedAt = clock.now();
|
|
4118
|
-
try {
|
|
4119
|
-
return await runTimed(ctx, (scoped) => operation(scoped, result));
|
|
4120
|
-
} catch (error) {
|
|
4121
|
-
return failed(ctx, errorOf(ctx, error, startedAt, clock));
|
|
3948
|
+
adapterFor(payment) {
|
|
3949
|
+
const key = adapterKey(payment.provider.provider_id, payment.method.method);
|
|
3950
|
+
return this.adapters.get(key) ?? null;
|
|
4122
3951
|
}
|
|
4123
|
-
|
|
4124
|
-
|
|
4125
|
-
const
|
|
4126
|
-
|
|
4127
|
-
|
|
4128
|
-
const
|
|
4129
|
-
|
|
4130
|
-
|
|
4131
|
-
if (next.status === "FAILED" && definitive && next.error?.code !== "AMBIGUOUS") return next;
|
|
3952
|
+
/** Device fields still missing for a payment on this device. */
|
|
3953
|
+
missingFields(payment) {
|
|
3954
|
+
const adapter = this.adapterFor(payment);
|
|
3955
|
+
if (!adapter) return [];
|
|
3956
|
+
const fromCatalog = record(payment.method.config).device_fields;
|
|
3957
|
+
const fields = adapter.deviceFields ?? fromCatalog;
|
|
3958
|
+
const settings = this.options.deviceSettings?.[payment.id];
|
|
3959
|
+
return missingDeviceFields(Array.isArray(fields) ? fields : [], settings);
|
|
4132
3960
|
}
|
|
4133
|
-
|
|
4134
|
-
|
|
4135
|
-
|
|
3961
|
+
/**
|
|
3962
|
+
* Merchant payments with an installed adapter (provider + method) that
|
|
3963
|
+
* are fully configured on this device.
|
|
3964
|
+
*/
|
|
3965
|
+
available(payments) {
|
|
3966
|
+
return payments.filter((payment) => {
|
|
3967
|
+
if (!this.adapterFor(payment)) return false;
|
|
3968
|
+
this.payments.set(payment.id, payment);
|
|
3969
|
+
return this.missingFields(payment).length === 0;
|
|
3970
|
+
});
|
|
4136
3971
|
}
|
|
4137
|
-
|
|
4138
|
-
|
|
4139
|
-
};
|
|
4140
|
-
/** PENDING with an action: poll until paid, rejected or the QR expires. */
|
|
4141
|
-
const pollPending = async (adapter, ctx, pending, clock, options) => {
|
|
4142
|
-
if (pending.metadata.confirm_after_inject) return pending;
|
|
4143
|
-
if (!adapter.query) return pending;
|
|
4144
|
-
const expiresAt = clock.now() + ctx.policy.qr_ttl_ms;
|
|
4145
|
-
let current = pending;
|
|
4146
|
-
while (clock.now() < expiresAt) {
|
|
4147
|
-
if (ctx.signal.aborted) break;
|
|
4148
|
-
await clock.sleep(ctx.policy.poll_interval_ms, ctx.signal);
|
|
4149
|
-
if (ctx.signal.aborted) break;
|
|
4150
|
-
const next = await callOperation(ctx, adapter.query, current, clock);
|
|
4151
|
-
if (next.status === "FAILED" && next.error?.retryable) continue;
|
|
4152
|
-
if (isFinal(next)) return next;
|
|
4153
|
-
current = next;
|
|
4154
|
-
await options.onProgress?.(current);
|
|
3972
|
+
remaining() {
|
|
3973
|
+
return remainingAmount(this.deps.getOrder());
|
|
4155
3974
|
}
|
|
4156
|
-
|
|
4157
|
-
|
|
4158
|
-
|
|
4159
|
-
|
|
4160
|
-
const
|
|
4161
|
-
|
|
4162
|
-
|
|
4163
|
-
|
|
4164
|
-
|
|
4165
|
-
|
|
4166
|
-
|
|
3975
|
+
server(payment, attempt) {
|
|
3976
|
+
const order_id = this.deps.getOrder().id;
|
|
3977
|
+
const http = this.deps.http;
|
|
3978
|
+
return { call: (operation, input) => {
|
|
3979
|
+
const body = {
|
|
3980
|
+
order_id,
|
|
3981
|
+
attempt_reference: attempt,
|
|
3982
|
+
input
|
|
3983
|
+
};
|
|
3984
|
+
const op = services.payments.operation;
|
|
3985
|
+
return op(payment.id, operation, body, http);
|
|
3986
|
+
} };
|
|
4167
3987
|
}
|
|
4168
|
-
|
|
4169
|
-
const
|
|
4170
|
-
|
|
4171
|
-
|
|
4172
|
-
|
|
4173
|
-
|
|
4174
|
-
|
|
4175
|
-
|
|
4176
|
-
|
|
4177
|
-
|
|
4178
|
-
|
|
4179
|
-
|
|
4180
|
-
|
|
4181
|
-
|
|
4182
|
-
};
|
|
4183
|
-
const b5 = {
|
|
4184
|
-
ui: ctx.ui,
|
|
4185
|
-
storage: ctx.storage,
|
|
4186
|
-
server: ctx.server
|
|
4187
|
-
};
|
|
4188
|
-
return {
|
|
4189
|
-
...b1,
|
|
4190
|
-
...b2,
|
|
4191
|
-
...b3,
|
|
4192
|
-
...b4,
|
|
4193
|
-
...b5,
|
|
4194
|
-
signal: ctx.signal
|
|
4195
|
-
};
|
|
4196
|
-
};
|
|
4197
|
-
const closeBatch = async (adapter, ctx) => {
|
|
4198
|
-
const close = adapter.terminal?.closeBatch;
|
|
4199
|
-
if (!close) return false;
|
|
4200
|
-
const done = await close(toTerminalContext(ctx)).catch(() => null);
|
|
4201
|
-
return Boolean(done?.ok);
|
|
4202
|
-
};
|
|
4203
|
-
/**
|
|
4204
|
-
* One payment attempt with the policy of section 7: timeout, retries on
|
|
4205
|
-
* retryable errors (same `attempt_reference`), batch close and retry,
|
|
4206
|
-
* polling for actions and resolution of ambiguous results.
|
|
4207
|
-
*/
|
|
4208
|
-
const runPaymentAttempt = async (adapter, ctx, options = {}) => {
|
|
4209
|
-
const clock = options.clock ?? defaultClock;
|
|
4210
|
-
return withTerminalLock(ctx.paymentId, async () => {
|
|
4211
|
-
let batchClosed = false;
|
|
4212
|
-
let attempt = 1;
|
|
4213
|
-
while (true) {
|
|
4214
|
-
const current = {
|
|
4215
|
-
...ctx,
|
|
4216
|
-
attempt
|
|
3988
|
+
async chargeAmount(payment, value, server) {
|
|
3989
|
+
const config = record(payment.config);
|
|
3990
|
+
const orderCurrency = this.deps.getOrder().totals.currency;
|
|
3991
|
+
const chargeCurrency = config.charge_currency;
|
|
3992
|
+
if (!chargeCurrency || chargeCurrency === orderCurrency) return toChargeAmount({
|
|
3993
|
+
value,
|
|
3994
|
+
orderCurrency
|
|
3995
|
+
});
|
|
3996
|
+
const exchange = record(config.exchange_rate);
|
|
3997
|
+
let rate = typeof exchange.rate === "number" ? exchange.rate : null;
|
|
3998
|
+
if (exchange.source === "provider") {
|
|
3999
|
+
const input = {
|
|
4000
|
+
from: orderCurrency,
|
|
4001
|
+
to: chargeCurrency
|
|
4217
4002
|
};
|
|
4218
|
-
|
|
4219
|
-
const code = result.error?.code;
|
|
4220
|
-
if (code === "BATCH_CLOSE_REQUIRED" && !batchClosed) {
|
|
4221
|
-
batchClosed = true;
|
|
4222
|
-
if (await closeBatch(adapter, current)) continue;
|
|
4223
|
-
}
|
|
4224
|
-
if (code === "AMBIGUOUS") {
|
|
4225
|
-
await options.onProgress?.({
|
|
4226
|
-
...result,
|
|
4227
|
-
status: "PENDING"
|
|
4228
|
-
});
|
|
4229
|
-
return resolveAmbiguous(adapter, current, result, clock);
|
|
4230
|
-
}
|
|
4231
|
-
const canRetry = attempt < ctx.policy.max_attempts;
|
|
4232
|
-
if (result.status === "FAILED" && result.error?.retryable && canRetry) {
|
|
4233
|
-
attempt += 1;
|
|
4234
|
-
continue;
|
|
4235
|
-
}
|
|
4236
|
-
if (result.status !== "PENDING") return result;
|
|
4237
|
-
await options.onProgress?.(result);
|
|
4238
|
-
return pollPending(adapter, current, result, clock, options);
|
|
4003
|
+
rate = (await server.call("exchange_rate", input)).rate;
|
|
4239
4004
|
}
|
|
4240
|
-
|
|
4241
|
-
|
|
4242
|
-
|
|
4243
|
-
|
|
4244
|
-
|
|
4245
|
-
|
|
4246
|
-
|
|
4247
|
-
|
|
4248
|
-
|
|
4249
|
-
|
|
4250
|
-
const
|
|
4251
|
-
const
|
|
4252
|
-
|
|
4253
|
-
|
|
4254
|
-
|
|
4005
|
+
return toChargeAmount({
|
|
4006
|
+
value,
|
|
4007
|
+
orderCurrency,
|
|
4008
|
+
chargeCurrency,
|
|
4009
|
+
rate
|
|
4010
|
+
});
|
|
4011
|
+
}
|
|
4012
|
+
async buildContext(payment, amount, attemptReference, signal) {
|
|
4013
|
+
const order = this.deps.getOrder();
|
|
4014
|
+
const server = this.server(payment, attemptReference);
|
|
4015
|
+
const conversion = await this.chargeAmount(payment, amount, server);
|
|
4016
|
+
const config = record(payment.config);
|
|
4017
|
+
const methodConfig = record(payment.method.config);
|
|
4018
|
+
const share = order.totals.total > 0 ? amount / order.totals.total : 1;
|
|
4019
|
+
const decimals = currencyDecimals(order.totals.currency);
|
|
4020
|
+
const taxes = taxBreakdown(order.lines, {
|
|
4021
|
+
share,
|
|
4022
|
+
decimals
|
|
4023
|
+
});
|
|
4024
|
+
const info = order.customer.customer_info;
|
|
4025
|
+
const customer = {
|
|
4026
|
+
document: info.document_number ?? null,
|
|
4027
|
+
documentType: info.document_type ?? null,
|
|
4028
|
+
phone: info.phone ?? null
|
|
4255
4029
|
};
|
|
4256
|
-
|
|
4257
|
-
|
|
4258
|
-
|
|
4259
|
-
|
|
4260
|
-
|
|
4261
|
-
|
|
4262
|
-
|
|
4263
|
-
};
|
|
4264
|
-
|
|
4265
|
-
|
|
4266
|
-
|
|
4267
|
-
|
|
4268
|
-
|
|
4269
|
-
|
|
4270
|
-
|
|
4271
|
-
|
|
4272
|
-
//#endregion
|
|
4273
|
-
//#region src/order/payments/session.ts
|
|
4274
|
-
const noopUI = {
|
|
4275
|
-
message: () => void 0,
|
|
4276
|
-
action: () => void 0,
|
|
4277
|
-
select: () => Promise.reject(/* @__PURE__ */ new Error("Payment UI not configured")),
|
|
4278
|
-
input: () => Promise.reject(/* @__PURE__ */ new Error("Payment UI not configured")),
|
|
4279
|
-
confirm: () => Promise.resolve(false)
|
|
4280
|
-
};
|
|
4281
|
-
const adapterKey = (provider, method) => {
|
|
4282
|
-
return `${provider}:${method}`;
|
|
4283
|
-
};
|
|
4284
|
-
const record = (value) => {
|
|
4285
|
-
if (!value || typeof value !== "object") return {};
|
|
4286
|
-
return value;
|
|
4287
|
-
};
|
|
4288
|
-
const isPendingConfirm = (tender) => {
|
|
4289
|
-
const confirm = record(tender.metadata).confirm_after_inject === true;
|
|
4290
|
-
return tender.status === "PENDING" && confirm;
|
|
4291
|
-
};
|
|
4292
|
-
/** Live tenders of the current intent that hold the customer's money. */
|
|
4293
|
-
const chargedTenders = (intents) => {
|
|
4294
|
-
return liveTenders(currentIntent(intents)).filter((tender) => {
|
|
4295
|
-
return isTenderSettled(tender) || isPendingConfirm(tender);
|
|
4296
|
-
});
|
|
4297
|
-
};
|
|
4298
|
-
/** Settled tenders plus PENDING `confirm_after_inject`, order currency. */
|
|
4299
|
-
const coveredAmount = (intents) => {
|
|
4300
|
-
return chargedTenders(intents).reduce((sum, tender) => {
|
|
4301
|
-
return sum + tender.amount.total;
|
|
4302
|
-
}, 0);
|
|
4303
|
-
};
|
|
4304
|
-
const remainingAmount = (order) => {
|
|
4305
|
-
const decimals = currencyDecimals(order.totals.currency);
|
|
4306
|
-
const left = order.totals.total - coveredAmount(order.payment_intents);
|
|
4307
|
-
return Math.max(roundTo(left, decimals), 0);
|
|
4308
|
-
};
|
|
4309
|
-
const isPlaced = (order) => order.status !== "OPEN";
|
|
4310
|
-
/** Orchestrator failures carry the charge-currency amount, not the order's. */
|
|
4311
|
-
const withOrderAmount = (result, amount) => {
|
|
4312
|
-
if (result.status !== "FAILED") return result;
|
|
4313
|
-
return {
|
|
4314
|
-
...result,
|
|
4315
|
-
amount
|
|
4316
|
-
};
|
|
4317
|
-
};
|
|
4318
|
-
const partialOf = (result) => {
|
|
4319
|
-
return record(result.metadata).partial_approval !== void 0;
|
|
4320
|
-
};
|
|
4321
|
-
/** D31: PAID for less than the tender (same currency only). */
|
|
4322
|
-
const isPartialApproval = (result, tender, ctx) => {
|
|
4323
|
-
if (result.status !== "PAID") return false;
|
|
4324
|
-
if (ctx.amount.currency !== tender.amount.currency) return false;
|
|
4325
|
-
const tolerance = amountTolerance(tender.amount.currency);
|
|
4326
|
-
return result.amount + tolerance < tender.amount.total;
|
|
4327
|
-
};
|
|
4328
|
-
const toFailure = (error) => {
|
|
4329
|
-
const message = error instanceof Error ? error.message : "Terminal failed";
|
|
4330
|
-
return {
|
|
4331
|
-
ok: false,
|
|
4332
|
-
message,
|
|
4333
|
-
error: createPaymentError("UNKNOWN", { provider_message: message })
|
|
4334
|
-
};
|
|
4335
|
-
};
|
|
4336
|
-
/**
|
|
4337
|
-
* Payment state of one order: adapters, device settings and the attempt
|
|
4338
|
-
* orchestration. The app only provides adapters, UI and storage.
|
|
4339
|
-
*/
|
|
4340
|
-
var PaymentSession = class {
|
|
4341
|
-
deps;
|
|
4342
|
-
adapters = /* @__PURE__ */ new Map();
|
|
4343
|
-
payments = /* @__PURE__ */ new Map();
|
|
4344
|
-
ui = noopUI;
|
|
4345
|
-
storage = memoryPaymentStorage();
|
|
4346
|
-
options = {};
|
|
4347
|
-
constructor(deps) {
|
|
4348
|
-
this.deps = deps;
|
|
4349
|
-
}
|
|
4350
|
-
setAdapters(adapters) {
|
|
4351
|
-
this.adapters.clear();
|
|
4352
|
-
for (const adapter of adapters) this.adapters.set(adapterKey(adapter.provider, adapter.method), adapter);
|
|
4353
|
-
}
|
|
4354
|
-
setUI(ui) {
|
|
4355
|
-
this.ui = ui;
|
|
4356
|
-
}
|
|
4357
|
-
setStorage(storage) {
|
|
4358
|
-
this.storage = storage;
|
|
4359
|
-
}
|
|
4360
|
-
setOptions(options) {
|
|
4361
|
-
this.options = {
|
|
4362
|
-
...this.options,
|
|
4363
|
-
...options
|
|
4364
|
-
};
|
|
4365
|
-
captureLogDevice(options.deviceId);
|
|
4366
|
-
}
|
|
4367
|
-
adapterFor(payment) {
|
|
4368
|
-
const key = adapterKey(payment.provider.provider_id, payment.method.method);
|
|
4369
|
-
return this.adapters.get(key) ?? null;
|
|
4370
|
-
}
|
|
4371
|
-
/** Device fields still missing for a payment on this device. */
|
|
4372
|
-
missingFields(payment) {
|
|
4373
|
-
const adapter = this.adapterFor(payment);
|
|
4374
|
-
if (!adapter) return [];
|
|
4375
|
-
const fromCatalog = record(payment.method.config).device_fields;
|
|
4376
|
-
const fields = adapter.deviceFields ?? fromCatalog;
|
|
4377
|
-
const settings = this.options.deviceSettings?.[payment.id];
|
|
4378
|
-
return missingDeviceFields(Array.isArray(fields) ? fields : [], settings);
|
|
4379
|
-
}
|
|
4380
|
-
/**
|
|
4381
|
-
* Merchant payments with an installed adapter (provider + method) that
|
|
4382
|
-
* are fully configured on this device.
|
|
4383
|
-
*/
|
|
4384
|
-
available(payments) {
|
|
4385
|
-
return payments.filter((payment) => {
|
|
4386
|
-
if (!this.adapterFor(payment)) return false;
|
|
4387
|
-
this.payments.set(payment.id, payment);
|
|
4388
|
-
return this.missingFields(payment).length === 0;
|
|
4389
|
-
});
|
|
4390
|
-
}
|
|
4391
|
-
remaining() {
|
|
4392
|
-
return remainingAmount(this.deps.getOrder());
|
|
4393
|
-
}
|
|
4394
|
-
server(payment, attempt) {
|
|
4395
|
-
const order_id = this.deps.getOrder().id;
|
|
4396
|
-
const http = this.deps.http;
|
|
4397
|
-
return { call: (operation, input) => {
|
|
4398
|
-
const body = {
|
|
4399
|
-
order_id,
|
|
4400
|
-
attempt_reference: attempt,
|
|
4401
|
-
input
|
|
4402
|
-
};
|
|
4403
|
-
const op = services.payments.operation;
|
|
4404
|
-
return op(payment.id, operation, body, http);
|
|
4405
|
-
} };
|
|
4406
|
-
}
|
|
4407
|
-
async chargeAmount(payment, value, server) {
|
|
4408
|
-
const config = record(payment.config);
|
|
4409
|
-
const orderCurrency = this.deps.getOrder().totals.currency;
|
|
4410
|
-
const chargeCurrency = config.charge_currency;
|
|
4411
|
-
if (!chargeCurrency || chargeCurrency === orderCurrency) return toChargeAmount({
|
|
4412
|
-
value,
|
|
4413
|
-
orderCurrency
|
|
4414
|
-
});
|
|
4415
|
-
const exchange = record(config.exchange_rate);
|
|
4416
|
-
let rate = typeof exchange.rate === "number" ? exchange.rate : null;
|
|
4417
|
-
if (exchange.source === "provider") {
|
|
4418
|
-
const input = {
|
|
4419
|
-
from: orderCurrency,
|
|
4420
|
-
to: chargeCurrency
|
|
4421
|
-
};
|
|
4422
|
-
rate = (await server.call("exchange_rate", input)).rate;
|
|
4423
|
-
}
|
|
4424
|
-
return toChargeAmount({
|
|
4425
|
-
value,
|
|
4426
|
-
orderCurrency,
|
|
4427
|
-
chargeCurrency,
|
|
4428
|
-
rate
|
|
4429
|
-
});
|
|
4430
|
-
}
|
|
4431
|
-
async buildContext(payment, amount, attemptReference, signal) {
|
|
4432
|
-
const order = this.deps.getOrder();
|
|
4433
|
-
const server = this.server(payment, attemptReference);
|
|
4434
|
-
const conversion = await this.chargeAmount(payment, amount, server);
|
|
4435
|
-
const config = record(payment.config);
|
|
4436
|
-
const methodConfig = record(payment.method.config);
|
|
4437
|
-
const share = order.totals.total > 0 ? amount / order.totals.total : 1;
|
|
4438
|
-
const decimals = currencyDecimals(order.totals.currency);
|
|
4439
|
-
const taxes = taxBreakdown(order.lines, {
|
|
4440
|
-
share,
|
|
4441
|
-
decimals
|
|
4442
|
-
});
|
|
4443
|
-
const info = order.customer.customer_info;
|
|
4444
|
-
const customer = {
|
|
4445
|
-
document: info.document_number ?? null,
|
|
4446
|
-
documentType: info.document_type ?? null,
|
|
4447
|
-
phone: info.phone ?? null
|
|
4448
|
-
};
|
|
4449
|
-
const environment = resolveProviderEnvironment({
|
|
4450
|
-
isProduction: this.options.isProduction ?? false,
|
|
4451
|
-
paymentConfig: config
|
|
4452
|
-
});
|
|
4453
|
-
const policy = resolvePaymentPolicy({
|
|
4454
|
-
methodConfig,
|
|
4455
|
-
paymentConfig: config
|
|
4456
|
-
});
|
|
4457
|
-
const prefix = `payments:${payment.id}`;
|
|
4458
|
-
const storage = scopedPaymentStorage(this.storage, prefix);
|
|
4459
|
-
const deviceSettings = this.options.deviceSettings?.[payment.id] ?? {};
|
|
4460
|
-
const totals = order.totals;
|
|
4461
|
-
const o1 = {
|
|
4462
|
-
id: order.id,
|
|
4463
|
-
currency: totals.currency,
|
|
4464
|
-
totals
|
|
4030
|
+
const environment = resolveProviderEnvironment({
|
|
4031
|
+
isProduction: this.options.isProduction ?? false,
|
|
4032
|
+
paymentConfig: config
|
|
4033
|
+
});
|
|
4034
|
+
const policy = resolvePaymentPolicy({
|
|
4035
|
+
methodConfig,
|
|
4036
|
+
paymentConfig: config
|
|
4037
|
+
});
|
|
4038
|
+
const prefix = `payments:${payment.id}`;
|
|
4039
|
+
const storage = scopedPaymentStorage(this.storage, prefix);
|
|
4040
|
+
const deviceSettings = this.options.deviceSettings?.[payment.id] ?? {};
|
|
4041
|
+
const totals = order.totals;
|
|
4042
|
+
const o1 = {
|
|
4043
|
+
id: order.id,
|
|
4044
|
+
currency: totals.currency,
|
|
4045
|
+
totals
|
|
4465
4046
|
};
|
|
4466
4047
|
const b1 = {
|
|
4467
4048
|
paymentId: payment.id,
|
|
@@ -4865,215 +4446,648 @@ var PaymentSession = class {
|
|
|
4865
4446
|
closeBatch: terminal.closeBatch,
|
|
4866
4447
|
status: terminal.status
|
|
4867
4448
|
};
|
|
4868
|
-
const named = {
|
|
4869
|
-
...base,
|
|
4870
|
-
...more
|
|
4449
|
+
const named = {
|
|
4450
|
+
...base,
|
|
4451
|
+
...more
|
|
4452
|
+
};
|
|
4453
|
+
const extra = terminal.extra?.find((item) => item.key === key)?.run;
|
|
4454
|
+
const operation = named[key] ?? extra;
|
|
4455
|
+
if (!operation) return {
|
|
4456
|
+
ok: false,
|
|
4457
|
+
message: `Unknown operation ${key}`
|
|
4458
|
+
};
|
|
4459
|
+
const signal = new AbortController().signal;
|
|
4460
|
+
const reference = createAttemptReference();
|
|
4461
|
+
const result = await operation(toTerminalContext(await this.buildContext(payment, 0, reference, signal))).catch(toFailure);
|
|
4462
|
+
if (!result.ok) await this.reportTerminal(payment, key, result);
|
|
4463
|
+
return result;
|
|
4464
|
+
}
|
|
4465
|
+
async reportTerminal(payment, operation, result) {
|
|
4466
|
+
const fallback = createPaymentError("UNKNOWN");
|
|
4467
|
+
const error = result.error ?? {
|
|
4468
|
+
...fallback,
|
|
4469
|
+
message: result.message ?? fallback.message
|
|
4470
|
+
};
|
|
4471
|
+
const body = {
|
|
4472
|
+
operation,
|
|
4473
|
+
device_id: this.options.deviceId ?? null,
|
|
4474
|
+
error
|
|
4475
|
+
};
|
|
4476
|
+
const report = services.payments.terminalFailure;
|
|
4477
|
+
await report(payment.id, body, this.deps.http).catch(() => void 0);
|
|
4478
|
+
}
|
|
4479
|
+
};
|
|
4480
|
+
//#endregion
|
|
4481
|
+
//#region src/schedule/schedule.ts
|
|
4482
|
+
const DAY_OF_WEEK = [
|
|
4483
|
+
"sunday",
|
|
4484
|
+
"monday",
|
|
4485
|
+
"tuesday",
|
|
4486
|
+
"wednesday",
|
|
4487
|
+
"thursday",
|
|
4488
|
+
"friday",
|
|
4489
|
+
"saturday"
|
|
4490
|
+
];
|
|
4491
|
+
const timeToMinutes = (time) => {
|
|
4492
|
+
const [hours = "0", minutes = "0"] = time.split(":");
|
|
4493
|
+
return Number(hours) * 60 + Number(minutes);
|
|
4494
|
+
};
|
|
4495
|
+
const hasSpecificDates = (period) => {
|
|
4496
|
+
return period.start_date != null && period.end_date != null;
|
|
4497
|
+
};
|
|
4498
|
+
const periodCoversDate = (period, now) => {
|
|
4499
|
+
if (!hasSpecificDates(period)) return false;
|
|
4500
|
+
const nowMs = now.getTime();
|
|
4501
|
+
const startMs = Date.parse(period.start_date);
|
|
4502
|
+
const endMs = Date.parse(period.end_date);
|
|
4503
|
+
if (Number.isNaN(startMs) || Number.isNaN(endMs)) return false;
|
|
4504
|
+
return startMs <= nowMs && nowMs <= endMs;
|
|
4505
|
+
};
|
|
4506
|
+
const timeMatches = (period, now) => {
|
|
4507
|
+
if (!period.start_time || !period.end_time) return false;
|
|
4508
|
+
const nowMinutes = now.getHours() * 60 + now.getMinutes();
|
|
4509
|
+
const start = timeToMinutes(period.start_time);
|
|
4510
|
+
const end = timeToMinutes(period.end_time);
|
|
4511
|
+
return nowMinutes >= start && nowMinutes < end;
|
|
4512
|
+
};
|
|
4513
|
+
const isClosedAt = (period, now) => {
|
|
4514
|
+
if (!period.closed) return false;
|
|
4515
|
+
if (!period.start_time || !period.end_time) return true;
|
|
4516
|
+
return timeMatches(period, now);
|
|
4517
|
+
};
|
|
4518
|
+
const isOpenAt = (period, now) => {
|
|
4519
|
+
if (period.closed) return false;
|
|
4520
|
+
return timeMatches(period, now);
|
|
4521
|
+
};
|
|
4522
|
+
const isPeriodsActiveAt = (periods, now) => {
|
|
4523
|
+
return getOpenPeriodAt(periods, now) != null;
|
|
4524
|
+
};
|
|
4525
|
+
const getOpenPeriodAt = (periods, now) => {
|
|
4526
|
+
if (periods.length === 0) return null;
|
|
4527
|
+
const coveringSpecific = periods.filter((period) => {
|
|
4528
|
+
return periodCoversDate(period, now);
|
|
4529
|
+
});
|
|
4530
|
+
const fallback = periods.filter((period) => !hasSpecificDates(period));
|
|
4531
|
+
const specificOpens = coveringSpecific.filter((period) => !period.closed);
|
|
4532
|
+
if (coveringSpecific.filter((period) => period.closed).some((period) => isClosedAt(period, now))) return null;
|
|
4533
|
+
if (specificOpens.length > 0) return specificOpens.find((period) => isOpenAt(period, now)) ?? null;
|
|
4534
|
+
if (fallback.some((period) => isClosedAt(period, now))) return null;
|
|
4535
|
+
return fallback.filter((period) => !period.closed).find((period) => isOpenAt(period, now)) ?? null;
|
|
4536
|
+
};
|
|
4537
|
+
const atTimeOnDate = (day, time) => {
|
|
4538
|
+
const [hours = "0", minutes = "0"] = time.split(":");
|
|
4539
|
+
const next = new Date(day);
|
|
4540
|
+
next.setHours(Number(hours), Number(minutes), 0, 0);
|
|
4541
|
+
return next;
|
|
4542
|
+
};
|
|
4543
|
+
const isWithinPeriodDates = (period, instantMs) => {
|
|
4544
|
+
if (!hasSpecificDates(period)) return true;
|
|
4545
|
+
const startMs = Date.parse(period.start_date);
|
|
4546
|
+
const endMs = Date.parse(period.end_date);
|
|
4547
|
+
if (Number.isNaN(startMs) || Number.isNaN(endMs)) return false;
|
|
4548
|
+
return startMs <= instantMs && instantMs <= endMs;
|
|
4549
|
+
};
|
|
4550
|
+
const dayPeriods = (schedule, instant) => {
|
|
4551
|
+
const day = DAY_OF_WEEK[instant.getDay()];
|
|
4552
|
+
if (!day) return [];
|
|
4553
|
+
return schedule.find((row) => row.day_of_week === day)?.periods ?? [];
|
|
4554
|
+
};
|
|
4555
|
+
const getActiveUntil = (periods, now) => {
|
|
4556
|
+
const open = getOpenPeriodAt(periods, now);
|
|
4557
|
+
if (!open?.end_time) return null;
|
|
4558
|
+
let untilMs = atTimeOnDate(now, open.end_time).getTime();
|
|
4559
|
+
const openIsSpecific = hasSpecificDates(open);
|
|
4560
|
+
for (const period of periods) {
|
|
4561
|
+
if (!period.closed || !period.start_time) continue;
|
|
4562
|
+
const closeMs = atTimeOnDate(now, period.start_time).getTime();
|
|
4563
|
+
if (closeMs <= now.getTime() || closeMs >= untilMs) continue;
|
|
4564
|
+
if (!isWithinPeriodDates(period, closeMs)) continue;
|
|
4565
|
+
if (!hasSpecificDates(period) && openIsSpecific) continue;
|
|
4566
|
+
untilMs = closeMs;
|
|
4567
|
+
}
|
|
4568
|
+
return new Date(untilMs);
|
|
4569
|
+
};
|
|
4570
|
+
const getNextStart = (schedule, now) => {
|
|
4571
|
+
const nowMs = now.getTime();
|
|
4572
|
+
let nextStartMs = null;
|
|
4573
|
+
const consider = (candidate, periods) => {
|
|
4574
|
+
const startMs = candidate.getTime();
|
|
4575
|
+
if (startMs <= nowMs) return;
|
|
4576
|
+
if (!isPeriodsActiveAt(periods, candidate)) return;
|
|
4577
|
+
if (nextStartMs === null || startMs < nextStartMs) nextStartMs = startMs;
|
|
4578
|
+
};
|
|
4579
|
+
for (let dayOffset = 0; dayOffset < 8; dayOffset += 1) {
|
|
4580
|
+
const candidateDate = new Date(now);
|
|
4581
|
+
candidateDate.setDate(candidateDate.getDate() + dayOffset);
|
|
4582
|
+
candidateDate.setSeconds(0, 0);
|
|
4583
|
+
const periods = dayPeriods(schedule, candidateDate);
|
|
4584
|
+
if (periods.length === 0) continue;
|
|
4585
|
+
for (const period of periods) {
|
|
4586
|
+
if (period.closed || !period.start_time) continue;
|
|
4587
|
+
const start = atTimeOnDate(candidateDate, period.start_time);
|
|
4588
|
+
if (!isWithinPeriodDates(period, start.getTime())) continue;
|
|
4589
|
+
consider(start, periods);
|
|
4590
|
+
}
|
|
4591
|
+
for (const period of periods) {
|
|
4592
|
+
if (!period.closed || !period.end_time) continue;
|
|
4593
|
+
const reopen = atTimeOnDate(candidateDate, period.end_time);
|
|
4594
|
+
if (!isWithinPeriodDates(period, reopen.getTime())) continue;
|
|
4595
|
+
consider(reopen, periods);
|
|
4596
|
+
}
|
|
4597
|
+
}
|
|
4598
|
+
return nextStartMs === null ? null : new Date(nextStartMs);
|
|
4599
|
+
};
|
|
4600
|
+
/** Whether `schedule` is in hours now, and until / next open. */
|
|
4601
|
+
const getScheduleWindow = (schedule, now = /* @__PURE__ */ new Date()) => {
|
|
4602
|
+
if (schedule == null) return {
|
|
4603
|
+
active: true,
|
|
4604
|
+
until: null
|
|
4605
|
+
};
|
|
4606
|
+
const periods = dayPeriods(schedule, now);
|
|
4607
|
+
if (isPeriodsActiveAt(periods, now)) return {
|
|
4608
|
+
active: true,
|
|
4609
|
+
until: getActiveUntil(periods, now)
|
|
4610
|
+
};
|
|
4611
|
+
return {
|
|
4612
|
+
active: false,
|
|
4613
|
+
next: getNextStart(schedule, now)
|
|
4614
|
+
};
|
|
4615
|
+
};
|
|
4616
|
+
const isOnSchedule = (schedule, now) => {
|
|
4617
|
+
return getScheduleWindow(schedule, now).active;
|
|
4618
|
+
};
|
|
4619
|
+
//#endregion
|
|
4620
|
+
//#region src/order/recommendations.ts
|
|
4621
|
+
const DEFAULT_RECOMMENDATION_TYPES = ["CROSS_SELL"];
|
|
4622
|
+
const linkedRecommendationIds = (pricing) => {
|
|
4623
|
+
return [...pricing.recommendations_ids ?? [], ...(pricing.recommendations ?? []).map((link) => link.recommendation_id)];
|
|
4624
|
+
};
|
|
4625
|
+
const collectRecommendations = (config) => {
|
|
4626
|
+
const { lines, lookup, quantity, types } = config;
|
|
4627
|
+
if (quantity <= 0) return [];
|
|
4628
|
+
const typeSet = new Set(types ?? DEFAULT_RECOMMENDATION_TYPES);
|
|
4629
|
+
const roots = lines.filter((line) => line.parent_uid == null);
|
|
4630
|
+
const fingerprints = roots.map((root) => computeLineFingerprint(root, lines));
|
|
4631
|
+
const cartKeys = new Set(fingerprints);
|
|
4632
|
+
const seenTargetIds = /* @__PURE__ */ new Set();
|
|
4633
|
+
const products = [];
|
|
4634
|
+
for (const root of roots) {
|
|
4635
|
+
const source = lookup.productsById[root.id];
|
|
4636
|
+
if (!source) continue;
|
|
4637
|
+
for (const recommendationId of linkedRecommendationIds(source.pricing)) {
|
|
4638
|
+
const recommendation = lookup.recommendationsById[recommendationId];
|
|
4639
|
+
if (!recommendation || !typeSet.has(recommendation.type)) continue;
|
|
4640
|
+
const targetId = recommendation.target_product_id;
|
|
4641
|
+
if (seenTargetIds.has(targetId)) continue;
|
|
4642
|
+
if (cartKeys.has(bareProductFingerprint(targetId))) continue;
|
|
4643
|
+
const target = lookup.productsById[targetId];
|
|
4644
|
+
if (!target || target.status !== "ACTIVE") continue;
|
|
4645
|
+
if (!isOnSchedule(target.availability.schedules)) continue;
|
|
4646
|
+
seenTargetIds.add(targetId);
|
|
4647
|
+
products.push(target);
|
|
4648
|
+
if (products.length >= quantity) return products;
|
|
4649
|
+
}
|
|
4650
|
+
}
|
|
4651
|
+
return products;
|
|
4652
|
+
};
|
|
4653
|
+
//#endregion
|
|
4654
|
+
//#region src/order/totals.ts
|
|
4655
|
+
/** Sum root line group totals into order totals (Big.js). */
|
|
4656
|
+
const recomputeTotals = (lines, currency = "USD") => {
|
|
4657
|
+
let subtotal = new Big(0);
|
|
4658
|
+
let discountTotal = new Big(0);
|
|
4659
|
+
let taxTotal = new Big(0);
|
|
4660
|
+
let total = new Big(0);
|
|
4661
|
+
for (const line of lines) {
|
|
4662
|
+
if (line.parent_uid != null) continue;
|
|
4663
|
+
subtotal = subtotal.plus(line.group_subtotal);
|
|
4664
|
+
discountTotal = discountTotal.plus(line.group_discount);
|
|
4665
|
+
taxTotal = taxTotal.plus(line.group_tax_total);
|
|
4666
|
+
total = total.plus(line.group_total);
|
|
4667
|
+
}
|
|
4668
|
+
let taxRate = 0;
|
|
4669
|
+
let discountRate = 0;
|
|
4670
|
+
if (subtotal.gt(0)) {
|
|
4671
|
+
taxRate = taxTotal.div(subtotal).toNumber();
|
|
4672
|
+
discountRate = discountTotal.div(subtotal).toNumber();
|
|
4673
|
+
}
|
|
4674
|
+
const discount_total = discountTotal.toNumber();
|
|
4675
|
+
const tax_total = taxTotal.toNumber();
|
|
4676
|
+
const b1 = {
|
|
4677
|
+
currency,
|
|
4678
|
+
subtotal: subtotal.toNumber()
|
|
4679
|
+
};
|
|
4680
|
+
const b2 = {
|
|
4681
|
+
discount_rate: discountRate,
|
|
4682
|
+
discount_total
|
|
4683
|
+
};
|
|
4684
|
+
const b3 = {
|
|
4685
|
+
tax_rate: taxRate,
|
|
4686
|
+
tax_total,
|
|
4687
|
+
total: total.toNumber()
|
|
4688
|
+
};
|
|
4689
|
+
return {
|
|
4690
|
+
...b1,
|
|
4691
|
+
...b2,
|
|
4692
|
+
...b3
|
|
4693
|
+
};
|
|
4694
|
+
};
|
|
4695
|
+
const emptyTotals = (currency = "USD") => {
|
|
4696
|
+
const b1 = {
|
|
4697
|
+
currency,
|
|
4698
|
+
subtotal: 0,
|
|
4699
|
+
discount_rate: 0,
|
|
4700
|
+
discount_total: 0
|
|
4701
|
+
};
|
|
4702
|
+
const b2 = {
|
|
4703
|
+
tax_rate: 0,
|
|
4704
|
+
tax_total: 0,
|
|
4705
|
+
total: 0
|
|
4706
|
+
};
|
|
4707
|
+
return {
|
|
4708
|
+
...b1,
|
|
4709
|
+
...b2
|
|
4710
|
+
};
|
|
4711
|
+
};
|
|
4712
|
+
//#endregion
|
|
4713
|
+
//#region src/order/redemptions.ts
|
|
4714
|
+
const ORDER_KEY = "ORDER";
|
|
4715
|
+
const buildPaymentRedeem = (input) => {
|
|
4716
|
+
const { reward_id, quantity, payment_id, amount, points } = input;
|
|
4717
|
+
if (amount === void 0 || !(amount > 0)) throw new Error("Redeem with payment_id requires amount > 0");
|
|
4718
|
+
if (points === void 0 || !(points >= 0)) throw new Error("Redeem with payment_id requires points ≥ 0");
|
|
4719
|
+
const redeem = {
|
|
4720
|
+
reward_id,
|
|
4721
|
+
quantity,
|
|
4722
|
+
payment_id,
|
|
4723
|
+
amount
|
|
4724
|
+
};
|
|
4725
|
+
redeem.points = points;
|
|
4726
|
+
if (input.program !== void 0) redeem.program = input.program;
|
|
4727
|
+
if (input.expires_at !== void 0) redeem.expires_at = input.expires_at;
|
|
4728
|
+
return redeem;
|
|
4729
|
+
};
|
|
4730
|
+
/** Same rules as `vOrders.redeem()`; throws on invalid input. */
|
|
4731
|
+
const buildRedeem = (input) => {
|
|
4732
|
+
const { reward_id, quantity, discount_rate, discount_amount } = input;
|
|
4733
|
+
const { before_taxes, expires_at } = input;
|
|
4734
|
+
if (!reward_id.trim()) throw new Error("Redeem requires reward_id");
|
|
4735
|
+
if (!Number.isInteger(quantity) || quantity < 1) throw new Error("Redeem quantity must be an integer ≥ 1");
|
|
4736
|
+
if (expires_at !== void 0 && Number.isNaN(Date.parse(expires_at))) throw new Error("Redeem expires_at must be an ISO datetime");
|
|
4737
|
+
if (input.payment_id) return buildPaymentRedeem(input);
|
|
4738
|
+
if (discount_rate === void 0 && discount_amount === void 0) throw new Error("Redeem requires discount_rate or discount_amount");
|
|
4739
|
+
const rate = discount_rate ?? 0;
|
|
4740
|
+
if (!(rate >= 0 && rate <= 1)) throw new Error("Redeem discount_rate must be between 0 and 1");
|
|
4741
|
+
if (discount_amount !== void 0 && !(discount_amount > 0)) throw new Error("Redeem discount_amount must be > 0");
|
|
4742
|
+
const redeem = {
|
|
4743
|
+
reward_id,
|
|
4744
|
+
quantity
|
|
4745
|
+
};
|
|
4746
|
+
if (discount_rate !== void 0) redeem.discount_rate = discount_rate;
|
|
4747
|
+
if (discount_amount !== void 0) redeem.discount_amount = discount_amount;
|
|
4748
|
+
if (before_taxes !== void 0) redeem.before_taxes = before_taxes;
|
|
4749
|
+
if (expires_at !== void 0) redeem.expires_at = expires_at;
|
|
4750
|
+
return redeem;
|
|
4751
|
+
};
|
|
4752
|
+
/** Payment redeems hold totals, so the kept units keep their share. */
|
|
4753
|
+
const keepUnits = (item, rest) => {
|
|
4754
|
+
if (!isPaymentRedeem(item) || rest === item.quantity) return {
|
|
4755
|
+
...item,
|
|
4756
|
+
quantity: rest
|
|
4757
|
+
};
|
|
4758
|
+
const ratio = new Big(rest).div(item.quantity);
|
|
4759
|
+
const amount = new Big(item.amount ?? 0).times(ratio).round(6).toNumber();
|
|
4760
|
+
const points = new Big(item.points ?? 0).times(ratio).round(0).toNumber();
|
|
4761
|
+
return {
|
|
4762
|
+
...item,
|
|
4763
|
+
quantity: rest,
|
|
4764
|
+
amount,
|
|
4765
|
+
points
|
|
4766
|
+
};
|
|
4767
|
+
};
|
|
4768
|
+
/**
|
|
4769
|
+
* Takes `quantity` units of `reward_id` off the latest entries first, so the
|
|
4770
|
+
* earlier redeems keep their step position. Entries that reach 0 are removed.
|
|
4771
|
+
*/
|
|
4772
|
+
const subtractRedeem = (items, reward_id, quantity) => {
|
|
4773
|
+
if (!Number.isInteger(quantity) || quantity < 1) throw new Error("Redeem quantity must be an integer ≥ 1");
|
|
4774
|
+
let pending = quantity;
|
|
4775
|
+
const kept = [];
|
|
4776
|
+
for (let i = items.length - 1; i >= 0; i--) {
|
|
4777
|
+
const item = items[i];
|
|
4778
|
+
if (pending === 0 || item.reward_id !== reward_id) {
|
|
4779
|
+
kept.unshift(item);
|
|
4780
|
+
continue;
|
|
4781
|
+
}
|
|
4782
|
+
const taken = Math.min(item.quantity, pending);
|
|
4783
|
+
pending -= taken;
|
|
4784
|
+
const rest = item.quantity - taken;
|
|
4785
|
+
if (rest > 0) kept.unshift(keepUnits(item, rest));
|
|
4786
|
+
}
|
|
4787
|
+
return kept;
|
|
4788
|
+
};
|
|
4789
|
+
const redemptionTargetKey = (target) => {
|
|
4790
|
+
if (target.target === "ORDER") return ORDER_KEY;
|
|
4791
|
+
return `LINE:${target.target_id ?? ""}`;
|
|
4792
|
+
};
|
|
4793
|
+
/**
|
|
4794
|
+
* Only root lines own redeems; child-line redeems are never applied.
|
|
4795
|
+
* Payment redeems are skipped on lines and on the order alike.
|
|
4796
|
+
*/
|
|
4797
|
+
const redeemPools = (lines, redeem) => {
|
|
4798
|
+
const pools = /* @__PURE__ */ new Map();
|
|
4799
|
+
for (const line of lines) {
|
|
4800
|
+
const items = discountRedeems(line.redeem ?? []);
|
|
4801
|
+
if (line.parent_uid != null || items.length === 0) continue;
|
|
4802
|
+
const target = {
|
|
4803
|
+
target: "LINE",
|
|
4804
|
+
target_id: line.uid
|
|
4805
|
+
};
|
|
4806
|
+
const pool = items.map((item) => ({
|
|
4807
|
+
...target,
|
|
4808
|
+
redeem: item
|
|
4809
|
+
}));
|
|
4810
|
+
pools.set(redemptionTargetKey(target), pool);
|
|
4811
|
+
}
|
|
4812
|
+
const order = { target: "ORDER" };
|
|
4813
|
+
const pool = discountRedeems(redeem).map((item) => ({
|
|
4814
|
+
...order,
|
|
4815
|
+
redeem: item
|
|
4816
|
+
}));
|
|
4817
|
+
pools.set(ORDER_KEY, pool);
|
|
4818
|
+
return pools;
|
|
4819
|
+
};
|
|
4820
|
+
/**
|
|
4821
|
+
* Pairs the k-th step of a target with the k-th redeem of that target, in
|
|
4822
|
+
* step order. Steps without a matching redeem are dropped; redeems without a
|
|
4823
|
+
* step are appended (lines first, then order).
|
|
4824
|
+
*/
|
|
4825
|
+
const sequenceRedeems = (lines, redeem, steps) => {
|
|
4826
|
+
const pools = redeemPools(lines, redeem);
|
|
4827
|
+
const cursors = /* @__PURE__ */ new Map();
|
|
4828
|
+
const sequence = [];
|
|
4829
|
+
for (const step of steps) {
|
|
4830
|
+
const key = redemptionTargetKey(step);
|
|
4831
|
+
const at = cursors.get(key) ?? 0;
|
|
4832
|
+
const next = pools.get(key)?.[at];
|
|
4833
|
+
if (!next || next.redeem.reward_id !== step.reward_id) continue;
|
|
4834
|
+
cursors.set(key, at + 1);
|
|
4835
|
+
sequence.push(next);
|
|
4836
|
+
}
|
|
4837
|
+
for (const [key, pool] of pools) sequence.push(...pool.slice(cursors.get(key) ?? 0));
|
|
4838
|
+
return sequence;
|
|
4839
|
+
};
|
|
4840
|
+
const minBig = (a, b) => {
|
|
4841
|
+
return a.lt(b) ? a : b;
|
|
4842
|
+
};
|
|
4843
|
+
const rateOf = (discount, base) => {
|
|
4844
|
+
if (base.lte(0)) return 0;
|
|
4845
|
+
return minBig(discount.div(base), new Big(1)).toNumber();
|
|
4846
|
+
};
|
|
4847
|
+
const snapshot = (roots, quantity) => {
|
|
4848
|
+
let subtotal = new Big(0);
|
|
4849
|
+
let total = new Big(0);
|
|
4850
|
+
let discount = new Big(0);
|
|
4851
|
+
for (const root of roots) {
|
|
4852
|
+
subtotal = subtotal.plus(root.subtotal);
|
|
4853
|
+
total = total.plus(root.total);
|
|
4854
|
+
discount = discount.plus(root.discount);
|
|
4855
|
+
}
|
|
4856
|
+
const b1 = {
|
|
4857
|
+
value: total.toNumber(),
|
|
4858
|
+
quantity
|
|
4859
|
+
};
|
|
4860
|
+
const b2 = { discount_amount: discount.toNumber() };
|
|
4861
|
+
const b3 = { discount_rate: rateOf(discount, subtotal) };
|
|
4862
|
+
return {
|
|
4863
|
+
...b1,
|
|
4864
|
+
...b2,
|
|
4865
|
+
...b3
|
|
4866
|
+
};
|
|
4867
|
+
};
|
|
4868
|
+
const netOf = (root) => {
|
|
4869
|
+
const net = root.total.minus(root.tax);
|
|
4870
|
+
return net.gt(0) ? net : new Big(0);
|
|
4871
|
+
};
|
|
4872
|
+
/** What a redeem can still discount on a root: pre-tax or with taxes. */
|
|
4873
|
+
const baseOf = (root, beforeTaxes) => {
|
|
4874
|
+
return beforeTaxes ? netOf(root) : root.total;
|
|
4875
|
+
};
|
|
4876
|
+
/** Before taxes, taxes shrink with the pre-tax value they were charged on. */
|
|
4877
|
+
const discountRoot = (root, amount, beforeTaxes) => {
|
|
4878
|
+
if (amount.lte(0)) return;
|
|
4879
|
+
root.discount = root.discount.plus(amount);
|
|
4880
|
+
if (!beforeTaxes) {
|
|
4881
|
+
root.total = root.total.minus(amount);
|
|
4882
|
+
return;
|
|
4883
|
+
}
|
|
4884
|
+
const net = netOf(root);
|
|
4885
|
+
const nextNet = net.minus(amount);
|
|
4886
|
+
root.tax = root.tax.times(nextNet).div(net);
|
|
4887
|
+
root.total = nextNet.plus(root.tax);
|
|
4888
|
+
};
|
|
4889
|
+
/** Splits an order discount across roots by what each can still discount. */
|
|
4890
|
+
const discountOrder = (roots, amount, beforeTaxes) => {
|
|
4891
|
+
const bases = roots.map((root) => baseOf(root, beforeTaxes));
|
|
4892
|
+
const sum = bases.reduce((acc, base) => acc.plus(base), new Big(0));
|
|
4893
|
+
if (sum.lte(0) || amount.lte(0)) return;
|
|
4894
|
+
let last = -1;
|
|
4895
|
+
bases.forEach((base, index) => {
|
|
4896
|
+
if (base.gt(0)) last = index;
|
|
4897
|
+
});
|
|
4898
|
+
let rest = amount;
|
|
4899
|
+
roots.forEach((root, index) => {
|
|
4900
|
+
const base = bases[index];
|
|
4901
|
+
if (base.lte(0)) return;
|
|
4902
|
+
const share = amount.times(base).div(sum);
|
|
4903
|
+
const part = minBig(index === last ? rest : share, base);
|
|
4904
|
+
rest = rest.minus(part);
|
|
4905
|
+
discountRoot(root, part, beforeTaxes);
|
|
4906
|
+
});
|
|
4907
|
+
};
|
|
4908
|
+
/**
|
|
4909
|
+
* Rate compounds once per reward unit over the remaining value; amount is
|
|
4910
|
+
* per unit. With both, the rate discount is capped at `amount × quantity`.
|
|
4911
|
+
*/
|
|
4912
|
+
const redeemDiscount = (redeem, remaining) => {
|
|
4913
|
+
const { quantity, discount_rate, discount_amount } = redeem;
|
|
4914
|
+
let cap = null;
|
|
4915
|
+
if (discount_amount !== void 0) cap = new Big(discount_amount).times(quantity);
|
|
4916
|
+
if (discount_rate === void 0) return cap ?? new Big(0);
|
|
4917
|
+
const kept = new Big(1).minus(discount_rate).pow(quantity);
|
|
4918
|
+
const byRate = remaining.times(new Big(1).minus(kept));
|
|
4919
|
+
return cap ? minBig(byRate, cap) : byRate;
|
|
4920
|
+
};
|
|
4921
|
+
/**
|
|
4922
|
+
* Root group money rebuilt from the line itself plus its children, i.e. the
|
|
4923
|
+
* menu price before any redemption. Child lines are never discounted.
|
|
4924
|
+
*/
|
|
4925
|
+
const stripRedemptionLines = (lines) => {
|
|
4926
|
+
const children = /* @__PURE__ */ new Map();
|
|
4927
|
+
for (const line of lines) {
|
|
4928
|
+
if (line.parent_uid == null) continue;
|
|
4929
|
+
const list = children.get(line.parent_uid) ?? [];
|
|
4930
|
+
list.push(line);
|
|
4931
|
+
children.set(line.parent_uid, list);
|
|
4932
|
+
}
|
|
4933
|
+
return lines.map((line) => {
|
|
4934
|
+
if (line.parent_uid != null) return line;
|
|
4935
|
+
const qty = new Big(line.quantity);
|
|
4936
|
+
let sub = new Big(0);
|
|
4937
|
+
let disc = new Big(0);
|
|
4938
|
+
let tax = new Big(0);
|
|
4939
|
+
let tot = new Big(0);
|
|
4940
|
+
for (const child of children.get(line.uid) ?? []) {
|
|
4941
|
+
sub = sub.plus(child.group_subtotal);
|
|
4942
|
+
disc = disc.plus(child.group_discount);
|
|
4943
|
+
tax = tax.plus(child.group_tax_total);
|
|
4944
|
+
tot = tot.plus(child.group_total);
|
|
4945
|
+
}
|
|
4946
|
+
const gSub = new Big(line.subtotal).plus(sub.times(qty));
|
|
4947
|
+
const gDisc = new Big(line.discount_total).plus(disc.times(qty));
|
|
4948
|
+
const gTax = new Big(line.tax_total).plus(tax.times(qty));
|
|
4949
|
+
const gTot = new Big(line.total).plus(tot.times(qty));
|
|
4950
|
+
const u1 = { unit_group_subtotal: gSub.div(qty).toNumber() };
|
|
4951
|
+
const u2 = { unit_group_discount: gDisc.div(qty).toNumber() };
|
|
4952
|
+
const u3 = { unit_group_tax_total: gTax.div(qty).toNumber() };
|
|
4953
|
+
const u4 = { unit_group_total: gTot.div(qty).toNumber() };
|
|
4954
|
+
const g1 = { group_subtotal: gSub.toNumber() };
|
|
4955
|
+
const g2 = { group_discount: gDisc.toNumber() };
|
|
4956
|
+
const g3 = { group_tax_total: gTax.toNumber() };
|
|
4957
|
+
const g4 = { group_total: gTot.toNumber() };
|
|
4958
|
+
const p1 = {
|
|
4959
|
+
...u1,
|
|
4960
|
+
...u2,
|
|
4961
|
+
...u3,
|
|
4962
|
+
...u4
|
|
4963
|
+
};
|
|
4964
|
+
return {
|
|
4965
|
+
...line,
|
|
4966
|
+
...p1,
|
|
4967
|
+
...g1,
|
|
4968
|
+
...g2,
|
|
4969
|
+
...g3,
|
|
4970
|
+
...g4
|
|
4971
|
+
};
|
|
4972
|
+
});
|
|
4973
|
+
};
|
|
4974
|
+
const withRootMoney = (line, money) => {
|
|
4975
|
+
const qty = new Big(line.quantity);
|
|
4976
|
+
const discount = new Big(line.group_discount).plus(money.discount);
|
|
4977
|
+
const u1 = { unit_group_discount: discount.div(qty).toNumber() };
|
|
4978
|
+
const u2 = { unit_group_tax_total: money.tax.div(qty).toNumber() };
|
|
4979
|
+
const u3 = { unit_group_total: money.total.div(qty).toNumber() };
|
|
4980
|
+
const g1 = { group_discount: discount.toNumber() };
|
|
4981
|
+
const g2 = { group_tax_total: money.tax.toNumber() };
|
|
4982
|
+
const g3 = { group_total: money.total.toNumber() };
|
|
4983
|
+
return {
|
|
4984
|
+
...line,
|
|
4985
|
+
...u1,
|
|
4986
|
+
...u2,
|
|
4987
|
+
...u3,
|
|
4988
|
+
...g1,
|
|
4989
|
+
...g2,
|
|
4990
|
+
...g3
|
|
4991
|
+
};
|
|
4992
|
+
};
|
|
4993
|
+
/**
|
|
4994
|
+
* Replays every redeem in persisted step order over the root lines' group
|
|
4995
|
+
* money. Line redeems discount their root; order redeems are prorated over
|
|
4996
|
+
* every root. A discount never exceeds what is left on its target.
|
|
4997
|
+
*/
|
|
4998
|
+
const replayRedemptions = (lines, redeem, steps) => {
|
|
4999
|
+
const base = stripRedemptionLines(lines);
|
|
5000
|
+
const sequence = sequenceRedeems(base, redeem, steps);
|
|
5001
|
+
const moneyByUid = /* @__PURE__ */ new Map();
|
|
5002
|
+
for (const line of base) {
|
|
5003
|
+
if (line.parent_uid != null) continue;
|
|
5004
|
+
const b1 = { quantity: line.quantity };
|
|
5005
|
+
const b2 = { subtotal: new Big(line.group_subtotal) };
|
|
5006
|
+
const b3 = { tax: new Big(line.group_tax_total) };
|
|
5007
|
+
const b4 = {
|
|
5008
|
+
total: new Big(line.group_total),
|
|
5009
|
+
discount: new Big(0)
|
|
5010
|
+
};
|
|
5011
|
+
moneyByUid.set(line.uid, {
|
|
5012
|
+
...b1,
|
|
5013
|
+
...b2,
|
|
5014
|
+
...b3,
|
|
5015
|
+
...b4
|
|
5016
|
+
});
|
|
5017
|
+
}
|
|
5018
|
+
const roots = [...moneyByUid.values()];
|
|
5019
|
+
const redemption_steps = sequence.map((item) => {
|
|
5020
|
+
const id = item.target_id;
|
|
5021
|
+
const root = id ? moneyByUid.get(id) : void 0;
|
|
5022
|
+
const beforeTaxes = item.redeem.before_taxes ?? true;
|
|
5023
|
+
const before = snapshot(roots, 1);
|
|
5024
|
+
const targetBefore = root ? snapshot([root], root.quantity) : void 0;
|
|
5025
|
+
let left = new Big(0);
|
|
5026
|
+
if (root) left = baseOf(root, beforeTaxes);
|
|
5027
|
+
else for (const r of roots) left = left.plus(baseOf(r, beforeTaxes));
|
|
5028
|
+
const raw = redeemDiscount(item.redeem, left);
|
|
5029
|
+
const discount = minBig(raw, left);
|
|
5030
|
+
if (root) discountRoot(root, discount, beforeTaxes);
|
|
5031
|
+
else discountOrder(roots, discount, beforeTaxes);
|
|
5032
|
+
const action = discount.gt(0) ? "UPDATE" : "READ";
|
|
5033
|
+
const { reward_id, quantity } = item.redeem;
|
|
5034
|
+
const b1 = {
|
|
5035
|
+
reward_id,
|
|
5036
|
+
reward_quantity: quantity,
|
|
5037
|
+
target: item.target
|
|
5038
|
+
};
|
|
5039
|
+
const b2 = id ? { target_id: id } : {};
|
|
5040
|
+
const b3 = {
|
|
5041
|
+
action,
|
|
5042
|
+
discount_amount: discount.toNumber()
|
|
4871
5043
|
};
|
|
4872
|
-
const
|
|
4873
|
-
|
|
4874
|
-
|
|
4875
|
-
ok: false,
|
|
4876
|
-
message: `Unknown operation ${key}`
|
|
5044
|
+
const b4 = {
|
|
5045
|
+
before,
|
|
5046
|
+
after: snapshot(roots, 1)
|
|
4877
5047
|
};
|
|
4878
|
-
|
|
4879
|
-
|
|
4880
|
-
|
|
4881
|
-
|
|
4882
|
-
return result;
|
|
4883
|
-
}
|
|
4884
|
-
async reportTerminal(payment, operation, result) {
|
|
4885
|
-
const fallback = createPaymentError("UNKNOWN");
|
|
4886
|
-
const error = result.error ?? {
|
|
4887
|
-
...fallback,
|
|
4888
|
-
message: result.message ?? fallback.message
|
|
5048
|
+
let b5 = {};
|
|
5049
|
+
if (root) b5 = {
|
|
5050
|
+
target_before: targetBefore,
|
|
5051
|
+
target_after: snapshot([root], root.quantity)
|
|
4889
5052
|
};
|
|
4890
|
-
|
|
4891
|
-
|
|
4892
|
-
|
|
4893
|
-
|
|
5053
|
+
return {
|
|
5054
|
+
...b1,
|
|
5055
|
+
...b2,
|
|
5056
|
+
...b3,
|
|
5057
|
+
...b4,
|
|
5058
|
+
...b5
|
|
4894
5059
|
};
|
|
4895
|
-
const report = services.payments.terminalFailure;
|
|
4896
|
-
await report(payment.id, body, this.deps.http).catch(() => void 0);
|
|
4897
|
-
}
|
|
4898
|
-
};
|
|
4899
|
-
//#endregion
|
|
4900
|
-
//#region src/schedule/schedule.ts
|
|
4901
|
-
const DAY_OF_WEEK = [
|
|
4902
|
-
"sunday",
|
|
4903
|
-
"monday",
|
|
4904
|
-
"tuesday",
|
|
4905
|
-
"wednesday",
|
|
4906
|
-
"thursday",
|
|
4907
|
-
"friday",
|
|
4908
|
-
"saturday"
|
|
4909
|
-
];
|
|
4910
|
-
const timeToMinutes = (time) => {
|
|
4911
|
-
const [hours = "0", minutes = "0"] = time.split(":");
|
|
4912
|
-
return Number(hours) * 60 + Number(minutes);
|
|
4913
|
-
};
|
|
4914
|
-
const hasSpecificDates = (period) => {
|
|
4915
|
-
return period.start_date != null && period.end_date != null;
|
|
4916
|
-
};
|
|
4917
|
-
const periodCoversDate = (period, now) => {
|
|
4918
|
-
if (!hasSpecificDates(period)) return false;
|
|
4919
|
-
const nowMs = now.getTime();
|
|
4920
|
-
const startMs = Date.parse(period.start_date);
|
|
4921
|
-
const endMs = Date.parse(period.end_date);
|
|
4922
|
-
if (Number.isNaN(startMs) || Number.isNaN(endMs)) return false;
|
|
4923
|
-
return startMs <= nowMs && nowMs <= endMs;
|
|
4924
|
-
};
|
|
4925
|
-
const timeMatches = (period, now) => {
|
|
4926
|
-
if (!period.start_time || !period.end_time) return false;
|
|
4927
|
-
const nowMinutes = now.getHours() * 60 + now.getMinutes();
|
|
4928
|
-
const start = timeToMinutes(period.start_time);
|
|
4929
|
-
const end = timeToMinutes(period.end_time);
|
|
4930
|
-
return nowMinutes >= start && nowMinutes < end;
|
|
4931
|
-
};
|
|
4932
|
-
const isClosedAt = (period, now) => {
|
|
4933
|
-
if (!period.closed) return false;
|
|
4934
|
-
if (!period.start_time || !period.end_time) return true;
|
|
4935
|
-
return timeMatches(period, now);
|
|
4936
|
-
};
|
|
4937
|
-
const isOpenAt = (period, now) => {
|
|
4938
|
-
if (period.closed) return false;
|
|
4939
|
-
return timeMatches(period, now);
|
|
4940
|
-
};
|
|
4941
|
-
const isPeriodsActiveAt = (periods, now) => {
|
|
4942
|
-
return getOpenPeriodAt(periods, now) != null;
|
|
4943
|
-
};
|
|
4944
|
-
const getOpenPeriodAt = (periods, now) => {
|
|
4945
|
-
if (periods.length === 0) return null;
|
|
4946
|
-
const coveringSpecific = periods.filter((period) => {
|
|
4947
|
-
return periodCoversDate(period, now);
|
|
4948
5060
|
});
|
|
4949
|
-
|
|
4950
|
-
|
|
4951
|
-
|
|
4952
|
-
|
|
4953
|
-
|
|
4954
|
-
|
|
4955
|
-
|
|
4956
|
-
const atTimeOnDate = (day, time) => {
|
|
4957
|
-
const [hours = "0", minutes = "0"] = time.split(":");
|
|
4958
|
-
const next = new Date(day);
|
|
4959
|
-
next.setHours(Number(hours), Number(minutes), 0, 0);
|
|
4960
|
-
return next;
|
|
4961
|
-
};
|
|
4962
|
-
const isWithinPeriodDates = (period, instantMs) => {
|
|
4963
|
-
if (!hasSpecificDates(period)) return true;
|
|
4964
|
-
const startMs = Date.parse(period.start_date);
|
|
4965
|
-
const endMs = Date.parse(period.end_date);
|
|
4966
|
-
if (Number.isNaN(startMs) || Number.isNaN(endMs)) return false;
|
|
4967
|
-
return startMs <= instantMs && instantMs <= endMs;
|
|
4968
|
-
};
|
|
4969
|
-
const dayPeriods = (schedule, instant) => {
|
|
4970
|
-
const day = DAY_OF_WEEK[instant.getDay()];
|
|
4971
|
-
if (!day) return [];
|
|
4972
|
-
return schedule.find((row) => row.day_of_week === day)?.periods ?? [];
|
|
4973
|
-
};
|
|
4974
|
-
const getActiveUntil = (periods, now) => {
|
|
4975
|
-
const open = getOpenPeriodAt(periods, now);
|
|
4976
|
-
if (!open?.end_time) return null;
|
|
4977
|
-
let untilMs = atTimeOnDate(now, open.end_time).getTime();
|
|
4978
|
-
const openIsSpecific = hasSpecificDates(open);
|
|
4979
|
-
for (const period of periods) {
|
|
4980
|
-
if (!period.closed || !period.start_time) continue;
|
|
4981
|
-
const closeMs = atTimeOnDate(now, period.start_time).getTime();
|
|
4982
|
-
if (closeMs <= now.getTime() || closeMs >= untilMs) continue;
|
|
4983
|
-
if (!isWithinPeriodDates(period, closeMs)) continue;
|
|
4984
|
-
if (!hasSpecificDates(period) && openIsSpecific) continue;
|
|
4985
|
-
untilMs = closeMs;
|
|
4986
|
-
}
|
|
4987
|
-
return new Date(untilMs);
|
|
4988
|
-
};
|
|
4989
|
-
const getNextStart = (schedule, now) => {
|
|
4990
|
-
const nowMs = now.getTime();
|
|
4991
|
-
let nextStartMs = null;
|
|
4992
|
-
const consider = (candidate, periods) => {
|
|
4993
|
-
const startMs = candidate.getTime();
|
|
4994
|
-
if (startMs <= nowMs) return;
|
|
4995
|
-
if (!isPeriodsActiveAt(periods, candidate)) return;
|
|
4996
|
-
if (nextStartMs === null || startMs < nextStartMs) nextStartMs = startMs;
|
|
5061
|
+
return {
|
|
5062
|
+
lines: base.map((line) => {
|
|
5063
|
+
const money = moneyByUid.get(line.uid);
|
|
5064
|
+
if (!money || money.discount.eq(0)) return line;
|
|
5065
|
+
return withRootMoney(line, money);
|
|
5066
|
+
}),
|
|
5067
|
+
redemption_steps
|
|
4997
5068
|
};
|
|
4998
|
-
for (let dayOffset = 0; dayOffset < 8; dayOffset += 1) {
|
|
4999
|
-
const candidateDate = new Date(now);
|
|
5000
|
-
candidateDate.setDate(candidateDate.getDate() + dayOffset);
|
|
5001
|
-
candidateDate.setSeconds(0, 0);
|
|
5002
|
-
const periods = dayPeriods(schedule, candidateDate);
|
|
5003
|
-
if (periods.length === 0) continue;
|
|
5004
|
-
for (const period of periods) {
|
|
5005
|
-
if (period.closed || !period.start_time) continue;
|
|
5006
|
-
const start = atTimeOnDate(candidateDate, period.start_time);
|
|
5007
|
-
if (!isWithinPeriodDates(period, start.getTime())) continue;
|
|
5008
|
-
consider(start, periods);
|
|
5009
|
-
}
|
|
5010
|
-
for (const period of periods) {
|
|
5011
|
-
if (!period.closed || !period.end_time) continue;
|
|
5012
|
-
const reopen = atTimeOnDate(candidateDate, period.end_time);
|
|
5013
|
-
if (!isWithinPeriodDates(period, reopen.getTime())) continue;
|
|
5014
|
-
consider(reopen, periods);
|
|
5015
|
-
}
|
|
5016
|
-
}
|
|
5017
|
-
return nextStartMs === null ? null : new Date(nextStartMs);
|
|
5018
5069
|
};
|
|
5019
|
-
/**
|
|
5020
|
-
const
|
|
5021
|
-
|
|
5022
|
-
|
|
5023
|
-
|
|
5024
|
-
};
|
|
5025
|
-
const periods = dayPeriods(schedule, now);
|
|
5026
|
-
if (isPeriodsActiveAt(periods, now)) return {
|
|
5027
|
-
active: true,
|
|
5028
|
-
until: getActiveUntil(periods, now)
|
|
5029
|
-
};
|
|
5070
|
+
/** Discounted lines, steps and totals, keeping step order. */
|
|
5071
|
+
const recomputeRedemptions = (input) => {
|
|
5072
|
+
const { redeem, currency } = input;
|
|
5073
|
+
const steps = input.redemption_steps;
|
|
5074
|
+
const { lines, redemption_steps } = replayRedemptions(input.lines, redeem, steps);
|
|
5030
5075
|
return {
|
|
5031
|
-
|
|
5032
|
-
|
|
5076
|
+
lines,
|
|
5077
|
+
redemption_steps,
|
|
5078
|
+
totals: recomputeTotals(lines, currency)
|
|
5033
5079
|
};
|
|
5034
5080
|
};
|
|
5035
|
-
const isOnSchedule = (schedule, now) => {
|
|
5036
|
-
return getScheduleWindow(schedule, now).active;
|
|
5037
|
-
};
|
|
5038
|
-
//#endregion
|
|
5039
|
-
//#region src/order/recommendations.ts
|
|
5040
|
-
const DEFAULT_RECOMMENDATION_TYPES = ["CROSS_SELL"];
|
|
5041
|
-
const linkedRecommendationIds = (pricing) => {
|
|
5042
|
-
return [...pricing.recommendations_ids ?? [], ...(pricing.recommendations ?? []).map((link) => link.recommendation_id)];
|
|
5043
|
-
};
|
|
5044
|
-
const collectRecommendations = (config) => {
|
|
5045
|
-
const { lines, lookup, quantity, types } = config;
|
|
5046
|
-
if (quantity <= 0) return [];
|
|
5047
|
-
const typeSet = new Set(types ?? DEFAULT_RECOMMENDATION_TYPES);
|
|
5048
|
-
const roots = lines.filter((line) => line.parent_uid == null);
|
|
5049
|
-
const fingerprints = roots.map((root) => computeLineFingerprint(root, lines));
|
|
5050
|
-
const cartKeys = new Set(fingerprints);
|
|
5051
|
-
const seenTargetIds = /* @__PURE__ */ new Set();
|
|
5052
|
-
const products = [];
|
|
5053
|
-
for (const root of roots) {
|
|
5054
|
-
const source = lookup.productsById[root.id];
|
|
5055
|
-
if (!source) continue;
|
|
5056
|
-
for (const recommendationId of linkedRecommendationIds(source.pricing)) {
|
|
5057
|
-
const recommendation = lookup.recommendationsById[recommendationId];
|
|
5058
|
-
if (!recommendation || !typeSet.has(recommendation.type)) continue;
|
|
5059
|
-
const targetId = recommendation.target_product_id;
|
|
5060
|
-
if (seenTargetIds.has(targetId)) continue;
|
|
5061
|
-
if (cartKeys.has(bareProductFingerprint(targetId))) continue;
|
|
5062
|
-
const target = lookup.productsById[targetId];
|
|
5063
|
-
if (!target || target.status !== "ACTIVE") continue;
|
|
5064
|
-
if (!isOnSchedule(target.availability.schedules)) continue;
|
|
5065
|
-
seenTargetIds.add(targetId);
|
|
5066
|
-
products.push(target);
|
|
5067
|
-
if (products.length >= quantity) return products;
|
|
5068
|
-
}
|
|
5069
|
-
}
|
|
5070
|
-
return products;
|
|
5071
|
-
};
|
|
5072
5081
|
//#endregion
|
|
5073
5082
|
//#region src/order/Order.ts
|
|
5074
5083
|
const cloneOrder = (order) => {
|
|
5075
5084
|
return structuredClone(order);
|
|
5076
5085
|
};
|
|
5086
|
+
const redeemSignature$1 = (items) => {
|
|
5087
|
+
return items.map((item) => {
|
|
5088
|
+
return `${`${item.reward_id}:${item.quantity}:${item.payment_id ?? ""}`}:${`${item.amount ?? ""}:${item.points ?? ""}`}:${`${item.discount_rate ?? ""}:${item.discount_amount ?? ""}`}`;
|
|
5089
|
+
}).join("|");
|
|
5090
|
+
};
|
|
5077
5091
|
const buildHttpOptions = (options) => {
|
|
5078
5092
|
return {
|
|
5079
5093
|
baseUrl: options.baseUrl,
|
|
@@ -5279,22 +5293,30 @@ var Order = class Order {
|
|
|
5279
5293
|
listeners = /* @__PURE__ */ new Set();
|
|
5280
5294
|
externalNotify;
|
|
5281
5295
|
http;
|
|
5296
|
+
debounceMs;
|
|
5282
5297
|
flush;
|
|
5283
5298
|
payments;
|
|
5284
5299
|
/** Real tenders chosen with `setTenders`; REDEMPTION ones come from redeem. */
|
|
5285
5300
|
realTenders = null;
|
|
5286
5301
|
redemptionProviders = /* @__PURE__ */ new Map();
|
|
5302
|
+
/**
|
|
5303
|
+
* Redeems as applied, before the D18 cap trims payment amounts. The cap
|
|
5304
|
+
* is re-derived from these on every edit, so a redeem trimmed while the
|
|
5305
|
+
* total was low recovers its amount once the total grows back.
|
|
5306
|
+
*/
|
|
5307
|
+
requestedRedeem = null;
|
|
5287
5308
|
constructor(config, notify) {
|
|
5288
5309
|
this.draft = cloneOrder(config.order);
|
|
5289
5310
|
this.synced = cloneOrder(config.order);
|
|
5290
5311
|
this.rememberLogScope();
|
|
5291
5312
|
this.persisted = config.persisted;
|
|
5292
5313
|
this.http = config.http;
|
|
5314
|
+
this.debounceMs = config.debounceMs;
|
|
5293
5315
|
this.externalNotify = notify;
|
|
5294
5316
|
this.flush = createFlushController({
|
|
5295
5317
|
getDraft: () => this.draft,
|
|
5296
5318
|
setDraft: (next) => {
|
|
5297
|
-
this.
|
|
5319
|
+
this.replaceDraft(next);
|
|
5298
5320
|
},
|
|
5299
5321
|
getSynced: () => this.synced,
|
|
5300
5322
|
setSynced: (next) => {
|
|
@@ -5314,11 +5336,7 @@ var Order = class Order {
|
|
|
5314
5336
|
this.payments = new PaymentSession({
|
|
5315
5337
|
getOrder: () => this.draft,
|
|
5316
5338
|
placeOrder: (input) => this.placeOrder(input),
|
|
5317
|
-
applyOrder: (next) =>
|
|
5318
|
-
this.draft = cloneOrder(next);
|
|
5319
|
-
this.synced = cloneOrder(next);
|
|
5320
|
-
this.notify();
|
|
5321
|
-
},
|
|
5339
|
+
applyOrder: (next) => this.applyServerOrder(next),
|
|
5322
5340
|
http: this.http
|
|
5323
5341
|
});
|
|
5324
5342
|
}
|
|
@@ -5496,7 +5514,7 @@ var Order = class Order {
|
|
|
5496
5514
|
if (isPaymentRedeem(redeem) && line_uid !== void 0) throw new Error("Points / balance redeems apply to the whole order");
|
|
5497
5515
|
if (redeem.payment_id && provider) this.redemptionProviders.set(redeem.payment_id, provider);
|
|
5498
5516
|
if (line_uid === void 0) {
|
|
5499
|
-
this.commitLines({ redeem: [...this.
|
|
5517
|
+
this.commitLines({ redeem: [...this.appliedRedeem(), redeem] });
|
|
5500
5518
|
return;
|
|
5501
5519
|
}
|
|
5502
5520
|
const line = this.draft.lines.find((item) => item.uid === line_uid);
|
|
@@ -5522,7 +5540,7 @@ var Order = class Order {
|
|
|
5522
5540
|
const { reward_id, quantity, line_uid } = input;
|
|
5523
5541
|
if (line_uid === void 0) {
|
|
5524
5542
|
const p = [
|
|
5525
|
-
this.
|
|
5543
|
+
this.appliedRedeem(),
|
|
5526
5544
|
reward_id,
|
|
5527
5545
|
quantity
|
|
5528
5546
|
];
|
|
@@ -5620,6 +5638,32 @@ var Order = class Order {
|
|
|
5620
5638
|
this.deleted = true;
|
|
5621
5639
|
this.notify();
|
|
5622
5640
|
}
|
|
5641
|
+
/**
|
|
5642
|
+
* Reopens this placed, unpaid order: the server voids every payment
|
|
5643
|
+
* (REDEMPTION points released), cancels it as `VOIDED` and opens a new
|
|
5644
|
+
* `OPEN` order with the same content. This instance is closed afterwards;
|
|
5645
|
+
* replace it with the returned `Order` (same HTTP options and listener).
|
|
5646
|
+
*
|
|
5647
|
+
* Rejects with `StorefrontHttpError` 400 and
|
|
5648
|
+
* `details.code: "ORDER_PAYMENT_CAPTURED"` when a payment was already
|
|
5649
|
+
* captured; nothing changes then and this instance stays usable.
|
|
5650
|
+
*/
|
|
5651
|
+
async reopen() {
|
|
5652
|
+
if (!this.persisted) throw new Error("Only a placed order is reopened");
|
|
5653
|
+
this.flush.cancel();
|
|
5654
|
+
const c1 = {
|
|
5655
|
+
order: await services.orders.reopen(this.draft.id, this.http),
|
|
5656
|
+
http: this.http,
|
|
5657
|
+
debounceMs: this.debounceMs
|
|
5658
|
+
};
|
|
5659
|
+
const reopened = new Order({
|
|
5660
|
+
...c1,
|
|
5661
|
+
persisted: true
|
|
5662
|
+
}, this.externalNotify);
|
|
5663
|
+
this.deleted = true;
|
|
5664
|
+
this.notify();
|
|
5665
|
+
return reopened;
|
|
5666
|
+
}
|
|
5623
5667
|
async refundOrder() {
|
|
5624
5668
|
if (this.deleted) return;
|
|
5625
5669
|
this.flush.cancel();
|
|
@@ -5659,13 +5703,17 @@ var Order = class Order {
|
|
|
5659
5703
|
};
|
|
5660
5704
|
await services.orders.validate(body, this.http);
|
|
5661
5705
|
}
|
|
5662
|
-
/**
|
|
5706
|
+
/**
|
|
5707
|
+
* Tenders of the next intent: real ones plus one per payment redeem.
|
|
5708
|
+
* When the payment redeems cover the total, only REDEMPTION tenders.
|
|
5709
|
+
*/
|
|
5663
5710
|
getTenders(payment) {
|
|
5664
|
-
const simple = payment ? simpleTenderInput(payment) : null;
|
|
5665
|
-
const real = simple ? [simple] : this.realTenders ?? [];
|
|
5666
5711
|
const redeem = this.draft.redeem ?? [];
|
|
5667
5712
|
const providers = this.redemptionProviders;
|
|
5668
|
-
|
|
5713
|
+
const redemption = redemptionTenderInputs(redeem, providers);
|
|
5714
|
+
if (paymentRedeemsCoverTotal(redeem, this.draft.totals)) return redemption;
|
|
5715
|
+
const simple = payment ? simpleTenderInput(payment) : null;
|
|
5716
|
+
return [...simple ? [simple] : this.realTenders ?? [], ...redemption];
|
|
5669
5717
|
}
|
|
5670
5718
|
/**
|
|
5671
5719
|
* Chooses the real tenders (with allocation for mixed payments) and saves
|
|
@@ -5696,10 +5744,18 @@ var Order = class Order {
|
|
|
5696
5744
|
return this.getOrder();
|
|
5697
5745
|
}
|
|
5698
5746
|
applyServerOrder(next) {
|
|
5699
|
-
this.
|
|
5747
|
+
this.replaceDraft(cloneOrder(next));
|
|
5700
5748
|
this.synced = cloneOrder(next);
|
|
5701
5749
|
this.notify();
|
|
5702
5750
|
}
|
|
5751
|
+
/** Keeps the applied redeems while the server holds the same capped ones. */
|
|
5752
|
+
replaceDraft(next) {
|
|
5753
|
+
if (redeemSignature$1(this.draft.redeem ?? []) !== redeemSignature$1(next.redeem ?? [])) this.requestedRedeem = null;
|
|
5754
|
+
this.draft = next;
|
|
5755
|
+
}
|
|
5756
|
+
appliedRedeem() {
|
|
5757
|
+
return this.requestedRedeem ?? this.draft.redeem ?? [];
|
|
5758
|
+
}
|
|
5703
5759
|
/**
|
|
5704
5760
|
* Places the order with a DRAFT intent: `intent_id` as is, else the
|
|
5705
5761
|
* tenders of `getTenders(input.payment)` saved first (simple flow: one
|
|
@@ -5825,7 +5881,7 @@ var Order = class Order {
|
|
|
5825
5881
|
/** Applies lines/redeem, replaying redemption steps and totals. */
|
|
5826
5882
|
commitLines(patch) {
|
|
5827
5883
|
const patched = patch.lines ?? this.draft.lines;
|
|
5828
|
-
const applied = patch.redeem ?? this.
|
|
5884
|
+
const applied = patch.redeem ?? this.appliedRedeem();
|
|
5829
5885
|
const steps = this.draft.redemption_steps ?? [];
|
|
5830
5886
|
const currency = this.draft.totals.currency;
|
|
5831
5887
|
const { lines, redemption_steps, totals } = recomputeRedemptions({
|
|
@@ -5838,6 +5894,7 @@ var Order = class Order {
|
|
|
5838
5894
|
lines,
|
|
5839
5895
|
totals
|
|
5840
5896
|
}), currency);
|
|
5897
|
+
this.requestedRedeem = applied;
|
|
5841
5898
|
this.draft = {
|
|
5842
5899
|
...this.draft,
|
|
5843
5900
|
lines,
|
|
@@ -6401,7 +6458,10 @@ const redeemSignature = (items) => {
|
|
|
6401
6458
|
const stepSignature = (steps) => {
|
|
6402
6459
|
return steps.map((step) => `${step.reward_id}:${step.reward_quantity}`).join(",");
|
|
6403
6460
|
};
|
|
6404
|
-
/**
|
|
6461
|
+
/**
|
|
6462
|
+
* Every target's discount redeems must have one step each, in the same
|
|
6463
|
+
* order. Payment redeems are a REDEMPTION tender, never a step (D16).
|
|
6464
|
+
*/
|
|
6405
6465
|
const checkRedemptionTargets = (lines, redeem, steps, issues, lineIssues) => {
|
|
6406
6466
|
const byTarget = /* @__PURE__ */ new Map();
|
|
6407
6467
|
for (const step of steps) {
|
|
@@ -6411,7 +6471,7 @@ const checkRedemptionTargets = (lines, redeem, steps, issues, lineIssues) => {
|
|
|
6411
6471
|
const check = (key, items) => {
|
|
6412
6472
|
const targetSteps = byTarget.get(key) ?? [];
|
|
6413
6473
|
byTarget.delete(key);
|
|
6414
|
-
const expected = redeemSignature(items);
|
|
6474
|
+
const expected = redeemSignature(discountRedeems(items));
|
|
6415
6475
|
const actual = stepSignature(targetSteps);
|
|
6416
6476
|
if (expected === actual) return null;
|
|
6417
6477
|
return {
|