@fire-spark/core 0.1.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +185 -23
- package/dist/index.js +1379 -705
- 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, isDeliveryFee, isPaymentError, isRealPaymentMethod, 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,7 +2637,7 @@ 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
|
|
@@ -2857,6 +2932,11 @@ const services = {
|
|
|
2857
2932
|
validate: validateOrder,
|
|
2858
2933
|
place: placeOrder,
|
|
2859
2934
|
recordPayment: recordOrderPayment,
|
|
2935
|
+
putPaymentIntent,
|
|
2936
|
+
deletePaymentIntent,
|
|
2937
|
+
replaceTender,
|
|
2938
|
+
voidTender,
|
|
2939
|
+
refundTender,
|
|
2860
2940
|
getFulfillment: getOrderFulfillment,
|
|
2861
2941
|
listFulfillments: listOrderFulfillments
|
|
2862
2942
|
},
|
|
@@ -3043,21 +3123,18 @@ const createFlushController = (runtime) => {
|
|
|
3043
3123
|
totals: order.totals,
|
|
3044
3124
|
metadata: order.metadata
|
|
3045
3125
|
};
|
|
3046
|
-
const b5 = {
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
const
|
|
3126
|
+
const b5 = {
|
|
3127
|
+
customer: order.customer,
|
|
3128
|
+
redeem: order.redeem
|
|
3129
|
+
};
|
|
3130
|
+
const b6 = { redemption_steps: order.redemption_steps };
|
|
3051
3131
|
return {
|
|
3052
3132
|
...b1,
|
|
3053
3133
|
...b2,
|
|
3054
3134
|
...b3,
|
|
3055
3135
|
...b4,
|
|
3056
3136
|
...b5,
|
|
3057
|
-
...b6
|
|
3058
|
-
...b7,
|
|
3059
|
-
...b8,
|
|
3060
|
-
...b9
|
|
3137
|
+
...b6
|
|
3061
3138
|
};
|
|
3062
3139
|
};
|
|
3063
3140
|
const insertBody = (order) => {
|
|
@@ -3079,10 +3156,8 @@ const createFlushController = (runtime) => {
|
|
|
3079
3156
|
totals: order.totals,
|
|
3080
3157
|
metadata: order.metadata
|
|
3081
3158
|
};
|
|
3082
|
-
const b7 = {
|
|
3083
|
-
const b8 = {
|
|
3084
|
-
const b9 = { redeem: order.redeem };
|
|
3085
|
-
const b10 = { redemption_steps: order.redemption_steps };
|
|
3159
|
+
const b7 = { redeem: order.redeem };
|
|
3160
|
+
const b8 = { redemption_steps: order.redemption_steps };
|
|
3086
3161
|
return {
|
|
3087
3162
|
...b1,
|
|
3088
3163
|
...b2,
|
|
@@ -3091,9 +3166,7 @@ const createFlushController = (runtime) => {
|
|
|
3091
3166
|
...b5,
|
|
3092
3167
|
...b6,
|
|
3093
3168
|
...b7,
|
|
3094
|
-
...b8
|
|
3095
|
-
...b9,
|
|
3096
|
-
...b10
|
|
3169
|
+
...b8
|
|
3097
3170
|
};
|
|
3098
3171
|
};
|
|
3099
3172
|
const flushNow = () => {
|
|
@@ -3357,168 +3430,693 @@ const emptyLines = () => {
|
|
|
3357
3430
|
return [];
|
|
3358
3431
|
};
|
|
3359
3432
|
//#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));
|
|
3388
|
-
};
|
|
3389
|
-
const stop = () => {
|
|
3390
|
-
if (!timer) return;
|
|
3391
|
-
clearTimeout(timer);
|
|
3392
|
-
timer = null;
|
|
3393
|
-
remaining -= Date.now() - startedAt;
|
|
3433
|
+
//#region src/order/totals.ts
|
|
3434
|
+
/** Sum root line group totals into order totals (Big.js). */
|
|
3435
|
+
const recomputeTotals = (lines, currency = "USD") => {
|
|
3436
|
+
let subtotal = new Big(0);
|
|
3437
|
+
let discountTotal = new Big(0);
|
|
3438
|
+
let taxTotal = new Big(0);
|
|
3439
|
+
let total = new Big(0);
|
|
3440
|
+
for (const line of lines) {
|
|
3441
|
+
if (line.parent_uid != null) continue;
|
|
3442
|
+
subtotal = subtotal.plus(line.group_subtotal);
|
|
3443
|
+
discountTotal = discountTotal.plus(line.group_discount);
|
|
3444
|
+
taxTotal = taxTotal.plus(line.group_tax_total);
|
|
3445
|
+
total = total.plus(line.group_total);
|
|
3446
|
+
}
|
|
3447
|
+
let taxRate = 0;
|
|
3448
|
+
let discountRate = 0;
|
|
3449
|
+
if (subtotal.gt(0)) {
|
|
3450
|
+
taxRate = taxTotal.div(subtotal).toNumber();
|
|
3451
|
+
discountRate = discountTotal.div(subtotal).toNumber();
|
|
3452
|
+
}
|
|
3453
|
+
const discount_total = discountTotal.toNumber();
|
|
3454
|
+
const tax_total = taxTotal.toNumber();
|
|
3455
|
+
const b1 = {
|
|
3456
|
+
currency,
|
|
3457
|
+
subtotal: subtotal.toNumber()
|
|
3394
3458
|
};
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
if (paused === 0) stop();
|
|
3399
|
-
paused += 1;
|
|
3400
|
-
},
|
|
3401
|
-
resume: () => {
|
|
3402
|
-
paused -= 1;
|
|
3403
|
-
if (paused === 0) run();
|
|
3404
|
-
},
|
|
3405
|
-
clear: stop
|
|
3459
|
+
const b2 = {
|
|
3460
|
+
discount_rate: discountRate,
|
|
3461
|
+
discount_total
|
|
3406
3462
|
};
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
reject(new PaymentUserTimeout());
|
|
3412
|
-
}, ms);
|
|
3413
|
-
promise.then((value) => {
|
|
3414
|
-
clearTimeout(timer);
|
|
3415
|
-
resolve(value);
|
|
3416
|
-
}, (error) => {
|
|
3417
|
-
clearTimeout(timer);
|
|
3418
|
-
reject(error);
|
|
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());
|
|
3463
|
+
const b3 = {
|
|
3464
|
+
tax_rate: taxRate,
|
|
3465
|
+
tax_total,
|
|
3466
|
+
total: total.toNumber()
|
|
3427
3467
|
};
|
|
3428
3468
|
return {
|
|
3429
|
-
|
|
3430
|
-
|
|
3431
|
-
|
|
3432
|
-
return wrap(() => ui.select(input));
|
|
3433
|
-
},
|
|
3434
|
-
input: (input) => wrap(() => ui.input(input)),
|
|
3435
|
-
confirm: (input) => wrap(() => ui.confirm(input))
|
|
3469
|
+
...b1,
|
|
3470
|
+
...b2,
|
|
3471
|
+
...b3
|
|
3436
3472
|
};
|
|
3437
3473
|
};
|
|
3438
|
-
|
|
3439
|
-
const
|
|
3440
|
-
|
|
3441
|
-
|
|
3442
|
-
|
|
3443
|
-
|
|
3444
|
-
let expire = () => void 0;
|
|
3445
|
-
const expired = new Promise((_, reject) => {
|
|
3446
|
-
expire = () => {
|
|
3447
|
-
controller.abort();
|
|
3448
|
-
reject(new TimeoutSignal());
|
|
3449
|
-
};
|
|
3450
|
-
});
|
|
3451
|
-
const deadline = createDeadline(ctx.policy.timeout_ms, () => expire());
|
|
3452
|
-
const interaction = ctx.policy.interaction_timeout_ms;
|
|
3453
|
-
const ui = timedUI(ctx.ui, deadline, interaction);
|
|
3454
|
-
const scoped = {
|
|
3455
|
-
...ctx,
|
|
3456
|
-
ui,
|
|
3457
|
-
signal: controller.signal
|
|
3474
|
+
const emptyTotals = (currency = "USD") => {
|
|
3475
|
+
const b1 = {
|
|
3476
|
+
currency,
|
|
3477
|
+
subtotal: 0,
|
|
3478
|
+
discount_rate: 0,
|
|
3479
|
+
discount_total: 0
|
|
3458
3480
|
};
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
|
|
3463
|
-
deadline.clear();
|
|
3464
|
-
parent.removeEventListener("abort", abort);
|
|
3465
|
-
}
|
|
3466
|
-
};
|
|
3467
|
-
const failed = (ctx, error, extra = {}) => {
|
|
3468
|
-
const metadata = {
|
|
3469
|
-
attempt_reference: ctx.attemptReference,
|
|
3470
|
-
error,
|
|
3471
|
-
...extra
|
|
3481
|
+
const b2 = {
|
|
3482
|
+
tax_rate: 0,
|
|
3483
|
+
tax_total: 0,
|
|
3484
|
+
total: 0
|
|
3472
3485
|
};
|
|
3473
3486
|
return {
|
|
3474
|
-
|
|
3475
|
-
|
|
3476
|
-
metadata,
|
|
3477
|
-
error
|
|
3487
|
+
...b1,
|
|
3488
|
+
...b2
|
|
3478
3489
|
};
|
|
3479
3490
|
};
|
|
3480
|
-
|
|
3481
|
-
|
|
3482
|
-
|
|
3483
|
-
|
|
3484
|
-
|
|
3485
|
-
|
|
3486
|
-
|
|
3487
|
-
|
|
3488
|
-
|
|
3489
|
-
if (
|
|
3490
|
-
if (
|
|
3491
|
-
const
|
|
3492
|
-
|
|
3493
|
-
|
|
3494
|
-
|
|
3491
|
+
//#endregion
|
|
3492
|
+
//#region src/order/redemptions.ts
|
|
3493
|
+
const ORDER_KEY = "ORDER";
|
|
3494
|
+
/** Points / balance paid with a REDEMPTION tender, not a discount (D16). */
|
|
3495
|
+
const isPaymentRedeem = (redeem) => {
|
|
3496
|
+
return Boolean(redeem.payment_id);
|
|
3497
|
+
};
|
|
3498
|
+
const buildPaymentRedeem = (input) => {
|
|
3499
|
+
const { reward_id, quantity, payment_id, amount, points } = input;
|
|
3500
|
+
if (amount === void 0 || !(amount > 0)) throw new Error("Redeem with payment_id requires amount > 0");
|
|
3501
|
+
if (points === void 0 || !(points >= 0)) throw new Error("Redeem with payment_id requires points ≥ 0");
|
|
3502
|
+
const redeem = {
|
|
3503
|
+
reward_id,
|
|
3504
|
+
quantity,
|
|
3505
|
+
payment_id,
|
|
3506
|
+
amount
|
|
3495
3507
|
};
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
if (error.code !== "TIMEOUT") return error;
|
|
3501
|
-
return createPaymentError("AMBIGUOUS", {
|
|
3502
|
-
attempt: error.attempt,
|
|
3503
|
-
elapsed_ms: error.elapsed_ms
|
|
3504
|
-
});
|
|
3508
|
+
redeem.points = points;
|
|
3509
|
+
if (input.program !== void 0) redeem.program = input.program;
|
|
3510
|
+
if (input.expires_at !== void 0) redeem.expires_at = input.expires_at;
|
|
3511
|
+
return redeem;
|
|
3505
3512
|
};
|
|
3506
|
-
|
|
3507
|
-
const
|
|
3508
|
-
const
|
|
3509
|
-
|
|
3510
|
-
|
|
3511
|
-
|
|
3512
|
-
|
|
3513
|
-
|
|
3513
|
+
/** Same rules as `vOrders.redeem()`; throws on invalid input. */
|
|
3514
|
+
const buildRedeem = (input) => {
|
|
3515
|
+
const { reward_id, quantity, discount_rate, discount_amount } = input;
|
|
3516
|
+
const { before_taxes, expires_at } = input;
|
|
3517
|
+
if (!reward_id.trim()) throw new Error("Redeem requires reward_id");
|
|
3518
|
+
if (!Number.isInteger(quantity) || quantity < 1) throw new Error("Redeem quantity must be an integer ≥ 1");
|
|
3519
|
+
if (expires_at !== void 0 && Number.isNaN(Date.parse(expires_at))) throw new Error("Redeem expires_at must be an ISO datetime");
|
|
3520
|
+
if (input.payment_id) return buildPaymentRedeem(input);
|
|
3521
|
+
if (discount_rate === void 0 && discount_amount === void 0) throw new Error("Redeem requires discount_rate or discount_amount");
|
|
3522
|
+
const rate = discount_rate ?? 0;
|
|
3523
|
+
if (!(rate >= 0 && rate <= 1)) throw new Error("Redeem discount_rate must be between 0 and 1");
|
|
3524
|
+
if (discount_amount !== void 0 && !(discount_amount > 0)) throw new Error("Redeem discount_amount must be > 0");
|
|
3525
|
+
const redeem = {
|
|
3526
|
+
reward_id,
|
|
3527
|
+
quantity
|
|
3528
|
+
};
|
|
3529
|
+
if (discount_rate !== void 0) redeem.discount_rate = discount_rate;
|
|
3530
|
+
if (discount_amount !== void 0) redeem.discount_amount = discount_amount;
|
|
3531
|
+
if (before_taxes !== void 0) redeem.before_taxes = before_taxes;
|
|
3532
|
+
if (expires_at !== void 0) redeem.expires_at = expires_at;
|
|
3533
|
+
return redeem;
|
|
3514
3534
|
};
|
|
3515
|
-
/**
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
3520
|
-
|
|
3521
|
-
|
|
3535
|
+
/**
|
|
3536
|
+
* Takes `quantity` units of `reward_id` off the latest entries first, so the
|
|
3537
|
+
* earlier redeems keep their step position. Entries that reach 0 are removed.
|
|
3538
|
+
*/
|
|
3539
|
+
const subtractRedeem = (items, reward_id, quantity) => {
|
|
3540
|
+
if (!Number.isInteger(quantity) || quantity < 1) throw new Error("Redeem quantity must be an integer ≥ 1");
|
|
3541
|
+
let pending = quantity;
|
|
3542
|
+
const kept = [];
|
|
3543
|
+
for (let i = items.length - 1; i >= 0; i--) {
|
|
3544
|
+
const item = items[i];
|
|
3545
|
+
if (pending === 0 || item.reward_id !== reward_id) {
|
|
3546
|
+
kept.unshift(item);
|
|
3547
|
+
continue;
|
|
3548
|
+
}
|
|
3549
|
+
const taken = Math.min(item.quantity, pending);
|
|
3550
|
+
pending -= taken;
|
|
3551
|
+
const rest = item.quantity - taken;
|
|
3552
|
+
if (rest > 0) kept.unshift({
|
|
3553
|
+
...item,
|
|
3554
|
+
quantity: rest
|
|
3555
|
+
});
|
|
3556
|
+
}
|
|
3557
|
+
return kept;
|
|
3558
|
+
};
|
|
3559
|
+
const redemptionTargetKey = (target) => {
|
|
3560
|
+
if (target.target === "ORDER") return ORDER_KEY;
|
|
3561
|
+
return `LINE:${target.target_id ?? ""}`;
|
|
3562
|
+
};
|
|
3563
|
+
/** Only root lines own redeems; child-line redeems are never applied. */
|
|
3564
|
+
const redeemPools = (lines, redeem) => {
|
|
3565
|
+
const pools = /* @__PURE__ */ new Map();
|
|
3566
|
+
for (const line of lines) {
|
|
3567
|
+
const items = line.redeem ?? [];
|
|
3568
|
+
if (line.parent_uid != null || items.length === 0) continue;
|
|
3569
|
+
const target = {
|
|
3570
|
+
target: "LINE",
|
|
3571
|
+
target_id: line.uid
|
|
3572
|
+
};
|
|
3573
|
+
const pool = items.map((item) => ({
|
|
3574
|
+
...target,
|
|
3575
|
+
redeem: item
|
|
3576
|
+
}));
|
|
3577
|
+
pools.set(redemptionTargetKey(target), pool);
|
|
3578
|
+
}
|
|
3579
|
+
const order = { target: "ORDER" };
|
|
3580
|
+
const pool = redeem.filter((item) => !isPaymentRedeem(item)).map((item) => ({
|
|
3581
|
+
...order,
|
|
3582
|
+
redeem: item
|
|
3583
|
+
}));
|
|
3584
|
+
pools.set(ORDER_KEY, pool);
|
|
3585
|
+
return pools;
|
|
3586
|
+
};
|
|
3587
|
+
/**
|
|
3588
|
+
* Pairs the k-th step of a target with the k-th redeem of that target, in
|
|
3589
|
+
* step order. Steps without a matching redeem are dropped; redeems without a
|
|
3590
|
+
* step are appended (lines first, then order).
|
|
3591
|
+
*/
|
|
3592
|
+
const sequenceRedeems = (lines, redeem, steps) => {
|
|
3593
|
+
const pools = redeemPools(lines, redeem);
|
|
3594
|
+
const cursors = /* @__PURE__ */ new Map();
|
|
3595
|
+
const sequence = [];
|
|
3596
|
+
for (const step of steps) {
|
|
3597
|
+
const key = redemptionTargetKey(step);
|
|
3598
|
+
const at = cursors.get(key) ?? 0;
|
|
3599
|
+
const next = pools.get(key)?.[at];
|
|
3600
|
+
if (!next || next.redeem.reward_id !== step.reward_id) continue;
|
|
3601
|
+
cursors.set(key, at + 1);
|
|
3602
|
+
sequence.push(next);
|
|
3603
|
+
}
|
|
3604
|
+
for (const [key, pool] of pools) sequence.push(...pool.slice(cursors.get(key) ?? 0));
|
|
3605
|
+
return sequence;
|
|
3606
|
+
};
|
|
3607
|
+
const minBig = (a, b) => {
|
|
3608
|
+
return a.lt(b) ? a : b;
|
|
3609
|
+
};
|
|
3610
|
+
const rateOf = (discount, base) => {
|
|
3611
|
+
if (base.lte(0)) return 0;
|
|
3612
|
+
return minBig(discount.div(base), new Big(1)).toNumber();
|
|
3613
|
+
};
|
|
3614
|
+
const snapshot = (roots, quantity) => {
|
|
3615
|
+
let subtotal = new Big(0);
|
|
3616
|
+
let total = new Big(0);
|
|
3617
|
+
let discount = new Big(0);
|
|
3618
|
+
for (const root of roots) {
|
|
3619
|
+
subtotal = subtotal.plus(root.subtotal);
|
|
3620
|
+
total = total.plus(root.total);
|
|
3621
|
+
discount = discount.plus(root.discount);
|
|
3622
|
+
}
|
|
3623
|
+
const b1 = {
|
|
3624
|
+
value: total.toNumber(),
|
|
3625
|
+
quantity
|
|
3626
|
+
};
|
|
3627
|
+
const b2 = { discount_amount: discount.toNumber() };
|
|
3628
|
+
const b3 = { discount_rate: rateOf(discount, subtotal) };
|
|
3629
|
+
return {
|
|
3630
|
+
...b1,
|
|
3631
|
+
...b2,
|
|
3632
|
+
...b3
|
|
3633
|
+
};
|
|
3634
|
+
};
|
|
3635
|
+
const netOf = (root) => {
|
|
3636
|
+
const net = root.total.minus(root.tax);
|
|
3637
|
+
return net.gt(0) ? net : new Big(0);
|
|
3638
|
+
};
|
|
3639
|
+
/** What a redeem can still discount on a root: pre-tax or with taxes. */
|
|
3640
|
+
const baseOf = (root, beforeTaxes) => {
|
|
3641
|
+
return beforeTaxes ? netOf(root) : root.total;
|
|
3642
|
+
};
|
|
3643
|
+
/** Before taxes, taxes shrink with the pre-tax value they were charged on. */
|
|
3644
|
+
const discountRoot = (root, amount, beforeTaxes) => {
|
|
3645
|
+
if (amount.lte(0)) return;
|
|
3646
|
+
root.discount = root.discount.plus(amount);
|
|
3647
|
+
if (!beforeTaxes) {
|
|
3648
|
+
root.total = root.total.minus(amount);
|
|
3649
|
+
return;
|
|
3650
|
+
}
|
|
3651
|
+
const net = netOf(root);
|
|
3652
|
+
const nextNet = net.minus(amount);
|
|
3653
|
+
root.tax = root.tax.times(nextNet).div(net);
|
|
3654
|
+
root.total = nextNet.plus(root.tax);
|
|
3655
|
+
};
|
|
3656
|
+
/** Splits an order discount across roots by what each can still discount. */
|
|
3657
|
+
const discountOrder = (roots, amount, beforeTaxes) => {
|
|
3658
|
+
const bases = roots.map((root) => baseOf(root, beforeTaxes));
|
|
3659
|
+
const sum = bases.reduce((acc, base) => acc.plus(base), new Big(0));
|
|
3660
|
+
if (sum.lte(0) || amount.lte(0)) return;
|
|
3661
|
+
let last = -1;
|
|
3662
|
+
bases.forEach((base, index) => {
|
|
3663
|
+
if (base.gt(0)) last = index;
|
|
3664
|
+
});
|
|
3665
|
+
let rest = amount;
|
|
3666
|
+
roots.forEach((root, index) => {
|
|
3667
|
+
const base = bases[index];
|
|
3668
|
+
if (base.lte(0)) return;
|
|
3669
|
+
const share = amount.times(base).div(sum);
|
|
3670
|
+
const part = minBig(index === last ? rest : share, base);
|
|
3671
|
+
rest = rest.minus(part);
|
|
3672
|
+
discountRoot(root, part, beforeTaxes);
|
|
3673
|
+
});
|
|
3674
|
+
};
|
|
3675
|
+
/**
|
|
3676
|
+
* Rate compounds once per reward unit over the remaining value; amount is
|
|
3677
|
+
* per unit. With both, the rate discount is capped at `amount × quantity`.
|
|
3678
|
+
*/
|
|
3679
|
+
const redeemDiscount = (redeem, remaining) => {
|
|
3680
|
+
const { quantity, discount_rate, discount_amount } = redeem;
|
|
3681
|
+
let cap = null;
|
|
3682
|
+
if (discount_amount !== void 0) cap = new Big(discount_amount).times(quantity);
|
|
3683
|
+
if (discount_rate === void 0) return cap ?? new Big(0);
|
|
3684
|
+
const kept = new Big(1).minus(discount_rate).pow(quantity);
|
|
3685
|
+
const byRate = remaining.times(new Big(1).minus(kept));
|
|
3686
|
+
return cap ? minBig(byRate, cap) : byRate;
|
|
3687
|
+
};
|
|
3688
|
+
/**
|
|
3689
|
+
* Root group money rebuilt from the line itself plus its children, i.e. the
|
|
3690
|
+
* menu price before any redemption. Child lines are never discounted.
|
|
3691
|
+
*/
|
|
3692
|
+
const stripRedemptionLines = (lines) => {
|
|
3693
|
+
const children = /* @__PURE__ */ new Map();
|
|
3694
|
+
for (const line of lines) {
|
|
3695
|
+
if (line.parent_uid == null) continue;
|
|
3696
|
+
const list = children.get(line.parent_uid) ?? [];
|
|
3697
|
+
list.push(line);
|
|
3698
|
+
children.set(line.parent_uid, list);
|
|
3699
|
+
}
|
|
3700
|
+
return lines.map((line) => {
|
|
3701
|
+
if (line.parent_uid != null) return line;
|
|
3702
|
+
const qty = new Big(line.quantity);
|
|
3703
|
+
let sub = new Big(0);
|
|
3704
|
+
let disc = new Big(0);
|
|
3705
|
+
let tax = new Big(0);
|
|
3706
|
+
let tot = new Big(0);
|
|
3707
|
+
for (const child of children.get(line.uid) ?? []) {
|
|
3708
|
+
sub = sub.plus(child.group_subtotal);
|
|
3709
|
+
disc = disc.plus(child.group_discount);
|
|
3710
|
+
tax = tax.plus(child.group_tax_total);
|
|
3711
|
+
tot = tot.plus(child.group_total);
|
|
3712
|
+
}
|
|
3713
|
+
const gSub = new Big(line.subtotal).plus(sub.times(qty));
|
|
3714
|
+
const gDisc = new Big(line.discount_total).plus(disc.times(qty));
|
|
3715
|
+
const gTax = new Big(line.tax_total).plus(tax.times(qty));
|
|
3716
|
+
const gTot = new Big(line.total).plus(tot.times(qty));
|
|
3717
|
+
const u1 = { unit_group_subtotal: gSub.div(qty).toNumber() };
|
|
3718
|
+
const u2 = { unit_group_discount: gDisc.div(qty).toNumber() };
|
|
3719
|
+
const u3 = { unit_group_tax_total: gTax.div(qty).toNumber() };
|
|
3720
|
+
const u4 = { unit_group_total: gTot.div(qty).toNumber() };
|
|
3721
|
+
const g1 = { group_subtotal: gSub.toNumber() };
|
|
3722
|
+
const g2 = { group_discount: gDisc.toNumber() };
|
|
3723
|
+
const g3 = { group_tax_total: gTax.toNumber() };
|
|
3724
|
+
const g4 = { group_total: gTot.toNumber() };
|
|
3725
|
+
const p1 = {
|
|
3726
|
+
...u1,
|
|
3727
|
+
...u2,
|
|
3728
|
+
...u3,
|
|
3729
|
+
...u4
|
|
3730
|
+
};
|
|
3731
|
+
return {
|
|
3732
|
+
...line,
|
|
3733
|
+
...p1,
|
|
3734
|
+
...g1,
|
|
3735
|
+
...g2,
|
|
3736
|
+
...g3,
|
|
3737
|
+
...g4
|
|
3738
|
+
};
|
|
3739
|
+
});
|
|
3740
|
+
};
|
|
3741
|
+
const withRootMoney = (line, money) => {
|
|
3742
|
+
const qty = new Big(line.quantity);
|
|
3743
|
+
const discount = new Big(line.group_discount).plus(money.discount);
|
|
3744
|
+
const u1 = { unit_group_discount: discount.div(qty).toNumber() };
|
|
3745
|
+
const u2 = { unit_group_tax_total: money.tax.div(qty).toNumber() };
|
|
3746
|
+
const u3 = { unit_group_total: money.total.div(qty).toNumber() };
|
|
3747
|
+
const g1 = { group_discount: discount.toNumber() };
|
|
3748
|
+
const g2 = { group_tax_total: money.tax.toNumber() };
|
|
3749
|
+
const g3 = { group_total: money.total.toNumber() };
|
|
3750
|
+
return {
|
|
3751
|
+
...line,
|
|
3752
|
+
...u1,
|
|
3753
|
+
...u2,
|
|
3754
|
+
...u3,
|
|
3755
|
+
...g1,
|
|
3756
|
+
...g2,
|
|
3757
|
+
...g3
|
|
3758
|
+
};
|
|
3759
|
+
};
|
|
3760
|
+
/**
|
|
3761
|
+
* Replays every redeem in persisted step order over the root lines' group
|
|
3762
|
+
* money. Line redeems discount their root; order redeems are prorated over
|
|
3763
|
+
* every root. A discount never exceeds what is left on its target.
|
|
3764
|
+
*/
|
|
3765
|
+
const replayRedemptions = (lines, redeem, steps) => {
|
|
3766
|
+
const base = stripRedemptionLines(lines);
|
|
3767
|
+
const sequence = sequenceRedeems(base, redeem, steps);
|
|
3768
|
+
const moneyByUid = /* @__PURE__ */ new Map();
|
|
3769
|
+
for (const line of base) {
|
|
3770
|
+
if (line.parent_uid != null) continue;
|
|
3771
|
+
const b1 = { quantity: line.quantity };
|
|
3772
|
+
const b2 = { subtotal: new Big(line.group_subtotal) };
|
|
3773
|
+
const b3 = { tax: new Big(line.group_tax_total) };
|
|
3774
|
+
const b4 = {
|
|
3775
|
+
total: new Big(line.group_total),
|
|
3776
|
+
discount: new Big(0)
|
|
3777
|
+
};
|
|
3778
|
+
moneyByUid.set(line.uid, {
|
|
3779
|
+
...b1,
|
|
3780
|
+
...b2,
|
|
3781
|
+
...b3,
|
|
3782
|
+
...b4
|
|
3783
|
+
});
|
|
3784
|
+
}
|
|
3785
|
+
const roots = [...moneyByUid.values()];
|
|
3786
|
+
const redemption_steps = sequence.map((item) => {
|
|
3787
|
+
const id = item.target_id;
|
|
3788
|
+
const root = id ? moneyByUid.get(id) : void 0;
|
|
3789
|
+
const beforeTaxes = item.redeem.before_taxes ?? true;
|
|
3790
|
+
const before = snapshot(roots, 1);
|
|
3791
|
+
const targetBefore = root ? snapshot([root], root.quantity) : void 0;
|
|
3792
|
+
let left = new Big(0);
|
|
3793
|
+
if (root) left = baseOf(root, beforeTaxes);
|
|
3794
|
+
else for (const r of roots) left = left.plus(baseOf(r, beforeTaxes));
|
|
3795
|
+
const raw = redeemDiscount(item.redeem, left);
|
|
3796
|
+
const discount = minBig(raw, left);
|
|
3797
|
+
if (root) discountRoot(root, discount, beforeTaxes);
|
|
3798
|
+
else discountOrder(roots, discount, beforeTaxes);
|
|
3799
|
+
const action = discount.gt(0) ? "UPDATE" : "READ";
|
|
3800
|
+
const { reward_id, quantity } = item.redeem;
|
|
3801
|
+
const b1 = {
|
|
3802
|
+
reward_id,
|
|
3803
|
+
reward_quantity: quantity,
|
|
3804
|
+
target: item.target
|
|
3805
|
+
};
|
|
3806
|
+
const b2 = id ? { target_id: id } : {};
|
|
3807
|
+
const b3 = {
|
|
3808
|
+
action,
|
|
3809
|
+
discount_amount: discount.toNumber()
|
|
3810
|
+
};
|
|
3811
|
+
const b4 = {
|
|
3812
|
+
before,
|
|
3813
|
+
after: snapshot(roots, 1)
|
|
3814
|
+
};
|
|
3815
|
+
let b5 = {};
|
|
3816
|
+
if (root) b5 = {
|
|
3817
|
+
target_before: targetBefore,
|
|
3818
|
+
target_after: snapshot([root], root.quantity)
|
|
3819
|
+
};
|
|
3820
|
+
return {
|
|
3821
|
+
...b1,
|
|
3822
|
+
...b2,
|
|
3823
|
+
...b3,
|
|
3824
|
+
...b4,
|
|
3825
|
+
...b5
|
|
3826
|
+
};
|
|
3827
|
+
});
|
|
3828
|
+
return {
|
|
3829
|
+
lines: base.map((line) => {
|
|
3830
|
+
const money = moneyByUid.get(line.uid);
|
|
3831
|
+
if (!money || money.discount.eq(0)) return line;
|
|
3832
|
+
return withRootMoney(line, money);
|
|
3833
|
+
}),
|
|
3834
|
+
redemption_steps
|
|
3835
|
+
};
|
|
3836
|
+
};
|
|
3837
|
+
/** Discounted lines, steps and totals, keeping step order. */
|
|
3838
|
+
const recomputeRedemptions = (input) => {
|
|
3839
|
+
const { redeem, currency } = input;
|
|
3840
|
+
const steps = input.redemption_steps;
|
|
3841
|
+
const { lines, redemption_steps } = replayRedemptions(input.lines, redeem, steps);
|
|
3842
|
+
return {
|
|
3843
|
+
lines,
|
|
3844
|
+
redemption_steps,
|
|
3845
|
+
totals: recomputeTotals(lines, currency)
|
|
3846
|
+
};
|
|
3847
|
+
};
|
|
3848
|
+
//#endregion
|
|
3849
|
+
//#region src/order/tenders.ts
|
|
3850
|
+
/** Catalog provider of `REDEMPTION` payments (§3.3). */
|
|
3851
|
+
const DEFAULT_REDEMPTION_PROVIDER = "firespark";
|
|
3852
|
+
/**
|
|
3853
|
+
* Digital tender the SDK charges (`DEVICE`, §7.3). `KIOSK`, `COUNTER`,
|
|
3854
|
+
* `DOOR` and `OTHER` are in person: the POS, an operator or the courier
|
|
3855
|
+
* collects them (§12).
|
|
3856
|
+
*/
|
|
3857
|
+
const isDeviceCharged = (tender) => {
|
|
3858
|
+
return DIGITAL_COLLECT_AT.includes(tender.collect_at);
|
|
3859
|
+
};
|
|
3860
|
+
/** Most a REDEMPTION can cover: order total − tip − delivery (D18). */
|
|
3861
|
+
const redemptionCap = (order) => {
|
|
3862
|
+
const primaryOnly = order.lines.filter((line) => line.parent_uid == null).reduce((sum, line) => {
|
|
3863
|
+
return isTipFee(line) || isDeliveryFee(line) ? sum + line.group_total : sum;
|
|
3864
|
+
}, 0);
|
|
3865
|
+
const decimals = currencyDecimals(order.totals.currency);
|
|
3866
|
+
return Math.max(roundTo(order.totals.total - primaryOnly, decimals), 0);
|
|
3867
|
+
};
|
|
3868
|
+
/**
|
|
3869
|
+
* Trims payment redeems in application order so their sum fits the cap;
|
|
3870
|
+
* points shrink in proportion. Discount redeems are left untouched.
|
|
3871
|
+
*/
|
|
3872
|
+
const clampPaymentRedeems = (redeem, cap, currency) => {
|
|
3873
|
+
const decimals = currencyDecimals(currency);
|
|
3874
|
+
let left = cap;
|
|
3875
|
+
return redeem.flatMap((item) => {
|
|
3876
|
+
if (!isPaymentRedeem(item)) return [item];
|
|
3877
|
+
const amount = item.amount ?? 0;
|
|
3878
|
+
const fits = Math.max(roundTo(Math.min(amount, left), decimals), 0);
|
|
3879
|
+
left = roundTo(left - fits, decimals);
|
|
3880
|
+
if (fits <= 0) return [];
|
|
3881
|
+
if (fits + amountTolerance(currency) >= amount) return [item];
|
|
3882
|
+
const ratio = amount > 0 ? fits / amount : 0;
|
|
3883
|
+
const points = roundTo((item.points ?? 0) * ratio, 0);
|
|
3884
|
+
return [{
|
|
3885
|
+
...item,
|
|
3886
|
+
amount: fits,
|
|
3887
|
+
points
|
|
3888
|
+
}];
|
|
3889
|
+
});
|
|
3890
|
+
};
|
|
3891
|
+
/** One REDEMPTION tender per payment redeem of the order (D15, D19). */
|
|
3892
|
+
const redemptionTenderInputs = (redeem, providers = /* @__PURE__ */ new Map()) => {
|
|
3893
|
+
return redeem.filter(isPaymentRedeem).map((item) => {
|
|
3894
|
+
const payment_id = item.payment_id;
|
|
3895
|
+
const fallback = DEFAULT_REDEMPTION_PROVIDER;
|
|
3896
|
+
const provider = providers.get(payment_id) ?? fallback;
|
|
3897
|
+
const program = item.program ?? item.reward_id;
|
|
3898
|
+
const d1 = {
|
|
3899
|
+
method: "REDEMPTION",
|
|
3900
|
+
reward_id: item.reward_id
|
|
3901
|
+
};
|
|
3902
|
+
const d2 = {
|
|
3903
|
+
program,
|
|
3904
|
+
points: item.points ?? 0
|
|
3905
|
+
};
|
|
3906
|
+
const details = {
|
|
3907
|
+
...d1,
|
|
3908
|
+
...d2,
|
|
3909
|
+
points_value: item.amount ?? 0
|
|
3910
|
+
};
|
|
3911
|
+
const t1 = {
|
|
3912
|
+
method: "REDEMPTION",
|
|
3913
|
+
provider,
|
|
3914
|
+
payment_id
|
|
3915
|
+
};
|
|
3916
|
+
const t2 = {
|
|
3917
|
+
collect_at: "OTHER",
|
|
3918
|
+
allocation: null,
|
|
3919
|
+
details
|
|
3920
|
+
};
|
|
3921
|
+
return {
|
|
3922
|
+
...t1,
|
|
3923
|
+
...t2
|
|
3924
|
+
};
|
|
3925
|
+
});
|
|
3926
|
+
};
|
|
3927
|
+
/** The one real tender of the simple flow (`allocation: null`). */
|
|
3928
|
+
const simpleTenderInput = (payment) => {
|
|
3929
|
+
if (!payment.type || !payment.provider) return null;
|
|
3930
|
+
if (!isRealPaymentMethod(payment.type)) return null;
|
|
3931
|
+
const collect_at = payment.collect_at ?? "DEVICE";
|
|
3932
|
+
const t1 = {
|
|
3933
|
+
method: payment.type,
|
|
3934
|
+
provider: payment.provider
|
|
3935
|
+
};
|
|
3936
|
+
const t2 = {
|
|
3937
|
+
payment_id: payment.id,
|
|
3938
|
+
collect_at,
|
|
3939
|
+
allocation: null
|
|
3940
|
+
};
|
|
3941
|
+
return {
|
|
3942
|
+
...t1,
|
|
3943
|
+
...t2
|
|
3944
|
+
};
|
|
3945
|
+
};
|
|
3946
|
+
/**
|
|
3947
|
+
* Charge order of §6.1: deferred capture (authorize only), immediate
|
|
3948
|
+
* capture reversible with cancel/refund, manual reversal only, then cash /
|
|
3949
|
+
* IDLE last.
|
|
3950
|
+
*/
|
|
3951
|
+
const chargeRank = (tender, adapter) => {
|
|
3952
|
+
if (tender.method === "CASH") return 3;
|
|
3953
|
+
if (adapter.capabilities?.deferredCapture) return 0;
|
|
3954
|
+
if (adapter.cancel || adapter.refund) return 1;
|
|
3955
|
+
return 2;
|
|
3956
|
+
};
|
|
3957
|
+
//#endregion
|
|
3958
|
+
//#region src/order/payments/orchestrator.ts
|
|
3959
|
+
const AMBIGUOUS_QUERIES = 3;
|
|
3960
|
+
const defaultSleep = (ms, signal) => {
|
|
3961
|
+
return new Promise((resolve) => {
|
|
3962
|
+
const timer = setTimeout(resolve, ms);
|
|
3963
|
+
signal?.addEventListener("abort", () => {
|
|
3964
|
+
clearTimeout(timer);
|
|
3965
|
+
resolve();
|
|
3966
|
+
});
|
|
3967
|
+
});
|
|
3968
|
+
};
|
|
3969
|
+
const defaultClock = {
|
|
3970
|
+
now: () => Date.now(),
|
|
3971
|
+
sleep: defaultSleep
|
|
3972
|
+
};
|
|
3973
|
+
var TimeoutSignal = class extends Error {};
|
|
3974
|
+
/**
|
|
3975
|
+
* Provider deadline that pauses while the customer is answering `ctx.ui`;
|
|
3976
|
+
* user waits run on `interaction_timeout_ms` instead.
|
|
3977
|
+
*/
|
|
3978
|
+
const createDeadline = (timeoutMs, onExpire) => {
|
|
3979
|
+
let remaining = timeoutMs;
|
|
3980
|
+
let startedAt = 0;
|
|
3981
|
+
let timer = null;
|
|
3982
|
+
let paused = 0;
|
|
3983
|
+
const run = () => {
|
|
3984
|
+
startedAt = Date.now();
|
|
3985
|
+
timer = setTimeout(onExpire, Math.max(remaining, 0));
|
|
3986
|
+
};
|
|
3987
|
+
const stop = () => {
|
|
3988
|
+
if (!timer) return;
|
|
3989
|
+
clearTimeout(timer);
|
|
3990
|
+
timer = null;
|
|
3991
|
+
remaining -= Date.now() - startedAt;
|
|
3992
|
+
};
|
|
3993
|
+
return {
|
|
3994
|
+
start: run,
|
|
3995
|
+
pause: () => {
|
|
3996
|
+
if (paused === 0) stop();
|
|
3997
|
+
paused += 1;
|
|
3998
|
+
},
|
|
3999
|
+
resume: () => {
|
|
4000
|
+
paused -= 1;
|
|
4001
|
+
if (paused === 0) run();
|
|
4002
|
+
},
|
|
4003
|
+
clear: stop
|
|
4004
|
+
};
|
|
4005
|
+
};
|
|
4006
|
+
const withUserTimeout = (promise, ms) => {
|
|
4007
|
+
return new Promise((resolve, reject) => {
|
|
4008
|
+
const timer = setTimeout(() => {
|
|
4009
|
+
reject(new PaymentUserTimeout());
|
|
4010
|
+
}, ms);
|
|
4011
|
+
promise.then((value) => {
|
|
4012
|
+
clearTimeout(timer);
|
|
4013
|
+
resolve(value);
|
|
4014
|
+
}, (error) => {
|
|
4015
|
+
clearTimeout(timer);
|
|
4016
|
+
reject(error);
|
|
4017
|
+
});
|
|
4018
|
+
});
|
|
4019
|
+
};
|
|
4020
|
+
var PaymentUserTimeout = class extends Error {};
|
|
4021
|
+
const timedUI = (ui, deadline, interactionMs) => {
|
|
4022
|
+
const wrap = (call) => {
|
|
4023
|
+
deadline.pause();
|
|
4024
|
+
return withUserTimeout(call(), interactionMs).finally(() => deadline.resume());
|
|
4025
|
+
};
|
|
4026
|
+
return {
|
|
4027
|
+
message: (input) => ui.message(input),
|
|
4028
|
+
action: (input) => ui.action(input),
|
|
4029
|
+
select: (input) => {
|
|
4030
|
+
return wrap(() => ui.select(input));
|
|
4031
|
+
},
|
|
4032
|
+
input: (input) => wrap(() => ui.input(input)),
|
|
4033
|
+
confirm: (input) => wrap(() => ui.confirm(input))
|
|
4034
|
+
};
|
|
4035
|
+
};
|
|
4036
|
+
/** Runs one adapter call with the provider timeout and the user clock. */
|
|
4037
|
+
const runTimed = async (ctx, call) => {
|
|
4038
|
+
const controller = new AbortController();
|
|
4039
|
+
const parent = ctx.signal;
|
|
4040
|
+
const abort = () => controller.abort();
|
|
4041
|
+
parent.addEventListener("abort", abort);
|
|
4042
|
+
let expire = () => void 0;
|
|
4043
|
+
const expired = new Promise((_, reject) => {
|
|
4044
|
+
expire = () => {
|
|
4045
|
+
controller.abort();
|
|
4046
|
+
reject(new TimeoutSignal());
|
|
4047
|
+
};
|
|
4048
|
+
});
|
|
4049
|
+
const deadline = createDeadline(ctx.policy.timeout_ms, () => expire());
|
|
4050
|
+
const interaction = ctx.policy.interaction_timeout_ms;
|
|
4051
|
+
const ui = timedUI(ctx.ui, deadline, interaction);
|
|
4052
|
+
const scoped = {
|
|
4053
|
+
...ctx,
|
|
4054
|
+
ui,
|
|
4055
|
+
signal: controller.signal
|
|
4056
|
+
};
|
|
4057
|
+
deadline.start();
|
|
4058
|
+
try {
|
|
4059
|
+
return await Promise.race([call(scoped), expired]);
|
|
4060
|
+
} finally {
|
|
4061
|
+
deadline.clear();
|
|
4062
|
+
parent.removeEventListener("abort", abort);
|
|
4063
|
+
}
|
|
4064
|
+
};
|
|
4065
|
+
const failed = (ctx, error, extra = {}) => {
|
|
4066
|
+
const metadata = {
|
|
4067
|
+
attempt_reference: ctx.attemptReference,
|
|
4068
|
+
error,
|
|
4069
|
+
...extra
|
|
4070
|
+
};
|
|
4071
|
+
return {
|
|
4072
|
+
status: "FAILED",
|
|
4073
|
+
amount: ctx.amount.value,
|
|
4074
|
+
metadata,
|
|
4075
|
+
error
|
|
4076
|
+
};
|
|
4077
|
+
};
|
|
4078
|
+
const errorOf = (ctx, error, startedAt, clock) => {
|
|
4079
|
+
const elapsed_ms = clock.now() - startedAt;
|
|
4080
|
+
const attempt = ctx.attempt;
|
|
4081
|
+
if (isPaymentError(error)) return {
|
|
4082
|
+
...error.error,
|
|
4083
|
+
attempt,
|
|
4084
|
+
elapsed_ms
|
|
4085
|
+
};
|
|
4086
|
+
let code = "UNKNOWN";
|
|
4087
|
+
if (error instanceof TimeoutSignal) code = "TIMEOUT";
|
|
4088
|
+
if (error instanceof PaymentUserTimeout) code = "USER_TIMEOUT";
|
|
4089
|
+
const extra = {
|
|
4090
|
+
attempt,
|
|
4091
|
+
elapsed_ms,
|
|
4092
|
+
provider_message: error instanceof Error ? error.message : null
|
|
4093
|
+
};
|
|
4094
|
+
return createPaymentError(code, extra);
|
|
4095
|
+
};
|
|
4096
|
+
/** `start` that timed out after leaving the device is ambiguous. */
|
|
4097
|
+
const startError = (error) => {
|
|
4098
|
+
if (error.code !== "TIMEOUT") return error;
|
|
4099
|
+
return createPaymentError("AMBIGUOUS", {
|
|
4100
|
+
attempt: error.attempt,
|
|
4101
|
+
elapsed_ms: error.elapsed_ms
|
|
4102
|
+
});
|
|
4103
|
+
};
|
|
4104
|
+
const isFinal = (result) => result.status !== "PENDING";
|
|
4105
|
+
const callOperation = async (ctx, operation, result, clock) => {
|
|
4106
|
+
const startedAt = clock.now();
|
|
4107
|
+
try {
|
|
4108
|
+
return await runTimed(ctx, (scoped) => operation(scoped, result));
|
|
4109
|
+
} catch (error) {
|
|
4110
|
+
return failed(ctx, errorOf(ctx, error, startedAt, clock));
|
|
4111
|
+
}
|
|
4112
|
+
};
|
|
4113
|
+
/** Query up to 3 times, cancel once, else FAILED + needs_review. */
|
|
4114
|
+
const resolveAmbiguous = async (adapter, ctx, pending, clock) => {
|
|
4115
|
+
if (adapter.query) for (let i = 0; i < AMBIGUOUS_QUERIES; i += 1) {
|
|
4116
|
+
await clock.sleep(ctx.policy.poll_interval_ms, ctx.signal);
|
|
4117
|
+
const next = await callOperation(ctx, adapter.query, pending, clock);
|
|
4118
|
+
if (next.status === "PAID") return next;
|
|
4119
|
+
const definitive = next.error?.code !== "TIMEOUT" && !next.error?.retryable;
|
|
3522
4120
|
if (next.status === "FAILED" && definitive && next.error?.code !== "AMBIGUOUS") return next;
|
|
3523
4121
|
}
|
|
3524
4122
|
if (adapter.cancel) {
|
|
@@ -3676,13 +4274,20 @@ const record = (value) => {
|
|
|
3676
4274
|
if (!value || typeof value !== "object") return {};
|
|
3677
4275
|
return value;
|
|
3678
4276
|
};
|
|
3679
|
-
|
|
4277
|
+
const isPendingConfirm = (tender) => {
|
|
4278
|
+
const confirm = record(tender.metadata).confirm_after_inject === true;
|
|
4279
|
+
return tender.status === "PENDING" && confirm;
|
|
4280
|
+
};
|
|
4281
|
+
/** Live tenders of the current intent that hold the customer's money. */
|
|
4282
|
+
const chargedTenders = (intents) => {
|
|
4283
|
+
return liveTenders(currentIntent(intents)).filter((tender) => {
|
|
4284
|
+
return isTenderSettled(tender) || isPendingConfirm(tender);
|
|
4285
|
+
});
|
|
4286
|
+
};
|
|
4287
|
+
/** Settled tenders plus PENDING `confirm_after_inject`, order currency. */
|
|
3680
4288
|
const coveredAmount = (intents) => {
|
|
3681
|
-
return intents.reduce((sum,
|
|
3682
|
-
|
|
3683
|
-
const pendingConfirm = intent.status === "PENDING" && confirm;
|
|
3684
|
-
if (intent.status !== "PAID" && !pendingConfirm) return sum;
|
|
3685
|
-
return sum + intent.totals.total;
|
|
4289
|
+
return chargedTenders(intents).reduce((sum, tender) => {
|
|
4290
|
+
return sum + tender.amount.total;
|
|
3686
4291
|
}, 0);
|
|
3687
4292
|
};
|
|
3688
4293
|
const remainingAmount = (order) => {
|
|
@@ -3699,6 +4304,16 @@ const withOrderAmount = (result, amount) => {
|
|
|
3699
4304
|
amount
|
|
3700
4305
|
};
|
|
3701
4306
|
};
|
|
4307
|
+
const partialOf = (result) => {
|
|
4308
|
+
return record(result.metadata).partial_approval !== void 0;
|
|
4309
|
+
};
|
|
4310
|
+
/** D31: PAID for less than the tender (same currency only). */
|
|
4311
|
+
const isPartialApproval = (result, tender, ctx) => {
|
|
4312
|
+
if (result.status !== "PAID") return false;
|
|
4313
|
+
if (ctx.amount.currency !== tender.amount.currency) return false;
|
|
4314
|
+
const tolerance = amountTolerance(tender.amount.currency);
|
|
4315
|
+
return result.amount + tolerance < tender.amount.total;
|
|
4316
|
+
};
|
|
3702
4317
|
const toFailure = (error) => {
|
|
3703
4318
|
const message = error instanceof Error ? error.message : "Terminal failed";
|
|
3704
4319
|
return {
|
|
@@ -3875,87 +4490,131 @@ var PaymentSession = class {
|
|
|
3875
4490
|
if (payment.method.method === "CASH") return this.options.cashCollectAt ?? "DOOR";
|
|
3876
4491
|
return this.options.collectAt ?? "DEVICE";
|
|
3877
4492
|
}
|
|
3878
|
-
|
|
3879
|
-
|
|
3880
|
-
|
|
3881
|
-
|
|
3882
|
-
|
|
3883
|
-
|
|
3884
|
-
const
|
|
4493
|
+
/** `collect_at` of a new tender: cash in person, the rest digital. */
|
|
4494
|
+
tenderCollectAt(payment) {
|
|
4495
|
+
if (payment.method.method === "CASH") return this.options.cashCollectAt ?? "DOOR";
|
|
4496
|
+
return "DEVICE";
|
|
4497
|
+
}
|
|
4498
|
+
toRecord(tender, result) {
|
|
4499
|
+
const metadata = { ...result.metadata };
|
|
4500
|
+
const failure = result.error?.message;
|
|
4501
|
+
const message = partialOf(result) ? PARTIAL_APPROVAL_ERROR : failure;
|
|
3885
4502
|
const b1 = {
|
|
3886
|
-
|
|
3887
|
-
|
|
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
|
|
4503
|
+
tender_id: tender.id,
|
|
4504
|
+
status: result.status,
|
|
4505
|
+
metadata
|
|
3902
4506
|
};
|
|
4507
|
+
const b2 = { attempt_reference: tender.attempt_reference };
|
|
4508
|
+
const b3 = result.details ? { details: result.details } : {};
|
|
3903
4509
|
return {
|
|
3904
4510
|
...b1,
|
|
3905
4511
|
...b2,
|
|
3906
4512
|
...b3,
|
|
3907
|
-
|
|
3908
|
-
...b5
|
|
4513
|
+
error_message: message
|
|
3909
4514
|
};
|
|
3910
4515
|
}
|
|
3911
|
-
async recordResult(
|
|
4516
|
+
async recordResult(tender, result) {
|
|
3912
4517
|
const order = this.deps.getOrder();
|
|
3913
|
-
const
|
|
4518
|
+
const input = this.toRecord(tender, result);
|
|
3914
4519
|
const http = this.deps.http;
|
|
3915
|
-
const
|
|
3916
|
-
order.id,
|
|
3917
|
-
{ intent },
|
|
3918
|
-
http
|
|
3919
|
-
];
|
|
3920
|
-
const next = await services.orders.recordPayment(...p1);
|
|
4520
|
+
const next = await services.orders.recordPayment(order.id, input, http);
|
|
3921
4521
|
this.deps.applyOrder(next);
|
|
3922
4522
|
}
|
|
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
|
-
}
|
|
4523
|
+
registerPayment(payment) {
|
|
3933
4524
|
this.payments.set(payment.id, payment);
|
|
4525
|
+
}
|
|
4526
|
+
/** Merchant payments the intent tenders point to (`payment_id`). */
|
|
4527
|
+
registerPayments(payments) {
|
|
4528
|
+
payments.forEach((payment) => this.registerPayment(payment));
|
|
4529
|
+
}
|
|
4530
|
+
intentTenders() {
|
|
4531
|
+
return liveTenders(currentIntent(this.deps.getOrder().payment_intents));
|
|
4532
|
+
}
|
|
4533
|
+
/** Adapter + payment of a device-charged real tender, or why not. */
|
|
4534
|
+
chargerFor(tender) {
|
|
4535
|
+
const id = tender.payment_id;
|
|
4536
|
+
const payment = id ? this.payments.get(id) : void 0;
|
|
4537
|
+
if (!payment) throw new Error(`Unknown payment ${id ?? "(none)"}`);
|
|
4538
|
+
const adapter = this.adapterFor(payment);
|
|
4539
|
+
if (!adapter) throw new Error(`No payment adapter installed for payment ${id}`);
|
|
3934
4540
|
const missing = this.missingFields(payment);
|
|
3935
4541
|
if (missing.length > 0) {
|
|
3936
4542
|
const keys = missing.map((field) => field.key).join(", ");
|
|
3937
|
-
const id = payment.id;
|
|
3938
4543
|
throw new Error(`Payment ${id} is missing device fields: ${keys}`);
|
|
3939
4544
|
}
|
|
3940
|
-
|
|
3941
|
-
|
|
3942
|
-
|
|
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 }
|
|
4545
|
+
return {
|
|
4546
|
+
payment,
|
|
4547
|
+
adapter
|
|
3949
4548
|
};
|
|
3950
|
-
|
|
3951
|
-
|
|
3952
|
-
|
|
3953
|
-
|
|
3954
|
-
|
|
3955
|
-
|
|
4549
|
+
}
|
|
4550
|
+
/** Real tenders this device still has to charge, in §6.1 order. */
|
|
4551
|
+
pendingCharges() {
|
|
4552
|
+
return this.intentTenders().filter((tender) => {
|
|
4553
|
+
if (!isRealPaymentMethod(tender.method)) return false;
|
|
4554
|
+
if (!isDeviceCharged(tender)) return false;
|
|
4555
|
+
return tender.status === "IDLE" || tender.status === "FAILED";
|
|
4556
|
+
}).map((tender) => {
|
|
4557
|
+
const charger = this.chargerFor(tender);
|
|
4558
|
+
return {
|
|
4559
|
+
tender,
|
|
4560
|
+
...charger,
|
|
4561
|
+
rank: chargeRank(tender, charger.adapter)
|
|
3956
4562
|
};
|
|
3957
|
-
|
|
3958
|
-
|
|
4563
|
+
}).toSorted((a, b) => {
|
|
4564
|
+
if (a.rank !== b.rank) return a.rank - b.rank;
|
|
4565
|
+
return a.tender.sequence - b.tender.sequence;
|
|
4566
|
+
});
|
|
4567
|
+
}
|
|
4568
|
+
/** A FAILED tender is retried with a new `attempt_reference` (D25). */
|
|
4569
|
+
withNewAttempt(tender) {
|
|
4570
|
+
if (tender.status !== "FAILED") return tender;
|
|
4571
|
+
const b1 = {
|
|
4572
|
+
attempt_reference: createAttemptReference(),
|
|
4573
|
+
previous_attempt_references: [...tender.previous_attempt_references, tender.attempt_reference]
|
|
4574
|
+
};
|
|
4575
|
+
return {
|
|
4576
|
+
...tender,
|
|
4577
|
+
...b1
|
|
4578
|
+
};
|
|
4579
|
+
}
|
|
4580
|
+
/** D31: the provider approved less than the tender; void it, FAILED. */
|
|
4581
|
+
async rejectPartial(adapter, ctx, result, tender) {
|
|
4582
|
+
const voided = adapter.cancel ? await adapter.cancel(ctx, result).catch(() => null) : null;
|
|
4583
|
+
const error = createPaymentError("INSUFFICIENT_FUNDS", { provider_message: `Approved ${result.amount}` });
|
|
4584
|
+
const needs_review = voided?.status !== "CANCELLED";
|
|
4585
|
+
const partial_approval = { approved_amount: result.amount };
|
|
4586
|
+
const metadata = {
|
|
4587
|
+
...result.metadata,
|
|
4588
|
+
error,
|
|
4589
|
+
needs_review,
|
|
4590
|
+
partial_approval
|
|
4591
|
+
};
|
|
4592
|
+
return {
|
|
4593
|
+
status: "FAILED",
|
|
4594
|
+
amount: tender.amount.total,
|
|
4595
|
+
metadata,
|
|
4596
|
+
error
|
|
4597
|
+
};
|
|
4598
|
+
}
|
|
4599
|
+
async chargeTender(current, charger, options) {
|
|
4600
|
+
const { payment, adapter } = charger;
|
|
4601
|
+
const tender = this.withNewAttempt(current);
|
|
4602
|
+
const emit = options.onProgress ?? (() => void 0);
|
|
4603
|
+
await emit({
|
|
4604
|
+
type: "tender_started",
|
|
4605
|
+
tender
|
|
4606
|
+
});
|
|
4607
|
+
const amount = tender.amount.total;
|
|
4608
|
+
const attemptReference = tender.attempt_reference;
|
|
4609
|
+
const metadata = { attempt_reference: attemptReference };
|
|
4610
|
+
if (!(payment.method.method === "CASH") && tender.status !== "PENDING") {
|
|
4611
|
+
const pending = {
|
|
4612
|
+
status: "PENDING",
|
|
4613
|
+
amount,
|
|
4614
|
+
metadata
|
|
4615
|
+
};
|
|
4616
|
+
await this.recordResult(tender, pending);
|
|
4617
|
+
}
|
|
3959
4618
|
const controller = new AbortController();
|
|
3960
4619
|
const abort = () => controller.abort();
|
|
3961
4620
|
options.signal?.addEventListener("abort", abort);
|
|
@@ -3968,66 +4627,171 @@ var PaymentSession = class {
|
|
|
3968
4627
|
];
|
|
3969
4628
|
const ctx = await this.buildContext(...a1);
|
|
3970
4629
|
const onProgress = async (progress) => {
|
|
3971
|
-
await
|
|
4630
|
+
await emit({
|
|
4631
|
+
type: "tender_pending",
|
|
4632
|
+
tender,
|
|
4633
|
+
result: progress
|
|
4634
|
+
});
|
|
3972
4635
|
if (progress.status !== "PENDING") return;
|
|
3973
|
-
await this.recordResult(
|
|
4636
|
+
await this.recordResult(tender, progress);
|
|
3974
4637
|
};
|
|
3975
4638
|
const attempt = await runPaymentAttempt(adapter, ctx, {
|
|
3976
4639
|
...this.deps.runOptions,
|
|
3977
4640
|
onProgress
|
|
3978
4641
|
});
|
|
3979
|
-
|
|
3980
|
-
if (result
|
|
4642
|
+
let result = withOrderAmount(attempt, amount);
|
|
4643
|
+
if (isPartialApproval(result, tender, ctx)) result = await this.rejectPartial(adapter, ctx, result, tender);
|
|
4644
|
+
if (result.status !== "IDLE") await this.recordResult(tender, result);
|
|
4645
|
+
const type = result.status === "FAILED" ? "tender_failed" : "tender_paid";
|
|
4646
|
+
if (result.status !== "IDLE") await emit({
|
|
4647
|
+
type,
|
|
4648
|
+
tender,
|
|
4649
|
+
result
|
|
4650
|
+
});
|
|
3981
4651
|
return result;
|
|
3982
4652
|
} finally {
|
|
3983
4653
|
options.signal?.removeEventListener("abort", abort);
|
|
3984
4654
|
}
|
|
3985
4655
|
}
|
|
3986
|
-
|
|
3987
|
-
const
|
|
4656
|
+
outcome(results, failed) {
|
|
4657
|
+
const order = this.deps.getOrder();
|
|
4658
|
+
const tenders = liveTenders(currentIntent(order.payment_intents));
|
|
4659
|
+
const s1 = {
|
|
4660
|
+
order,
|
|
4661
|
+
results,
|
|
4662
|
+
needsReview: tenders.some((tender) => {
|
|
4663
|
+
return record(tender.metadata).needs_review === true;
|
|
4664
|
+
})
|
|
4665
|
+
};
|
|
4666
|
+
if (failed) return {
|
|
4667
|
+
status: "FAILED",
|
|
4668
|
+
...s1,
|
|
4669
|
+
...failed
|
|
4670
|
+
};
|
|
4671
|
+
return {
|
|
4672
|
+
status: tenders.every((tender) => isTenderSettled(tender)) ? "PAID" : "PENDING",
|
|
4673
|
+
...s1
|
|
4674
|
+
};
|
|
4675
|
+
}
|
|
4676
|
+
/** Charges every device tender left, stopping at the first failure. */
|
|
4677
|
+
async chargeRemaining(options) {
|
|
4678
|
+
const results = [];
|
|
4679
|
+
for (const next of this.pendingCharges()) {
|
|
4680
|
+
const tender = this.intentTenders().find((t) => t.id === next.tender.id);
|
|
4681
|
+
if (!tender) continue;
|
|
4682
|
+
const result = await this.chargeTender(tender, next, options);
|
|
4683
|
+
results.push(result);
|
|
4684
|
+
if (result.status !== "FAILED") continue;
|
|
4685
|
+
const failed = this.intentTenders().find((t) => t.id === tender.id);
|
|
4686
|
+
return this.outcome(results, {
|
|
4687
|
+
tender: failed ?? tender,
|
|
4688
|
+
result
|
|
4689
|
+
});
|
|
4690
|
+
}
|
|
4691
|
+
return this.outcome(results);
|
|
4692
|
+
}
|
|
4693
|
+
/**
|
|
4694
|
+
* Places the order (simple flow: `payment` becomes the one real tender)
|
|
4695
|
+
* and charges the real device tenders in sequence (§6.1). A failure
|
|
4696
|
+
* stops here with the order frozen: `retryTender`, `replaceTender` or
|
|
4697
|
+
* cancel and refund. In-person tenders are never charged here.
|
|
4698
|
+
*/
|
|
4699
|
+
async payIntent(options = {}) {
|
|
4700
|
+
const { payment } = options;
|
|
4701
|
+
if (payment) this.registerPayment(payment);
|
|
4702
|
+
if (!isPlaced(this.deps.getOrder())) {
|
|
4703
|
+
if (payment) this.chargerFor({ payment_id: payment.id });
|
|
4704
|
+
const p1 = payment ? this.placePayment(payment) : void 0;
|
|
4705
|
+
await this.deps.placeOrder(p1 ? { payment: p1 } : {});
|
|
4706
|
+
}
|
|
4707
|
+
return this.chargeRemaining(options);
|
|
4708
|
+
}
|
|
4709
|
+
/** Charges a FAILED tender again with a new `attempt_reference`. */
|
|
4710
|
+
async retryTender(tenderId, options = {}) {
|
|
4711
|
+
const tender = this.intentTenders().find((item) => item.id === tenderId);
|
|
4712
|
+
if (!tender) throw new Error(`Unknown tender ${tenderId}`);
|
|
4713
|
+
if (tender.status !== "FAILED") throw new Error(`Only a FAILED tender can be retried (${tender.status})`);
|
|
4714
|
+
return this.chargeRemaining(options);
|
|
4715
|
+
}
|
|
4716
|
+
/**
|
|
4717
|
+
* Changes the method of a FAILED tender (same allocation and amount);
|
|
4718
|
+
* then `payIntent()` / `retryTender` charges the new tender.
|
|
4719
|
+
*/
|
|
4720
|
+
async replaceTender(tenderId, payment, collectAt) {
|
|
4721
|
+
this.registerPayment(payment);
|
|
4722
|
+
const order = this.deps.getOrder();
|
|
4723
|
+
const method = payment.method.method;
|
|
4724
|
+
const provider = payment.provider.provider_id;
|
|
4725
|
+
const collect_at = collectAt ?? this.tenderCollectAt(payment);
|
|
4726
|
+
const body = {
|
|
4727
|
+
method,
|
|
4728
|
+
payment_id: payment.id,
|
|
4729
|
+
provider,
|
|
4730
|
+
collect_at
|
|
4731
|
+
};
|
|
4732
|
+
const http = this.deps.http;
|
|
4733
|
+
const replace = services.orders.replaceTender;
|
|
4734
|
+
const next = await replace(order.id, tenderId, body, http);
|
|
4735
|
+
this.deps.applyOrder(next);
|
|
4736
|
+
return next;
|
|
4737
|
+
}
|
|
4738
|
+
/** `PlaceOrderInput.payment` of the simple flow for `payment`. */
|
|
4739
|
+
placePayment(payment) {
|
|
4740
|
+
const type = payment.method.method;
|
|
4741
|
+
const provider = payment.provider.provider_id;
|
|
4742
|
+
const collect_at = this.tenderCollectAt(payment);
|
|
4743
|
+
return {
|
|
4744
|
+
id: payment.id,
|
|
4745
|
+
type,
|
|
4746
|
+
provider,
|
|
4747
|
+
collect_at
|
|
4748
|
+
};
|
|
4749
|
+
}
|
|
4750
|
+
tenderResult(tender) {
|
|
4751
|
+
const metadata = record(tender.metadata);
|
|
3988
4752
|
const error = metadata.error;
|
|
4753
|
+
const attempt_reference = tender.attempt_reference;
|
|
4754
|
+
const m1 = {
|
|
4755
|
+
...metadata,
|
|
4756
|
+
attempt_reference
|
|
4757
|
+
};
|
|
3989
4758
|
return {
|
|
3990
|
-
status:
|
|
3991
|
-
amount:
|
|
3992
|
-
metadata,
|
|
4759
|
+
status: tender.status,
|
|
4760
|
+
amount: tender.amount.total,
|
|
4761
|
+
metadata: m1,
|
|
3993
4762
|
...error ? { error } : {}
|
|
3994
4763
|
};
|
|
3995
4764
|
}
|
|
3996
|
-
async contextFor(
|
|
3997
|
-
const payment =
|
|
4765
|
+
async contextFor(tender) {
|
|
4766
|
+
const payment = tender.payment_id ? this.payments.get(tender.payment_id) : null;
|
|
3998
4767
|
if (!payment) return null;
|
|
3999
4768
|
const adapter = this.adapterFor(payment);
|
|
4000
4769
|
if (!adapter) return null;
|
|
4001
|
-
const result = this.
|
|
4002
|
-
const reference =
|
|
4003
|
-
if (!reference) return null;
|
|
4770
|
+
const result = this.tenderResult(tender);
|
|
4771
|
+
const reference = tender.attempt_reference;
|
|
4004
4772
|
const signal = new AbortController().signal;
|
|
4005
4773
|
const a1 = [
|
|
4006
4774
|
payment,
|
|
4007
|
-
|
|
4775
|
+
tender.amount.total,
|
|
4008
4776
|
reference,
|
|
4009
4777
|
signal
|
|
4010
4778
|
];
|
|
4011
4779
|
return {
|
|
4012
|
-
payment,
|
|
4013
4780
|
adapter,
|
|
4014
4781
|
result,
|
|
4015
|
-
ctx: await this.buildContext(...a1)
|
|
4016
|
-
placedAt: intent.placed_at
|
|
4782
|
+
ctx: await this.buildContext(...a1)
|
|
4017
4783
|
};
|
|
4018
4784
|
}
|
|
4019
4785
|
/** After the order is injected: confirm PENDING `confirm_after_inject`. */
|
|
4020
4786
|
async confirmAfterInject() {
|
|
4021
4787
|
const intents = this.deps.getOrder().payment_intents;
|
|
4788
|
+
const tenders = liveTenders(currentIntent(intents));
|
|
4022
4789
|
const results = [];
|
|
4023
|
-
for (const
|
|
4024
|
-
const
|
|
4025
|
-
if (intent.status !== "PENDING") continue;
|
|
4026
|
-
if (metadata.confirm_after_inject !== true) continue;
|
|
4027
|
-
const found = await this.contextFor(intent);
|
|
4790
|
+
for (const tender of tenders.filter(isPendingConfirm)) {
|
|
4791
|
+
const found = await this.contextFor(tender);
|
|
4028
4792
|
const confirm = found?.adapter.confirm;
|
|
4029
4793
|
if (!found || !confirm) continue;
|
|
4030
|
-
const {
|
|
4794
|
+
const { result, ctx } = found;
|
|
4031
4795
|
const confirmed = await confirm(ctx, result).catch((error) => {
|
|
4032
4796
|
const err = createPaymentError("UNKNOWN", { provider_message: error instanceof Error ? error.message : null });
|
|
4033
4797
|
const m1 = {
|
|
@@ -4046,27 +4810,31 @@ var PaymentSession = class {
|
|
|
4046
4810
|
metadata: { ...confirmed.metadata }
|
|
4047
4811
|
};
|
|
4048
4812
|
delete next.metadata.confirm_after_inject;
|
|
4049
|
-
await this.recordResult(
|
|
4813
|
+
await this.recordResult(tender, next);
|
|
4050
4814
|
results.push(next);
|
|
4051
4815
|
}
|
|
4052
4816
|
return results;
|
|
4053
4817
|
}
|
|
4054
4818
|
/**
|
|
4055
|
-
* Undo charges of this order (injection failed or
|
|
4056
|
-
*
|
|
4819
|
+
* Undo the device charges of this order (injection failed, or the
|
|
4820
|
+
* customer chose "cancel and refund"): cancel, refund or flag for manual
|
|
4821
|
+
* reversal. REDEMPTION reserves are released by the server on cancel.
|
|
4057
4822
|
*/
|
|
4058
|
-
async reverseAll() {
|
|
4823
|
+
async reverseAll(options = {}) {
|
|
4059
4824
|
const intents = this.deps.getOrder().payment_intents;
|
|
4060
4825
|
const outcomes = [];
|
|
4061
|
-
for (const
|
|
4062
|
-
|
|
4063
|
-
|
|
4064
|
-
const found = await this.contextFor(intent);
|
|
4826
|
+
for (const tender of chargedTenders(intents)) {
|
|
4827
|
+
if (tender.status === "IDLE") continue;
|
|
4828
|
+
const found = await this.contextFor(tender);
|
|
4065
4829
|
if (!found) continue;
|
|
4066
|
-
|
|
4830
|
+
await options.onProgress?.({
|
|
4831
|
+
type: "reversing",
|
|
4832
|
+
tender
|
|
4833
|
+
});
|
|
4834
|
+
const { adapter, result, ctx } = found;
|
|
4067
4835
|
const outcome = await reversePayment(adapter, ctx, result);
|
|
4068
|
-
const reversed = withOrderAmount(outcome.result,
|
|
4069
|
-
await this.recordResult(
|
|
4836
|
+
const reversed = withOrderAmount(outcome.result, tender.amount.total);
|
|
4837
|
+
await this.recordResult(tender, reversed);
|
|
4070
4838
|
outcomes.push(outcome);
|
|
4071
4839
|
}
|
|
4072
4840
|
return outcomes;
|
|
@@ -4166,409 +4934,129 @@ const getOpenPeriodAt = (periods, now) => {
|
|
|
4166
4934
|
if (periods.length === 0) return null;
|
|
4167
4935
|
const coveringSpecific = periods.filter((period) => {
|
|
4168
4936
|
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);
|
|
4239
|
-
};
|
|
4240
|
-
/** Whether `schedule` is in hours now, and until / next open. */
|
|
4241
|
-
const getScheduleWindow = (schedule, now = /* @__PURE__ */ new Date()) => {
|
|
4242
|
-
if (schedule == null) return {
|
|
4243
|
-
active: true,
|
|
4244
|
-
until: null
|
|
4245
|
-
};
|
|
4246
|
-
const periods = dayPeriods(schedule, now);
|
|
4247
|
-
if (isPeriodsActiveAt(periods, now)) return {
|
|
4248
|
-
active: true,
|
|
4249
|
-
until: getActiveUntil(periods, now)
|
|
4250
|
-
};
|
|
4251
|
-
return {
|
|
4252
|
-
active: false,
|
|
4253
|
-
next: getNextStart(schedule, now)
|
|
4254
|
-
};
|
|
4255
|
-
};
|
|
4256
|
-
const isOnSchedule = (schedule, now) => {
|
|
4257
|
-
return getScheduleWindow(schedule, now).active;
|
|
4258
|
-
};
|
|
4259
|
-
//#endregion
|
|
4260
|
-
//#region src/order/recommendations.ts
|
|
4261
|
-
const DEFAULT_RECOMMENDATION_TYPES = ["CROSS_SELL"];
|
|
4262
|
-
const linkedRecommendationIds = (pricing) => {
|
|
4263
|
-
return [...pricing.recommendations_ids ?? [], ...(pricing.recommendations ?? []).map((link) => link.recommendation_id)];
|
|
4264
|
-
};
|
|
4265
|
-
const collectRecommendations = (config) => {
|
|
4266
|
-
const { lines, lookup, quantity, types } = config;
|
|
4267
|
-
if (quantity <= 0) return [];
|
|
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;
|
|
4292
|
-
};
|
|
4293
|
-
//#endregion
|
|
4294
|
-
//#region src/order/totals.ts
|
|
4295
|
-
/** Sum root line group totals into order totals (Big.js). */
|
|
4296
|
-
const recomputeTotals = (lines, currency = "USD") => {
|
|
4297
|
-
let subtotal = new Big(0);
|
|
4298
|
-
let discountTotal = new Big(0);
|
|
4299
|
-
let taxTotal = new Big(0);
|
|
4300
|
-
let total = new Big(0);
|
|
4301
|
-
for (const line of lines) {
|
|
4302
|
-
if (line.parent_uid != null) continue;
|
|
4303
|
-
subtotal = subtotal.plus(line.group_subtotal);
|
|
4304
|
-
discountTotal = discountTotal.plus(line.group_discount);
|
|
4305
|
-
taxTotal = taxTotal.plus(line.group_tax_total);
|
|
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
|
|
4323
|
-
};
|
|
4324
|
-
const b3 = {
|
|
4325
|
-
tax_rate: taxRate,
|
|
4326
|
-
tax_total,
|
|
4327
|
-
total: total.toNumber()
|
|
4328
|
-
};
|
|
4329
|
-
return {
|
|
4330
|
-
...b1,
|
|
4331
|
-
...b2,
|
|
4332
|
-
...b3
|
|
4333
|
-
};
|
|
4334
|
-
};
|
|
4335
|
-
const emptyTotals = (currency = "USD") => {
|
|
4336
|
-
const b1 = {
|
|
4337
|
-
currency,
|
|
4338
|
-
subtotal: 0,
|
|
4339
|
-
discount_rate: 0,
|
|
4340
|
-
discount_total: 0
|
|
4341
|
-
};
|
|
4342
|
-
const b2 = {
|
|
4343
|
-
tax_rate: 0,
|
|
4344
|
-
tax_total: 0,
|
|
4345
|
-
total: 0
|
|
4346
|
-
};
|
|
4347
|
-
return {
|
|
4348
|
-
...b1,
|
|
4349
|
-
...b2
|
|
4350
|
-
};
|
|
4351
|
-
};
|
|
4352
|
-
//#endregion
|
|
4353
|
-
//#region src/order/redemptions.ts
|
|
4354
|
-
const ORDER_KEY = "ORDER";
|
|
4355
|
-
/** Same rules as `vOrders.redeem()`; throws on invalid input. */
|
|
4356
|
-
const buildRedeem = (input) => {
|
|
4357
|
-
const { reward_id, quantity, discount_rate, discount_amount } = input;
|
|
4358
|
-
const { expires_at } = input;
|
|
4359
|
-
if (!reward_id.trim()) throw new Error("Redeem requires reward_id");
|
|
4360
|
-
if (!Number.isInteger(quantity) || quantity < 1) throw new Error("Redeem quantity must be an integer ≥ 1");
|
|
4361
|
-
if (discount_rate === void 0 && discount_amount === void 0) throw new Error("Redeem requires discount_rate or discount_amount");
|
|
4362
|
-
const rate = discount_rate ?? 0;
|
|
4363
|
-
if (!(rate >= 0 && rate <= 1)) throw new Error("Redeem discount_rate must be between 0 and 1");
|
|
4364
|
-
if (discount_amount !== void 0 && !(discount_amount > 0)) throw new Error("Redeem discount_amount must be > 0");
|
|
4365
|
-
if (expires_at !== void 0 && Number.isNaN(Date.parse(expires_at))) throw new Error("Redeem expires_at must be an ISO datetime");
|
|
4366
|
-
const redeem = {
|
|
4367
|
-
reward_id,
|
|
4368
|
-
quantity
|
|
4369
|
-
};
|
|
4370
|
-
if (discount_rate !== void 0) redeem.discount_rate = discount_rate;
|
|
4371
|
-
if (discount_amount !== void 0) redeem.discount_amount = discount_amount;
|
|
4372
|
-
if (expires_at !== void 0) redeem.expires_at = expires_at;
|
|
4373
|
-
return redeem;
|
|
4374
|
-
};
|
|
4375
|
-
/**
|
|
4376
|
-
* Takes `quantity` units of `reward_id` off the latest entries first, so the
|
|
4377
|
-
* earlier redeems keep their step position. Entries that reach 0 are removed.
|
|
4378
|
-
*/
|
|
4379
|
-
const subtractRedeem = (items, reward_id, quantity) => {
|
|
4380
|
-
if (!Number.isInteger(quantity) || quantity < 1) throw new Error("Redeem quantity must be an integer ≥ 1");
|
|
4381
|
-
let pending = quantity;
|
|
4382
|
-
const kept = [];
|
|
4383
|
-
for (let i = items.length - 1; i >= 0; i--) {
|
|
4384
|
-
const item = items[i];
|
|
4385
|
-
if (pending === 0 || item.reward_id !== reward_id) {
|
|
4386
|
-
kept.unshift(item);
|
|
4387
|
-
continue;
|
|
4388
|
-
}
|
|
4389
|
-
const taken = Math.min(item.quantity, pending);
|
|
4390
|
-
pending -= taken;
|
|
4391
|
-
const rest = item.quantity - taken;
|
|
4392
|
-
if (rest > 0) kept.unshift({
|
|
4393
|
-
...item,
|
|
4394
|
-
quantity: rest
|
|
4395
|
-
});
|
|
4396
|
-
}
|
|
4397
|
-
return kept;
|
|
4398
|
-
};
|
|
4399
|
-
const redemptionTargetKey = (target) => {
|
|
4400
|
-
if (target.target === "ORDER") return ORDER_KEY;
|
|
4401
|
-
return `LINE:${target.target_id ?? ""}`;
|
|
4402
|
-
};
|
|
4403
|
-
/** Only root lines own redeems; child-line redeems are never applied. */
|
|
4404
|
-
const redeemPools = (lines, redeem) => {
|
|
4405
|
-
const pools = /* @__PURE__ */ new Map();
|
|
4406
|
-
for (const line of lines) {
|
|
4407
|
-
const items = line.redeem ?? [];
|
|
4408
|
-
if (line.parent_uid != null || items.length === 0) continue;
|
|
4409
|
-
const target = {
|
|
4410
|
-
target: "LINE",
|
|
4411
|
-
target_id: line.uid
|
|
4412
|
-
};
|
|
4413
|
-
const pool = items.map((item) => ({
|
|
4414
|
-
...target,
|
|
4415
|
-
redeem: item
|
|
4416
|
-
}));
|
|
4417
|
-
pools.set(redemptionTargetKey(target), pool);
|
|
4418
|
-
}
|
|
4419
|
-
const order = { target: "ORDER" };
|
|
4420
|
-
const pool = redeem.map((item) => ({
|
|
4421
|
-
...order,
|
|
4422
|
-
redeem: item
|
|
4423
|
-
}));
|
|
4424
|
-
pools.set(ORDER_KEY, pool);
|
|
4425
|
-
return pools;
|
|
4937
|
+
});
|
|
4938
|
+
const fallback = periods.filter((period) => !hasSpecificDates(period));
|
|
4939
|
+
const specificOpens = coveringSpecific.filter((period) => !period.closed);
|
|
4940
|
+
if (coveringSpecific.filter((period) => period.closed).some((period) => isClosedAt(period, now))) return null;
|
|
4941
|
+
if (specificOpens.length > 0) return specificOpens.find((period) => isOpenAt(period, now)) ?? null;
|
|
4942
|
+
if (fallback.some((period) => isClosedAt(period, now))) return null;
|
|
4943
|
+
return fallback.filter((period) => !period.closed).find((period) => isOpenAt(period, now)) ?? null;
|
|
4426
4944
|
};
|
|
4427
|
-
|
|
4428
|
-
|
|
4429
|
-
|
|
4430
|
-
|
|
4431
|
-
|
|
4432
|
-
const sequenceRedeems = (lines, redeem, steps) => {
|
|
4433
|
-
const pools = redeemPools(lines, redeem);
|
|
4434
|
-
const cursors = /* @__PURE__ */ new Map();
|
|
4435
|
-
const sequence = [];
|
|
4436
|
-
for (const step of steps) {
|
|
4437
|
-
const key = redemptionTargetKey(step);
|
|
4438
|
-
const at = cursors.get(key) ?? 0;
|
|
4439
|
-
const next = pools.get(key)?.[at];
|
|
4440
|
-
if (!next || next.redeem.reward_id !== step.reward_id) continue;
|
|
4441
|
-
cursors.set(key, at + 1);
|
|
4442
|
-
sequence.push(next);
|
|
4443
|
-
}
|
|
4444
|
-
for (const [key, pool] of pools) sequence.push(...pool.slice(cursors.get(key) ?? 0));
|
|
4445
|
-
return sequence;
|
|
4945
|
+
const atTimeOnDate = (day, time) => {
|
|
4946
|
+
const [hours = "0", minutes = "0"] = time.split(":");
|
|
4947
|
+
const next = new Date(day);
|
|
4948
|
+
next.setHours(Number(hours), Number(minutes), 0, 0);
|
|
4949
|
+
return next;
|
|
4446
4950
|
};
|
|
4447
|
-
const
|
|
4448
|
-
|
|
4951
|
+
const isWithinPeriodDates = (period, instantMs) => {
|
|
4952
|
+
if (!hasSpecificDates(period)) return true;
|
|
4953
|
+
const startMs = Date.parse(period.start_date);
|
|
4954
|
+
const endMs = Date.parse(period.end_date);
|
|
4955
|
+
if (Number.isNaN(startMs) || Number.isNaN(endMs)) return false;
|
|
4956
|
+
return startMs <= instantMs && instantMs <= endMs;
|
|
4449
4957
|
};
|
|
4450
|
-
const
|
|
4451
|
-
|
|
4958
|
+
const dayPeriods = (schedule, instant) => {
|
|
4959
|
+
const day = DAY_OF_WEEK[instant.getDay()];
|
|
4960
|
+
if (!day) return [];
|
|
4961
|
+
return schedule.find((row) => row.day_of_week === day)?.periods ?? [];
|
|
4452
4962
|
};
|
|
4453
|
-
const
|
|
4454
|
-
|
|
4455
|
-
|
|
4963
|
+
const getActiveUntil = (periods, now) => {
|
|
4964
|
+
const open = getOpenPeriodAt(periods, now);
|
|
4965
|
+
if (!open?.end_time) return null;
|
|
4966
|
+
let untilMs = atTimeOnDate(now, open.end_time).getTime();
|
|
4967
|
+
const openIsSpecific = hasSpecificDates(open);
|
|
4968
|
+
for (const period of periods) {
|
|
4969
|
+
if (!period.closed || !period.start_time) continue;
|
|
4970
|
+
const closeMs = atTimeOnDate(now, period.start_time).getTime();
|
|
4971
|
+
if (closeMs <= now.getTime() || closeMs >= untilMs) continue;
|
|
4972
|
+
if (!isWithinPeriodDates(period, closeMs)) continue;
|
|
4973
|
+
if (!hasSpecificDates(period) && openIsSpecific) continue;
|
|
4974
|
+
untilMs = closeMs;
|
|
4975
|
+
}
|
|
4976
|
+
return new Date(untilMs);
|
|
4456
4977
|
};
|
|
4457
|
-
const
|
|
4458
|
-
const
|
|
4459
|
-
|
|
4460
|
-
|
|
4978
|
+
const getNextStart = (schedule, now) => {
|
|
4979
|
+
const nowMs = now.getTime();
|
|
4980
|
+
let nextStartMs = null;
|
|
4981
|
+
const consider = (candidate, periods) => {
|
|
4982
|
+
const startMs = candidate.getTime();
|
|
4983
|
+
if (startMs <= nowMs) return;
|
|
4984
|
+
if (!isPeriodsActiveAt(periods, candidate)) return;
|
|
4985
|
+
if (nextStartMs === null || startMs < nextStartMs) nextStartMs = startMs;
|
|
4986
|
+
};
|
|
4987
|
+
for (let dayOffset = 0; dayOffset < 8; dayOffset += 1) {
|
|
4988
|
+
const candidateDate = new Date(now);
|
|
4989
|
+
candidateDate.setDate(candidateDate.getDate() + dayOffset);
|
|
4990
|
+
candidateDate.setSeconds(0, 0);
|
|
4991
|
+
const periods = dayPeriods(schedule, candidateDate);
|
|
4992
|
+
if (periods.length === 0) continue;
|
|
4993
|
+
for (const period of periods) {
|
|
4994
|
+
if (period.closed || !period.start_time) continue;
|
|
4995
|
+
const start = atTimeOnDate(candidateDate, period.start_time);
|
|
4996
|
+
if (!isWithinPeriodDates(period, start.getTime())) continue;
|
|
4997
|
+
consider(start, periods);
|
|
4998
|
+
}
|
|
4999
|
+
for (const period of periods) {
|
|
5000
|
+
if (!period.closed || !period.end_time) continue;
|
|
5001
|
+
const reopen = atTimeOnDate(candidateDate, period.end_time);
|
|
5002
|
+
if (!isWithinPeriodDates(period, reopen.getTime())) continue;
|
|
5003
|
+
consider(reopen, periods);
|
|
5004
|
+
}
|
|
5005
|
+
}
|
|
5006
|
+
return nextStartMs === null ? null : new Date(nextStartMs);
|
|
5007
|
+
};
|
|
5008
|
+
/** Whether `schedule` is in hours now, and until / next open. */
|
|
5009
|
+
const getScheduleWindow = (schedule, now = /* @__PURE__ */ new Date()) => {
|
|
5010
|
+
if (schedule == null) return {
|
|
5011
|
+
active: true,
|
|
5012
|
+
until: null
|
|
5013
|
+
};
|
|
5014
|
+
const periods = dayPeriods(schedule, now);
|
|
5015
|
+
if (isPeriodsActiveAt(periods, now)) return {
|
|
5016
|
+
active: true,
|
|
5017
|
+
until: getActiveUntil(periods, now)
|
|
4461
5018
|
};
|
|
4462
|
-
const b2 = { discount_amount: run.discount.toNumber() };
|
|
4463
|
-
const b3 = { discount_rate: rateOf(run.discount, run.base) };
|
|
4464
5019
|
return {
|
|
4465
|
-
|
|
4466
|
-
|
|
4467
|
-
...b3
|
|
5020
|
+
active: false,
|
|
5021
|
+
next: getNextStart(schedule, now)
|
|
4468
5022
|
};
|
|
4469
5023
|
};
|
|
4470
|
-
|
|
4471
|
-
|
|
4472
|
-
* per unit. With both, the rate discount is capped at `amount × quantity`.
|
|
4473
|
-
*/
|
|
4474
|
-
const redeemDiscount = (redeem, remaining) => {
|
|
4475
|
-
const { quantity, discount_rate, discount_amount } = redeem;
|
|
4476
|
-
let cap = null;
|
|
4477
|
-
if (discount_amount !== void 0) cap = new Big(discount_amount).times(quantity);
|
|
4478
|
-
if (discount_rate === void 0) return cap ?? new Big(0);
|
|
4479
|
-
const kept = new Big(1).minus(discount_rate).pow(quantity);
|
|
4480
|
-
const byRate = remaining.times(new Big(1).minus(kept));
|
|
4481
|
-
return cap ? minBig(byRate, cap) : byRate;
|
|
5024
|
+
const isOnSchedule = (schedule, now) => {
|
|
5025
|
+
return getScheduleWindow(schedule, now).active;
|
|
4482
5026
|
};
|
|
4483
|
-
|
|
4484
|
-
|
|
4485
|
-
|
|
4486
|
-
|
|
4487
|
-
|
|
4488
|
-
};
|
|
5027
|
+
//#endregion
|
|
5028
|
+
//#region src/order/recommendations.ts
|
|
5029
|
+
const DEFAULT_RECOMMENDATION_TYPES = ["CROSS_SELL"];
|
|
5030
|
+
const linkedRecommendationIds = (pricing) => {
|
|
5031
|
+
return [...pricing.recommendations_ids ?? [], ...(pricing.recommendations ?? []).map((link) => link.recommendation_id)];
|
|
4489
5032
|
};
|
|
4490
|
-
|
|
4491
|
-
|
|
4492
|
-
|
|
4493
|
-
|
|
4494
|
-
const computeRedemptionSteps = (lines, redeem, steps) => {
|
|
4495
|
-
const sequence = sequenceRedeems(lines, redeem, steps);
|
|
4496
|
-
if (sequence.length === 0) return [];
|
|
5033
|
+
const collectRecommendations = (config) => {
|
|
5034
|
+
const { lines, lookup, quantity, types } = config;
|
|
5035
|
+
if (quantity <= 0) return [];
|
|
5036
|
+
const typeSet = new Set(types ?? DEFAULT_RECOMMENDATION_TYPES);
|
|
4497
5037
|
const roots = lines.filter((line) => line.parent_uid == null);
|
|
4498
|
-
|
|
4499
|
-
const
|
|
5038
|
+
const fingerprints = roots.map((root) => computeLineFingerprint(root, lines));
|
|
5039
|
+
const cartKeys = new Set(fingerprints);
|
|
5040
|
+
const seenTargetIds = /* @__PURE__ */ new Set();
|
|
5041
|
+
const products = [];
|
|
4500
5042
|
for (const root of roots) {
|
|
4501
|
-
|
|
4502
|
-
|
|
4503
|
-
|
|
4504
|
-
|
|
4505
|
-
|
|
4506
|
-
|
|
4507
|
-
|
|
4508
|
-
|
|
4509
|
-
|
|
4510
|
-
|
|
4511
|
-
|
|
4512
|
-
|
|
4513
|
-
|
|
4514
|
-
|
|
4515
|
-
|
|
4516
|
-
|
|
4517
|
-
|
|
4518
|
-
reward_id,
|
|
4519
|
-
reward_quantity: quantity,
|
|
4520
|
-
target: item.target
|
|
4521
|
-
};
|
|
4522
|
-
const b2 = item.target_id ? { target_id: item.target_id } : {};
|
|
4523
|
-
const b3 = {
|
|
4524
|
-
action,
|
|
4525
|
-
discount_amount: discount.toNumber()
|
|
4526
|
-
};
|
|
4527
|
-
const b4 = {
|
|
4528
|
-
before,
|
|
4529
|
-
after: snapshot(order)
|
|
4530
|
-
};
|
|
4531
|
-
let b5 = {};
|
|
4532
|
-
if (line) b5 = {
|
|
4533
|
-
target_before: targetBefore,
|
|
4534
|
-
target_after: snapshot(line)
|
|
4535
|
-
};
|
|
4536
|
-
return {
|
|
4537
|
-
...b1,
|
|
4538
|
-
...b2,
|
|
4539
|
-
...b3,
|
|
4540
|
-
...b4,
|
|
4541
|
-
...b5
|
|
4542
|
-
};
|
|
4543
|
-
});
|
|
4544
|
-
};
|
|
4545
|
-
/** Subtract redemption discounts from line-based order totals. */
|
|
4546
|
-
const applyRedemptionTotals = (totals, steps) => {
|
|
4547
|
-
if (steps.length === 0) return totals;
|
|
4548
|
-
let redeemed = new Big(0);
|
|
4549
|
-
for (const step of steps) redeemed = redeemed.plus(step.discount_amount);
|
|
4550
|
-
const discountTotal = new Big(totals.discount_total).plus(redeemed);
|
|
4551
|
-
const subtotal = new Big(totals.subtotal);
|
|
4552
|
-
const discount_rate = rateOf(discountTotal, subtotal);
|
|
4553
|
-
const discount_total = discountTotal.toNumber();
|
|
4554
|
-
const total = new Big(totals.total).minus(redeemed).toNumber();
|
|
4555
|
-
return {
|
|
4556
|
-
...totals,
|
|
4557
|
-
discount_rate,
|
|
4558
|
-
discount_total,
|
|
4559
|
-
total
|
|
4560
|
-
};
|
|
4561
|
-
};
|
|
4562
|
-
/** Steps + totals for a set of lines and redeems, keeping step order. */
|
|
4563
|
-
const recomputeRedemptions = (input) => {
|
|
4564
|
-
const { lines, redeem, currency } = input;
|
|
4565
|
-
const steps = input.redemption_steps;
|
|
4566
|
-
const redemption_steps = computeRedemptionSteps(lines, redeem, steps);
|
|
4567
|
-
const lineTotals = recomputeTotals(lines, currency);
|
|
4568
|
-
return {
|
|
4569
|
-
redemption_steps,
|
|
4570
|
-
totals: applyRedemptionTotals(lineTotals, redemption_steps)
|
|
4571
|
-
};
|
|
5043
|
+
const source = lookup.productsById[root.id];
|
|
5044
|
+
if (!source) continue;
|
|
5045
|
+
for (const recommendationId of linkedRecommendationIds(source.pricing)) {
|
|
5046
|
+
const recommendation = lookup.recommendationsById[recommendationId];
|
|
5047
|
+
if (!recommendation || !typeSet.has(recommendation.type)) continue;
|
|
5048
|
+
const targetId = recommendation.target_product_id;
|
|
5049
|
+
if (seenTargetIds.has(targetId)) continue;
|
|
5050
|
+
if (cartKeys.has(bareProductFingerprint(targetId))) continue;
|
|
5051
|
+
const target = lookup.productsById[targetId];
|
|
5052
|
+
if (!target || target.status !== "ACTIVE") continue;
|
|
5053
|
+
if (!isOnSchedule(target.availability.schedules)) continue;
|
|
5054
|
+
seenTargetIds.add(targetId);
|
|
5055
|
+
products.push(target);
|
|
5056
|
+
if (products.length >= quantity) return products;
|
|
5057
|
+
}
|
|
5058
|
+
}
|
|
5059
|
+
return products;
|
|
4572
5060
|
};
|
|
4573
5061
|
//#endregion
|
|
4574
5062
|
//#region src/order/Order.ts
|
|
@@ -4633,11 +5121,11 @@ const buildInsertFromSource = (options, source) => {
|
|
|
4633
5121
|
if (options.scope?.brand_id !== void 0) brand_id = optionalBrandId(options.scope.brand_id);
|
|
4634
5122
|
let customer = source.customer;
|
|
4635
5123
|
if (options.customer) customer = buildCustomerSnapshot(options.customer);
|
|
4636
|
-
const
|
|
5124
|
+
const sourceLines = structuredClone(source.lines);
|
|
4637
5125
|
const is_anonymous = options.isAnonymous ?? source.is_anonymous;
|
|
4638
5126
|
const redeem = structuredClone(source.redeem ?? []);
|
|
4639
|
-
const { redemption_steps, totals } = recomputeRedemptions({
|
|
4640
|
-
lines,
|
|
5127
|
+
const { lines, redemption_steps, totals } = recomputeRedemptions({
|
|
5128
|
+
lines: sourceLines,
|
|
4641
5129
|
redeem,
|
|
4642
5130
|
redemption_steps: source.redemption_steps ?? [],
|
|
4643
5131
|
currency: source.totals.currency
|
|
@@ -4689,14 +5177,8 @@ const buildLocalDraft = (insert) => {
|
|
|
4689
5177
|
const is_anonymous = insert.is_anonymous ?? false;
|
|
4690
5178
|
const totals = insert.totals ?? emptyTotals();
|
|
4691
5179
|
const metadata = insert.metadata ?? {};
|
|
4692
|
-
const payment_intents =
|
|
4693
|
-
|
|
4694
|
-
return {
|
|
4695
|
-
...intent,
|
|
4696
|
-
metadata
|
|
4697
|
-
};
|
|
4698
|
-
});
|
|
4699
|
-
const payment_status = insert.payment_status ?? "IDLE";
|
|
5180
|
+
const payment_intents = [];
|
|
5181
|
+
const payment_status = "IDLE";
|
|
4700
5182
|
const b1 = {
|
|
4701
5183
|
uid: randomUUID(),
|
|
4702
5184
|
id: insert.id,
|
|
@@ -4788,6 +5270,9 @@ var Order = class Order {
|
|
|
4788
5270
|
http;
|
|
4789
5271
|
flush;
|
|
4790
5272
|
payments;
|
|
5273
|
+
/** Real tenders chosen with `setTenders`; REDEMPTION ones come from redeem. */
|
|
5274
|
+
realTenders = null;
|
|
5275
|
+
redemptionProviders = /* @__PURE__ */ new Map();
|
|
4791
5276
|
constructor(config, notify) {
|
|
4792
5277
|
this.draft = cloneOrder(config.order);
|
|
4793
5278
|
this.synced = cloneOrder(config.order);
|
|
@@ -4986,12 +5471,16 @@ var Order = class Order {
|
|
|
4986
5471
|
}
|
|
4987
5472
|
/**
|
|
4988
5473
|
* Appends a redeem to `line_uid` (root line) or to the order. Steps keep
|
|
4989
|
-
* the order in which redeems were applied.
|
|
5474
|
+
* the order in which redeems were applied. With `payment_id` (points /
|
|
5475
|
+
* balance) it is not a discount: the order gets a REDEMPTION tender for
|
|
5476
|
+
* it, capped at total − tip − delivery (D15, D18, D19).
|
|
4990
5477
|
*/
|
|
4991
5478
|
applyRedeem(input) {
|
|
4992
5479
|
if (this.deleted) return;
|
|
4993
|
-
const { line_uid, ...rest } = input;
|
|
5480
|
+
const { line_uid, provider, ...rest } = input;
|
|
4994
5481
|
const redeem = buildRedeem(rest);
|
|
5482
|
+
if (isPaymentRedeem(redeem) && line_uid !== void 0) throw new Error("Points / balance redeems apply to the whole order");
|
|
5483
|
+
if (redeem.payment_id && provider) this.redemptionProviders.set(redeem.payment_id, provider);
|
|
4995
5484
|
if (line_uid === void 0) {
|
|
4996
5485
|
this.commitLines({ redeem: [...this.draft.redeem ?? [], redeem] });
|
|
4997
5486
|
return;
|
|
@@ -5009,7 +5498,8 @@ var Order = class Order {
|
|
|
5009
5498
|
}
|
|
5010
5499
|
/**
|
|
5011
5500
|
* Removes `quantity` units of `reward_id` from `line_uid` or the order,
|
|
5012
|
-
* latest applied first. Removing more units than applied clears them all
|
|
5501
|
+
* latest applied first. Removing more units than applied clears them all
|
|
5502
|
+
* (and the REDEMPTION tender of a points / balance redeem).
|
|
5013
5503
|
*/
|
|
5014
5504
|
removeRedeem(input) {
|
|
5015
5505
|
if (this.deleted) return;
|
|
@@ -5153,19 +5643,72 @@ var Order = class Order {
|
|
|
5153
5643
|
};
|
|
5154
5644
|
await services.orders.validate(body, this.http);
|
|
5155
5645
|
}
|
|
5646
|
+
/** Tenders of the next intent: real ones plus one per payment redeem. */
|
|
5647
|
+
getTenders(payment) {
|
|
5648
|
+
const simple = payment ? simpleTenderInput(payment) : null;
|
|
5649
|
+
const real = simple ? [simple] : this.realTenders ?? [];
|
|
5650
|
+
const redeem = this.draft.redeem ?? [];
|
|
5651
|
+
const providers = this.redemptionProviders;
|
|
5652
|
+
return [...real, ...redemptionTenderInputs(redeem, providers)];
|
|
5653
|
+
}
|
|
5654
|
+
/**
|
|
5655
|
+
* Chooses the real tenders (with allocation for mixed payments) and saves
|
|
5656
|
+
* the DRAFT intent; the server returns amounts per tender. REDEMPTION
|
|
5657
|
+
* tenders are added from the redeems, never passed here.
|
|
5658
|
+
*/
|
|
5659
|
+
async setTenders(tenders) {
|
|
5660
|
+
if (tenders.some((tender) => !isRealPaymentMethod(tender.method))) throw new Error("REDEMPTION tenders come from applyRedeem");
|
|
5661
|
+
this.realTenders = tenders;
|
|
5662
|
+
return this.syncPaymentIntent(this.getTenders());
|
|
5663
|
+
}
|
|
5664
|
+
/** Drops the chosen tenders and discards the DRAFT intent. */
|
|
5665
|
+
async clearTenders() {
|
|
5666
|
+
this.realTenders = null;
|
|
5667
|
+
if (activeIntent(this.draft.payment_intents)?.status !== "DRAFT") return this.getOrder();
|
|
5668
|
+
const id = this.draft.id;
|
|
5669
|
+
const next = await services.orders.deletePaymentIntent(id, this.http);
|
|
5670
|
+
this.applyServerOrder(next);
|
|
5671
|
+
return this.getOrder();
|
|
5672
|
+
}
|
|
5673
|
+
async syncPaymentIntent(tenders) {
|
|
5674
|
+
await this.persist();
|
|
5675
|
+
await this.flush.settle();
|
|
5676
|
+
const id = this.draft.id;
|
|
5677
|
+
const put = services.orders.putPaymentIntent;
|
|
5678
|
+
const next = await put(id, { tenders }, this.http);
|
|
5679
|
+
this.applyServerOrder(next);
|
|
5680
|
+
return this.getOrder();
|
|
5681
|
+
}
|
|
5682
|
+
applyServerOrder(next) {
|
|
5683
|
+
this.draft = cloneOrder(next);
|
|
5684
|
+
this.synced = cloneOrder(next);
|
|
5685
|
+
this.notify();
|
|
5686
|
+
}
|
|
5687
|
+
/**
|
|
5688
|
+
* Places the order with a DRAFT intent: `intent_id` as is, else the
|
|
5689
|
+
* tenders of `getTenders(input.payment)` saved first (simple flow: one
|
|
5690
|
+
* real tender plus REDEMPTION). The intent is `LOCKED` afterwards (D25).
|
|
5691
|
+
*/
|
|
5156
5692
|
async placeOrder(input = {}) {
|
|
5157
5693
|
await this.persist();
|
|
5158
5694
|
await this.flush.settle();
|
|
5159
5695
|
await this.validateOrder();
|
|
5696
|
+
let body = input;
|
|
5697
|
+
if (!input.intent_id) {
|
|
5698
|
+
const tenders = this.getTenders(input.payment);
|
|
5699
|
+
const needsPayment = input.payment && !simpleTenderInput(input.payment);
|
|
5700
|
+
if (tenders.length > 0 && !needsPayment) {
|
|
5701
|
+
const intent = activeIntent((await this.syncPaymentIntent(tenders)).payment_intents);
|
|
5702
|
+
if (intent) body = { intent_id: intent.id };
|
|
5703
|
+
}
|
|
5704
|
+
}
|
|
5160
5705
|
try {
|
|
5161
|
-
await services.orders.place(this.draft.id,
|
|
5706
|
+
await services.orders.place(this.draft.id, body, this.http);
|
|
5162
5707
|
} catch (error) {
|
|
5163
5708
|
if (!(error instanceof StorefrontHttpError) || error.status !== 409) throw error;
|
|
5164
5709
|
}
|
|
5165
5710
|
const next = await services.orders.get(this.draft.id, this.http);
|
|
5166
|
-
this.
|
|
5167
|
-
this.synced = cloneOrder(next);
|
|
5168
|
-
this.notify();
|
|
5711
|
+
this.applyServerOrder(next);
|
|
5169
5712
|
}
|
|
5170
5713
|
/** Installed `@fire-spark/payments-*` adapters (provider + method). */
|
|
5171
5714
|
setPaymentAdapters(adapters) {
|
|
@@ -5191,21 +5734,73 @@ var Order = class Order {
|
|
|
5191
5734
|
getMissingPaymentFields(payment) {
|
|
5192
5735
|
return this.payments.missingFields(payment);
|
|
5193
5736
|
}
|
|
5194
|
-
/** Order total minus
|
|
5737
|
+
/** Order total minus settled (and confirm-after-inject) tenders. */
|
|
5195
5738
|
getRemainingAmount() {
|
|
5196
5739
|
return this.payments.remaining();
|
|
5197
5740
|
}
|
|
5198
|
-
/**
|
|
5741
|
+
/** Merchant payments the tenders point to (`payment_id`). */
|
|
5742
|
+
setPayments(payments) {
|
|
5743
|
+
this.payments.registerPayments(payments);
|
|
5744
|
+
}
|
|
5745
|
+
/**
|
|
5746
|
+
* Places the order if needed and charges its real device tenders in
|
|
5747
|
+
* sequence (§6.1); stops at the first failure with the order frozen.
|
|
5748
|
+
*/
|
|
5749
|
+
payIntent(options) {
|
|
5750
|
+
return this.payments.payIntent(options);
|
|
5751
|
+
}
|
|
5752
|
+
/** Charges a FAILED tender again with a new attempt reference (D25). */
|
|
5753
|
+
retryTender(tenderId, options) {
|
|
5754
|
+
return this.payments.retryTender(tenderId, options);
|
|
5755
|
+
}
|
|
5756
|
+
/** Changes the method of a FAILED tender; then `payIntent()` again. */
|
|
5757
|
+
replaceTender(tenderId, payment, collectAt) {
|
|
5758
|
+
return this.payments.replaceTender(tenderId, payment, collectAt);
|
|
5759
|
+
}
|
|
5760
|
+
/** "Cancel and refund": reverses device charges, then cancels the order. */
|
|
5761
|
+
async cancelAndRefund(options) {
|
|
5762
|
+
const outcomes = await this.payments.reverseAll(options);
|
|
5763
|
+
await this.cancelOrder();
|
|
5764
|
+
return outcomes;
|
|
5765
|
+
}
|
|
5766
|
+
/** Voids one tender (operator flow); idempotent per call. */
|
|
5767
|
+
async voidTender(tenderId, reason) {
|
|
5768
|
+
const idempotency_key = createAttemptReference();
|
|
5769
|
+
const body = reason ? {
|
|
5770
|
+
idempotency_key,
|
|
5771
|
+
reason
|
|
5772
|
+
} : { idempotency_key };
|
|
5773
|
+
const id = this.draft.id;
|
|
5774
|
+
const next = await services.orders.voidTender(id, tenderId, body, this.http);
|
|
5775
|
+
this.applyServerOrder(next);
|
|
5776
|
+
return this.getOrder();
|
|
5777
|
+
}
|
|
5778
|
+
/** Refunds one tender: all of it, an amount, or some units. */
|
|
5779
|
+
async refundTender(tenderId, input = {}) {
|
|
5780
|
+
const body = {
|
|
5781
|
+
...input,
|
|
5782
|
+
idempotency_key: createAttemptReference()
|
|
5783
|
+
};
|
|
5784
|
+
const id = this.draft.id;
|
|
5785
|
+
const refund = services.orders.refundTender;
|
|
5786
|
+
const next = await refund(id, tenderId, body, this.http);
|
|
5787
|
+
this.applyServerOrder(next);
|
|
5788
|
+
return this.getOrder();
|
|
5789
|
+
}
|
|
5790
|
+
/** @deprecated Use `payIntent({ payment })`. */
|
|
5199
5791
|
pay(payment, options) {
|
|
5200
|
-
return this.payments.
|
|
5792
|
+
return this.payments.payIntent({
|
|
5793
|
+
...options,
|
|
5794
|
+
payment
|
|
5795
|
+
});
|
|
5201
5796
|
}
|
|
5202
5797
|
/** Confirms intents that waited for the order injection. */
|
|
5203
5798
|
confirmPaymentsAfterInject() {
|
|
5204
5799
|
return this.payments.confirmAfterInject();
|
|
5205
5800
|
}
|
|
5206
5801
|
/** Cancels or refunds every charge of this order (or flags it manual). */
|
|
5207
|
-
reversePayments() {
|
|
5208
|
-
return this.payments.reverseAll();
|
|
5802
|
+
reversePayments(options) {
|
|
5803
|
+
return this.payments.reverseAll(options);
|
|
5209
5804
|
}
|
|
5210
5805
|
/** Terminal admin operation (init, test, closeBatch, status, extra). */
|
|
5211
5806
|
runPaymentTerminal(payment, key) {
|
|
@@ -5213,14 +5808,20 @@ var Order = class Order {
|
|
|
5213
5808
|
}
|
|
5214
5809
|
/** Applies lines/redeem, replaying redemption steps and totals. */
|
|
5215
5810
|
commitLines(patch) {
|
|
5216
|
-
const
|
|
5217
|
-
const
|
|
5218
|
-
const
|
|
5219
|
-
|
|
5220
|
-
|
|
5221
|
-
|
|
5222
|
-
|
|
5811
|
+
const patched = patch.lines ?? this.draft.lines;
|
|
5812
|
+
const applied = patch.redeem ?? this.draft.redeem ?? [];
|
|
5813
|
+
const steps = this.draft.redemption_steps ?? [];
|
|
5814
|
+
const currency = this.draft.totals.currency;
|
|
5815
|
+
const { lines, redemption_steps, totals } = recomputeRedemptions({
|
|
5816
|
+
lines: patched,
|
|
5817
|
+
redeem: applied,
|
|
5818
|
+
redemption_steps: steps,
|
|
5819
|
+
currency
|
|
5223
5820
|
});
|
|
5821
|
+
const redeem = clampPaymentRedeems(applied, redemptionCap({
|
|
5822
|
+
lines,
|
|
5823
|
+
totals
|
|
5824
|
+
}), currency);
|
|
5224
5825
|
this.draft = {
|
|
5225
5826
|
...this.draft,
|
|
5226
5827
|
lines,
|
|
@@ -5903,9 +6504,38 @@ const validateRedemptions = (input, issues, lineIssues) => {
|
|
|
5903
6504
|
for (const issue of expired) pushLineIssue(lineIssues, line.uid, issue);
|
|
5904
6505
|
}
|
|
5905
6506
|
checkRedemptionTargets(input.lines, redeem, steps, issues, lineIssues);
|
|
5906
|
-
const
|
|
5907
|
-
compareRedemptionSteps(steps,
|
|
5908
|
-
return
|
|
6507
|
+
const replayed = replayRedemptions(input.lines, redeem, steps);
|
|
6508
|
+
compareRedemptionSteps(steps, replayed.redemption_steps, issues);
|
|
6509
|
+
return replayed.lines;
|
|
6510
|
+
};
|
|
6511
|
+
const REDEEMED_GROUP_FIELDS = [
|
|
6512
|
+
["group_discount", "DISCOUNT_MISMATCH"],
|
|
6513
|
+
["unit_group_discount", "DISCOUNT_MISMATCH"],
|
|
6514
|
+
["group_tax_total", "TAX_MISMATCH"],
|
|
6515
|
+
["unit_group_tax_total", "TAX_MISMATCH"],
|
|
6516
|
+
["group_total", "PRICE_MISMATCH"],
|
|
6517
|
+
["unit_group_total", "PRICE_MISMATCH"]
|
|
6518
|
+
];
|
|
6519
|
+
/** Root group money must carry exactly the replayed redemption discounts. */
|
|
6520
|
+
const compareRedeemedRoots = (submitted, expected, out) => {
|
|
6521
|
+
const byUid = new Map(expected.map((line) => [line.uid, line]));
|
|
6522
|
+
for (const line of submitted) {
|
|
6523
|
+
if (line.parent_uid != null) continue;
|
|
6524
|
+
const wanted = byUid.get(line.uid);
|
|
6525
|
+
if (!wanted) continue;
|
|
6526
|
+
for (const [field, code] of REDEEMED_GROUP_FIELDS) {
|
|
6527
|
+
if (bigEq(line[field], wanted[field])) continue;
|
|
6528
|
+
const b1 = {
|
|
6529
|
+
code,
|
|
6530
|
+
field,
|
|
6531
|
+
expected: bigStr(wanted[field])
|
|
6532
|
+
};
|
|
6533
|
+
pushLineIssue(out, line.uid, {
|
|
6534
|
+
...b1,
|
|
6535
|
+
actual: bigStr(line[field])
|
|
6536
|
+
});
|
|
6537
|
+
}
|
|
6538
|
+
}
|
|
5909
6539
|
};
|
|
5910
6540
|
/** Compare cart lines/totals against a fresh menu (exact Big.js equality). */
|
|
5911
6541
|
const validateOrderAgainstMenu = (input) => {
|
|
@@ -5918,7 +6548,8 @@ const validateOrderAgainstMenu = (input) => {
|
|
|
5918
6548
|
lines: {}
|
|
5919
6549
|
};
|
|
5920
6550
|
if (!input.menuActive) issues.push({ code: "MENU_CLOSED" });
|
|
5921
|
-
const
|
|
6551
|
+
const menuLines = stripRedemptionLines(input.lines);
|
|
6552
|
+
const roots = menuLines.filter((line) => line.parent_uid == null);
|
|
5922
6553
|
if (roots.length === 0) {
|
|
5923
6554
|
issues.push({ code: "EMPTY_ORDER" });
|
|
5924
6555
|
return {
|
|
@@ -5933,12 +6564,13 @@ const validateOrderAgainstMenu = (input) => {
|
|
|
5933
6564
|
checkFeeSelfConsistency(root, lineIssues);
|
|
5934
6565
|
continue;
|
|
5935
6566
|
}
|
|
5936
|
-
validateProductRoot(root,
|
|
6567
|
+
validateProductRoot(root, menuLines, lookup, locale, lineIssues);
|
|
5937
6568
|
}
|
|
5938
6569
|
checkRootProductQuantities(roots, lookup, lineIssues);
|
|
5939
6570
|
if (input.expectedFeeLines) compareExpectedFeeLines(input.lines, input.expectedFeeLines, lineIssues);
|
|
5940
|
-
const
|
|
5941
|
-
|
|
6571
|
+
const redeemed = validateRedemptions(input, issues, lineIssues);
|
|
6572
|
+
compareRedeemedRoots(input.lines, redeemed, lineIssues);
|
|
6573
|
+
const expectedTotals = recomputeTotals(redeemed, input.totals.currency);
|
|
5942
6574
|
for (const field of TOTALS_FIELDS) {
|
|
5943
6575
|
if (bigEq(input.totals[field], expectedTotals[field])) continue;
|
|
5944
6576
|
issues.push({
|
|
@@ -6324,6 +6956,22 @@ const isAnonymousOrder = (condition, isAnonymous) => {
|
|
|
6324
6956
|
assert(value != null, "Invalid value");
|
|
6325
6957
|
return matchBooleanOperator(condition, isAnonymous ?? false);
|
|
6326
6958
|
};
|
|
6959
|
+
const paymentIsMixed = (condition, isMixed) => {
|
|
6960
|
+
const { type, primitive, value } = condition;
|
|
6961
|
+
const matcherType = "Payment is mixed";
|
|
6962
|
+
assert(primitive === "boolean", primitiveError("boolean"));
|
|
6963
|
+
assert(type === matcherType, typeError(matcherType));
|
|
6964
|
+
assert(value != null, "Invalid value");
|
|
6965
|
+
return matchBooleanOperator(condition, isMixed ?? false);
|
|
6966
|
+
};
|
|
6967
|
+
const paymentCaptured = (condition, captured) => {
|
|
6968
|
+
const { type, primitive, value } = condition;
|
|
6969
|
+
const matcherType = "Payment captured";
|
|
6970
|
+
assert(primitive === "boolean", primitiveError("boolean"));
|
|
6971
|
+
assert(type === matcherType, typeError(matcherType));
|
|
6972
|
+
assert(value != null, "Invalid value");
|
|
6973
|
+
return matchBooleanOperator(condition, captured ?? false);
|
|
6974
|
+
};
|
|
6327
6975
|
//#endregion
|
|
6328
6976
|
//#region src/rules/currencyMatcher.ts
|
|
6329
6977
|
const matchCurrencyOperator = (condition, matcher) => {
|
|
@@ -6436,17 +7084,29 @@ const matchEnumOperator = (condition, matcher) => {
|
|
|
6436
7084
|
default: throw new Error(`Operator ${operator} not supported in enum matcher`);
|
|
6437
7085
|
}
|
|
6438
7086
|
};
|
|
7087
|
+
const NEGATIVE_ENUM_OPERATORS = ["is not", "is none of"];
|
|
7088
|
+
/**
|
|
7089
|
+
* Enum condition over a set (one value per tender): positive operators
|
|
7090
|
+
* match when some value matches, negative ones when none matches.
|
|
7091
|
+
*/
|
|
7092
|
+
const matchEnumSet = (condition, matcher) => {
|
|
7093
|
+
if (typeof matcher === "undefined" || typeof matcher === "string") return matchEnumOperator(condition, matcher);
|
|
7094
|
+
if (matcher.length === 0) return false;
|
|
7095
|
+
const matches = (value) => matchEnumOperator(condition, value);
|
|
7096
|
+
if (NEGATIVE_ENUM_OPERATORS.includes(condition.operator)) return matcher.every(matches);
|
|
7097
|
+
return matcher.some(matches);
|
|
7098
|
+
};
|
|
6439
7099
|
const paymentCollectAt = (condition, collectAt) => {
|
|
6440
7100
|
const matcherType = "Payment collect at";
|
|
6441
7101
|
assert(condition.primitive === "enum", primitiveError("enum"));
|
|
6442
7102
|
assert(condition.type === matcherType, typeError(matcherType));
|
|
6443
|
-
return
|
|
7103
|
+
return matchEnumSet(condition, collectAt);
|
|
6444
7104
|
};
|
|
6445
7105
|
const paymentMethod = (condition, method) => {
|
|
6446
7106
|
const matcherType = "Payment method";
|
|
6447
7107
|
assert(condition.primitive === "enum", primitiveError("enum"));
|
|
6448
7108
|
assert(condition.type === matcherType, typeError(matcherType));
|
|
6449
|
-
return
|
|
7109
|
+
return matchEnumSet(condition, method);
|
|
6450
7110
|
};
|
|
6451
7111
|
const paymentStatus = (condition, status) => {
|
|
6452
7112
|
const matcherType = "Payment status";
|
|
@@ -7204,6 +7864,14 @@ var RulesEngine = class {
|
|
|
7204
7864
|
if (typeof value !== "boolean") return false;
|
|
7205
7865
|
return orderIncludesProductWithTag$3(condition, value);
|
|
7206
7866
|
});
|
|
7867
|
+
if (type === "Payment is mixed") {
|
|
7868
|
+
const payment = this.getDependency("payment", type);
|
|
7869
|
+
return paymentIsMixed(condition, payment.is_mixed);
|
|
7870
|
+
}
|
|
7871
|
+
if (type === "Payment captured") {
|
|
7872
|
+
const payment = this.getDependency("payment", type);
|
|
7873
|
+
return paymentCaptured(condition, payment.captured);
|
|
7874
|
+
}
|
|
7207
7875
|
throw new Error(`Condition ${type} not supported in boolean matcher`);
|
|
7208
7876
|
};
|
|
7209
7877
|
assertCurrency = (condition) => {
|
|
@@ -7224,8 +7892,14 @@ var RulesEngine = class {
|
|
|
7224
7892
|
assert(primitive === "enum", primitiveError("enum"));
|
|
7225
7893
|
const payment = this.getDependency("payment", type);
|
|
7226
7894
|
switch (type) {
|
|
7227
|
-
case "Payment collect at":
|
|
7228
|
-
|
|
7895
|
+
case "Payment collect at": {
|
|
7896
|
+
const collectAt = payment.collect_ats ?? payment.collect_at;
|
|
7897
|
+
return paymentCollectAt(condition, collectAt);
|
|
7898
|
+
}
|
|
7899
|
+
case "Payment method": {
|
|
7900
|
+
const method = payment.methods ?? payment.method;
|
|
7901
|
+
return paymentMethod(condition, method);
|
|
7902
|
+
}
|
|
7229
7903
|
case "Payment status": return paymentStatus(condition, payment.status);
|
|
7230
7904
|
default: throw new Error(`Condition ${type} not supported in enum matcher`);
|
|
7231
7905
|
}
|
|
@@ -7857,4 +8531,4 @@ const cardPreviewGroups = (value) => {
|
|
|
7857
8531
|
return groups;
|
|
7858
8532
|
};
|
|
7859
8533
|
//#endregion
|
|
7860
|
-
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, toOrderLines, toPaymentCardBrand, validateCardNumber, validateCardholderName, validateCvc, validateExpiry, validateOrderAgainstMenu, verifyWebhookSignature, visitedStates };
|
|
8534
|
+
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, serializeError, services, setLineQuantity, storefrontJson, stripRedemptionLines, toOrderLines, toPaymentCardBrand, validateCardNumber, validateCardholderName, validateCvc, validateExpiry, validateOrderAgainstMenu, verifyWebhookSignature, visitedStates };
|