@fire-spark/core 0.2.0 → 0.3.1
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 +196 -22
- package/dist/index.js +1810 -1267
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createAttemptReference, createPaymentError, currencyDecimals, defaultReversalFor, isPaymentError, 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, groupPaymentRedeems, isDeliveryFee, isPaymentError, isRealPaymentMethod, isRealPaymentMethod as isRealPaymentMethod$1, isTenderSettled, isTipFee, liveTenders, memoryPaymentStorage, missingDeviceFields, 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";
|
|
@@ -2069,14 +2069,36 @@ const getPosLogDevice = () => {
|
|
|
2069
2069
|
//#region src/services/http.ts
|
|
2070
2070
|
var StorefrontHttpError = class extends Error {
|
|
2071
2071
|
status;
|
|
2072
|
+
/** `details.code` when present, else the top-level `code`. */
|
|
2072
2073
|
code;
|
|
2073
|
-
|
|
2074
|
+
/** API `details` object (e.g. `{ code, payment_id, reason }`). */
|
|
2075
|
+
details;
|
|
2076
|
+
constructor(message, status, code, details = null) {
|
|
2074
2077
|
super(message);
|
|
2075
2078
|
this.name = "StorefrontHttpError";
|
|
2076
2079
|
this.status = status;
|
|
2077
2080
|
this.code = code;
|
|
2081
|
+
this.details = details;
|
|
2078
2082
|
}
|
|
2079
2083
|
};
|
|
2084
|
+
const parseErrorBody = (text) => {
|
|
2085
|
+
try {
|
|
2086
|
+
const json = JSON.parse(text);
|
|
2087
|
+
if (!json || typeof json !== "object") return null;
|
|
2088
|
+
return json;
|
|
2089
|
+
} catch {
|
|
2090
|
+
return null;
|
|
2091
|
+
}
|
|
2092
|
+
};
|
|
2093
|
+
/** Error from a failed response body; message stays the raw body text. */
|
|
2094
|
+
const toStorefrontHttpError = (text, status) => {
|
|
2095
|
+
const body = parseErrorBody(text);
|
|
2096
|
+
const raw = body?.details;
|
|
2097
|
+
const details = raw !== null && typeof raw === "object" ? raw : null;
|
|
2098
|
+
const detailsCode = typeof details?.code === "string" ? details.code : null;
|
|
2099
|
+
const bodyCode = typeof body?.code === "string" ? body.code : void 0;
|
|
2100
|
+
return new StorefrontHttpError(text, status, detailsCode ?? bodyCode, details);
|
|
2101
|
+
};
|
|
2080
2102
|
const isStorefrontNotFound = (error) => {
|
|
2081
2103
|
return error instanceof StorefrontHttpError && error.status === 404;
|
|
2082
2104
|
};
|
|
@@ -2117,14 +2139,20 @@ const storefrontRequest = async (path, init = {}, options = {}) => {
|
|
|
2117
2139
|
};
|
|
2118
2140
|
const storefrontJson = async (path, init = {}, options = {}) => {
|
|
2119
2141
|
const response = await storefrontRequest(path, init, options);
|
|
2120
|
-
if (!response.ok)
|
|
2142
|
+
if (!response.ok) {
|
|
2143
|
+
const body = await response.text();
|
|
2144
|
+
throw toStorefrontHttpError(body || response.statusText, response.status);
|
|
2145
|
+
}
|
|
2121
2146
|
const json = await response.json();
|
|
2122
2147
|
if (json.data === void 0) throw new Error("Storefront response missing data");
|
|
2123
2148
|
return json.data;
|
|
2124
2149
|
};
|
|
2125
2150
|
const storefrontVoid = async (path, init = {}, options = {}) => {
|
|
2126
2151
|
const response = await storefrontRequest(path, init, options);
|
|
2127
|
-
if (!response.ok)
|
|
2152
|
+
if (!response.ok) {
|
|
2153
|
+
const body = await response.text();
|
|
2154
|
+
throw toStorefrontHttpError(body || response.statusText, response.status);
|
|
2155
|
+
}
|
|
2128
2156
|
};
|
|
2129
2157
|
const parseNdjsonDataLine = (line) => {
|
|
2130
2158
|
const trimmed = line.trim();
|
|
@@ -2156,7 +2184,10 @@ const readNdjsonLines = async function* (body) {
|
|
|
2156
2184
|
/** Parse an NDJSON storefront body and yield each `{ data }` payload. */
|
|
2157
2185
|
const storefrontNdjson = async function* (path, init = {}, options = {}) {
|
|
2158
2186
|
const response = await storefrontRequest(path, init, options);
|
|
2159
|
-
if (!response.ok)
|
|
2187
|
+
if (!response.ok) {
|
|
2188
|
+
const body = await response.text();
|
|
2189
|
+
throw toStorefrontHttpError(body || response.statusText, response.status);
|
|
2190
|
+
}
|
|
2160
2191
|
if (response.body) {
|
|
2161
2192
|
for await (const line of readNdjsonLines(response.body)) {
|
|
2162
2193
|
const data = parseNdjsonDataLine(line);
|
|
@@ -2481,7 +2512,7 @@ const placeOrder = (id, body = {}, options) => {
|
|
|
2481
2512
|
options
|
|
2482
2513
|
]);
|
|
2483
2514
|
};
|
|
2484
|
-
/** Records (
|
|
2515
|
+
/** Records one tender attempt result (idempotent by `attempt_reference`). */
|
|
2485
2516
|
const recordOrderPayment = (id, body, options) => {
|
|
2486
2517
|
const init = {
|
|
2487
2518
|
method: "POST",
|
|
@@ -2493,6 +2524,50 @@ const recordOrderPayment = (id, body, options) => {
|
|
|
2493
2524
|
options
|
|
2494
2525
|
]);
|
|
2495
2526
|
};
|
|
2527
|
+
const postJson$1 = (path, body, options) => {
|
|
2528
|
+
return storefrontJson(path, {
|
|
2529
|
+
method: "POST",
|
|
2530
|
+
body: JSON.stringify(body)
|
|
2531
|
+
}, options);
|
|
2532
|
+
};
|
|
2533
|
+
const tenderPath = (id, tenderId, action) => {
|
|
2534
|
+
const tender = encodeURIComponent(tenderId);
|
|
2535
|
+
return `${orderPath(id)}/tenders/${tender}/${action}`;
|
|
2536
|
+
};
|
|
2537
|
+
/** Creates or replaces the DRAFT intent; the server computes amounts. */
|
|
2538
|
+
const putPaymentIntent = (id, body, options) => {
|
|
2539
|
+
const init = {
|
|
2540
|
+
method: "PUT",
|
|
2541
|
+
body: JSON.stringify(body)
|
|
2542
|
+
};
|
|
2543
|
+
return storefrontJson(...[
|
|
2544
|
+
`${orderPath(id)}/payment-intent`,
|
|
2545
|
+
init,
|
|
2546
|
+
options
|
|
2547
|
+
]);
|
|
2548
|
+
};
|
|
2549
|
+
/** Discards the DRAFT intent (`409 PAYMENT_INTENT_LOCKED` once placed). */
|
|
2550
|
+
const deletePaymentIntent = (id, options) => {
|
|
2551
|
+
return storefrontJson(...[
|
|
2552
|
+
`${orderPath(id)}/payment-intent`,
|
|
2553
|
+
{ method: "DELETE" },
|
|
2554
|
+
options
|
|
2555
|
+
]);
|
|
2556
|
+
};
|
|
2557
|
+
/** New tender with `replaces_tender_id`; only over a `FAILED` tender (D25). */
|
|
2558
|
+
const replaceTender = (id, tenderId, body, options) => {
|
|
2559
|
+
const path = tenderPath(id, tenderId, "replace");
|
|
2560
|
+
return postJson$1(path, body, options);
|
|
2561
|
+
};
|
|
2562
|
+
const voidTender = (id, tenderId, body, options) => {
|
|
2563
|
+
const path = tenderPath(id, tenderId, "void");
|
|
2564
|
+
return postJson$1(path, body, options);
|
|
2565
|
+
};
|
|
2566
|
+
/** Full refund without `amount` / `items`; partial by amount or units. */
|
|
2567
|
+
const refundTender = (id, tenderId, body, options) => {
|
|
2568
|
+
const path = tenderPath(id, tenderId, "refund");
|
|
2569
|
+
return postJson$1(path, body, options);
|
|
2570
|
+
};
|
|
2496
2571
|
const getOrderFulfillment = (id, options) => {
|
|
2497
2572
|
return storefrontJson(...[
|
|
2498
2573
|
`${orderPath(id)}/fulfillment`,
|
|
@@ -2562,14 +2637,25 @@ const validateOrder = async (body, options) => {
|
|
|
2562
2637
|
const details = parseValidationDetails(parsed);
|
|
2563
2638
|
if (details) throw new OrderValidationError(details.issues, details.lines);
|
|
2564
2639
|
}
|
|
2565
|
-
throw
|
|
2640
|
+
throw toStorefrontHttpError(text || response.statusText, response.status);
|
|
2566
2641
|
};
|
|
2567
2642
|
//#endregion
|
|
2568
2643
|
//#region src/services/payments.ts
|
|
2569
|
-
|
|
2644
|
+
/**
|
|
2645
|
+
* Payments a customer can pick at checkout. `REDEMPTION` payments are
|
|
2646
|
+
* applied through the order's `redeem` entries, never chosen as a method.
|
|
2647
|
+
*/
|
|
2648
|
+
const selectablePayments = (payments) => {
|
|
2649
|
+
return payments.filter((payment) => isRealPaymentMethod$1(payment.method.method));
|
|
2650
|
+
};
|
|
2651
|
+
/** Lists payments without `REDEMPTION` unless `includeRedemptions` is set. */
|
|
2652
|
+
const listPayments = async (input, options = {}) => {
|
|
2653
|
+
const { includeRedemptions = false, ...http } = options;
|
|
2570
2654
|
const params = new URLSearchParams();
|
|
2571
2655
|
for (const [key, value] of Object.entries(input)) if (value) params.set(key, value);
|
|
2572
|
-
|
|
2656
|
+
const payments = await storefrontJson(`/api/storefront/v1/payments?${params.toString()}`, {}, http);
|
|
2657
|
+
if (includeRedemptions) return payments;
|
|
2658
|
+
return selectablePayments(payments);
|
|
2573
2659
|
};
|
|
2574
2660
|
/** Server half of an adapter (`POST /payments/:id/operations/:operation`). */
|
|
2575
2661
|
const callPaymentOperation = (paymentId, operation, body, options) => {
|
|
@@ -2857,6 +2943,11 @@ const services = {
|
|
|
2857
2943
|
validate: validateOrder,
|
|
2858
2944
|
place: placeOrder,
|
|
2859
2945
|
recordPayment: recordOrderPayment,
|
|
2946
|
+
putPaymentIntent,
|
|
2947
|
+
deletePaymentIntent,
|
|
2948
|
+
replaceTender,
|
|
2949
|
+
voidTender,
|
|
2950
|
+
refundTender,
|
|
2860
2951
|
getFulfillment: getOrderFulfillment,
|
|
2861
2952
|
listFulfillments: listOrderFulfillments
|
|
2862
2953
|
},
|
|
@@ -3043,21 +3134,18 @@ const createFlushController = (runtime) => {
|
|
|
3043
3134
|
totals: order.totals,
|
|
3044
3135
|
metadata: order.metadata
|
|
3045
3136
|
};
|
|
3046
|
-
const b5 = {
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
const
|
|
3137
|
+
const b5 = {
|
|
3138
|
+
customer: order.customer,
|
|
3139
|
+
redeem: order.redeem
|
|
3140
|
+
};
|
|
3141
|
+
const b6 = { redemption_steps: order.redemption_steps };
|
|
3051
3142
|
return {
|
|
3052
3143
|
...b1,
|
|
3053
3144
|
...b2,
|
|
3054
3145
|
...b3,
|
|
3055
3146
|
...b4,
|
|
3056
3147
|
...b5,
|
|
3057
|
-
...b6
|
|
3058
|
-
...b7,
|
|
3059
|
-
...b8,
|
|
3060
|
-
...b9
|
|
3148
|
+
...b6
|
|
3061
3149
|
};
|
|
3062
3150
|
};
|
|
3063
3151
|
const insertBody = (order) => {
|
|
@@ -3079,10 +3167,8 @@ const createFlushController = (runtime) => {
|
|
|
3079
3167
|
totals: order.totals,
|
|
3080
3168
|
metadata: order.metadata
|
|
3081
3169
|
};
|
|
3082
|
-
const b7 = {
|
|
3083
|
-
const b8 = {
|
|
3084
|
-
const b9 = { redeem: order.redeem };
|
|
3085
|
-
const b10 = { redemption_steps: order.redemption_steps };
|
|
3170
|
+
const b7 = { redeem: order.redeem };
|
|
3171
|
+
const b8 = { redemption_steps: order.redemption_steps };
|
|
3086
3172
|
return {
|
|
3087
3173
|
...b1,
|
|
3088
3174
|
...b2,
|
|
@@ -3091,9 +3177,7 @@ const createFlushController = (runtime) => {
|
|
|
3091
3177
|
...b5,
|
|
3092
3178
|
...b6,
|
|
3093
3179
|
...b7,
|
|
3094
|
-
...b8
|
|
3095
|
-
...b9,
|
|
3096
|
-
...b10
|
|
3180
|
+
...b8
|
|
3097
3181
|
};
|
|
3098
3182
|
};
|
|
3099
3183
|
const flushNow = () => {
|
|
@@ -3357,1335 +3441,1634 @@ const emptyLines = () => {
|
|
|
3357
3441
|
return [];
|
|
3358
3442
|
};
|
|
3359
3443
|
//#endregion
|
|
3360
|
-
//#region src/order/
|
|
3361
|
-
|
|
3362
|
-
const
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
|
|
3371
|
-
|
|
3372
|
-
|
|
3373
|
-
|
|
3374
|
-
|
|
3375
|
-
|
|
3376
|
-
|
|
3377
|
-
|
|
3378
|
-
|
|
3379
|
-
|
|
3380
|
-
const
|
|
3381
|
-
|
|
3382
|
-
|
|
3383
|
-
|
|
3384
|
-
|
|
3385
|
-
const run = () => {
|
|
3386
|
-
startedAt = Date.now();
|
|
3387
|
-
timer = setTimeout(onExpire, Math.max(remaining, 0));
|
|
3444
|
+
//#region src/order/totals.ts
|
|
3445
|
+
/** Sum root line group totals into order totals (Big.js). */
|
|
3446
|
+
const recomputeTotals = (lines, currency = "USD") => {
|
|
3447
|
+
let subtotal = new Big(0);
|
|
3448
|
+
let discountTotal = new Big(0);
|
|
3449
|
+
let taxTotal = new Big(0);
|
|
3450
|
+
let total = new Big(0);
|
|
3451
|
+
for (const line of lines) {
|
|
3452
|
+
if (line.parent_uid != null) continue;
|
|
3453
|
+
subtotal = subtotal.plus(line.group_subtotal);
|
|
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()
|
|
3388
3469
|
};
|
|
3389
|
-
const
|
|
3390
|
-
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
|
|
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()
|
|
3394
3478
|
};
|
|
3395
3479
|
return {
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
paused += 1;
|
|
3400
|
-
},
|
|
3401
|
-
resume: () => {
|
|
3402
|
-
paused -= 1;
|
|
3403
|
-
if (paused === 0) run();
|
|
3404
|
-
},
|
|
3405
|
-
clear: stop
|
|
3480
|
+
...b1,
|
|
3481
|
+
...b2,
|
|
3482
|
+
...b3
|
|
3406
3483
|
};
|
|
3407
3484
|
};
|
|
3408
|
-
const
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
});
|
|
3420
|
-
});
|
|
3421
|
-
};
|
|
3422
|
-
var PaymentUserTimeout = class extends Error {};
|
|
3423
|
-
const timedUI = (ui, deadline, interactionMs) => {
|
|
3424
|
-
const wrap = (call) => {
|
|
3425
|
-
deadline.pause();
|
|
3426
|
-
return withUserTimeout(call(), interactionMs).finally(() => deadline.resume());
|
|
3485
|
+
const emptyTotals = (currency = "USD") => {
|
|
3486
|
+
const b1 = {
|
|
3487
|
+
currency,
|
|
3488
|
+
subtotal: 0,
|
|
3489
|
+
discount_rate: 0,
|
|
3490
|
+
discount_total: 0
|
|
3491
|
+
};
|
|
3492
|
+
const b2 = {
|
|
3493
|
+
tax_rate: 0,
|
|
3494
|
+
tax_total: 0,
|
|
3495
|
+
total: 0
|
|
3427
3496
|
};
|
|
3428
3497
|
return {
|
|
3429
|
-
|
|
3430
|
-
|
|
3431
|
-
select: (input) => {
|
|
3432
|
-
return wrap(() => ui.select(input));
|
|
3433
|
-
},
|
|
3434
|
-
input: (input) => wrap(() => ui.input(input)),
|
|
3435
|
-
confirm: (input) => wrap(() => ui.confirm(input))
|
|
3498
|
+
...b1,
|
|
3499
|
+
...b2
|
|
3436
3500
|
};
|
|
3437
3501
|
};
|
|
3438
|
-
|
|
3439
|
-
|
|
3440
|
-
|
|
3441
|
-
|
|
3442
|
-
|
|
3443
|
-
|
|
3444
|
-
|
|
3445
|
-
|
|
3446
|
-
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
|
|
3453
|
-
|
|
3454
|
-
const scoped = {
|
|
3455
|
-
...ctx,
|
|
3456
|
-
ui,
|
|
3457
|
-
signal: controller.signal
|
|
3502
|
+
//#endregion
|
|
3503
|
+
//#region src/order/redemptions.ts
|
|
3504
|
+
const ORDER_KEY = "ORDER";
|
|
3505
|
+
/** Points / balance paid with a REDEMPTION tender, not a discount (D16). */
|
|
3506
|
+
const isPaymentRedeem = (redeem) => {
|
|
3507
|
+
return Boolean(redeem.payment_id);
|
|
3508
|
+
};
|
|
3509
|
+
const buildPaymentRedeem = (input) => {
|
|
3510
|
+
const { reward_id, quantity, payment_id, amount, points } = input;
|
|
3511
|
+
if (amount === void 0 || !(amount > 0)) throw new Error("Redeem with payment_id requires amount > 0");
|
|
3512
|
+
if (points === void 0 || !(points >= 0)) throw new Error("Redeem with payment_id requires points ≥ 0");
|
|
3513
|
+
const redeem = {
|
|
3514
|
+
reward_id,
|
|
3515
|
+
quantity,
|
|
3516
|
+
payment_id,
|
|
3517
|
+
amount
|
|
3458
3518
|
};
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
|
|
3463
|
-
deadline.clear();
|
|
3464
|
-
parent.removeEventListener("abort", abort);
|
|
3465
|
-
}
|
|
3519
|
+
redeem.points = points;
|
|
3520
|
+
if (input.program !== void 0) redeem.program = input.program;
|
|
3521
|
+
if (input.expires_at !== void 0) redeem.expires_at = input.expires_at;
|
|
3522
|
+
return redeem;
|
|
3466
3523
|
};
|
|
3467
|
-
|
|
3468
|
-
|
|
3469
|
-
|
|
3470
|
-
|
|
3471
|
-
|
|
3472
|
-
|
|
3473
|
-
|
|
3474
|
-
|
|
3475
|
-
|
|
3476
|
-
|
|
3477
|
-
|
|
3524
|
+
/** Same rules as `vOrders.redeem()`; throws on invalid input. */
|
|
3525
|
+
const buildRedeem = (input) => {
|
|
3526
|
+
const { reward_id, quantity, discount_rate, discount_amount } = input;
|
|
3527
|
+
const { before_taxes, expires_at } = input;
|
|
3528
|
+
if (!reward_id.trim()) throw new Error("Redeem requires reward_id");
|
|
3529
|
+
if (!Number.isInteger(quantity) || quantity < 1) throw new Error("Redeem quantity must be an integer ≥ 1");
|
|
3530
|
+
if (expires_at !== void 0 && Number.isNaN(Date.parse(expires_at))) throw new Error("Redeem expires_at must be an ISO datetime");
|
|
3531
|
+
if (input.payment_id) return buildPaymentRedeem(input);
|
|
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
|
|
3478
3539
|
};
|
|
3540
|
+
if (discount_rate !== void 0) redeem.discount_rate = discount_rate;
|
|
3541
|
+
if (discount_amount !== void 0) redeem.discount_amount = discount_amount;
|
|
3542
|
+
if (before_taxes !== void 0) redeem.before_taxes = before_taxes;
|
|
3543
|
+
if (expires_at !== void 0) redeem.expires_at = expires_at;
|
|
3544
|
+
return redeem;
|
|
3479
3545
|
};
|
|
3480
|
-
|
|
3481
|
-
|
|
3482
|
-
|
|
3483
|
-
|
|
3484
|
-
|
|
3485
|
-
attempt,
|
|
3486
|
-
elapsed_ms
|
|
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
|
|
3487
3551
|
};
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
|
|
3491
|
-
|
|
3492
|
-
|
|
3493
|
-
|
|
3494
|
-
|
|
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
|
+
return {
|
|
3556
|
+
...item,
|
|
3557
|
+
quantity: rest,
|
|
3558
|
+
amount,
|
|
3559
|
+
points
|
|
3495
3560
|
};
|
|
3496
|
-
return createPaymentError(code, extra);
|
|
3497
|
-
};
|
|
3498
|
-
/** `start` that timed out after leaving the device is ambiguous. */
|
|
3499
|
-
const startError = (error) => {
|
|
3500
|
-
if (error.code !== "TIMEOUT") return error;
|
|
3501
|
-
return createPaymentError("AMBIGUOUS", {
|
|
3502
|
-
attempt: error.attempt,
|
|
3503
|
-
elapsed_ms: error.elapsed_ms
|
|
3504
|
-
});
|
|
3505
|
-
};
|
|
3506
|
-
const isFinal = (result) => result.status !== "PENDING";
|
|
3507
|
-
const callOperation = async (ctx, operation, result, clock) => {
|
|
3508
|
-
const startedAt = clock.now();
|
|
3509
|
-
try {
|
|
3510
|
-
return await runTimed(ctx, (scoped) => operation(scoped, result));
|
|
3511
|
-
} catch (error) {
|
|
3512
|
-
return failed(ctx, errorOf(ctx, error, startedAt, clock));
|
|
3513
|
-
}
|
|
3514
3561
|
};
|
|
3515
|
-
/**
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
3520
|
-
|
|
3521
|
-
|
|
3522
|
-
|
|
3523
|
-
|
|
3524
|
-
|
|
3525
|
-
|
|
3526
|
-
|
|
3562
|
+
/**
|
|
3563
|
+
* Takes `quantity` units of `reward_id` off the latest entries first, so the
|
|
3564
|
+
* earlier redeems keep their step position. Entries that reach 0 are removed.
|
|
3565
|
+
*/
|
|
3566
|
+
const subtractRedeem = (items, reward_id, quantity) => {
|
|
3567
|
+
if (!Number.isInteger(quantity) || quantity < 1) throw new Error("Redeem quantity must be an integer ≥ 1");
|
|
3568
|
+
let pending = quantity;
|
|
3569
|
+
const kept = [];
|
|
3570
|
+
for (let i = items.length - 1; i >= 0; i--) {
|
|
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));
|
|
3527
3580
|
}
|
|
3528
|
-
|
|
3529
|
-
return failed(ctx, error, { needs_review: true });
|
|
3581
|
+
return kept;
|
|
3530
3582
|
};
|
|
3531
|
-
|
|
3532
|
-
|
|
3533
|
-
|
|
3534
|
-
|
|
3535
|
-
|
|
3536
|
-
|
|
3537
|
-
|
|
3538
|
-
|
|
3539
|
-
|
|
3540
|
-
if (
|
|
3541
|
-
const
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
|
|
3583
|
+
const redemptionTargetKey = (target) => {
|
|
3584
|
+
if (target.target === "ORDER") return ORDER_KEY;
|
|
3585
|
+
return `LINE:${target.target_id ?? ""}`;
|
|
3586
|
+
};
|
|
3587
|
+
/** Only root lines own redeems; child-line redeems are never applied. */
|
|
3588
|
+
const redeemPools = (lines, redeem) => {
|
|
3589
|
+
const pools = /* @__PURE__ */ new Map();
|
|
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);
|
|
3546
3602
|
}
|
|
3547
|
-
|
|
3548
|
-
const
|
|
3549
|
-
|
|
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;
|
|
3550
3610
|
};
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
|
|
3555
|
-
|
|
3556
|
-
|
|
3557
|
-
|
|
3611
|
+
/**
|
|
3612
|
+
* Pairs the k-th step of a target with the k-th redeem of that target, in
|
|
3613
|
+
* step order. Steps without a matching redeem are dropped; redeems without a
|
|
3614
|
+
* step are appended (lines first, then order).
|
|
3615
|
+
*/
|
|
3616
|
+
const sequenceRedeems = (lines, redeem, steps) => {
|
|
3617
|
+
const pools = redeemPools(lines, redeem);
|
|
3618
|
+
const cursors = /* @__PURE__ */ new Map();
|
|
3619
|
+
const sequence = [];
|
|
3620
|
+
for (const step of steps) {
|
|
3621
|
+
const key = redemptionTargetKey(step);
|
|
3622
|
+
const at = cursors.get(key) ?? 0;
|
|
3623
|
+
const next = pools.get(key)?.[at];
|
|
3624
|
+
if (!next || next.redeem.reward_id !== step.reward_id) continue;
|
|
3625
|
+
cursors.set(key, at + 1);
|
|
3626
|
+
sequence.push(next);
|
|
3558
3627
|
}
|
|
3628
|
+
for (const [key, pool] of pools) sequence.push(...pool.slice(cursors.get(key) ?? 0));
|
|
3629
|
+
return sequence;
|
|
3559
3630
|
};
|
|
3560
|
-
const
|
|
3631
|
+
const minBig = (a, b) => {
|
|
3632
|
+
return a.lt(b) ? a : b;
|
|
3633
|
+
};
|
|
3634
|
+
const rateOf = (discount, base) => {
|
|
3635
|
+
if (base.lte(0)) return 0;
|
|
3636
|
+
return minBig(discount.div(base), new Big(1)).toNumber();
|
|
3637
|
+
};
|
|
3638
|
+
const snapshot = (roots, quantity) => {
|
|
3639
|
+
let subtotal = new Big(0);
|
|
3640
|
+
let total = new Big(0);
|
|
3641
|
+
let discount = new Big(0);
|
|
3642
|
+
for (const root of roots) {
|
|
3643
|
+
subtotal = subtotal.plus(root.subtotal);
|
|
3644
|
+
total = total.plus(root.total);
|
|
3645
|
+
discount = discount.plus(root.discount);
|
|
3646
|
+
}
|
|
3561
3647
|
const b1 = {
|
|
3562
|
-
|
|
3563
|
-
|
|
3564
|
-
};
|
|
3565
|
-
const b2 = { attemptReference: ctx.attemptReference };
|
|
3566
|
-
const b3 = {
|
|
3567
|
-
environment: ctx.environment,
|
|
3568
|
-
config: ctx.config
|
|
3569
|
-
};
|
|
3570
|
-
const b4 = {
|
|
3571
|
-
deviceSettings: ctx.deviceSettings,
|
|
3572
|
-
policy: ctx.policy
|
|
3573
|
-
};
|
|
3574
|
-
const b5 = {
|
|
3575
|
-
ui: ctx.ui,
|
|
3576
|
-
storage: ctx.storage,
|
|
3577
|
-
server: ctx.server
|
|
3648
|
+
value: total.toNumber(),
|
|
3649
|
+
quantity
|
|
3578
3650
|
};
|
|
3651
|
+
const b2 = { discount_amount: discount.toNumber() };
|
|
3652
|
+
const b3 = { discount_rate: rateOf(discount, subtotal) };
|
|
3579
3653
|
return {
|
|
3580
3654
|
...b1,
|
|
3581
3655
|
...b2,
|
|
3582
|
-
...b3
|
|
3583
|
-
...b4,
|
|
3584
|
-
...b5,
|
|
3585
|
-
signal: ctx.signal
|
|
3656
|
+
...b3
|
|
3586
3657
|
};
|
|
3587
3658
|
};
|
|
3588
|
-
const
|
|
3589
|
-
const
|
|
3590
|
-
|
|
3591
|
-
const done = await close(toTerminalContext(ctx)).catch(() => null);
|
|
3592
|
-
return Boolean(done?.ok);
|
|
3659
|
+
const netOf = (root) => {
|
|
3660
|
+
const net = root.total.minus(root.tax);
|
|
3661
|
+
return net.gt(0) ? net : new Big(0);
|
|
3593
3662
|
};
|
|
3594
|
-
/**
|
|
3595
|
-
|
|
3596
|
-
|
|
3597
|
-
* polling for actions and resolution of ambiguous results.
|
|
3598
|
-
*/
|
|
3599
|
-
const runPaymentAttempt = async (adapter, ctx, options = {}) => {
|
|
3600
|
-
const clock = options.clock ?? defaultClock;
|
|
3601
|
-
return withTerminalLock(ctx.paymentId, async () => {
|
|
3602
|
-
let batchClosed = false;
|
|
3603
|
-
let attempt = 1;
|
|
3604
|
-
while (true) {
|
|
3605
|
-
const current = {
|
|
3606
|
-
...ctx,
|
|
3607
|
-
attempt
|
|
3608
|
-
};
|
|
3609
|
-
const result = await runStart(adapter, current, clock);
|
|
3610
|
-
const code = result.error?.code;
|
|
3611
|
-
if (code === "BATCH_CLOSE_REQUIRED" && !batchClosed) {
|
|
3612
|
-
batchClosed = true;
|
|
3613
|
-
if (await closeBatch(adapter, current)) continue;
|
|
3614
|
-
}
|
|
3615
|
-
if (code === "AMBIGUOUS") {
|
|
3616
|
-
await options.onProgress?.({
|
|
3617
|
-
...result,
|
|
3618
|
-
status: "PENDING"
|
|
3619
|
-
});
|
|
3620
|
-
return resolveAmbiguous(adapter, current, result, clock);
|
|
3621
|
-
}
|
|
3622
|
-
const canRetry = attempt < ctx.policy.max_attempts;
|
|
3623
|
-
if (result.status === "FAILED" && result.error?.retryable && canRetry) {
|
|
3624
|
-
attempt += 1;
|
|
3625
|
-
continue;
|
|
3626
|
-
}
|
|
3627
|
-
if (result.status !== "PENDING") return result;
|
|
3628
|
-
await options.onProgress?.(result);
|
|
3629
|
-
return pollPending(adapter, current, result, clock, options);
|
|
3630
|
-
}
|
|
3631
|
-
});
|
|
3663
|
+
/** What a redeem can still discount on a root: pre-tax or with taxes. */
|
|
3664
|
+
const baseOf = (root, beforeTaxes) => {
|
|
3665
|
+
return beforeTaxes ? netOf(root) : root.total;
|
|
3632
3666
|
};
|
|
3633
|
-
/**
|
|
3634
|
-
const
|
|
3635
|
-
|
|
3636
|
-
|
|
3637
|
-
|
|
3638
|
-
|
|
3639
|
-
|
|
3640
|
-
if (kind === "manual" || !operation) {
|
|
3641
|
-
const error = createPaymentError("MANUAL_REVERSAL_REQUIRED");
|
|
3642
|
-
const m1 = {
|
|
3643
|
-
...result.metadata,
|
|
3644
|
-
error,
|
|
3645
|
-
needs_review: true
|
|
3646
|
-
};
|
|
3647
|
-
return {
|
|
3648
|
-
kind: "manual",
|
|
3649
|
-
result: {
|
|
3650
|
-
...result,
|
|
3651
|
-
metadata: m1,
|
|
3652
|
-
error
|
|
3653
|
-
}
|
|
3654
|
-
};
|
|
3667
|
+
/** Before taxes, taxes shrink with the pre-tax value they were charged on. */
|
|
3668
|
+
const discountRoot = (root, amount, beforeTaxes) => {
|
|
3669
|
+
if (amount.lte(0)) return;
|
|
3670
|
+
root.discount = root.discount.plus(amount);
|
|
3671
|
+
if (!beforeTaxes) {
|
|
3672
|
+
root.total = root.total.minus(amount);
|
|
3673
|
+
return;
|
|
3655
3674
|
}
|
|
3656
|
-
const
|
|
3657
|
-
const
|
|
3658
|
-
|
|
3659
|
-
|
|
3660
|
-
result: next
|
|
3661
|
-
};
|
|
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);
|
|
3662
3679
|
};
|
|
3663
|
-
|
|
3664
|
-
|
|
3665
|
-
const
|
|
3666
|
-
|
|
3667
|
-
|
|
3668
|
-
|
|
3669
|
-
|
|
3670
|
-
|
|
3680
|
+
/** Splits an order discount across roots by what each can still discount. */
|
|
3681
|
+
const discountOrder = (roots, amount, beforeTaxes) => {
|
|
3682
|
+
const bases = roots.map((root) => baseOf(root, beforeTaxes));
|
|
3683
|
+
const sum = bases.reduce((acc, base) => acc.plus(base), new Big(0));
|
|
3684
|
+
if (sum.lte(0) || amount.lte(0)) return;
|
|
3685
|
+
let last = -1;
|
|
3686
|
+
bases.forEach((base, index) => {
|
|
3687
|
+
if (base.gt(0)) last = index;
|
|
3688
|
+
});
|
|
3689
|
+
let rest = amount;
|
|
3690
|
+
roots.forEach((root, index) => {
|
|
3691
|
+
const base = bases[index];
|
|
3692
|
+
if (base.lte(0)) return;
|
|
3693
|
+
const share = amount.times(base).div(sum);
|
|
3694
|
+
const part = minBig(index === last ? rest : share, base);
|
|
3695
|
+
rest = rest.minus(part);
|
|
3696
|
+
discountRoot(root, part, beforeTaxes);
|
|
3697
|
+
});
|
|
3671
3698
|
};
|
|
3672
|
-
|
|
3673
|
-
|
|
3674
|
-
|
|
3675
|
-
|
|
3676
|
-
|
|
3677
|
-
|
|
3699
|
+
/**
|
|
3700
|
+
* Rate compounds once per reward unit over the remaining value; amount is
|
|
3701
|
+
* per unit. With both, the rate discount is capped at `amount × quantity`.
|
|
3702
|
+
*/
|
|
3703
|
+
const redeemDiscount = (redeem, remaining) => {
|
|
3704
|
+
const { quantity, discount_rate, discount_amount } = redeem;
|
|
3705
|
+
let cap = null;
|
|
3706
|
+
if (discount_amount !== void 0) cap = new Big(discount_amount).times(quantity);
|
|
3707
|
+
if (discount_rate === void 0) return cap ?? new Big(0);
|
|
3708
|
+
const kept = new Big(1).minus(discount_rate).pow(quantity);
|
|
3709
|
+
const byRate = remaining.times(new Big(1).minus(kept));
|
|
3710
|
+
return cap ? minBig(byRate, cap) : byRate;
|
|
3678
3711
|
};
|
|
3679
|
-
/**
|
|
3680
|
-
|
|
3681
|
-
|
|
3682
|
-
|
|
3683
|
-
|
|
3684
|
-
|
|
3685
|
-
|
|
3686
|
-
|
|
3712
|
+
/**
|
|
3713
|
+
* Root group money rebuilt from the line itself plus its children, i.e. the
|
|
3714
|
+
* menu price before any redemption. Child lines are never discounted.
|
|
3715
|
+
*/
|
|
3716
|
+
const stripRedemptionLines = (lines) => {
|
|
3717
|
+
const children = /* @__PURE__ */ new Map();
|
|
3718
|
+
for (const line of lines) {
|
|
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);
|
|
3723
|
+
}
|
|
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
|
+
});
|
|
3687
3764
|
};
|
|
3688
|
-
const
|
|
3689
|
-
const
|
|
3690
|
-
const
|
|
3691
|
-
|
|
3765
|
+
const withRootMoney = (line, money) => {
|
|
3766
|
+
const qty = new Big(line.quantity);
|
|
3767
|
+
const discount = new Big(line.group_discount).plus(money.discount);
|
|
3768
|
+
const u1 = { unit_group_discount: discount.div(qty).toNumber() };
|
|
3769
|
+
const u2 = { unit_group_tax_total: money.tax.div(qty).toNumber() };
|
|
3770
|
+
const u3 = { unit_group_total: money.total.div(qty).toNumber() };
|
|
3771
|
+
const g1 = { group_discount: discount.toNumber() };
|
|
3772
|
+
const g2 = { group_tax_total: money.tax.toNumber() };
|
|
3773
|
+
const g3 = { group_total: money.total.toNumber() };
|
|
3774
|
+
return {
|
|
3775
|
+
...line,
|
|
3776
|
+
...u1,
|
|
3777
|
+
...u2,
|
|
3778
|
+
...u3,
|
|
3779
|
+
...g1,
|
|
3780
|
+
...g2,
|
|
3781
|
+
...g3
|
|
3782
|
+
};
|
|
3692
3783
|
};
|
|
3693
|
-
|
|
3694
|
-
|
|
3695
|
-
|
|
3696
|
-
|
|
3784
|
+
/**
|
|
3785
|
+
* Replays every redeem in persisted step order over the root lines' group
|
|
3786
|
+
* money. Line redeems discount their root; order redeems are prorated over
|
|
3787
|
+
* every root. A discount never exceeds what is left on its target.
|
|
3788
|
+
*/
|
|
3789
|
+
const replayRedemptions = (lines, redeem, steps) => {
|
|
3790
|
+
const base = stripRedemptionLines(lines);
|
|
3791
|
+
const sequence = sequenceRedeems(base, redeem, steps);
|
|
3792
|
+
const moneyByUid = /* @__PURE__ */ new Map();
|
|
3793
|
+
for (const line of base) {
|
|
3794
|
+
if (line.parent_uid != null) continue;
|
|
3795
|
+
const b1 = { quantity: line.quantity };
|
|
3796
|
+
const b2 = { subtotal: new Big(line.group_subtotal) };
|
|
3797
|
+
const b3 = { tax: new Big(line.group_tax_total) };
|
|
3798
|
+
const b4 = {
|
|
3799
|
+
total: new Big(line.group_total),
|
|
3800
|
+
discount: new Big(0)
|
|
3801
|
+
};
|
|
3802
|
+
moneyByUid.set(line.uid, {
|
|
3803
|
+
...b1,
|
|
3804
|
+
...b2,
|
|
3805
|
+
...b3,
|
|
3806
|
+
...b4
|
|
3807
|
+
});
|
|
3808
|
+
}
|
|
3809
|
+
const roots = [...moneyByUid.values()];
|
|
3810
|
+
const redemption_steps = sequence.map((item) => {
|
|
3811
|
+
const id = item.target_id;
|
|
3812
|
+
const root = id ? moneyByUid.get(id) : void 0;
|
|
3813
|
+
const beforeTaxes = item.redeem.before_taxes ?? true;
|
|
3814
|
+
const before = snapshot(roots, 1);
|
|
3815
|
+
const targetBefore = root ? snapshot([root], root.quantity) : void 0;
|
|
3816
|
+
let left = new Big(0);
|
|
3817
|
+
if (root) left = baseOf(root, beforeTaxes);
|
|
3818
|
+
else for (const r of roots) left = left.plus(baseOf(r, beforeTaxes));
|
|
3819
|
+
const raw = redeemDiscount(item.redeem, left);
|
|
3820
|
+
const discount = minBig(raw, left);
|
|
3821
|
+
if (root) discountRoot(root, discount, beforeTaxes);
|
|
3822
|
+
else discountOrder(roots, discount, beforeTaxes);
|
|
3823
|
+
const action = discount.gt(0) ? "UPDATE" : "READ";
|
|
3824
|
+
const { reward_id, quantity } = item.redeem;
|
|
3825
|
+
const b1 = {
|
|
3826
|
+
reward_id,
|
|
3827
|
+
reward_quantity: quantity,
|
|
3828
|
+
target: item.target
|
|
3829
|
+
};
|
|
3830
|
+
const b2 = id ? { target_id: id } : {};
|
|
3831
|
+
const b3 = {
|
|
3832
|
+
action,
|
|
3833
|
+
discount_amount: discount.toNumber()
|
|
3834
|
+
};
|
|
3835
|
+
const b4 = {
|
|
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)
|
|
3843
|
+
};
|
|
3844
|
+
return {
|
|
3845
|
+
...b1,
|
|
3846
|
+
...b2,
|
|
3847
|
+
...b3,
|
|
3848
|
+
...b4,
|
|
3849
|
+
...b5
|
|
3850
|
+
};
|
|
3851
|
+
});
|
|
3697
3852
|
return {
|
|
3698
|
-
|
|
3699
|
-
|
|
3853
|
+
lines: base.map((line) => {
|
|
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
|
|
3700
3859
|
};
|
|
3701
3860
|
};
|
|
3702
|
-
|
|
3703
|
-
|
|
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);
|
|
3704
3866
|
return {
|
|
3705
|
-
|
|
3706
|
-
|
|
3707
|
-
|
|
3867
|
+
lines,
|
|
3868
|
+
redemption_steps,
|
|
3869
|
+
totals: recomputeTotals(lines, currency)
|
|
3708
3870
|
};
|
|
3709
3871
|
};
|
|
3872
|
+
//#endregion
|
|
3873
|
+
//#region src/order/tenders.ts
|
|
3874
|
+
/** Catalog provider of `REDEMPTION` payments (§3.3). */
|
|
3875
|
+
const DEFAULT_REDEMPTION_PROVIDER = "firespark";
|
|
3710
3876
|
/**
|
|
3711
|
-
*
|
|
3712
|
-
*
|
|
3877
|
+
* Digital tender the SDK charges (`DEVICE`, §7.3). `KIOSK`, `COUNTER`,
|
|
3878
|
+
* `DOOR` and `OTHER` are in person: the POS, an operator or the courier
|
|
3879
|
+
* collects them (§12).
|
|
3713
3880
|
*/
|
|
3714
|
-
|
|
3715
|
-
|
|
3716
|
-
|
|
3717
|
-
|
|
3718
|
-
|
|
3719
|
-
|
|
3720
|
-
|
|
3721
|
-
|
|
3722
|
-
|
|
3723
|
-
|
|
3724
|
-
|
|
3725
|
-
|
|
3726
|
-
|
|
3727
|
-
|
|
3728
|
-
|
|
3729
|
-
|
|
3730
|
-
|
|
3731
|
-
|
|
3732
|
-
|
|
3733
|
-
|
|
3734
|
-
|
|
3735
|
-
|
|
3736
|
-
|
|
3737
|
-
|
|
3881
|
+
const isDeviceCharged = (tender) => {
|
|
3882
|
+
return DIGITAL_COLLECT_AT.includes(tender.collect_at);
|
|
3883
|
+
};
|
|
3884
|
+
/** Most a REDEMPTION can cover: order total − tip − delivery (D18). */
|
|
3885
|
+
const redemptionCap = (order) => {
|
|
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);
|
|
3891
|
+
};
|
|
3892
|
+
/**
|
|
3893
|
+
* Trims payment redeems in application order so their sum fits the cap;
|
|
3894
|
+
* points shrink in proportion. Discount redeems are left untouched.
|
|
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
|
|
3738
3926
|
};
|
|
3739
|
-
|
|
3740
|
-
|
|
3741
|
-
|
|
3742
|
-
|
|
3743
|
-
return this.adapters.get(key) ?? null;
|
|
3744
|
-
}
|
|
3745
|
-
/** Device fields still missing for a payment on this device. */
|
|
3746
|
-
missingFields(payment) {
|
|
3747
|
-
const adapter = this.adapterFor(payment);
|
|
3748
|
-
if (!adapter) return [];
|
|
3749
|
-
const fromCatalog = record(payment.method.config).device_fields;
|
|
3750
|
-
const fields = adapter.deviceFields ?? fromCatalog;
|
|
3751
|
-
const settings = this.options.deviceSettings?.[payment.id];
|
|
3752
|
-
return missingDeviceFields(Array.isArray(fields) ? fields : [], settings);
|
|
3753
|
-
}
|
|
3754
|
-
/**
|
|
3755
|
-
* Merchant payments with an installed adapter (provider + method) that
|
|
3756
|
-
* are fully configured on this device.
|
|
3757
|
-
*/
|
|
3758
|
-
available(payments) {
|
|
3759
|
-
return payments.filter((payment) => {
|
|
3760
|
-
if (!this.adapterFor(payment)) return false;
|
|
3761
|
-
this.payments.set(payment.id, payment);
|
|
3762
|
-
return this.missingFields(payment).length === 0;
|
|
3763
|
-
});
|
|
3764
|
-
}
|
|
3765
|
-
remaining() {
|
|
3766
|
-
return remainingAmount(this.deps.getOrder());
|
|
3767
|
-
}
|
|
3768
|
-
server(payment, attempt) {
|
|
3769
|
-
const order_id = this.deps.getOrder().id;
|
|
3770
|
-
const http = this.deps.http;
|
|
3771
|
-
return { call: (operation, input) => {
|
|
3772
|
-
const body = {
|
|
3773
|
-
order_id,
|
|
3774
|
-
attempt_reference: attempt,
|
|
3775
|
-
input
|
|
3776
|
-
};
|
|
3777
|
-
const op = services.payments.operation;
|
|
3778
|
-
return op(payment.id, operation, body, http);
|
|
3779
|
-
} };
|
|
3780
|
-
}
|
|
3781
|
-
async chargeAmount(payment, value, server) {
|
|
3782
|
-
const config = record(payment.config);
|
|
3783
|
-
const orderCurrency = this.deps.getOrder().totals.currency;
|
|
3784
|
-
const chargeCurrency = config.charge_currency;
|
|
3785
|
-
if (!chargeCurrency || chargeCurrency === orderCurrency) return toChargeAmount({
|
|
3786
|
-
value,
|
|
3787
|
-
orderCurrency
|
|
3788
|
-
});
|
|
3789
|
-
const exchange = record(config.exchange_rate);
|
|
3790
|
-
let rate = typeof exchange.rate === "number" ? exchange.rate : null;
|
|
3791
|
-
if (exchange.source === "provider") {
|
|
3792
|
-
const input = {
|
|
3793
|
-
from: orderCurrency,
|
|
3794
|
-
to: chargeCurrency
|
|
3795
|
-
};
|
|
3796
|
-
rate = (await server.call("exchange_rate", input)).rate;
|
|
3797
|
-
}
|
|
3798
|
-
return toChargeAmount({
|
|
3799
|
-
value,
|
|
3800
|
-
orderCurrency,
|
|
3801
|
-
chargeCurrency,
|
|
3802
|
-
rate
|
|
3803
|
-
});
|
|
3804
|
-
}
|
|
3805
|
-
async buildContext(payment, amount, attemptReference, signal) {
|
|
3806
|
-
const order = this.deps.getOrder();
|
|
3807
|
-
const server = this.server(payment, attemptReference);
|
|
3808
|
-
const conversion = await this.chargeAmount(payment, amount, server);
|
|
3809
|
-
const config = record(payment.config);
|
|
3810
|
-
const methodConfig = record(payment.method.config);
|
|
3811
|
-
const share = order.totals.total > 0 ? amount / order.totals.total : 1;
|
|
3812
|
-
const decimals = currencyDecimals(order.totals.currency);
|
|
3813
|
-
const taxes = taxBreakdown(order.lines, {
|
|
3814
|
-
share,
|
|
3815
|
-
decimals
|
|
3816
|
-
});
|
|
3817
|
-
const info = order.customer.customer_info;
|
|
3818
|
-
const customer = {
|
|
3819
|
-
document: info.document_number ?? null,
|
|
3820
|
-
documentType: info.document_type ?? null,
|
|
3821
|
-
phone: info.phone ?? null
|
|
3927
|
+
const t2 = {
|
|
3928
|
+
collect_at: "OTHER",
|
|
3929
|
+
allocation: null,
|
|
3930
|
+
details
|
|
3822
3931
|
};
|
|
3823
|
-
|
|
3824
|
-
|
|
3825
|
-
|
|
3826
|
-
}
|
|
3827
|
-
|
|
3828
|
-
methodConfig,
|
|
3829
|
-
paymentConfig: config
|
|
3830
|
-
});
|
|
3831
|
-
const prefix = `payments:${payment.id}`;
|
|
3832
|
-
const storage = scopedPaymentStorage(this.storage, prefix);
|
|
3833
|
-
const deviceSettings = this.options.deviceSettings?.[payment.id] ?? {};
|
|
3834
|
-
const totals = order.totals;
|
|
3835
|
-
const o1 = {
|
|
3836
|
-
id: order.id,
|
|
3837
|
-
currency: totals.currency,
|
|
3838
|
-
totals
|
|
3839
|
-
};
|
|
3840
|
-
const b1 = {
|
|
3841
|
-
paymentId: payment.id,
|
|
3842
|
-
order: o1,
|
|
3843
|
-
customer
|
|
3844
|
-
};
|
|
3845
|
-
const b2 = {
|
|
3846
|
-
amount: conversion.amount,
|
|
3847
|
-
taxes,
|
|
3848
|
-
attemptReference
|
|
3849
|
-
};
|
|
3850
|
-
const b3 = {
|
|
3851
|
-
collectAt: this.collectAt(payment),
|
|
3852
|
-
attempt: 1
|
|
3853
|
-
};
|
|
3854
|
-
const b4 = {
|
|
3855
|
-
environment,
|
|
3856
|
-
config,
|
|
3857
|
-
deviceSettings,
|
|
3858
|
-
policy
|
|
3859
|
-
};
|
|
3860
|
-
const b5 = {
|
|
3861
|
-
ui: this.ui,
|
|
3862
|
-
storage,
|
|
3863
|
-
server,
|
|
3864
|
-
signal
|
|
3865
|
-
};
|
|
3866
|
-
return {
|
|
3867
|
-
...b1,
|
|
3868
|
-
...b2,
|
|
3869
|
-
...b3,
|
|
3870
|
-
...b4,
|
|
3871
|
-
...b5
|
|
3872
|
-
};
|
|
3873
|
-
}
|
|
3874
|
-
collectAt(payment) {
|
|
3875
|
-
if (payment.method.method === "CASH") return this.options.cashCollectAt ?? "DOOR";
|
|
3876
|
-
return this.options.collectAt ?? "DEVICE";
|
|
3877
|
-
}
|
|
3878
|
-
toIntent(payment, result, placedAt) {
|
|
3879
|
-
const totals = {
|
|
3880
|
-
...this.deps.getOrder().totals,
|
|
3881
|
-
total: result.amount
|
|
3882
|
-
};
|
|
3883
|
-
const resolved_at = result.status !== "PENDING" && result.status !== "IDLE" ? (/* @__PURE__ */ new Date()).toISOString() : void 0;
|
|
3884
|
-
const message = result.error?.message;
|
|
3885
|
-
const b1 = {
|
|
3886
|
-
payment_id: payment.id,
|
|
3887
|
-
method: payment.method.method
|
|
3888
|
-
};
|
|
3889
|
-
const b2 = { provider: payment.provider.provider_id };
|
|
3890
|
-
const b3 = {
|
|
3891
|
-
collect_at: this.collectAt(payment),
|
|
3892
|
-
status: result.status
|
|
3893
|
-
};
|
|
3894
|
-
const b4 = {
|
|
3895
|
-
totals,
|
|
3896
|
-
placed_at: placedAt,
|
|
3897
|
-
resolved_at
|
|
3898
|
-
};
|
|
3899
|
-
const b5 = {
|
|
3900
|
-
metadata: { ...result.metadata },
|
|
3901
|
-
error_message: message
|
|
3902
|
-
};
|
|
3903
|
-
return {
|
|
3904
|
-
...b1,
|
|
3905
|
-
...b2,
|
|
3906
|
-
...b3,
|
|
3907
|
-
...b4,
|
|
3908
|
-
...b5
|
|
3909
|
-
};
|
|
3910
|
-
}
|
|
3911
|
-
async recordResult(payment, result, placedAt) {
|
|
3912
|
-
const order = this.deps.getOrder();
|
|
3913
|
-
const intent = this.toIntent(payment, result, placedAt);
|
|
3914
|
-
const http = this.deps.http;
|
|
3915
|
-
const p1 = [
|
|
3916
|
-
order.id,
|
|
3917
|
-
{ intent },
|
|
3918
|
-
http
|
|
3919
|
-
];
|
|
3920
|
-
const next = await services.orders.recordPayment(...p1);
|
|
3921
|
-
this.deps.applyOrder(next);
|
|
3922
|
-
}
|
|
3923
|
-
/**
|
|
3924
|
-
* Charges `payment`: places the order with a PENDING intent if needed,
|
|
3925
|
-
* runs the adapter with the merchant policy and records every result.
|
|
3926
|
-
*/
|
|
3927
|
-
async pay(payment, options = {}) {
|
|
3928
|
-
const adapter = this.adapterFor(payment);
|
|
3929
|
-
if (!adapter) {
|
|
3930
|
-
const id = payment.id;
|
|
3931
|
-
throw new Error(`No payment adapter installed for payment ${id}`);
|
|
3932
|
-
}
|
|
3933
|
-
this.payments.set(payment.id, payment);
|
|
3934
|
-
const missing = this.missingFields(payment);
|
|
3935
|
-
if (missing.length > 0) {
|
|
3936
|
-
const keys = missing.map((field) => field.key).join(", ");
|
|
3937
|
-
const id = payment.id;
|
|
3938
|
-
throw new Error(`Payment ${id} is missing device fields: ${keys}`);
|
|
3939
|
-
}
|
|
3940
|
-
const remaining = this.remaining();
|
|
3941
|
-
const amount = Math.min(options.amount ?? remaining, remaining);
|
|
3942
|
-
if (amount <= 0) throw new Error("Order is already covered");
|
|
3943
|
-
const attemptReference = createAttemptReference();
|
|
3944
|
-
const placedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3945
|
-
const pending = {
|
|
3946
|
-
status: payment.method.method === "CASH" ? "IDLE" : "PENDING",
|
|
3947
|
-
amount: isPlaced(this.deps.getOrder()) ? amount : remaining,
|
|
3948
|
-
metadata: { attempt_reference: attemptReference }
|
|
3949
|
-
};
|
|
3950
|
-
const order = this.deps.getOrder();
|
|
3951
|
-
if (!isPlaced(order)) {
|
|
3952
|
-
const intent = this.toIntent(payment, pending, placedAt);
|
|
3953
|
-
const p1 = {
|
|
3954
|
-
id: payment.id,
|
|
3955
|
-
intents: [intent]
|
|
3956
|
-
};
|
|
3957
|
-
await this.deps.placeOrder({ payment: p1 });
|
|
3958
|
-
} else await this.recordResult(payment, pending, placedAt);
|
|
3959
|
-
const controller = new AbortController();
|
|
3960
|
-
const abort = () => controller.abort();
|
|
3961
|
-
options.signal?.addEventListener("abort", abort);
|
|
3962
|
-
try {
|
|
3963
|
-
const a1 = [
|
|
3964
|
-
payment,
|
|
3965
|
-
amount,
|
|
3966
|
-
attemptReference,
|
|
3967
|
-
controller.signal
|
|
3968
|
-
];
|
|
3969
|
-
const ctx = await this.buildContext(...a1);
|
|
3970
|
-
const onProgress = async (progress) => {
|
|
3971
|
-
await options.onProgress?.(progress);
|
|
3972
|
-
if (progress.status !== "PENDING") return;
|
|
3973
|
-
await this.recordResult(payment, progress, placedAt);
|
|
3974
|
-
};
|
|
3975
|
-
const attempt = await runPaymentAttempt(adapter, ctx, {
|
|
3976
|
-
...this.deps.runOptions,
|
|
3977
|
-
onProgress
|
|
3978
|
-
});
|
|
3979
|
-
const result = withOrderAmount(attempt, amount);
|
|
3980
|
-
if (result.status !== "IDLE") await this.recordResult(payment, result, placedAt);
|
|
3981
|
-
return result;
|
|
3982
|
-
} finally {
|
|
3983
|
-
options.signal?.removeEventListener("abort", abort);
|
|
3984
|
-
}
|
|
3985
|
-
}
|
|
3986
|
-
intentResult(intent) {
|
|
3987
|
-
const metadata = record(intent.metadata);
|
|
3988
|
-
const error = metadata.error;
|
|
3989
|
-
return {
|
|
3990
|
-
status: intent.status,
|
|
3991
|
-
amount: intent.totals.total,
|
|
3992
|
-
metadata,
|
|
3993
|
-
...error ? { error } : {}
|
|
3994
|
-
};
|
|
3995
|
-
}
|
|
3996
|
-
async contextFor(intent) {
|
|
3997
|
-
const payment = intent.payment_id ? this.payments.get(intent.payment_id) : null;
|
|
3998
|
-
if (!payment) return null;
|
|
3999
|
-
const adapter = this.adapterFor(payment);
|
|
4000
|
-
if (!adapter) return null;
|
|
4001
|
-
const result = this.intentResult(intent);
|
|
4002
|
-
const reference = result.metadata.attempt_reference;
|
|
4003
|
-
if (!reference) return null;
|
|
4004
|
-
const signal = new AbortController().signal;
|
|
4005
|
-
const a1 = [
|
|
4006
|
-
payment,
|
|
4007
|
-
intent.totals.total,
|
|
4008
|
-
reference,
|
|
4009
|
-
signal
|
|
4010
|
-
];
|
|
4011
|
-
return {
|
|
4012
|
-
payment,
|
|
4013
|
-
adapter,
|
|
4014
|
-
result,
|
|
4015
|
-
ctx: await this.buildContext(...a1),
|
|
4016
|
-
placedAt: intent.placed_at
|
|
4017
|
-
};
|
|
4018
|
-
}
|
|
4019
|
-
/** After the order is injected: confirm PENDING `confirm_after_inject`. */
|
|
4020
|
-
async confirmAfterInject() {
|
|
4021
|
-
const intents = this.deps.getOrder().payment_intents;
|
|
4022
|
-
const results = [];
|
|
4023
|
-
for (const intent of intents) {
|
|
4024
|
-
const metadata = record(intent.metadata);
|
|
4025
|
-
if (intent.status !== "PENDING") continue;
|
|
4026
|
-
if (metadata.confirm_after_inject !== true) continue;
|
|
4027
|
-
const found = await this.contextFor(intent);
|
|
4028
|
-
const confirm = found?.adapter.confirm;
|
|
4029
|
-
if (!found || !confirm) continue;
|
|
4030
|
-
const { payment, result, ctx, placedAt } = found;
|
|
4031
|
-
const confirmed = await confirm(ctx, result).catch((error) => {
|
|
4032
|
-
const err = createPaymentError("UNKNOWN", { provider_message: error instanceof Error ? error.message : null });
|
|
4033
|
-
const m1 = {
|
|
4034
|
-
...result.metadata,
|
|
4035
|
-
error: err
|
|
4036
|
-
};
|
|
4037
|
-
return {
|
|
4038
|
-
...result,
|
|
4039
|
-
status: "FAILED",
|
|
4040
|
-
metadata: m1,
|
|
4041
|
-
error: err
|
|
4042
|
-
};
|
|
4043
|
-
});
|
|
4044
|
-
const next = {
|
|
4045
|
-
...confirmed,
|
|
4046
|
-
metadata: { ...confirmed.metadata }
|
|
4047
|
-
};
|
|
4048
|
-
delete next.metadata.confirm_after_inject;
|
|
4049
|
-
await this.recordResult(payment, next, placedAt);
|
|
4050
|
-
results.push(next);
|
|
4051
|
-
}
|
|
4052
|
-
return results;
|
|
4053
|
-
}
|
|
4054
|
-
/**
|
|
4055
|
-
* Undo charges of this order (injection failed or split payment
|
|
4056
|
-
* abandoned): cancel, refund or flag for manual reversal.
|
|
4057
|
-
*/
|
|
4058
|
-
async reverseAll() {
|
|
4059
|
-
const intents = this.deps.getOrder().payment_intents;
|
|
4060
|
-
const outcomes = [];
|
|
4061
|
-
for (const intent of intents) {
|
|
4062
|
-
const confirm = record(intent.metadata).confirm_after_inject === true;
|
|
4063
|
-
if (!(intent.status === "PAID" || confirm) || intent.status === "IDLE") continue;
|
|
4064
|
-
const found = await this.contextFor(intent);
|
|
4065
|
-
if (!found) continue;
|
|
4066
|
-
const { payment, adapter, result, ctx, placedAt } = found;
|
|
4067
|
-
const outcome = await reversePayment(adapter, ctx, result);
|
|
4068
|
-
const reversed = withOrderAmount(outcome.result, intent.totals.total);
|
|
4069
|
-
await this.recordResult(payment, reversed, placedAt);
|
|
4070
|
-
outcomes.push(outcome);
|
|
4071
|
-
}
|
|
4072
|
-
return outcomes;
|
|
4073
|
-
}
|
|
4074
|
-
/** Runs a terminal operation (init, test, closeBatch, status, extra). */
|
|
4075
|
-
async runTerminal(payment, key) {
|
|
4076
|
-
const terminal = this.adapterFor(payment)?.terminal;
|
|
4077
|
-
if (!terminal) return {
|
|
4078
|
-
ok: false,
|
|
4079
|
-
message: "No terminal operations"
|
|
4080
|
-
};
|
|
4081
|
-
const base = {
|
|
4082
|
-
init: terminal.init,
|
|
4083
|
-
test: terminal.test
|
|
4084
|
-
};
|
|
4085
|
-
const more = {
|
|
4086
|
-
closeBatch: terminal.closeBatch,
|
|
4087
|
-
status: terminal.status
|
|
4088
|
-
};
|
|
4089
|
-
const named = {
|
|
4090
|
-
...base,
|
|
4091
|
-
...more
|
|
4092
|
-
};
|
|
4093
|
-
const extra = terminal.extra?.find((item) => item.key === key)?.run;
|
|
4094
|
-
const operation = named[key] ?? extra;
|
|
4095
|
-
if (!operation) return {
|
|
4096
|
-
ok: false,
|
|
4097
|
-
message: `Unknown operation ${key}`
|
|
4098
|
-
};
|
|
4099
|
-
const signal = new AbortController().signal;
|
|
4100
|
-
const reference = createAttemptReference();
|
|
4101
|
-
const result = await operation(toTerminalContext(await this.buildContext(payment, 0, reference, signal))).catch(toFailure);
|
|
4102
|
-
if (!result.ok) await this.reportTerminal(payment, key, result);
|
|
4103
|
-
return result;
|
|
4104
|
-
}
|
|
4105
|
-
async reportTerminal(payment, operation, result) {
|
|
4106
|
-
const fallback = createPaymentError("UNKNOWN");
|
|
4107
|
-
const error = result.error ?? {
|
|
4108
|
-
...fallback,
|
|
4109
|
-
message: result.message ?? fallback.message
|
|
4110
|
-
};
|
|
4111
|
-
const body = {
|
|
4112
|
-
operation,
|
|
4113
|
-
device_id: this.options.deviceId ?? null,
|
|
4114
|
-
error
|
|
4115
|
-
};
|
|
4116
|
-
const report = services.payments.terminalFailure;
|
|
4117
|
-
await report(payment.id, body, this.deps.http).catch(() => void 0);
|
|
4118
|
-
}
|
|
4119
|
-
};
|
|
4120
|
-
//#endregion
|
|
4121
|
-
//#region src/schedule/schedule.ts
|
|
4122
|
-
const DAY_OF_WEEK = [
|
|
4123
|
-
"sunday",
|
|
4124
|
-
"monday",
|
|
4125
|
-
"tuesday",
|
|
4126
|
-
"wednesday",
|
|
4127
|
-
"thursday",
|
|
4128
|
-
"friday",
|
|
4129
|
-
"saturday"
|
|
4130
|
-
];
|
|
4131
|
-
const timeToMinutes = (time) => {
|
|
4132
|
-
const [hours = "0", minutes = "0"] = time.split(":");
|
|
4133
|
-
return Number(hours) * 60 + Number(minutes);
|
|
4134
|
-
};
|
|
4135
|
-
const hasSpecificDates = (period) => {
|
|
4136
|
-
return period.start_date != null && period.end_date != null;
|
|
4137
|
-
};
|
|
4138
|
-
const periodCoversDate = (period, now) => {
|
|
4139
|
-
if (!hasSpecificDates(period)) return false;
|
|
4140
|
-
const nowMs = now.getTime();
|
|
4141
|
-
const startMs = Date.parse(period.start_date);
|
|
4142
|
-
const endMs = Date.parse(period.end_date);
|
|
4143
|
-
if (Number.isNaN(startMs) || Number.isNaN(endMs)) return false;
|
|
4144
|
-
return startMs <= nowMs && nowMs <= endMs;
|
|
4145
|
-
};
|
|
4146
|
-
const timeMatches = (period, now) => {
|
|
4147
|
-
if (!period.start_time || !period.end_time) return false;
|
|
4148
|
-
const nowMinutes = now.getHours() * 60 + now.getMinutes();
|
|
4149
|
-
const start = timeToMinutes(period.start_time);
|
|
4150
|
-
const end = timeToMinutes(period.end_time);
|
|
4151
|
-
return nowMinutes >= start && nowMinutes < end;
|
|
4152
|
-
};
|
|
4153
|
-
const isClosedAt = (period, now) => {
|
|
4154
|
-
if (!period.closed) return false;
|
|
4155
|
-
if (!period.start_time || !period.end_time) return true;
|
|
4156
|
-
return timeMatches(period, now);
|
|
4157
|
-
};
|
|
4158
|
-
const isOpenAt = (period, now) => {
|
|
4159
|
-
if (period.closed) return false;
|
|
4160
|
-
return timeMatches(period, now);
|
|
4161
|
-
};
|
|
4162
|
-
const isPeriodsActiveAt = (periods, now) => {
|
|
4163
|
-
return getOpenPeriodAt(periods, now) != null;
|
|
4164
|
-
};
|
|
4165
|
-
const getOpenPeriodAt = (periods, now) => {
|
|
4166
|
-
if (periods.length === 0) return null;
|
|
4167
|
-
const coveringSpecific = periods.filter((period) => {
|
|
4168
|
-
return periodCoversDate(period, now);
|
|
4169
|
-
});
|
|
4170
|
-
const fallback = periods.filter((period) => !hasSpecificDates(period));
|
|
4171
|
-
const specificOpens = coveringSpecific.filter((period) => !period.closed);
|
|
4172
|
-
if (coveringSpecific.filter((period) => period.closed).some((period) => isClosedAt(period, now))) return null;
|
|
4173
|
-
if (specificOpens.length > 0) return specificOpens.find((period) => isOpenAt(period, now)) ?? null;
|
|
4174
|
-
if (fallback.some((period) => isClosedAt(period, now))) return null;
|
|
4175
|
-
return fallback.filter((period) => !period.closed).find((period) => isOpenAt(period, now)) ?? null;
|
|
4176
|
-
};
|
|
4177
|
-
const atTimeOnDate = (day, time) => {
|
|
4178
|
-
const [hours = "0", minutes = "0"] = time.split(":");
|
|
4179
|
-
const next = new Date(day);
|
|
4180
|
-
next.setHours(Number(hours), Number(minutes), 0, 0);
|
|
4181
|
-
return next;
|
|
4182
|
-
};
|
|
4183
|
-
const isWithinPeriodDates = (period, instantMs) => {
|
|
4184
|
-
if (!hasSpecificDates(period)) return true;
|
|
4185
|
-
const startMs = Date.parse(period.start_date);
|
|
4186
|
-
const endMs = Date.parse(period.end_date);
|
|
4187
|
-
if (Number.isNaN(startMs) || Number.isNaN(endMs)) return false;
|
|
4188
|
-
return startMs <= instantMs && instantMs <= endMs;
|
|
4189
|
-
};
|
|
4190
|
-
const dayPeriods = (schedule, instant) => {
|
|
4191
|
-
const day = DAY_OF_WEEK[instant.getDay()];
|
|
4192
|
-
if (!day) return [];
|
|
4193
|
-
return schedule.find((row) => row.day_of_week === day)?.periods ?? [];
|
|
4194
|
-
};
|
|
4195
|
-
const getActiveUntil = (periods, now) => {
|
|
4196
|
-
const open = getOpenPeriodAt(periods, now);
|
|
4197
|
-
if (!open?.end_time) return null;
|
|
4198
|
-
let untilMs = atTimeOnDate(now, open.end_time).getTime();
|
|
4199
|
-
const openIsSpecific = hasSpecificDates(open);
|
|
4200
|
-
for (const period of periods) {
|
|
4201
|
-
if (!period.closed || !period.start_time) continue;
|
|
4202
|
-
const closeMs = atTimeOnDate(now, period.start_time).getTime();
|
|
4203
|
-
if (closeMs <= now.getTime() || closeMs >= untilMs) continue;
|
|
4204
|
-
if (!isWithinPeriodDates(period, closeMs)) continue;
|
|
4205
|
-
if (!hasSpecificDates(period) && openIsSpecific) continue;
|
|
4206
|
-
untilMs = closeMs;
|
|
4207
|
-
}
|
|
4208
|
-
return new Date(untilMs);
|
|
4209
|
-
};
|
|
4210
|
-
const getNextStart = (schedule, now) => {
|
|
4211
|
-
const nowMs = now.getTime();
|
|
4212
|
-
let nextStartMs = null;
|
|
4213
|
-
const consider = (candidate, periods) => {
|
|
4214
|
-
const startMs = candidate.getTime();
|
|
4215
|
-
if (startMs <= nowMs) return;
|
|
4216
|
-
if (!isPeriodsActiveAt(periods, candidate)) return;
|
|
4217
|
-
if (nextStartMs === null || startMs < nextStartMs) nextStartMs = startMs;
|
|
4218
|
-
};
|
|
4219
|
-
for (let dayOffset = 0; dayOffset < 8; dayOffset += 1) {
|
|
4220
|
-
const candidateDate = new Date(now);
|
|
4221
|
-
candidateDate.setDate(candidateDate.getDate() + dayOffset);
|
|
4222
|
-
candidateDate.setSeconds(0, 0);
|
|
4223
|
-
const periods = dayPeriods(schedule, candidateDate);
|
|
4224
|
-
if (periods.length === 0) continue;
|
|
4225
|
-
for (const period of periods) {
|
|
4226
|
-
if (period.closed || !period.start_time) continue;
|
|
4227
|
-
const start = atTimeOnDate(candidateDate, period.start_time);
|
|
4228
|
-
if (!isWithinPeriodDates(period, start.getTime())) continue;
|
|
4229
|
-
consider(start, periods);
|
|
4230
|
-
}
|
|
4231
|
-
for (const period of periods) {
|
|
4232
|
-
if (!period.closed || !period.end_time) continue;
|
|
4233
|
-
const reopen = atTimeOnDate(candidateDate, period.end_time);
|
|
4234
|
-
if (!isWithinPeriodDates(period, reopen.getTime())) continue;
|
|
4235
|
-
consider(reopen, periods);
|
|
4236
|
-
}
|
|
4237
|
-
}
|
|
4238
|
-
return nextStartMs === null ? null : new Date(nextStartMs);
|
|
3932
|
+
return {
|
|
3933
|
+
...t1,
|
|
3934
|
+
...t2
|
|
3935
|
+
};
|
|
3936
|
+
});
|
|
4239
3937
|
};
|
|
4240
|
-
/**
|
|
4241
|
-
const
|
|
4242
|
-
if (
|
|
4243
|
-
|
|
4244
|
-
|
|
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
|
|
4245
3946
|
};
|
|
4246
|
-
const
|
|
4247
|
-
|
|
4248
|
-
|
|
4249
|
-
|
|
3947
|
+
const t2 = {
|
|
3948
|
+
payment_id: payment.id,
|
|
3949
|
+
collect_at,
|
|
3950
|
+
allocation: null
|
|
4250
3951
|
};
|
|
4251
3952
|
return {
|
|
4252
|
-
|
|
4253
|
-
|
|
3953
|
+
...t1,
|
|
3954
|
+
...t2
|
|
4254
3955
|
};
|
|
4255
3956
|
};
|
|
4256
|
-
|
|
4257
|
-
|
|
3957
|
+
/**
|
|
3958
|
+
* Charge order of §6.1: deferred capture (authorize only), immediate
|
|
3959
|
+
* capture reversible with cancel/refund, manual reversal only, then cash /
|
|
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;
|
|
4258
3967
|
};
|
|
4259
3968
|
//#endregion
|
|
4260
|
-
//#region src/order/
|
|
4261
|
-
const
|
|
4262
|
-
const
|
|
4263
|
-
return
|
|
3969
|
+
//#region src/order/payments/orchestrator.ts
|
|
3970
|
+
const AMBIGUOUS_QUERIES = 3;
|
|
3971
|
+
const defaultSleep = (ms, signal) => {
|
|
3972
|
+
return new Promise((resolve) => {
|
|
3973
|
+
const timer = setTimeout(resolve, ms);
|
|
3974
|
+
signal?.addEventListener("abort", () => {
|
|
3975
|
+
clearTimeout(timer);
|
|
3976
|
+
resolve();
|
|
3977
|
+
});
|
|
3978
|
+
});
|
|
4264
3979
|
};
|
|
4265
|
-
const
|
|
4266
|
-
|
|
4267
|
-
|
|
4268
|
-
const typeSet = new Set(types ?? DEFAULT_RECOMMENDATION_TYPES);
|
|
4269
|
-
const roots = lines.filter((line) => line.parent_uid == null);
|
|
4270
|
-
const fingerprints = roots.map((root) => computeLineFingerprint(root, lines));
|
|
4271
|
-
const cartKeys = new Set(fingerprints);
|
|
4272
|
-
const seenTargetIds = /* @__PURE__ */ new Set();
|
|
4273
|
-
const products = [];
|
|
4274
|
-
for (const root of roots) {
|
|
4275
|
-
const source = lookup.productsById[root.id];
|
|
4276
|
-
if (!source) continue;
|
|
4277
|
-
for (const recommendationId of linkedRecommendationIds(source.pricing)) {
|
|
4278
|
-
const recommendation = lookup.recommendationsById[recommendationId];
|
|
4279
|
-
if (!recommendation || !typeSet.has(recommendation.type)) continue;
|
|
4280
|
-
const targetId = recommendation.target_product_id;
|
|
4281
|
-
if (seenTargetIds.has(targetId)) continue;
|
|
4282
|
-
if (cartKeys.has(bareProductFingerprint(targetId))) continue;
|
|
4283
|
-
const target = lookup.productsById[targetId];
|
|
4284
|
-
if (!target || target.status !== "ACTIVE") continue;
|
|
4285
|
-
if (!isOnSchedule(target.availability.schedules)) continue;
|
|
4286
|
-
seenTargetIds.add(targetId);
|
|
4287
|
-
products.push(target);
|
|
4288
|
-
if (products.length >= quantity) return products;
|
|
4289
|
-
}
|
|
4290
|
-
}
|
|
4291
|
-
return products;
|
|
3980
|
+
const defaultClock = {
|
|
3981
|
+
now: () => Date.now(),
|
|
3982
|
+
sleep: defaultSleep
|
|
4292
3983
|
};
|
|
4293
|
-
|
|
4294
|
-
|
|
4295
|
-
|
|
4296
|
-
|
|
4297
|
-
|
|
4298
|
-
|
|
4299
|
-
let
|
|
4300
|
-
let
|
|
4301
|
-
|
|
4302
|
-
|
|
4303
|
-
|
|
4304
|
-
|
|
4305
|
-
|
|
4306
|
-
total = total.plus(line.group_total);
|
|
4307
|
-
}
|
|
4308
|
-
let taxRate = 0;
|
|
4309
|
-
let discountRate = 0;
|
|
4310
|
-
if (subtotal.gt(0)) {
|
|
4311
|
-
taxRate = taxTotal.div(subtotal).toNumber();
|
|
4312
|
-
discountRate = discountTotal.div(subtotal).toNumber();
|
|
4313
|
-
}
|
|
4314
|
-
const discount_total = discountTotal.toNumber();
|
|
4315
|
-
const tax_total = taxTotal.toNumber();
|
|
4316
|
-
const b1 = {
|
|
4317
|
-
currency,
|
|
4318
|
-
subtotal: subtotal.toNumber()
|
|
4319
|
-
};
|
|
4320
|
-
const b2 = {
|
|
4321
|
-
discount_rate: discountRate,
|
|
4322
|
-
discount_total
|
|
3984
|
+
var TimeoutSignal = class extends Error {};
|
|
3985
|
+
/**
|
|
3986
|
+
* Provider deadline that pauses while the customer is answering `ctx.ui`;
|
|
3987
|
+
* user waits run on `interaction_timeout_ms` instead.
|
|
3988
|
+
*/
|
|
3989
|
+
const createDeadline = (timeoutMs, onExpire) => {
|
|
3990
|
+
let remaining = timeoutMs;
|
|
3991
|
+
let startedAt = 0;
|
|
3992
|
+
let timer = null;
|
|
3993
|
+
let paused = 0;
|
|
3994
|
+
const run = () => {
|
|
3995
|
+
startedAt = Date.now();
|
|
3996
|
+
timer = setTimeout(onExpire, Math.max(remaining, 0));
|
|
4323
3997
|
};
|
|
4324
|
-
const
|
|
4325
|
-
|
|
4326
|
-
|
|
4327
|
-
|
|
3998
|
+
const stop = () => {
|
|
3999
|
+
if (!timer) return;
|
|
4000
|
+
clearTimeout(timer);
|
|
4001
|
+
timer = null;
|
|
4002
|
+
remaining -= Date.now() - startedAt;
|
|
4328
4003
|
};
|
|
4329
4004
|
return {
|
|
4330
|
-
|
|
4331
|
-
|
|
4332
|
-
|
|
4005
|
+
start: run,
|
|
4006
|
+
pause: () => {
|
|
4007
|
+
if (paused === 0) stop();
|
|
4008
|
+
paused += 1;
|
|
4009
|
+
},
|
|
4010
|
+
resume: () => {
|
|
4011
|
+
paused -= 1;
|
|
4012
|
+
if (paused === 0) run();
|
|
4013
|
+
},
|
|
4014
|
+
clear: stop
|
|
4333
4015
|
};
|
|
4334
4016
|
};
|
|
4335
|
-
const
|
|
4336
|
-
|
|
4337
|
-
|
|
4338
|
-
|
|
4339
|
-
|
|
4340
|
-
|
|
4341
|
-
|
|
4342
|
-
|
|
4343
|
-
|
|
4344
|
-
|
|
4345
|
-
|
|
4017
|
+
const withUserTimeout = (promise, ms) => {
|
|
4018
|
+
return new Promise((resolve, reject) => {
|
|
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
|
+
});
|
|
4030
|
+
};
|
|
4031
|
+
var PaymentUserTimeout = class extends Error {};
|
|
4032
|
+
const timedUI = (ui, deadline, interactionMs) => {
|
|
4033
|
+
const wrap = (call) => {
|
|
4034
|
+
deadline.pause();
|
|
4035
|
+
return withUserTimeout(call(), interactionMs).finally(() => deadline.resume());
|
|
4346
4036
|
};
|
|
4347
4037
|
return {
|
|
4348
|
-
|
|
4349
|
-
|
|
4038
|
+
message: (input) => ui.message(input),
|
|
4039
|
+
action: (input) => ui.action(input),
|
|
4040
|
+
select: (input) => {
|
|
4041
|
+
return wrap(() => ui.select(input));
|
|
4042
|
+
},
|
|
4043
|
+
input: (input) => wrap(() => ui.input(input)),
|
|
4044
|
+
confirm: (input) => wrap(() => ui.confirm(input))
|
|
4350
4045
|
};
|
|
4351
4046
|
};
|
|
4352
|
-
|
|
4353
|
-
|
|
4354
|
-
const
|
|
4355
|
-
|
|
4356
|
-
const
|
|
4357
|
-
|
|
4358
|
-
|
|
4359
|
-
|
|
4360
|
-
|
|
4361
|
-
|
|
4362
|
-
|
|
4363
|
-
|
|
4364
|
-
|
|
4365
|
-
|
|
4366
|
-
const
|
|
4367
|
-
|
|
4368
|
-
|
|
4047
|
+
/** Runs one adapter call with the provider timeout and the user clock. */
|
|
4048
|
+
const runTimed = async (ctx, call) => {
|
|
4049
|
+
const controller = new AbortController();
|
|
4050
|
+
const parent = ctx.signal;
|
|
4051
|
+
const abort = () => controller.abort();
|
|
4052
|
+
parent.addEventListener("abort", abort);
|
|
4053
|
+
let expire = () => void 0;
|
|
4054
|
+
const expired = new Promise((_, reject) => {
|
|
4055
|
+
expire = () => {
|
|
4056
|
+
controller.abort();
|
|
4057
|
+
reject(new TimeoutSignal());
|
|
4058
|
+
};
|
|
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
|
|
4369
4067
|
};
|
|
4370
|
-
|
|
4371
|
-
|
|
4372
|
-
|
|
4373
|
-
|
|
4374
|
-
|
|
4375
|
-
|
|
4376
|
-
/**
|
|
4377
|
-
* Takes `quantity` units of `reward_id` off the latest entries first, so the
|
|
4378
|
-
* earlier redeems keep their step position. Entries that reach 0 are removed.
|
|
4379
|
-
*/
|
|
4380
|
-
const subtractRedeem = (items, reward_id, quantity) => {
|
|
4381
|
-
if (!Number.isInteger(quantity) || quantity < 1) throw new Error("Redeem quantity must be an integer ≥ 1");
|
|
4382
|
-
let pending = quantity;
|
|
4383
|
-
const kept = [];
|
|
4384
|
-
for (let i = items.length - 1; i >= 0; i--) {
|
|
4385
|
-
const item = items[i];
|
|
4386
|
-
if (pending === 0 || item.reward_id !== reward_id) {
|
|
4387
|
-
kept.unshift(item);
|
|
4388
|
-
continue;
|
|
4389
|
-
}
|
|
4390
|
-
const taken = Math.min(item.quantity, pending);
|
|
4391
|
-
pending -= taken;
|
|
4392
|
-
const rest = item.quantity - taken;
|
|
4393
|
-
if (rest > 0) kept.unshift({
|
|
4394
|
-
...item,
|
|
4395
|
-
quantity: rest
|
|
4396
|
-
});
|
|
4068
|
+
deadline.start();
|
|
4069
|
+
try {
|
|
4070
|
+
return await Promise.race([call(scoped), expired]);
|
|
4071
|
+
} finally {
|
|
4072
|
+
deadline.clear();
|
|
4073
|
+
parent.removeEventListener("abort", abort);
|
|
4397
4074
|
}
|
|
4398
|
-
return kept;
|
|
4399
4075
|
};
|
|
4400
|
-
const
|
|
4401
|
-
|
|
4402
|
-
|
|
4076
|
+
const failed = (ctx, error, extra = {}) => {
|
|
4077
|
+
const metadata = {
|
|
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
|
+
});
|
|
4403
4114
|
};
|
|
4404
|
-
|
|
4405
|
-
const
|
|
4406
|
-
const
|
|
4407
|
-
|
|
4408
|
-
|
|
4409
|
-
|
|
4410
|
-
|
|
4411
|
-
target: "LINE",
|
|
4412
|
-
target_id: line.uid
|
|
4413
|
-
};
|
|
4414
|
-
const pool = items.map((item) => ({
|
|
4415
|
-
...target,
|
|
4416
|
-
redeem: item
|
|
4417
|
-
}));
|
|
4418
|
-
pools.set(redemptionTargetKey(target), pool);
|
|
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));
|
|
4419
4122
|
}
|
|
4420
|
-
const order = { target: "ORDER" };
|
|
4421
|
-
const pool = redeem.map((item) => ({
|
|
4422
|
-
...order,
|
|
4423
|
-
redeem: item
|
|
4424
|
-
}));
|
|
4425
|
-
pools.set(ORDER_KEY, pool);
|
|
4426
|
-
return pools;
|
|
4427
4123
|
};
|
|
4428
|
-
/**
|
|
4429
|
-
|
|
4430
|
-
|
|
4431
|
-
|
|
4432
|
-
|
|
4433
|
-
|
|
4434
|
-
|
|
4435
|
-
|
|
4436
|
-
const sequence = [];
|
|
4437
|
-
for (const step of steps) {
|
|
4438
|
-
const key = redemptionTargetKey(step);
|
|
4439
|
-
const at = cursors.get(key) ?? 0;
|
|
4440
|
-
const next = pools.get(key)?.[at];
|
|
4441
|
-
if (!next || next.redeem.reward_id !== step.reward_id) continue;
|
|
4442
|
-
cursors.set(key, at + 1);
|
|
4443
|
-
sequence.push(next);
|
|
4124
|
+
/** Query up to 3 times, cancel once, else FAILED + needs_review. */
|
|
4125
|
+
const resolveAmbiguous = async (adapter, ctx, pending, clock) => {
|
|
4126
|
+
if (adapter.query) for (let i = 0; i < AMBIGUOUS_QUERIES; i += 1) {
|
|
4127
|
+
await clock.sleep(ctx.policy.poll_interval_ms, ctx.signal);
|
|
4128
|
+
const next = await callOperation(ctx, adapter.query, pending, clock);
|
|
4129
|
+
if (next.status === "PAID") return next;
|
|
4130
|
+
const definitive = next.error?.code !== "TIMEOUT" && !next.error?.retryable;
|
|
4131
|
+
if (next.status === "FAILED" && definitive && next.error?.code !== "AMBIGUOUS") return next;
|
|
4444
4132
|
}
|
|
4445
|
-
|
|
4446
|
-
|
|
4447
|
-
|
|
4448
|
-
|
|
4449
|
-
|
|
4133
|
+
if (adapter.cancel) {
|
|
4134
|
+
const cancelled = await callOperation(ctx, adapter.cancel, pending, clock);
|
|
4135
|
+
if (cancelled.status === "CANCELLED") return cancelled;
|
|
4136
|
+
}
|
|
4137
|
+
const error = createPaymentError("AMBIGUOUS", { attempt: ctx.attempt });
|
|
4138
|
+
return failed(ctx, error, { needs_review: true });
|
|
4450
4139
|
};
|
|
4451
|
-
|
|
4452
|
-
|
|
4453
|
-
|
|
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);
|
|
4155
|
+
}
|
|
4156
|
+
if (adapter.cancel) await callOperation(ctx, adapter.cancel, current, clock);
|
|
4157
|
+
const code = ctx.signal.aborted ? "CANCELLED_BY_USER" : "WALLET_EXPIRED";
|
|
4158
|
+
return failed(ctx, createPaymentError(code, { attempt: ctx.attempt }));
|
|
4454
4159
|
};
|
|
4455
|
-
const
|
|
4456
|
-
|
|
4457
|
-
|
|
4458
|
-
|
|
4459
|
-
|
|
4460
|
-
|
|
4461
|
-
|
|
4462
|
-
discount = discount.plus(root.discount);
|
|
4160
|
+
const runStart = async (adapter, ctx, clock) => {
|
|
4161
|
+
const startedAt = clock.now();
|
|
4162
|
+
try {
|
|
4163
|
+
return await runTimed(ctx, (scoped) => adapter.start(scoped));
|
|
4164
|
+
} catch (error) {
|
|
4165
|
+
const raw = errorOf(ctx, error, startedAt, clock);
|
|
4166
|
+
return failed(ctx, startError(raw));
|
|
4463
4167
|
}
|
|
4168
|
+
};
|
|
4169
|
+
const toTerminalContext = (ctx) => {
|
|
4464
4170
|
const b1 = {
|
|
4465
|
-
|
|
4466
|
-
|
|
4171
|
+
paymentId: ctx.paymentId,
|
|
4172
|
+
attempt: ctx.attempt
|
|
4173
|
+
};
|
|
4174
|
+
const b2 = { attemptReference: ctx.attemptReference };
|
|
4175
|
+
const b3 = {
|
|
4176
|
+
environment: ctx.environment,
|
|
4177
|
+
config: ctx.config
|
|
4178
|
+
};
|
|
4179
|
+
const b4 = {
|
|
4180
|
+
deviceSettings: ctx.deviceSettings,
|
|
4181
|
+
policy: ctx.policy
|
|
4182
|
+
};
|
|
4183
|
+
const b5 = {
|
|
4184
|
+
ui: ctx.ui,
|
|
4185
|
+
storage: ctx.storage,
|
|
4186
|
+
server: ctx.server
|
|
4467
4187
|
};
|
|
4468
|
-
const b2 = { discount_amount: discount.toNumber() };
|
|
4469
|
-
const b3 = { discount_rate: rateOf(discount, subtotal) };
|
|
4470
4188
|
return {
|
|
4471
4189
|
...b1,
|
|
4472
4190
|
...b2,
|
|
4473
|
-
...b3
|
|
4191
|
+
...b3,
|
|
4192
|
+
...b4,
|
|
4193
|
+
...b5,
|
|
4194
|
+
signal: ctx.signal
|
|
4474
4195
|
};
|
|
4475
4196
|
};
|
|
4476
|
-
const
|
|
4477
|
-
const
|
|
4478
|
-
|
|
4479
|
-
|
|
4480
|
-
|
|
4481
|
-
const baseOf = (root, beforeTaxes) => {
|
|
4482
|
-
return beforeTaxes ? netOf(root) : root.total;
|
|
4483
|
-
};
|
|
4484
|
-
/** Before taxes, taxes shrink with the pre-tax value they were charged on. */
|
|
4485
|
-
const discountRoot = (root, amount, beforeTaxes) => {
|
|
4486
|
-
if (amount.lte(0)) return;
|
|
4487
|
-
root.discount = root.discount.plus(amount);
|
|
4488
|
-
if (!beforeTaxes) {
|
|
4489
|
-
root.total = root.total.minus(amount);
|
|
4490
|
-
return;
|
|
4491
|
-
}
|
|
4492
|
-
const net = netOf(root);
|
|
4493
|
-
const nextNet = net.minus(amount);
|
|
4494
|
-
root.tax = root.tax.times(nextNet).div(net);
|
|
4495
|
-
root.total = nextNet.plus(root.tax);
|
|
4496
|
-
};
|
|
4497
|
-
/** Splits an order discount across roots by what each can still discount. */
|
|
4498
|
-
const discountOrder = (roots, amount, beforeTaxes) => {
|
|
4499
|
-
const bases = roots.map((root) => baseOf(root, beforeTaxes));
|
|
4500
|
-
const sum = bases.reduce((acc, base) => acc.plus(base), new Big(0));
|
|
4501
|
-
if (sum.lte(0) || amount.lte(0)) return;
|
|
4502
|
-
let last = -1;
|
|
4503
|
-
bases.forEach((base, index) => {
|
|
4504
|
-
if (base.gt(0)) last = index;
|
|
4505
|
-
});
|
|
4506
|
-
let rest = amount;
|
|
4507
|
-
roots.forEach((root, index) => {
|
|
4508
|
-
const base = bases[index];
|
|
4509
|
-
if (base.lte(0)) return;
|
|
4510
|
-
const share = amount.times(base).div(sum);
|
|
4511
|
-
const part = minBig(index === last ? rest : share, base);
|
|
4512
|
-
rest = rest.minus(part);
|
|
4513
|
-
discountRoot(root, part, beforeTaxes);
|
|
4514
|
-
});
|
|
4515
|
-
};
|
|
4516
|
-
/**
|
|
4517
|
-
* Rate compounds once per reward unit over the remaining value; amount is
|
|
4518
|
-
* per unit. With both, the rate discount is capped at `amount × quantity`.
|
|
4519
|
-
*/
|
|
4520
|
-
const redeemDiscount = (redeem, remaining) => {
|
|
4521
|
-
const { quantity, discount_rate, discount_amount } = redeem;
|
|
4522
|
-
let cap = null;
|
|
4523
|
-
if (discount_amount !== void 0) cap = new Big(discount_amount).times(quantity);
|
|
4524
|
-
if (discount_rate === void 0) return cap ?? new Big(0);
|
|
4525
|
-
const kept = new Big(1).minus(discount_rate).pow(quantity);
|
|
4526
|
-
const byRate = remaining.times(new Big(1).minus(kept));
|
|
4527
|
-
return cap ? minBig(byRate, cap) : byRate;
|
|
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);
|
|
4528
4202
|
};
|
|
4529
4203
|
/**
|
|
4530
|
-
*
|
|
4531
|
-
*
|
|
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.
|
|
4532
4207
|
*/
|
|
4533
|
-
const
|
|
4534
|
-
const
|
|
4535
|
-
|
|
4536
|
-
|
|
4537
|
-
|
|
4538
|
-
|
|
4539
|
-
|
|
4540
|
-
|
|
4541
|
-
|
|
4542
|
-
|
|
4543
|
-
|
|
4544
|
-
|
|
4545
|
-
|
|
4546
|
-
|
|
4547
|
-
|
|
4548
|
-
|
|
4549
|
-
|
|
4550
|
-
|
|
4551
|
-
|
|
4552
|
-
|
|
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
|
|
4217
|
+
};
|
|
4218
|
+
const result = await runStart(adapter, current, clock);
|
|
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);
|
|
4553
4239
|
}
|
|
4554
|
-
|
|
4555
|
-
|
|
4556
|
-
|
|
4557
|
-
|
|
4558
|
-
|
|
4559
|
-
|
|
4560
|
-
|
|
4561
|
-
|
|
4562
|
-
|
|
4563
|
-
|
|
4564
|
-
const
|
|
4565
|
-
const
|
|
4566
|
-
|
|
4567
|
-
|
|
4568
|
-
|
|
4569
|
-
...u3,
|
|
4570
|
-
...u4
|
|
4240
|
+
});
|
|
4241
|
+
};
|
|
4242
|
+
/** Undo a charge after a failed injection or an incomplete split payment. */
|
|
4243
|
+
const reversePayment = async (adapter, ctx, result, options = {}) => {
|
|
4244
|
+
const clock = options.clock ?? defaultClock;
|
|
4245
|
+
const now = options.now ?? new Date(clock.now());
|
|
4246
|
+
let kind = (adapter.reversalFor ?? defaultReversalFor)(result, now);
|
|
4247
|
+
const operation = kind === "cancel" ? adapter.cancel : adapter.refund;
|
|
4248
|
+
if (kind !== "manual" && !operation) kind = "manual";
|
|
4249
|
+
if (kind === "manual" || !operation) {
|
|
4250
|
+
const error = createPaymentError("MANUAL_REVERSAL_REQUIRED");
|
|
4251
|
+
const m1 = {
|
|
4252
|
+
...result.metadata,
|
|
4253
|
+
error,
|
|
4254
|
+
needs_review: true
|
|
4571
4255
|
};
|
|
4572
4256
|
return {
|
|
4573
|
-
|
|
4574
|
-
|
|
4575
|
-
|
|
4576
|
-
|
|
4577
|
-
|
|
4578
|
-
|
|
4257
|
+
kind: "manual",
|
|
4258
|
+
result: {
|
|
4259
|
+
...result,
|
|
4260
|
+
metadata: m1,
|
|
4261
|
+
error
|
|
4262
|
+
}
|
|
4579
4263
|
};
|
|
4264
|
+
}
|
|
4265
|
+
const run = () => callOperation(ctx, operation, result, clock);
|
|
4266
|
+
const next = await withTerminalLock(ctx.paymentId, run);
|
|
4267
|
+
return {
|
|
4268
|
+
kind,
|
|
4269
|
+
result: next
|
|
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);
|
|
4580
4296
|
});
|
|
4581
4297
|
};
|
|
4582
|
-
|
|
4583
|
-
|
|
4584
|
-
|
|
4585
|
-
|
|
4586
|
-
|
|
4587
|
-
|
|
4588
|
-
|
|
4589
|
-
const
|
|
4590
|
-
const
|
|
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;
|
|
4591
4313
|
return {
|
|
4592
|
-
...
|
|
4593
|
-
|
|
4594
|
-
|
|
4595
|
-
|
|
4596
|
-
|
|
4597
|
-
|
|
4598
|
-
|
|
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 })
|
|
4599
4334
|
};
|
|
4600
4335
|
};
|
|
4601
4336
|
/**
|
|
4602
|
-
*
|
|
4603
|
-
*
|
|
4604
|
-
* every root. A discount never exceeds what is left on its target.
|
|
4337
|
+
* Payment state of one order: adapters, device settings and the attempt
|
|
4338
|
+
* orchestration. The app only provides adapters, UI and storage.
|
|
4605
4339
|
*/
|
|
4606
|
-
|
|
4607
|
-
|
|
4608
|
-
|
|
4609
|
-
|
|
4610
|
-
|
|
4611
|
-
|
|
4612
|
-
|
|
4613
|
-
|
|
4614
|
-
|
|
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
|
|
4465
|
+
};
|
|
4466
|
+
const b1 = {
|
|
4467
|
+
paymentId: payment.id,
|
|
4468
|
+
order: o1,
|
|
4469
|
+
customer
|
|
4470
|
+
};
|
|
4471
|
+
const b2 = {
|
|
4472
|
+
amount: conversion.amount,
|
|
4473
|
+
taxes,
|
|
4474
|
+
attemptReference
|
|
4475
|
+
};
|
|
4476
|
+
const b3 = {
|
|
4477
|
+
collectAt: this.collectAt(payment),
|
|
4478
|
+
attempt: 1
|
|
4479
|
+
};
|
|
4615
4480
|
const b4 = {
|
|
4616
|
-
|
|
4617
|
-
|
|
4481
|
+
environment,
|
|
4482
|
+
config,
|
|
4483
|
+
deviceSettings,
|
|
4484
|
+
policy
|
|
4618
4485
|
};
|
|
4619
|
-
|
|
4486
|
+
const b5 = {
|
|
4487
|
+
ui: this.ui,
|
|
4488
|
+
storage,
|
|
4489
|
+
server,
|
|
4490
|
+
signal
|
|
4491
|
+
};
|
|
4492
|
+
return {
|
|
4620
4493
|
...b1,
|
|
4621
4494
|
...b2,
|
|
4622
4495
|
...b3,
|
|
4623
|
-
...b4
|
|
4496
|
+
...b4,
|
|
4497
|
+
...b5
|
|
4498
|
+
};
|
|
4499
|
+
}
|
|
4500
|
+
collectAt(payment) {
|
|
4501
|
+
if (payment.method.method === "CASH") return this.options.cashCollectAt ?? "DOOR";
|
|
4502
|
+
return this.options.collectAt ?? "DEVICE";
|
|
4503
|
+
}
|
|
4504
|
+
/** `collect_at` of a new tender: cash in person, the rest digital. */
|
|
4505
|
+
tenderCollectAt(payment) {
|
|
4506
|
+
if (payment.method.method === "CASH") return this.options.cashCollectAt ?? "DOOR";
|
|
4507
|
+
return "DEVICE";
|
|
4508
|
+
}
|
|
4509
|
+
toRecord(tender, result) {
|
|
4510
|
+
const metadata = { ...result.metadata };
|
|
4511
|
+
const failure = result.error?.message;
|
|
4512
|
+
const message = partialOf(result) ? PARTIAL_APPROVAL_ERROR : failure;
|
|
4513
|
+
const b1 = {
|
|
4514
|
+
tender_id: tender.id,
|
|
4515
|
+
status: result.status,
|
|
4516
|
+
metadata
|
|
4517
|
+
};
|
|
4518
|
+
const b2 = { attempt_reference: tender.attempt_reference };
|
|
4519
|
+
const b3 = result.details ? { details: result.details } : {};
|
|
4520
|
+
return {
|
|
4521
|
+
...b1,
|
|
4522
|
+
...b2,
|
|
4523
|
+
...b3,
|
|
4524
|
+
error_message: message
|
|
4525
|
+
};
|
|
4526
|
+
}
|
|
4527
|
+
async recordResult(tender, result) {
|
|
4528
|
+
const order = this.deps.getOrder();
|
|
4529
|
+
const input = this.toRecord(tender, result);
|
|
4530
|
+
const http = this.deps.http;
|
|
4531
|
+
const next = await services.orders.recordPayment(order.id, input, http);
|
|
4532
|
+
this.deps.applyOrder(next);
|
|
4533
|
+
}
|
|
4534
|
+
registerPayment(payment) {
|
|
4535
|
+
this.payments.set(payment.id, payment);
|
|
4536
|
+
}
|
|
4537
|
+
/** Merchant payments the intent tenders point to (`payment_id`). */
|
|
4538
|
+
registerPayments(payments) {
|
|
4539
|
+
payments.forEach((payment) => this.registerPayment(payment));
|
|
4540
|
+
}
|
|
4541
|
+
intentTenders() {
|
|
4542
|
+
return liveTenders(currentIntent(this.deps.getOrder().payment_intents));
|
|
4543
|
+
}
|
|
4544
|
+
/** Adapter + payment of a device-charged real tender, or why not. */
|
|
4545
|
+
chargerFor(tender) {
|
|
4546
|
+
const id = tender.payment_id;
|
|
4547
|
+
const payment = id ? this.payments.get(id) : void 0;
|
|
4548
|
+
if (!payment) throw new Error(`Unknown payment ${id ?? "(none)"}`);
|
|
4549
|
+
const adapter = this.adapterFor(payment);
|
|
4550
|
+
if (!adapter) throw new Error(`No payment adapter installed for payment ${id}`);
|
|
4551
|
+
const missing = this.missingFields(payment);
|
|
4552
|
+
if (missing.length > 0) {
|
|
4553
|
+
const keys = missing.map((field) => field.key).join(", ");
|
|
4554
|
+
throw new Error(`Payment ${id} is missing device fields: ${keys}`);
|
|
4555
|
+
}
|
|
4556
|
+
return {
|
|
4557
|
+
payment,
|
|
4558
|
+
adapter
|
|
4559
|
+
};
|
|
4560
|
+
}
|
|
4561
|
+
/** Real tenders this device still has to charge, in §6.1 order. */
|
|
4562
|
+
pendingCharges() {
|
|
4563
|
+
return this.intentTenders().filter((tender) => {
|
|
4564
|
+
if (!isRealPaymentMethod(tender.method)) return false;
|
|
4565
|
+
if (!isDeviceCharged(tender)) return false;
|
|
4566
|
+
return tender.status === "IDLE" || tender.status === "FAILED";
|
|
4567
|
+
}).map((tender) => {
|
|
4568
|
+
const charger = this.chargerFor(tender);
|
|
4569
|
+
return {
|
|
4570
|
+
tender,
|
|
4571
|
+
...charger,
|
|
4572
|
+
rank: chargeRank(tender, charger.adapter)
|
|
4573
|
+
};
|
|
4574
|
+
}).toSorted((a, b) => {
|
|
4575
|
+
if (a.rank !== b.rank) return a.rank - b.rank;
|
|
4576
|
+
return a.tender.sequence - b.tender.sequence;
|
|
4624
4577
|
});
|
|
4625
4578
|
}
|
|
4626
|
-
|
|
4627
|
-
|
|
4628
|
-
|
|
4629
|
-
const root = id ? moneyByUid.get(id) : void 0;
|
|
4630
|
-
const beforeTaxes = item.redeem.before_taxes ?? true;
|
|
4631
|
-
const before = snapshot(roots, 1);
|
|
4632
|
-
const targetBefore = root ? snapshot([root], root.quantity) : void 0;
|
|
4633
|
-
let left = new Big(0);
|
|
4634
|
-
if (root) left = baseOf(root, beforeTaxes);
|
|
4635
|
-
else for (const r of roots) left = left.plus(baseOf(r, beforeTaxes));
|
|
4636
|
-
const raw = redeemDiscount(item.redeem, left);
|
|
4637
|
-
const discount = minBig(raw, left);
|
|
4638
|
-
if (root) discountRoot(root, discount, beforeTaxes);
|
|
4639
|
-
else discountOrder(roots, discount, beforeTaxes);
|
|
4640
|
-
const action = discount.gt(0) ? "UPDATE" : "READ";
|
|
4641
|
-
const { reward_id, quantity } = item.redeem;
|
|
4579
|
+
/** A FAILED tender is retried with a new `attempt_reference` (D25). */
|
|
4580
|
+
withNewAttempt(tender) {
|
|
4581
|
+
if (tender.status !== "FAILED") return tender;
|
|
4642
4582
|
const b1 = {
|
|
4643
|
-
|
|
4644
|
-
|
|
4645
|
-
target: item.target
|
|
4583
|
+
attempt_reference: createAttemptReference(),
|
|
4584
|
+
previous_attempt_references: [...tender.previous_attempt_references, tender.attempt_reference]
|
|
4646
4585
|
};
|
|
4647
|
-
|
|
4648
|
-
|
|
4649
|
-
|
|
4650
|
-
discount_amount: discount.toNumber()
|
|
4586
|
+
return {
|
|
4587
|
+
...tender,
|
|
4588
|
+
...b1
|
|
4651
4589
|
};
|
|
4652
|
-
|
|
4653
|
-
|
|
4654
|
-
|
|
4590
|
+
}
|
|
4591
|
+
/** D31: the provider approved less than the tender; void it, FAILED. */
|
|
4592
|
+
async rejectPartial(adapter, ctx, result, tender) {
|
|
4593
|
+
const voided = adapter.cancel ? await adapter.cancel(ctx, result).catch(() => null) : null;
|
|
4594
|
+
const error = createPaymentError("INSUFFICIENT_FUNDS", { provider_message: `Approved ${result.amount}` });
|
|
4595
|
+
const needs_review = voided?.status !== "CANCELLED";
|
|
4596
|
+
const partial_approval = { approved_amount: result.amount };
|
|
4597
|
+
const metadata = {
|
|
4598
|
+
...result.metadata,
|
|
4599
|
+
error,
|
|
4600
|
+
needs_review,
|
|
4601
|
+
partial_approval
|
|
4655
4602
|
};
|
|
4656
|
-
|
|
4657
|
-
|
|
4658
|
-
|
|
4659
|
-
|
|
4603
|
+
return {
|
|
4604
|
+
status: "FAILED",
|
|
4605
|
+
amount: tender.amount.total,
|
|
4606
|
+
metadata,
|
|
4607
|
+
error
|
|
4608
|
+
};
|
|
4609
|
+
}
|
|
4610
|
+
async chargeTender(current, charger, options) {
|
|
4611
|
+
const { payment, adapter } = charger;
|
|
4612
|
+
const tender = this.withNewAttempt(current);
|
|
4613
|
+
const emit = options.onProgress ?? (() => void 0);
|
|
4614
|
+
await emit({
|
|
4615
|
+
type: "tender_started",
|
|
4616
|
+
tender
|
|
4617
|
+
});
|
|
4618
|
+
const amount = tender.amount.total;
|
|
4619
|
+
const attemptReference = tender.attempt_reference;
|
|
4620
|
+
const metadata = { attempt_reference: attemptReference };
|
|
4621
|
+
if (!(payment.method.method === "CASH") && tender.status !== "PENDING") {
|
|
4622
|
+
const pending = {
|
|
4623
|
+
status: "PENDING",
|
|
4624
|
+
amount,
|
|
4625
|
+
metadata
|
|
4626
|
+
};
|
|
4627
|
+
await this.recordResult(tender, pending);
|
|
4628
|
+
}
|
|
4629
|
+
const controller = new AbortController();
|
|
4630
|
+
const abort = () => controller.abort();
|
|
4631
|
+
options.signal?.addEventListener("abort", abort);
|
|
4632
|
+
try {
|
|
4633
|
+
const a1 = [
|
|
4634
|
+
payment,
|
|
4635
|
+
amount,
|
|
4636
|
+
attemptReference,
|
|
4637
|
+
controller.signal
|
|
4638
|
+
];
|
|
4639
|
+
const ctx = await this.buildContext(...a1);
|
|
4640
|
+
const onProgress = async (progress) => {
|
|
4641
|
+
await emit({
|
|
4642
|
+
type: "tender_pending",
|
|
4643
|
+
tender,
|
|
4644
|
+
result: progress
|
|
4645
|
+
});
|
|
4646
|
+
if (progress.status !== "PENDING") return;
|
|
4647
|
+
await this.recordResult(tender, progress);
|
|
4648
|
+
};
|
|
4649
|
+
const attempt = await runPaymentAttempt(adapter, ctx, {
|
|
4650
|
+
...this.deps.runOptions,
|
|
4651
|
+
onProgress
|
|
4652
|
+
});
|
|
4653
|
+
let result = withOrderAmount(attempt, amount);
|
|
4654
|
+
if (isPartialApproval(result, tender, ctx)) result = await this.rejectPartial(adapter, ctx, result, tender);
|
|
4655
|
+
if (result.status !== "IDLE") await this.recordResult(tender, result);
|
|
4656
|
+
const type = result.status === "FAILED" ? "tender_failed" : "tender_paid";
|
|
4657
|
+
if (result.status !== "IDLE") await emit({
|
|
4658
|
+
type,
|
|
4659
|
+
tender,
|
|
4660
|
+
result
|
|
4661
|
+
});
|
|
4662
|
+
return result;
|
|
4663
|
+
} finally {
|
|
4664
|
+
options.signal?.removeEventListener("abort", abort);
|
|
4665
|
+
}
|
|
4666
|
+
}
|
|
4667
|
+
outcome(results, failed) {
|
|
4668
|
+
const order = this.deps.getOrder();
|
|
4669
|
+
const tenders = liveTenders(currentIntent(order.payment_intents));
|
|
4670
|
+
const s1 = {
|
|
4671
|
+
order,
|
|
4672
|
+
results,
|
|
4673
|
+
needsReview: tenders.some((tender) => {
|
|
4674
|
+
return record(tender.metadata).needs_review === true;
|
|
4675
|
+
})
|
|
4676
|
+
};
|
|
4677
|
+
if (failed) return {
|
|
4678
|
+
status: "FAILED",
|
|
4679
|
+
...s1,
|
|
4680
|
+
...failed
|
|
4681
|
+
};
|
|
4682
|
+
return {
|
|
4683
|
+
status: tenders.every((tender) => isTenderSettled(tender)) ? "PAID" : "PENDING",
|
|
4684
|
+
...s1
|
|
4685
|
+
};
|
|
4686
|
+
}
|
|
4687
|
+
/** Charges every device tender left, stopping at the first failure. */
|
|
4688
|
+
async chargeRemaining(options) {
|
|
4689
|
+
const results = [];
|
|
4690
|
+
for (const next of this.pendingCharges()) {
|
|
4691
|
+
const tender = this.intentTenders().find((t) => t.id === next.tender.id);
|
|
4692
|
+
if (!tender) continue;
|
|
4693
|
+
const result = await this.chargeTender(tender, next, options);
|
|
4694
|
+
results.push(result);
|
|
4695
|
+
if (result.status !== "FAILED") continue;
|
|
4696
|
+
const failed = this.intentTenders().find((t) => t.id === tender.id);
|
|
4697
|
+
return this.outcome(results, {
|
|
4698
|
+
tender: failed ?? tender,
|
|
4699
|
+
result
|
|
4700
|
+
});
|
|
4701
|
+
}
|
|
4702
|
+
return this.outcome(results);
|
|
4703
|
+
}
|
|
4704
|
+
/**
|
|
4705
|
+
* Places the order (simple flow: `payment` becomes the one real tender)
|
|
4706
|
+
* and charges the real device tenders in sequence (§6.1). A failure
|
|
4707
|
+
* stops here with the order frozen: `retryTender`, `replaceTender` or
|
|
4708
|
+
* cancel and refund. In-person tenders are never charged here.
|
|
4709
|
+
*/
|
|
4710
|
+
async payIntent(options = {}) {
|
|
4711
|
+
const { payment } = options;
|
|
4712
|
+
if (payment) this.registerPayment(payment);
|
|
4713
|
+
if (!isPlaced(this.deps.getOrder())) {
|
|
4714
|
+
if (payment) this.chargerFor({ payment_id: payment.id });
|
|
4715
|
+
const p1 = payment ? this.placePayment(payment) : void 0;
|
|
4716
|
+
await this.deps.placeOrder(p1 ? { payment: p1 } : {});
|
|
4717
|
+
}
|
|
4718
|
+
return this.chargeRemaining(options);
|
|
4719
|
+
}
|
|
4720
|
+
/** Charges a FAILED tender again with a new `attempt_reference`. */
|
|
4721
|
+
async retryTender(tenderId, options = {}) {
|
|
4722
|
+
const tender = this.intentTenders().find((item) => item.id === tenderId);
|
|
4723
|
+
if (!tender) throw new Error(`Unknown tender ${tenderId}`);
|
|
4724
|
+
if (tender.status !== "FAILED") throw new Error(`Only a FAILED tender can be retried (${tender.status})`);
|
|
4725
|
+
return this.chargeRemaining(options);
|
|
4726
|
+
}
|
|
4727
|
+
/**
|
|
4728
|
+
* Changes the method of a FAILED tender (same allocation and amount);
|
|
4729
|
+
* then `payIntent()` / `retryTender` charges the new tender.
|
|
4730
|
+
*/
|
|
4731
|
+
async replaceTender(tenderId, payment, collectAt) {
|
|
4732
|
+
this.registerPayment(payment);
|
|
4733
|
+
const order = this.deps.getOrder();
|
|
4734
|
+
const method = payment.method.method;
|
|
4735
|
+
const provider = payment.provider.provider_id;
|
|
4736
|
+
const collect_at = collectAt ?? this.tenderCollectAt(payment);
|
|
4737
|
+
const body = {
|
|
4738
|
+
method,
|
|
4739
|
+
payment_id: payment.id,
|
|
4740
|
+
provider,
|
|
4741
|
+
collect_at
|
|
4742
|
+
};
|
|
4743
|
+
const http = this.deps.http;
|
|
4744
|
+
const replace = services.orders.replaceTender;
|
|
4745
|
+
const next = await replace(order.id, tenderId, body, http);
|
|
4746
|
+
this.deps.applyOrder(next);
|
|
4747
|
+
return next;
|
|
4748
|
+
}
|
|
4749
|
+
/** `PlaceOrderInput.payment` of the simple flow for `payment`. */
|
|
4750
|
+
placePayment(payment) {
|
|
4751
|
+
const type = payment.method.method;
|
|
4752
|
+
const provider = payment.provider.provider_id;
|
|
4753
|
+
const collect_at = this.tenderCollectAt(payment);
|
|
4754
|
+
return {
|
|
4755
|
+
id: payment.id,
|
|
4756
|
+
type,
|
|
4757
|
+
provider,
|
|
4758
|
+
collect_at
|
|
4759
|
+
};
|
|
4760
|
+
}
|
|
4761
|
+
tenderResult(tender) {
|
|
4762
|
+
const metadata = record(tender.metadata);
|
|
4763
|
+
const error = metadata.error;
|
|
4764
|
+
const attempt_reference = tender.attempt_reference;
|
|
4765
|
+
const m1 = {
|
|
4766
|
+
...metadata,
|
|
4767
|
+
attempt_reference
|
|
4768
|
+
};
|
|
4769
|
+
return {
|
|
4770
|
+
status: tender.status,
|
|
4771
|
+
amount: tender.amount.total,
|
|
4772
|
+
metadata: m1,
|
|
4773
|
+
...error ? { error } : {}
|
|
4774
|
+
};
|
|
4775
|
+
}
|
|
4776
|
+
async contextFor(tender) {
|
|
4777
|
+
const payment = tender.payment_id ? this.payments.get(tender.payment_id) : null;
|
|
4778
|
+
if (!payment) return null;
|
|
4779
|
+
const adapter = this.adapterFor(payment);
|
|
4780
|
+
if (!adapter) return null;
|
|
4781
|
+
const result = this.tenderResult(tender);
|
|
4782
|
+
const reference = tender.attempt_reference;
|
|
4783
|
+
const signal = new AbortController().signal;
|
|
4784
|
+
const a1 = [
|
|
4785
|
+
payment,
|
|
4786
|
+
tender.amount.total,
|
|
4787
|
+
reference,
|
|
4788
|
+
signal
|
|
4789
|
+
];
|
|
4790
|
+
return {
|
|
4791
|
+
adapter,
|
|
4792
|
+
result,
|
|
4793
|
+
ctx: await this.buildContext(...a1)
|
|
4794
|
+
};
|
|
4795
|
+
}
|
|
4796
|
+
/** After the order is injected: confirm PENDING `confirm_after_inject`. */
|
|
4797
|
+
async confirmAfterInject() {
|
|
4798
|
+
const intents = this.deps.getOrder().payment_intents;
|
|
4799
|
+
const tenders = liveTenders(currentIntent(intents));
|
|
4800
|
+
const results = [];
|
|
4801
|
+
for (const tender of tenders.filter(isPendingConfirm)) {
|
|
4802
|
+
const found = await this.contextFor(tender);
|
|
4803
|
+
const confirm = found?.adapter.confirm;
|
|
4804
|
+
if (!found || !confirm) continue;
|
|
4805
|
+
const { result, ctx } = found;
|
|
4806
|
+
const confirmed = await confirm(ctx, result).catch((error) => {
|
|
4807
|
+
const err = createPaymentError("UNKNOWN", { provider_message: error instanceof Error ? error.message : null });
|
|
4808
|
+
const m1 = {
|
|
4809
|
+
...result.metadata,
|
|
4810
|
+
error: err
|
|
4811
|
+
};
|
|
4812
|
+
return {
|
|
4813
|
+
...result,
|
|
4814
|
+
status: "FAILED",
|
|
4815
|
+
metadata: m1,
|
|
4816
|
+
error: err
|
|
4817
|
+
};
|
|
4818
|
+
});
|
|
4819
|
+
const next = {
|
|
4820
|
+
...confirmed,
|
|
4821
|
+
metadata: { ...confirmed.metadata }
|
|
4822
|
+
};
|
|
4823
|
+
delete next.metadata.confirm_after_inject;
|
|
4824
|
+
await this.recordResult(tender, next);
|
|
4825
|
+
results.push(next);
|
|
4826
|
+
}
|
|
4827
|
+
return results;
|
|
4828
|
+
}
|
|
4829
|
+
/**
|
|
4830
|
+
* Undo the device charges of this order (injection failed, or the
|
|
4831
|
+
* customer chose "cancel and refund"): cancel, refund or flag for manual
|
|
4832
|
+
* reversal. REDEMPTION reserves are released by the server on cancel.
|
|
4833
|
+
*/
|
|
4834
|
+
async reverseAll(options = {}) {
|
|
4835
|
+
const intents = this.deps.getOrder().payment_intents;
|
|
4836
|
+
const outcomes = [];
|
|
4837
|
+
for (const tender of chargedTenders(intents)) {
|
|
4838
|
+
if (tender.status === "IDLE") continue;
|
|
4839
|
+
const found = await this.contextFor(tender);
|
|
4840
|
+
if (!found) continue;
|
|
4841
|
+
await options.onProgress?.({
|
|
4842
|
+
type: "reversing",
|
|
4843
|
+
tender
|
|
4844
|
+
});
|
|
4845
|
+
const { adapter, result, ctx } = found;
|
|
4846
|
+
const outcome = await reversePayment(adapter, ctx, result);
|
|
4847
|
+
const reversed = withOrderAmount(outcome.result, tender.amount.total);
|
|
4848
|
+
await this.recordResult(tender, reversed);
|
|
4849
|
+
outcomes.push(outcome);
|
|
4850
|
+
}
|
|
4851
|
+
return outcomes;
|
|
4852
|
+
}
|
|
4853
|
+
/** Runs a terminal operation (init, test, closeBatch, status, extra). */
|
|
4854
|
+
async runTerminal(payment, key) {
|
|
4855
|
+
const terminal = this.adapterFor(payment)?.terminal;
|
|
4856
|
+
if (!terminal) return {
|
|
4857
|
+
ok: false,
|
|
4858
|
+
message: "No terminal operations"
|
|
4859
|
+
};
|
|
4860
|
+
const base = {
|
|
4861
|
+
init: terminal.init,
|
|
4862
|
+
test: terminal.test
|
|
4863
|
+
};
|
|
4864
|
+
const more = {
|
|
4865
|
+
closeBatch: terminal.closeBatch,
|
|
4866
|
+
status: terminal.status
|
|
4867
|
+
};
|
|
4868
|
+
const named = {
|
|
4869
|
+
...base,
|
|
4870
|
+
...more
|
|
4871
|
+
};
|
|
4872
|
+
const extra = terminal.extra?.find((item) => item.key === key)?.run;
|
|
4873
|
+
const operation = named[key] ?? extra;
|
|
4874
|
+
if (!operation) return {
|
|
4875
|
+
ok: false,
|
|
4876
|
+
message: `Unknown operation ${key}`
|
|
4877
|
+
};
|
|
4878
|
+
const signal = new AbortController().signal;
|
|
4879
|
+
const reference = createAttemptReference();
|
|
4880
|
+
const result = await operation(toTerminalContext(await this.buildContext(payment, 0, reference, signal))).catch(toFailure);
|
|
4881
|
+
if (!result.ok) await this.reportTerminal(payment, key, result);
|
|
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
|
|
4660
4889
|
};
|
|
4661
|
-
|
|
4662
|
-
|
|
4663
|
-
|
|
4664
|
-
|
|
4665
|
-
...b4,
|
|
4666
|
-
...b5
|
|
4890
|
+
const body = {
|
|
4891
|
+
operation,
|
|
4892
|
+
device_id: this.options.deviceId ?? null,
|
|
4893
|
+
error
|
|
4667
4894
|
};
|
|
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);
|
|
4668
4948
|
});
|
|
4669
|
-
|
|
4670
|
-
|
|
4671
|
-
|
|
4672
|
-
|
|
4673
|
-
|
|
4674
|
-
|
|
4675
|
-
|
|
4949
|
+
const fallback = periods.filter((period) => !hasSpecificDates(period));
|
|
4950
|
+
const specificOpens = coveringSpecific.filter((period) => !period.closed);
|
|
4951
|
+
if (coveringSpecific.filter((period) => period.closed).some((period) => isClosedAt(period, now))) return null;
|
|
4952
|
+
if (specificOpens.length > 0) return specificOpens.find((period) => isOpenAt(period, now)) ?? null;
|
|
4953
|
+
if (fallback.some((period) => isClosedAt(period, now))) return null;
|
|
4954
|
+
return fallback.filter((period) => !period.closed).find((period) => isOpenAt(period, now)) ?? null;
|
|
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;
|
|
4676
4997
|
};
|
|
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);
|
|
4677
5018
|
};
|
|
4678
|
-
/**
|
|
4679
|
-
const
|
|
4680
|
-
|
|
4681
|
-
|
|
4682
|
-
|
|
5019
|
+
/** Whether `schedule` is in hours now, and until / next open. */
|
|
5020
|
+
const getScheduleWindow = (schedule, now = /* @__PURE__ */ new Date()) => {
|
|
5021
|
+
if (schedule == null) return {
|
|
5022
|
+
active: true,
|
|
5023
|
+
until: null
|
|
5024
|
+
};
|
|
5025
|
+
const periods = dayPeriods(schedule, now);
|
|
5026
|
+
if (isPeriodsActiveAt(periods, now)) return {
|
|
5027
|
+
active: true,
|
|
5028
|
+
until: getActiveUntil(periods, now)
|
|
5029
|
+
};
|
|
4683
5030
|
return {
|
|
4684
|
-
|
|
4685
|
-
|
|
4686
|
-
totals: recomputeTotals(lines, currency)
|
|
5031
|
+
active: false,
|
|
5032
|
+
next: getNextStart(schedule, now)
|
|
4687
5033
|
};
|
|
4688
5034
|
};
|
|
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
|
+
};
|
|
4689
5072
|
//#endregion
|
|
4690
5073
|
//#region src/order/Order.ts
|
|
4691
5074
|
const cloneOrder = (order) => {
|
|
@@ -4805,14 +5188,8 @@ const buildLocalDraft = (insert) => {
|
|
|
4805
5188
|
const is_anonymous = insert.is_anonymous ?? false;
|
|
4806
5189
|
const totals = insert.totals ?? emptyTotals();
|
|
4807
5190
|
const metadata = insert.metadata ?? {};
|
|
4808
|
-
const payment_intents =
|
|
4809
|
-
|
|
4810
|
-
return {
|
|
4811
|
-
...intent,
|
|
4812
|
-
metadata
|
|
4813
|
-
};
|
|
4814
|
-
});
|
|
4815
|
-
const payment_status = insert.payment_status ?? "IDLE";
|
|
5191
|
+
const payment_intents = [];
|
|
5192
|
+
const payment_status = "IDLE";
|
|
4816
5193
|
const b1 = {
|
|
4817
5194
|
uid: randomUUID(),
|
|
4818
5195
|
id: insert.id,
|
|
@@ -4904,6 +5281,9 @@ var Order = class Order {
|
|
|
4904
5281
|
http;
|
|
4905
5282
|
flush;
|
|
4906
5283
|
payments;
|
|
5284
|
+
/** Real tenders chosen with `setTenders`; REDEMPTION ones come from redeem. */
|
|
5285
|
+
realTenders = null;
|
|
5286
|
+
redemptionProviders = /* @__PURE__ */ new Map();
|
|
4907
5287
|
constructor(config, notify) {
|
|
4908
5288
|
this.draft = cloneOrder(config.order);
|
|
4909
5289
|
this.synced = cloneOrder(config.order);
|
|
@@ -5102,12 +5482,19 @@ var Order = class Order {
|
|
|
5102
5482
|
}
|
|
5103
5483
|
/**
|
|
5104
5484
|
* Appends a redeem to `line_uid` (root line) or to the order. Steps keep
|
|
5105
|
-
* the order in which redeems were applied.
|
|
5485
|
+
* the order in which redeems were applied. With `payment_id` (points /
|
|
5486
|
+
* balance) it is not a discount: `amount` and `points` are totals of the
|
|
5487
|
+
* whole entry (every unit, not per unit) and `quantity` is informational.
|
|
5488
|
+
* Every redeem with the same `payment_id` is paid by one REDEMPTION
|
|
5489
|
+
* tender whose `details.rewards[]` lists them; their summed amount is
|
|
5490
|
+
* capped at total − tip − delivery (D15, D18, D19).
|
|
5106
5491
|
*/
|
|
5107
5492
|
applyRedeem(input) {
|
|
5108
5493
|
if (this.deleted) return;
|
|
5109
|
-
const { line_uid, ...rest } = input;
|
|
5494
|
+
const { line_uid, provider, ...rest } = input;
|
|
5110
5495
|
const redeem = buildRedeem(rest);
|
|
5496
|
+
if (isPaymentRedeem(redeem) && line_uid !== void 0) throw new Error("Points / balance redeems apply to the whole order");
|
|
5497
|
+
if (redeem.payment_id && provider) this.redemptionProviders.set(redeem.payment_id, provider);
|
|
5111
5498
|
if (line_uid === void 0) {
|
|
5112
5499
|
this.commitLines({ redeem: [...this.draft.redeem ?? [], redeem] });
|
|
5113
5500
|
return;
|
|
@@ -5125,7 +5512,10 @@ var Order = class Order {
|
|
|
5125
5512
|
}
|
|
5126
5513
|
/**
|
|
5127
5514
|
* Removes `quantity` units of `reward_id` from `line_uid` or the order,
|
|
5128
|
-
* latest applied first. Removing more units than applied clears them all
|
|
5515
|
+
* latest applied first. Removing more units than applied clears them all
|
|
5516
|
+
* (and the REDEMPTION tender of a points / balance redeem). A points /
|
|
5517
|
+
* balance entry that keeps some units keeps the same share of its
|
|
5518
|
+
* `amount` and `points`.
|
|
5129
5519
|
*/
|
|
5130
5520
|
removeRedeem(input) {
|
|
5131
5521
|
if (this.deleted) return;
|
|
@@ -5269,19 +5659,72 @@ var Order = class Order {
|
|
|
5269
5659
|
};
|
|
5270
5660
|
await services.orders.validate(body, this.http);
|
|
5271
5661
|
}
|
|
5662
|
+
/** Tenders of the next intent: real ones plus one per payment redeem. */
|
|
5663
|
+
getTenders(payment) {
|
|
5664
|
+
const simple = payment ? simpleTenderInput(payment) : null;
|
|
5665
|
+
const real = simple ? [simple] : this.realTenders ?? [];
|
|
5666
|
+
const redeem = this.draft.redeem ?? [];
|
|
5667
|
+
const providers = this.redemptionProviders;
|
|
5668
|
+
return [...real, ...redemptionTenderInputs(redeem, providers)];
|
|
5669
|
+
}
|
|
5670
|
+
/**
|
|
5671
|
+
* Chooses the real tenders (with allocation for mixed payments) and saves
|
|
5672
|
+
* the DRAFT intent; the server returns amounts per tender. REDEMPTION
|
|
5673
|
+
* tenders are added from the redeems, never passed here.
|
|
5674
|
+
*/
|
|
5675
|
+
async setTenders(tenders) {
|
|
5676
|
+
if (tenders.some((tender) => !isRealPaymentMethod(tender.method))) throw new Error("REDEMPTION tenders come from applyRedeem");
|
|
5677
|
+
this.realTenders = tenders;
|
|
5678
|
+
return this.syncPaymentIntent(this.getTenders());
|
|
5679
|
+
}
|
|
5680
|
+
/** Drops the chosen tenders and discards the DRAFT intent. */
|
|
5681
|
+
async clearTenders() {
|
|
5682
|
+
this.realTenders = null;
|
|
5683
|
+
if (activeIntent(this.draft.payment_intents)?.status !== "DRAFT") return this.getOrder();
|
|
5684
|
+
const id = this.draft.id;
|
|
5685
|
+
const next = await services.orders.deletePaymentIntent(id, this.http);
|
|
5686
|
+
this.applyServerOrder(next);
|
|
5687
|
+
return this.getOrder();
|
|
5688
|
+
}
|
|
5689
|
+
async syncPaymentIntent(tenders) {
|
|
5690
|
+
await this.persist();
|
|
5691
|
+
await this.flush.settle();
|
|
5692
|
+
const id = this.draft.id;
|
|
5693
|
+
const put = services.orders.putPaymentIntent;
|
|
5694
|
+
const next = await put(id, { tenders }, this.http);
|
|
5695
|
+
this.applyServerOrder(next);
|
|
5696
|
+
return this.getOrder();
|
|
5697
|
+
}
|
|
5698
|
+
applyServerOrder(next) {
|
|
5699
|
+
this.draft = cloneOrder(next);
|
|
5700
|
+
this.synced = cloneOrder(next);
|
|
5701
|
+
this.notify();
|
|
5702
|
+
}
|
|
5703
|
+
/**
|
|
5704
|
+
* Places the order with a DRAFT intent: `intent_id` as is, else the
|
|
5705
|
+
* tenders of `getTenders(input.payment)` saved first (simple flow: one
|
|
5706
|
+
* real tender plus REDEMPTION). The intent is `LOCKED` afterwards (D25).
|
|
5707
|
+
*/
|
|
5272
5708
|
async placeOrder(input = {}) {
|
|
5273
5709
|
await this.persist();
|
|
5274
5710
|
await this.flush.settle();
|
|
5275
5711
|
await this.validateOrder();
|
|
5712
|
+
let body = input;
|
|
5713
|
+
if (!input.intent_id) {
|
|
5714
|
+
const tenders = this.getTenders(input.payment);
|
|
5715
|
+
const needsPayment = input.payment && !simpleTenderInput(input.payment);
|
|
5716
|
+
if (tenders.length > 0 && !needsPayment) {
|
|
5717
|
+
const intent = activeIntent((await this.syncPaymentIntent(tenders)).payment_intents);
|
|
5718
|
+
if (intent) body = { intent_id: intent.id };
|
|
5719
|
+
}
|
|
5720
|
+
}
|
|
5276
5721
|
try {
|
|
5277
|
-
await services.orders.place(this.draft.id,
|
|
5722
|
+
await services.orders.place(this.draft.id, body, this.http);
|
|
5278
5723
|
} catch (error) {
|
|
5279
5724
|
if (!(error instanceof StorefrontHttpError) || error.status !== 409) throw error;
|
|
5280
5725
|
}
|
|
5281
5726
|
const next = await services.orders.get(this.draft.id, this.http);
|
|
5282
|
-
this.
|
|
5283
|
-
this.synced = cloneOrder(next);
|
|
5284
|
-
this.notify();
|
|
5727
|
+
this.applyServerOrder(next);
|
|
5285
5728
|
}
|
|
5286
5729
|
/** Installed `@fire-spark/payments-*` adapters (provider + method). */
|
|
5287
5730
|
setPaymentAdapters(adapters) {
|
|
@@ -5307,21 +5750,73 @@ var Order = class Order {
|
|
|
5307
5750
|
getMissingPaymentFields(payment) {
|
|
5308
5751
|
return this.payments.missingFields(payment);
|
|
5309
5752
|
}
|
|
5310
|
-
/** Order total minus
|
|
5753
|
+
/** Order total minus settled (and confirm-after-inject) tenders. */
|
|
5311
5754
|
getRemainingAmount() {
|
|
5312
5755
|
return this.payments.remaining();
|
|
5313
5756
|
}
|
|
5314
|
-
/**
|
|
5757
|
+
/** Merchant payments the tenders point to (`payment_id`). */
|
|
5758
|
+
setPayments(payments) {
|
|
5759
|
+
this.payments.registerPayments(payments);
|
|
5760
|
+
}
|
|
5761
|
+
/**
|
|
5762
|
+
* Places the order if needed and charges its real device tenders in
|
|
5763
|
+
* sequence (§6.1); stops at the first failure with the order frozen.
|
|
5764
|
+
*/
|
|
5765
|
+
payIntent(options) {
|
|
5766
|
+
return this.payments.payIntent(options);
|
|
5767
|
+
}
|
|
5768
|
+
/** Charges a FAILED tender again with a new attempt reference (D25). */
|
|
5769
|
+
retryTender(tenderId, options) {
|
|
5770
|
+
return this.payments.retryTender(tenderId, options);
|
|
5771
|
+
}
|
|
5772
|
+
/** Changes the method of a FAILED tender; then `payIntent()` again. */
|
|
5773
|
+
replaceTender(tenderId, payment, collectAt) {
|
|
5774
|
+
return this.payments.replaceTender(tenderId, payment, collectAt);
|
|
5775
|
+
}
|
|
5776
|
+
/** "Cancel and refund": reverses device charges, then cancels the order. */
|
|
5777
|
+
async cancelAndRefund(options) {
|
|
5778
|
+
const outcomes = await this.payments.reverseAll(options);
|
|
5779
|
+
await this.cancelOrder();
|
|
5780
|
+
return outcomes;
|
|
5781
|
+
}
|
|
5782
|
+
/** Voids one tender (operator flow); idempotent per call. */
|
|
5783
|
+
async voidTender(tenderId, reason) {
|
|
5784
|
+
const idempotency_key = createAttemptReference();
|
|
5785
|
+
const body = reason ? {
|
|
5786
|
+
idempotency_key,
|
|
5787
|
+
reason
|
|
5788
|
+
} : { idempotency_key };
|
|
5789
|
+
const id = this.draft.id;
|
|
5790
|
+
const next = await services.orders.voidTender(id, tenderId, body, this.http);
|
|
5791
|
+
this.applyServerOrder(next);
|
|
5792
|
+
return this.getOrder();
|
|
5793
|
+
}
|
|
5794
|
+
/** Refunds one tender: all of it, an amount, or some units. */
|
|
5795
|
+
async refundTender(tenderId, input = {}) {
|
|
5796
|
+
const body = {
|
|
5797
|
+
...input,
|
|
5798
|
+
idempotency_key: createAttemptReference()
|
|
5799
|
+
};
|
|
5800
|
+
const id = this.draft.id;
|
|
5801
|
+
const refund = services.orders.refundTender;
|
|
5802
|
+
const next = await refund(id, tenderId, body, this.http);
|
|
5803
|
+
this.applyServerOrder(next);
|
|
5804
|
+
return this.getOrder();
|
|
5805
|
+
}
|
|
5806
|
+
/** @deprecated Use `payIntent({ payment })`. */
|
|
5315
5807
|
pay(payment, options) {
|
|
5316
|
-
return this.payments.
|
|
5808
|
+
return this.payments.payIntent({
|
|
5809
|
+
...options,
|
|
5810
|
+
payment
|
|
5811
|
+
});
|
|
5317
5812
|
}
|
|
5318
5813
|
/** Confirms intents that waited for the order injection. */
|
|
5319
5814
|
confirmPaymentsAfterInject() {
|
|
5320
5815
|
return this.payments.confirmAfterInject();
|
|
5321
5816
|
}
|
|
5322
5817
|
/** Cancels or refunds every charge of this order (or flags it manual). */
|
|
5323
|
-
reversePayments() {
|
|
5324
|
-
return this.payments.reverseAll();
|
|
5818
|
+
reversePayments(options) {
|
|
5819
|
+
return this.payments.reverseAll(options);
|
|
5325
5820
|
}
|
|
5326
5821
|
/** Terminal admin operation (init, test, closeBatch, status, extra). */
|
|
5327
5822
|
runPaymentTerminal(payment, key) {
|
|
@@ -5330,13 +5825,19 @@ var Order = class Order {
|
|
|
5330
5825
|
/** Applies lines/redeem, replaying redemption steps and totals. */
|
|
5331
5826
|
commitLines(patch) {
|
|
5332
5827
|
const patched = patch.lines ?? this.draft.lines;
|
|
5333
|
-
const
|
|
5828
|
+
const applied = patch.redeem ?? this.draft.redeem ?? [];
|
|
5829
|
+
const steps = this.draft.redemption_steps ?? [];
|
|
5830
|
+
const currency = this.draft.totals.currency;
|
|
5334
5831
|
const { lines, redemption_steps, totals } = recomputeRedemptions({
|
|
5335
5832
|
lines: patched,
|
|
5336
|
-
redeem,
|
|
5337
|
-
redemption_steps:
|
|
5338
|
-
currency
|
|
5833
|
+
redeem: applied,
|
|
5834
|
+
redemption_steps: steps,
|
|
5835
|
+
currency
|
|
5339
5836
|
});
|
|
5837
|
+
const redeem = clampPaymentRedeems(applied, redemptionCap({
|
|
5838
|
+
lines,
|
|
5839
|
+
totals
|
|
5840
|
+
}), currency);
|
|
5340
5841
|
this.draft = {
|
|
5341
5842
|
...this.draft,
|
|
5342
5843
|
lines,
|
|
@@ -6471,6 +6972,22 @@ const isAnonymousOrder = (condition, isAnonymous) => {
|
|
|
6471
6972
|
assert(value != null, "Invalid value");
|
|
6472
6973
|
return matchBooleanOperator(condition, isAnonymous ?? false);
|
|
6473
6974
|
};
|
|
6975
|
+
const paymentIsMixed = (condition, isMixed) => {
|
|
6976
|
+
const { type, primitive, value } = condition;
|
|
6977
|
+
const matcherType = "Payment is mixed";
|
|
6978
|
+
assert(primitive === "boolean", primitiveError("boolean"));
|
|
6979
|
+
assert(type === matcherType, typeError(matcherType));
|
|
6980
|
+
assert(value != null, "Invalid value");
|
|
6981
|
+
return matchBooleanOperator(condition, isMixed ?? false);
|
|
6982
|
+
};
|
|
6983
|
+
const paymentCaptured = (condition, captured) => {
|
|
6984
|
+
const { type, primitive, value } = condition;
|
|
6985
|
+
const matcherType = "Payment captured";
|
|
6986
|
+
assert(primitive === "boolean", primitiveError("boolean"));
|
|
6987
|
+
assert(type === matcherType, typeError(matcherType));
|
|
6988
|
+
assert(value != null, "Invalid value");
|
|
6989
|
+
return matchBooleanOperator(condition, captured ?? false);
|
|
6990
|
+
};
|
|
6474
6991
|
//#endregion
|
|
6475
6992
|
//#region src/rules/currencyMatcher.ts
|
|
6476
6993
|
const matchCurrencyOperator = (condition, matcher) => {
|
|
@@ -6583,17 +7100,29 @@ const matchEnumOperator = (condition, matcher) => {
|
|
|
6583
7100
|
default: throw new Error(`Operator ${operator} not supported in enum matcher`);
|
|
6584
7101
|
}
|
|
6585
7102
|
};
|
|
7103
|
+
const NEGATIVE_ENUM_OPERATORS = ["is not", "is none of"];
|
|
7104
|
+
/**
|
|
7105
|
+
* Enum condition over a set (one value per tender): positive operators
|
|
7106
|
+
* match when some value matches, negative ones when none matches.
|
|
7107
|
+
*/
|
|
7108
|
+
const matchEnumSet = (condition, matcher) => {
|
|
7109
|
+
if (typeof matcher === "undefined" || typeof matcher === "string") return matchEnumOperator(condition, matcher);
|
|
7110
|
+
if (matcher.length === 0) return false;
|
|
7111
|
+
const matches = (value) => matchEnumOperator(condition, value);
|
|
7112
|
+
if (NEGATIVE_ENUM_OPERATORS.includes(condition.operator)) return matcher.every(matches);
|
|
7113
|
+
return matcher.some(matches);
|
|
7114
|
+
};
|
|
6586
7115
|
const paymentCollectAt = (condition, collectAt) => {
|
|
6587
7116
|
const matcherType = "Payment collect at";
|
|
6588
7117
|
assert(condition.primitive === "enum", primitiveError("enum"));
|
|
6589
7118
|
assert(condition.type === matcherType, typeError(matcherType));
|
|
6590
|
-
return
|
|
7119
|
+
return matchEnumSet(condition, collectAt);
|
|
6591
7120
|
};
|
|
6592
7121
|
const paymentMethod = (condition, method) => {
|
|
6593
7122
|
const matcherType = "Payment method";
|
|
6594
7123
|
assert(condition.primitive === "enum", primitiveError("enum"));
|
|
6595
7124
|
assert(condition.type === matcherType, typeError(matcherType));
|
|
6596
|
-
return
|
|
7125
|
+
return matchEnumSet(condition, method);
|
|
6597
7126
|
};
|
|
6598
7127
|
const paymentStatus = (condition, status) => {
|
|
6599
7128
|
const matcherType = "Payment status";
|
|
@@ -7351,6 +7880,14 @@ var RulesEngine = class {
|
|
|
7351
7880
|
if (typeof value !== "boolean") return false;
|
|
7352
7881
|
return orderIncludesProductWithTag$3(condition, value);
|
|
7353
7882
|
});
|
|
7883
|
+
if (type === "Payment is mixed") {
|
|
7884
|
+
const payment = this.getDependency("payment", type);
|
|
7885
|
+
return paymentIsMixed(condition, payment.is_mixed);
|
|
7886
|
+
}
|
|
7887
|
+
if (type === "Payment captured") {
|
|
7888
|
+
const payment = this.getDependency("payment", type);
|
|
7889
|
+
return paymentCaptured(condition, payment.captured);
|
|
7890
|
+
}
|
|
7354
7891
|
throw new Error(`Condition ${type} not supported in boolean matcher`);
|
|
7355
7892
|
};
|
|
7356
7893
|
assertCurrency = (condition) => {
|
|
@@ -7371,8 +7908,14 @@ var RulesEngine = class {
|
|
|
7371
7908
|
assert(primitive === "enum", primitiveError("enum"));
|
|
7372
7909
|
const payment = this.getDependency("payment", type);
|
|
7373
7910
|
switch (type) {
|
|
7374
|
-
case "Payment collect at":
|
|
7375
|
-
|
|
7911
|
+
case "Payment collect at": {
|
|
7912
|
+
const collectAt = payment.collect_ats ?? payment.collect_at;
|
|
7913
|
+
return paymentCollectAt(condition, collectAt);
|
|
7914
|
+
}
|
|
7915
|
+
case "Payment method": {
|
|
7916
|
+
const method = payment.methods ?? payment.method;
|
|
7917
|
+
return paymentMethod(condition, method);
|
|
7918
|
+
}
|
|
7376
7919
|
case "Payment status": return paymentStatus(condition, payment.status);
|
|
7377
7920
|
default: throw new Error(`Condition ${type} not supported in enum matcher`);
|
|
7378
7921
|
}
|
|
@@ -8004,4 +8547,4 @@ const cardPreviewGroups = (value) => {
|
|
|
8004
8547
|
return groups;
|
|
8005
8548
|
};
|
|
8006
8549
|
//#endregion
|
|
8007
|
-
export { CARD_BRANDS, DEFAULT_RECOMMENDATION_TYPES, Order, OrderSyncPendingError, OrderValidationError, RecurringDateCron, RulesEngine, StorefrontHttpError, addLines, auth, buildCustomerSnapshot, cardBrandSpec, cardDigits, cardNumberGaps, cardNumberMaxLength, cardPreviewGroups, caretAfterDigits, collectOrderLineDiffs, computeStartingPrice, countCartProducts, coveredAmount, createSession, currentNodes, cvcMaxLength, defaultConditionError, detectCardBrand, flattenMenuCategories, flushLogs, formatCardNumber, formatCvc, formatExpiry, fromOrderLines, genId, genOrderId, getLogContext, getScheduleWindow, indexMenu, isCardNumberComplete, isCardNumberDefinitelyInvalid, isCheckoutFieldFilled, isCvcComplete, isDeviceNotLinked, isExpiryComplete, isKioskUnauthorized, isMilestoneStepDone, isOnSchedule, isOrderValidationError, isPosDeviceNotLinked, isPosUnauthorized, isStorefrontNotFound, isVariantBase, listMenusStream, localizePosError, log, logError, luhnCheck, matchAbsoluteDateOperator, matchBooleanOperator, matchCurrencyOperator, matchEnumOperator, matchNumberOperator, matchRecurringDateOperator, matchRelativeDateOperator, matchStringOperator, milestoneSteps, milestoneTrack, nestMenuCategories, normalizeCardholderName, parseExpiry, paymentMatchesClientConditions, recomputeRedemptions, recomputeTotals, remainingAmount, removeLine, replaceLine, resolveCheckout, reversePayment, runPaymentAttempt, serializeError, services, setLineQuantity, storefrontJson, stripRedemptionLines, toOrderLines, toPaymentCardBrand, validateCardNumber, validateCardholderName, validateCvc, validateExpiry, validateOrderAgainstMenu, verifyWebhookSignature, visitedStates };
|
|
8550
|
+
export { CARD_BRANDS, DEFAULT_RECOMMENDATION_TYPES, Order, OrderSyncPendingError, OrderValidationError, RecurringDateCron, RulesEngine, StorefrontHttpError, addLines, auth, buildCustomerSnapshot, cardBrandSpec, cardDigits, cardNumberGaps, cardNumberMaxLength, cardPreviewGroups, caretAfterDigits, collectOrderLineDiffs, computeStartingPrice, countCartProducts, coveredAmount, createSession, currentNodes, cvcMaxLength, defaultConditionError, detectCardBrand, flattenMenuCategories, flushLogs, formatCardNumber, formatCvc, formatExpiry, fromOrderLines, genId, genOrderId, getLogContext, getScheduleWindow, indexMenu, isCardNumberComplete, isCardNumberDefinitelyInvalid, isCheckoutFieldFilled, isCvcComplete, isDeviceCharged, isDeviceNotLinked, isExpiryComplete, isKioskUnauthorized, isMilestoneStepDone, isOnSchedule, isOrderValidationError, isPosDeviceNotLinked, isPosUnauthorized, isRealPaymentMethod, isStorefrontNotFound, isVariantBase, listMenusStream, localizePosError, log, logError, luhnCheck, matchAbsoluteDateOperator, matchBooleanOperator, matchCurrencyOperator, matchEnumOperator, matchNumberOperator, matchRecurringDateOperator, matchRelativeDateOperator, matchStringOperator, milestoneSteps, milestoneTrack, nestMenuCategories, normalizeCardholderName, parseExpiry, paymentMatchesClientConditions, recomputeRedemptions, recomputeTotals, redemptionCap, remainingAmount, removeLine, replaceLine, resolveCheckout, reversePayment, runPaymentAttempt, selectablePayments, serializeError, services, setLineQuantity, storefrontJson, stripRedemptionLines, toOrderLines, toPaymentCardBrand, validateCardNumber, validateCardholderName, validateCvc, validateExpiry, validateOrderAgainstMenu, verifyWebhookSignature, visitedStates };
|