@blamejs/blamejs-shop 0.0.64 → 0.0.66
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/CHANGELOG.md +4 -0
- package/lib/address-validation.js +529 -0
- package/lib/auto-discount.js +1133 -0
- package/lib/business-hours.js +980 -0
- package/lib/captcha-gate.js +961 -0
- package/lib/catalog-drafts.js +1614 -0
- package/lib/cookie-consent.js +605 -0
- package/lib/cost-layers.js +774 -0
- package/lib/credit-limits.js +752 -0
- package/lib/currency-rounding.js +525 -0
- package/lib/customer-roles.js +640 -0
- package/lib/cycle-counting.js +802 -0
- package/lib/delivery-estimate.js +1113 -0
- package/lib/discount-allocation.js +557 -0
- package/lib/email-warmup.js +795 -0
- package/lib/index.js +30 -0
- package/lib/metered-usage.js +782 -0
- package/lib/payment-retries.js +816 -0
- package/lib/pick-lists.js +639 -0
- package/lib/preorder.js +595 -0
- package/lib/price-display.js +699 -0
- package/lib/product-bulk-ops.js +797 -0
- package/lib/purchase-orders.js +923 -0
- package/lib/quotes.js +944 -0
- package/lib/recommendations.js +850 -0
- package/lib/reorder-thresholds.js +678 -0
- package/lib/shipping-zones.js +621 -0
- package/lib/site-redirects.js +690 -0
- package/lib/split-shipments.js +773 -0
- package/lib/theme-assets.js +711 -0
- package/lib/trust-badges.js +721 -0
- package/lib/webhook-receiver.js +1034 -0
- package/package.json +1 -1
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.discountAllocation
|
|
4
|
+
* @title Discount-allocation primitive — distribute order-level
|
|
5
|
+
* discounts across order lines for accounting + refund precision.
|
|
6
|
+
*
|
|
7
|
+
* @intro
|
|
8
|
+
* An order carries an order-level discount: "$20 off the whole
|
|
9
|
+
* order", "15% off everything after the threshold". The lines
|
|
10
|
+
* underneath the order each carry their own subtotal + quantity.
|
|
11
|
+
* For accounting + refund precision the operator needs to know,
|
|
12
|
+
* for any one line, what slice of the order-level discount belongs
|
|
13
|
+
* to it — a partial refund of one line must remove that line's
|
|
14
|
+
* share of the discount, not the whole-order amount.
|
|
15
|
+
*
|
|
16
|
+
* This primitive answers two questions:
|
|
17
|
+
*
|
|
18
|
+
* 1. Given lines + a discount + a kind, compute the per-line
|
|
19
|
+
* breakdown such that `sum(allocated_minor) === discount_minor`
|
|
20
|
+
* by construction. Rounding is half-even via the framework's
|
|
21
|
+
* Money class; the rounding remainder lands on the
|
|
22
|
+
* highest-subtotal line (deterministic, operator-readable —
|
|
23
|
+
* "the biggest line absorbs the cent").
|
|
24
|
+
*
|
|
25
|
+
* 2. Given a recorded allocation + a refund amount, walk the
|
|
26
|
+
* breakdown in reverse to compute the per-line refund shares
|
|
27
|
+
* proportional to the original allocation. The reverse
|
|
28
|
+
* breakdown is the audit trail proving the refund math came
|
|
29
|
+
* from the recorded breakdown — not a recomputation that
|
|
30
|
+
* could drift if the underlying subtotals were modified after
|
|
31
|
+
* the fact.
|
|
32
|
+
*
|
|
33
|
+
* Surface:
|
|
34
|
+
*
|
|
35
|
+
* - `allocate({ lines, discount_minor, kind })`
|
|
36
|
+
* Pure function. `lines` is `[{ line_id, subtotal_minor, quantity }]`.
|
|
37
|
+
* `kind` is one of `proportional` / `equal` / `by_subtotal` /
|
|
38
|
+
* `by_quantity`. Returns
|
|
39
|
+
* `[{ line_id, allocated_minor, remaining_minor }]` where the
|
|
40
|
+
* allocated values sum exactly to `discount_minor`.
|
|
41
|
+
*
|
|
42
|
+
* - `recordAllocation({ order_id, discount_source, kind,
|
|
43
|
+
* breakdown, total_minor, applied_at? })`
|
|
44
|
+
* Persists the breakdown as an audit row. Returns the
|
|
45
|
+
* hydrated row including the assigned id + applied_at.
|
|
46
|
+
*
|
|
47
|
+
* - `allocationsForOrder(order_id)`
|
|
48
|
+
* Returns every recorded allocation against an order, newest
|
|
49
|
+
* first. Breakdown JSON is parsed.
|
|
50
|
+
*
|
|
51
|
+
* - `reverseAllocation({ order_id, source, refund_minor,
|
|
52
|
+
* occurred_at? })`
|
|
53
|
+
* Looks up the allocation row matching (order_id, source),
|
|
54
|
+
* walks its breakdown in reverse, computes per-line refund
|
|
55
|
+
* shares proportional to the original allocated_minor values,
|
|
56
|
+
* persists the reversal row, and returns the reverse
|
|
57
|
+
* breakdown. Rounding is half-even; the rounding remainder
|
|
58
|
+
* lands on the line with the largest original allocation.
|
|
59
|
+
*
|
|
60
|
+
* - `metricsForKind({ kind, from, to })`
|
|
61
|
+
* Aggregates allocation totals + counts by kind across a
|
|
62
|
+
* time window. Returns `{ kind, count, sum_minor }`.
|
|
63
|
+
*
|
|
64
|
+
* Composition:
|
|
65
|
+
* b.money.fromMinorUnits + .multiply([num, den]) carry the
|
|
66
|
+
* half-even rounding semantics. The primitive computes share
|
|
67
|
+
* numerators (line weight) + denominators (sum of weights) and
|
|
68
|
+
* delegates the rounded multiply to Money; the remainder is
|
|
69
|
+
* applied to the highest-subtotal line so the sum invariant
|
|
70
|
+
* holds without re-running the math.
|
|
71
|
+
*
|
|
72
|
+
* Storage:
|
|
73
|
+
* - `discount_allocations` + `discount_reversals` (migration
|
|
74
|
+
* 0129_discount_allocation.sql).
|
|
75
|
+
*
|
|
76
|
+
* @primitive discountAllocation
|
|
77
|
+
* @related b.money, b.uuid.v7, shop.refundPolicy, shop.couponStacking
|
|
78
|
+
*/
|
|
79
|
+
|
|
80
|
+
var bShop;
|
|
81
|
+
function _b() {
|
|
82
|
+
if (!bShop) bShop = require("./index");
|
|
83
|
+
return bShop.framework;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---- constants ----------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
var KINDS = Object.freeze([
|
|
89
|
+
"proportional",
|
|
90
|
+
"equal",
|
|
91
|
+
"by_subtotal",
|
|
92
|
+
"by_quantity",
|
|
93
|
+
]);
|
|
94
|
+
|
|
95
|
+
var MAX_LINES = 5000;
|
|
96
|
+
var MAX_SOURCE_LEN = 200;
|
|
97
|
+
var MAX_LINE_ID_LEN = 200;
|
|
98
|
+
|
|
99
|
+
// Source / line_id are operator-supplied correlation handles — refuse
|
|
100
|
+
// all control bytes (including CR/LF and tab) so a log-injection
|
|
101
|
+
// payload can't land in the audit-trail breakdown JSON.
|
|
102
|
+
var PRINTABLE_RE = /^[^\x00-\x1f\x7f]*$/;
|
|
103
|
+
|
|
104
|
+
// The rounding currency is a placeholder; the math here is integer
|
|
105
|
+
// minor units and the half-even bump only depends on the rational
|
|
106
|
+
// remainder, not on the currency exponent. Picking a fixed code keeps
|
|
107
|
+
// the Money class from refusing the composition.
|
|
108
|
+
var ROUNDING_CURRENCY = "USD";
|
|
109
|
+
|
|
110
|
+
// ---- validators ---------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
function _kind(k) {
|
|
113
|
+
if (typeof k !== "string" || KINDS.indexOf(k) === -1) {
|
|
114
|
+
throw new TypeError("discountAllocation: kind must be one of " + KINDS.join(", "));
|
|
115
|
+
}
|
|
116
|
+
return k;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function _positiveInt(n, label) {
|
|
120
|
+
if (typeof n !== "number" || !Number.isInteger(n) || n <= 0) {
|
|
121
|
+
throw new TypeError("discountAllocation: " + label + " must be a positive integer (minor units)");
|
|
122
|
+
}
|
|
123
|
+
return n;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function _nonNegInt(n, label) {
|
|
127
|
+
if (typeof n !== "number" || !Number.isInteger(n) || n < 0) {
|
|
128
|
+
throw new TypeError("discountAllocation: " + label + " must be a non-negative integer");
|
|
129
|
+
}
|
|
130
|
+
return n;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function _shortString(s, label, max) {
|
|
134
|
+
if (typeof s !== "string" || s.length === 0) {
|
|
135
|
+
throw new TypeError("discountAllocation: " + label + " must be a non-empty string");
|
|
136
|
+
}
|
|
137
|
+
if (s.length > max) {
|
|
138
|
+
throw new TypeError("discountAllocation: " + label + " must be <= " + max + " chars");
|
|
139
|
+
}
|
|
140
|
+
if (!PRINTABLE_RE.test(s)) {
|
|
141
|
+
throw new TypeError("discountAllocation: " + label + " must not contain control bytes");
|
|
142
|
+
}
|
|
143
|
+
return s;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function _epochMs(ts, label) {
|
|
147
|
+
if (ts == null) return null;
|
|
148
|
+
if (typeof ts !== "number" || !Number.isInteger(ts) || ts < 0) {
|
|
149
|
+
throw new TypeError("discountAllocation: " + label + " must be a non-negative integer epoch-ms");
|
|
150
|
+
}
|
|
151
|
+
return ts;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function _validateLines(lines) {
|
|
155
|
+
if (!Array.isArray(lines)) {
|
|
156
|
+
throw new TypeError("discountAllocation: lines must be an array");
|
|
157
|
+
}
|
|
158
|
+
if (lines.length === 0) {
|
|
159
|
+
throw new TypeError("discountAllocation: lines must be a non-empty array");
|
|
160
|
+
}
|
|
161
|
+
if (lines.length > MAX_LINES) {
|
|
162
|
+
throw new TypeError("discountAllocation: lines must be <= " + MAX_LINES + " entries");
|
|
163
|
+
}
|
|
164
|
+
var seen = Object.create(null);
|
|
165
|
+
var out = [];
|
|
166
|
+
for (var i = 0; i < lines.length; i += 1) {
|
|
167
|
+
var L = lines[i];
|
|
168
|
+
if (!L || typeof L !== "object") {
|
|
169
|
+
throw new TypeError("discountAllocation: lines[" + i + "] must be an object");
|
|
170
|
+
}
|
|
171
|
+
var lineId = _shortString(L.line_id, "lines[" + i + "].line_id", MAX_LINE_ID_LEN);
|
|
172
|
+
var subtotal = _nonNegInt(L.subtotal_minor, "lines[" + i + "].subtotal_minor");
|
|
173
|
+
var quantity = L.quantity;
|
|
174
|
+
if (typeof quantity !== "number" || !Number.isInteger(quantity) || quantity <= 0) {
|
|
175
|
+
throw new TypeError("discountAllocation: lines[" + i + "].quantity must be a positive integer");
|
|
176
|
+
}
|
|
177
|
+
if (Object.prototype.hasOwnProperty.call(seen, lineId)) {
|
|
178
|
+
throw new TypeError("discountAllocation: lines[" + i + "].line_id duplicates an earlier line");
|
|
179
|
+
}
|
|
180
|
+
seen[lineId] = true;
|
|
181
|
+
out.push({ line_id: lineId, subtotal_minor: subtotal, quantity: quantity, _idx: i });
|
|
182
|
+
}
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ---- allocation math (pure) --------------------------------------------
|
|
187
|
+
|
|
188
|
+
// Compute the per-line share via b.money's half-even rounded multiply.
|
|
189
|
+
// Given total `T`, weights `[w0, w1, ...]` summing to `W`, each share
|
|
190
|
+
// is `floor_half_even(T * wi / W)`. The shares may sum to slightly
|
|
191
|
+
// less than `T` (rounding gives back fractions); the remainder is
|
|
192
|
+
// applied to the line with the highest subtotal so the audit row's
|
|
193
|
+
// `allocated_minor` values sum exactly to `T`.
|
|
194
|
+
//
|
|
195
|
+
// "By quantity" weights use quantity; "equal" weights every line by
|
|
196
|
+
// `1` (so the share is `T / N` rounded); "by_subtotal" /
|
|
197
|
+
// "proportional" weight by subtotal. The remainder placement is
|
|
198
|
+
// always by highest subtotal — the operator's "the biggest line
|
|
199
|
+
// absorbs the cent" mental model holds for every kind.
|
|
200
|
+
function _shares(total, lines, kind) {
|
|
201
|
+
var weights = new Array(lines.length);
|
|
202
|
+
var sumWeight = 0n;
|
|
203
|
+
for (var i = 0; i < lines.length; i += 1) {
|
|
204
|
+
var w;
|
|
205
|
+
if (kind === "equal") {
|
|
206
|
+
w = 1;
|
|
207
|
+
} else if (kind === "by_quantity") {
|
|
208
|
+
w = lines[i].quantity;
|
|
209
|
+
} else {
|
|
210
|
+
// proportional / by_subtotal
|
|
211
|
+
w = lines[i].subtotal_minor;
|
|
212
|
+
}
|
|
213
|
+
weights[i] = w;
|
|
214
|
+
sumWeight += BigInt(w);
|
|
215
|
+
}
|
|
216
|
+
if (sumWeight === 0n) {
|
|
217
|
+
// Every weight is zero — for by_subtotal/proportional this means
|
|
218
|
+
// every line subtotal is zero. Fall back to equal-by-line so the
|
|
219
|
+
// operator still gets a complete breakdown rather than a refusal
|
|
220
|
+
// they have to special-case.
|
|
221
|
+
for (var k = 0; k < weights.length; k += 1) {
|
|
222
|
+
weights[k] = 1;
|
|
223
|
+
sumWeight += 1n;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
var money = _b().money;
|
|
228
|
+
var totalMoney = money.fromMinorUnits(BigInt(total), ROUNDING_CURRENCY);
|
|
229
|
+
var shares = new Array(lines.length);
|
|
230
|
+
var allocated = 0n;
|
|
231
|
+
for (var j = 0; j < lines.length; j += 1) {
|
|
232
|
+
var share = totalMoney
|
|
233
|
+
.multiply([BigInt(weights[j]), sumWeight])
|
|
234
|
+
.toMinorUnits();
|
|
235
|
+
shares[j] = share;
|
|
236
|
+
allocated += share;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Place the rounding remainder (positive or negative) on the line
|
|
240
|
+
// with the highest subtotal_minor. Ties broken by original index so
|
|
241
|
+
// the placement is deterministic across runtimes.
|
|
242
|
+
var remainder = BigInt(total) - allocated;
|
|
243
|
+
if (remainder !== 0n) {
|
|
244
|
+
var bestIdx = 0;
|
|
245
|
+
var bestSubtotal = lines[0].subtotal_minor;
|
|
246
|
+
for (var m = 1; m < lines.length; m += 1) {
|
|
247
|
+
if (lines[m].subtotal_minor > bestSubtotal) {
|
|
248
|
+
bestSubtotal = lines[m].subtotal_minor;
|
|
249
|
+
bestIdx = m;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
shares[bestIdx] = shares[bestIdx] + remainder;
|
|
253
|
+
}
|
|
254
|
+
return shares;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Public allocate — composes _shares + emits the integer-shape
|
|
258
|
+
// breakdown the caller persists. Refuses negative remaining lines
|
|
259
|
+
// (the discount exceeds the line subtotal); the operator surfaces
|
|
260
|
+
// that as a refusal upstream when the discount value is too large
|
|
261
|
+
// for the order subtotal. Clamps to zero rather than going negative
|
|
262
|
+
// because the receipt-line "remaining" column shouldn't expose
|
|
263
|
+
// negative minor units to the storefront layer.
|
|
264
|
+
function _allocate(input) {
|
|
265
|
+
if (!input || typeof input !== "object") {
|
|
266
|
+
throw new TypeError("discountAllocation.allocate: input object required");
|
|
267
|
+
}
|
|
268
|
+
var lines = _validateLines(input.lines);
|
|
269
|
+
var discount = _positiveInt(input.discount_minor, "discount_minor");
|
|
270
|
+
var kind = _kind(input.kind);
|
|
271
|
+
|
|
272
|
+
var shares = _shares(discount, lines, kind);
|
|
273
|
+
var out = new Array(lines.length);
|
|
274
|
+
for (var i = 0; i < lines.length; i += 1) {
|
|
275
|
+
var allocated = Number(shares[i]);
|
|
276
|
+
var remaining = lines[i].subtotal_minor - allocated;
|
|
277
|
+
if (remaining < 0) remaining = 0;
|
|
278
|
+
out[i] = {
|
|
279
|
+
line_id: lines[i].line_id,
|
|
280
|
+
allocated_minor: allocated,
|
|
281
|
+
remaining_minor: remaining,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
return out;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ---- factory ------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
function create(opts) {
|
|
290
|
+
opts = opts || {};
|
|
291
|
+
var query = opts.query;
|
|
292
|
+
if (!query) {
|
|
293
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Two writes against the same order in the same millisecond would
|
|
297
|
+
// tie on `applied_at` and make `allocationsForOrder` ordering
|
|
298
|
+
// ambiguous. Bump the requested timestamp to `prior + 1` when it
|
|
299
|
+
// would collide (or land older than the prior row, which an
|
|
300
|
+
// out-of-order operator write could trigger). The result is a
|
|
301
|
+
// strictly-monotonic per-order `applied_at` sequence.
|
|
302
|
+
function _resolveAppliedAt(requestedTs, latestTs) {
|
|
303
|
+
if (latestTs == null) return requestedTs;
|
|
304
|
+
if (requestedTs > latestTs) return requestedTs;
|
|
305
|
+
return latestTs + 1;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function _latestAppliedAt(orderId) {
|
|
309
|
+
var r = await query(
|
|
310
|
+
"SELECT applied_at FROM discount_allocations " +
|
|
311
|
+
"WHERE order_id = ?1 ORDER BY applied_at DESC LIMIT 1",
|
|
312
|
+
[orderId],
|
|
313
|
+
);
|
|
314
|
+
if (!r.rows.length) return null;
|
|
315
|
+
return r.rows[0].applied_at;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function _latestReversalAt(allocationId) {
|
|
319
|
+
var r = await query(
|
|
320
|
+
"SELECT occurred_at FROM discount_reversals " +
|
|
321
|
+
"WHERE allocation_id = ?1 ORDER BY occurred_at DESC LIMIT 1",
|
|
322
|
+
[allocationId],
|
|
323
|
+
);
|
|
324
|
+
if (!r.rows.length) return null;
|
|
325
|
+
return r.rows[0].occurred_at;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function recordAllocation(input) {
|
|
329
|
+
if (!input || typeof input !== "object") {
|
|
330
|
+
throw new TypeError("discountAllocation.recordAllocation: input object required");
|
|
331
|
+
}
|
|
332
|
+
var orderId = _shortString(input.order_id, "order_id", MAX_LINE_ID_LEN);
|
|
333
|
+
var source = _shortString(input.discount_source, "discount_source", MAX_SOURCE_LEN);
|
|
334
|
+
var kind = _kind(input.kind);
|
|
335
|
+
var total = _positiveInt(input.total_minor, "total_minor");
|
|
336
|
+
var breakdown = input.breakdown;
|
|
337
|
+
if (!Array.isArray(breakdown) || breakdown.length === 0) {
|
|
338
|
+
throw new TypeError("discountAllocation.recordAllocation: breakdown must be a non-empty array");
|
|
339
|
+
}
|
|
340
|
+
// Validate breakdown shape + the sum invariant. The persisted
|
|
341
|
+
// row's audit value is only meaningful if `sum(allocated_minor)`
|
|
342
|
+
// equals `total_minor` exactly; refuse the write rather than
|
|
343
|
+
// silently store an inconsistent breakdown.
|
|
344
|
+
var sum = 0;
|
|
345
|
+
var seenIds = Object.create(null);
|
|
346
|
+
for (var i = 0; i < breakdown.length; i += 1) {
|
|
347
|
+
var row = breakdown[i];
|
|
348
|
+
if (!row || typeof row !== "object") {
|
|
349
|
+
throw new TypeError("discountAllocation.recordAllocation: breakdown[" + i + "] must be an object");
|
|
350
|
+
}
|
|
351
|
+
_shortString(row.line_id, "breakdown[" + i + "].line_id", MAX_LINE_ID_LEN);
|
|
352
|
+
_nonNegInt(row.allocated_minor, "breakdown[" + i + "].allocated_minor");
|
|
353
|
+
_nonNegInt(row.remaining_minor, "breakdown[" + i + "].remaining_minor");
|
|
354
|
+
if (Object.prototype.hasOwnProperty.call(seenIds, row.line_id)) {
|
|
355
|
+
throw new TypeError("discountAllocation.recordAllocation: breakdown[" + i + "].line_id duplicates an earlier row");
|
|
356
|
+
}
|
|
357
|
+
seenIds[row.line_id] = true;
|
|
358
|
+
sum += row.allocated_minor;
|
|
359
|
+
}
|
|
360
|
+
if (sum !== total) {
|
|
361
|
+
throw new TypeError(
|
|
362
|
+
"discountAllocation.recordAllocation: breakdown sum (" + sum +
|
|
363
|
+
") does not equal total_minor (" + total + ")",
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
var requested = _epochMs(input.applied_at, "applied_at");
|
|
367
|
+
if (requested == null) requested = Date.now();
|
|
368
|
+
var latest = await _latestAppliedAt(orderId);
|
|
369
|
+
var ts = _resolveAppliedAt(requested, latest);
|
|
370
|
+
|
|
371
|
+
var id = _b().uuid.v7();
|
|
372
|
+
await query(
|
|
373
|
+
"INSERT INTO discount_allocations " +
|
|
374
|
+
"(id, order_id, source, kind, total_minor, breakdown_json, applied_at) " +
|
|
375
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
|
376
|
+
[id, orderId, source, kind, total, JSON.stringify(breakdown), ts],
|
|
377
|
+
);
|
|
378
|
+
return {
|
|
379
|
+
id: id,
|
|
380
|
+
order_id: orderId,
|
|
381
|
+
source: source,
|
|
382
|
+
kind: kind,
|
|
383
|
+
total_minor: total,
|
|
384
|
+
breakdown: breakdown.slice(),
|
|
385
|
+
applied_at: ts,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async function allocationsForOrder(orderId) {
|
|
390
|
+
_shortString(orderId, "order_id", MAX_LINE_ID_LEN);
|
|
391
|
+
var r = await query(
|
|
392
|
+
"SELECT id, order_id, source, kind, total_minor, breakdown_json, applied_at " +
|
|
393
|
+
"FROM discount_allocations WHERE order_id = ?1 " +
|
|
394
|
+
"ORDER BY applied_at DESC, id DESC",
|
|
395
|
+
[orderId],
|
|
396
|
+
);
|
|
397
|
+
var rows = r.rows;
|
|
398
|
+
var out = new Array(rows.length);
|
|
399
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
400
|
+
var row = rows[i];
|
|
401
|
+
out[i] = {
|
|
402
|
+
id: row.id,
|
|
403
|
+
order_id: row.order_id,
|
|
404
|
+
source: row.source,
|
|
405
|
+
kind: row.kind,
|
|
406
|
+
total_minor: row.total_minor,
|
|
407
|
+
breakdown: JSON.parse(row.breakdown_json),
|
|
408
|
+
applied_at: row.applied_at,
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
return out;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Reverse-walk an allocation: given a refund amount, distribute it
|
|
415
|
+
// across the original breakdown proportional to each line's
|
|
416
|
+
// `allocated_minor`. Half-even rounding via b.money; the rounding
|
|
417
|
+
// remainder lands on the line with the largest original allocation
|
|
418
|
+
// (the same "biggest line absorbs the cent" rule applied to the
|
|
419
|
+
// reverse direction).
|
|
420
|
+
function _reverseShares(refund, breakdown) {
|
|
421
|
+
var weights = new Array(breakdown.length);
|
|
422
|
+
var sumWeight = 0n;
|
|
423
|
+
for (var i = 0; i < breakdown.length; i += 1) {
|
|
424
|
+
weights[i] = breakdown[i].allocated_minor;
|
|
425
|
+
sumWeight += BigInt(breakdown[i].allocated_minor);
|
|
426
|
+
}
|
|
427
|
+
if (sumWeight === 0n) {
|
|
428
|
+
// Every original share was zero (shouldn't be possible because
|
|
429
|
+
// recordAllocation refuses a sum-zero breakdown when total > 0,
|
|
430
|
+
// but defensive). Spread evenly so the refund still produces a
|
|
431
|
+
// complete breakdown.
|
|
432
|
+
for (var k = 0; k < weights.length; k += 1) {
|
|
433
|
+
weights[k] = 1;
|
|
434
|
+
sumWeight += 1n;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
var money = _b().money;
|
|
438
|
+
var refundMoney = money.fromMinorUnits(BigInt(refund), ROUNDING_CURRENCY);
|
|
439
|
+
var shares = new Array(breakdown.length);
|
|
440
|
+
var allocated = 0n;
|
|
441
|
+
for (var j = 0; j < breakdown.length; j += 1) {
|
|
442
|
+
var share = refundMoney
|
|
443
|
+
.multiply([BigInt(weights[j]), sumWeight])
|
|
444
|
+
.toMinorUnits();
|
|
445
|
+
shares[j] = share;
|
|
446
|
+
allocated += share;
|
|
447
|
+
}
|
|
448
|
+
var remainder = BigInt(refund) - allocated;
|
|
449
|
+
if (remainder !== 0n) {
|
|
450
|
+
var bestIdx = 0;
|
|
451
|
+
var bestAlloc = breakdown[0].allocated_minor;
|
|
452
|
+
for (var m = 1; m < breakdown.length; m += 1) {
|
|
453
|
+
if (breakdown[m].allocated_minor > bestAlloc) {
|
|
454
|
+
bestAlloc = breakdown[m].allocated_minor;
|
|
455
|
+
bestIdx = m;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
shares[bestIdx] = shares[bestIdx] + remainder;
|
|
459
|
+
}
|
|
460
|
+
return shares;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
async function reverseAllocation(input) {
|
|
464
|
+
if (!input || typeof input !== "object") {
|
|
465
|
+
throw new TypeError("discountAllocation.reverseAllocation: input object required");
|
|
466
|
+
}
|
|
467
|
+
var orderId = _shortString(input.order_id, "order_id", MAX_LINE_ID_LEN);
|
|
468
|
+
var source = _shortString(input.source, "source", MAX_SOURCE_LEN);
|
|
469
|
+
var refund = _positiveInt(input.refund_minor, "refund_minor");
|
|
470
|
+
|
|
471
|
+
var r = await query(
|
|
472
|
+
"SELECT id, breakdown_json, total_minor FROM discount_allocations " +
|
|
473
|
+
"WHERE order_id = ?1 AND source = ?2 " +
|
|
474
|
+
"ORDER BY applied_at DESC, id DESC LIMIT 1",
|
|
475
|
+
[orderId, source],
|
|
476
|
+
);
|
|
477
|
+
if (!r.rows.length) {
|
|
478
|
+
var err = new Error("discountAllocation.reverseAllocation: no allocation found for order_id + source");
|
|
479
|
+
err.code = "DISCOUNT_ALLOCATION_NOT_FOUND";
|
|
480
|
+
throw err;
|
|
481
|
+
}
|
|
482
|
+
var allocationId = r.rows[0].id;
|
|
483
|
+
var breakdown = JSON.parse(r.rows[0].breakdown_json);
|
|
484
|
+
|
|
485
|
+
var shares = _reverseShares(refund, breakdown);
|
|
486
|
+
var reverseBreakdown = new Array(breakdown.length);
|
|
487
|
+
for (var i = 0; i < breakdown.length; i += 1) {
|
|
488
|
+
reverseBreakdown[i] = {
|
|
489
|
+
line_id: breakdown[i].line_id,
|
|
490
|
+
refund_minor: Number(shares[i]),
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
var requested = _epochMs(input.occurred_at, "occurred_at");
|
|
495
|
+
if (requested == null) requested = Date.now();
|
|
496
|
+
var latest = await _latestReversalAt(allocationId);
|
|
497
|
+
var ts = _resolveAppliedAt(requested, latest);
|
|
498
|
+
|
|
499
|
+
var id = _b().uuid.v7();
|
|
500
|
+
await query(
|
|
501
|
+
"INSERT INTO discount_reversals " +
|
|
502
|
+
"(id, allocation_id, refund_minor, reverse_breakdown_json, occurred_at) " +
|
|
503
|
+
"VALUES (?1, ?2, ?3, ?4, ?5)",
|
|
504
|
+
[id, allocationId, refund, JSON.stringify(reverseBreakdown), ts],
|
|
505
|
+
);
|
|
506
|
+
return {
|
|
507
|
+
id: id,
|
|
508
|
+
allocation_id: allocationId,
|
|
509
|
+
refund_minor: refund,
|
|
510
|
+
reverse_breakdown: reverseBreakdown,
|
|
511
|
+
occurred_at: ts,
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
async function metricsForKind(input) {
|
|
516
|
+
if (!input || typeof input !== "object") {
|
|
517
|
+
throw new TypeError("discountAllocation.metricsForKind: input object required");
|
|
518
|
+
}
|
|
519
|
+
var kind = _kind(input.kind);
|
|
520
|
+
var from = _epochMs(input.from, "from");
|
|
521
|
+
var to = _epochMs(input.to, "to");
|
|
522
|
+
if (from == null || to == null) {
|
|
523
|
+
throw new TypeError("discountAllocation.metricsForKind: from + to required");
|
|
524
|
+
}
|
|
525
|
+
if (to < from) {
|
|
526
|
+
throw new TypeError("discountAllocation.metricsForKind: to must be >= from");
|
|
527
|
+
}
|
|
528
|
+
var r = await query(
|
|
529
|
+
"SELECT COUNT(*) AS cnt, COALESCE(SUM(total_minor), 0) AS total " +
|
|
530
|
+
"FROM discount_allocations " +
|
|
531
|
+
"WHERE kind = ?1 AND applied_at >= ?2 AND applied_at <= ?3",
|
|
532
|
+
[kind, from, to],
|
|
533
|
+
);
|
|
534
|
+
var row = r.rows[0] || { cnt: 0, total: 0 };
|
|
535
|
+
return {
|
|
536
|
+
kind: kind,
|
|
537
|
+
from: from,
|
|
538
|
+
to: to,
|
|
539
|
+
count: Number(row.cnt),
|
|
540
|
+
sum_minor: Number(row.total),
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
return {
|
|
545
|
+
KINDS: KINDS.slice(),
|
|
546
|
+
allocate: function (input) { return _allocate(input); },
|
|
547
|
+
recordAllocation: recordAllocation,
|
|
548
|
+
allocationsForOrder: allocationsForOrder,
|
|
549
|
+
reverseAllocation: reverseAllocation,
|
|
550
|
+
metricsForKind: metricsForKind,
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
module.exports = {
|
|
555
|
+
create: create,
|
|
556
|
+
KINDS: KINDS,
|
|
557
|
+
};
|