@blamejs/blamejs-shop 0.0.59 → 0.0.61
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/api-keys.js +789 -0
- package/lib/barcodes.js +671 -0
- package/lib/carrier-rates.js +683 -0
- package/lib/cart-bulk-ops.js +711 -0
- package/lib/cms-blocks.js +651 -0
- package/lib/code-minter.js +535 -0
- package/lib/coupon-stacking.js +717 -0
- package/lib/customer-import.js +590 -0
- package/lib/customer-portal.js +359 -0
- package/lib/discount-analytics.js +548 -0
- package/lib/dunning.js +700 -0
- package/lib/experiments.js +697 -0
- package/lib/gift-card-ledger.js +483 -0
- package/lib/index.js +25 -0
- package/lib/inventory-snapshots.js +691 -0
- package/lib/operator-audit-log.js +621 -0
- package/lib/print-receipts.js +675 -0
- package/lib/product-import.js +1034 -0
- package/lib/search-facets.js +825 -0
- package/lib/sms-dispatcher.js +945 -0
- package/lib/storefront-forms.js +884 -0
- package/lib/storefront-pages.js +701 -0
- package/lib/subscription-billing.js +644 -0
- package/lib/tax-rates.js +559 -0
- package/lib/tenants.js +665 -0
- package/lib/translations.js +553 -0
- package/lib/webhook-subscriptions.js +565 -0
- package/package.json +1 -1
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.giftCardLedger
|
|
4
|
+
* @title Gift-card ledger primitive — append-only balance history
|
|
5
|
+
*
|
|
6
|
+
* @intro
|
|
7
|
+
* Distinct from the `giftcards` primitive (which owns the bearer
|
|
8
|
+
* credential — code generation, hash storage, single-action redeem
|
|
9
|
+
* against the snapshot column on the card row). This is the
|
|
10
|
+
* LEDGER side: one row per credit / debit / expire event,
|
|
11
|
+
* denormalized `balance_after_minor` snapshot on each row so a
|
|
12
|
+
* current-balance read is O(1) against the
|
|
13
|
+
* `(gift_card_id, occurred_at DESC)` index.
|
|
14
|
+
*
|
|
15
|
+
* The two primitives are intentionally separate because they
|
|
16
|
+
* answer different questions:
|
|
17
|
+
*
|
|
18
|
+
* - giftcards.balance(code) — "what's left to spend on this card?"
|
|
19
|
+
* - giftCardLedger.history(id) — "what events landed against this card, in order?"
|
|
20
|
+
* - giftCardLedger.bulkBalance() — "what's the live balance for these N card ids?"
|
|
21
|
+
* - giftCardLedger.expiringBalance({ before }) — "which cards are about
|
|
22
|
+
* to lose money I can sweep into promotional credit?"
|
|
23
|
+
* - giftCardLedger.transactionsForOrder(id) — "which gift-card movements
|
|
24
|
+
* are part of this order's settlement?"
|
|
25
|
+
*
|
|
26
|
+
* The ledger is replay-derivable: SUM(credits) - SUM(debits) -
|
|
27
|
+
* SUM(expires) reconstructs the live balance from scratch. The
|
|
28
|
+
* `balance_after_minor` column is denormalization for read speed,
|
|
29
|
+
* not the source of truth — every write recomputes it from the
|
|
30
|
+
* prior row so the column is always exactly the running balance.
|
|
31
|
+
*
|
|
32
|
+
* Composition:
|
|
33
|
+
* var ledger = bShop.giftCardLedger.create({ query: q });
|
|
34
|
+
* await ledger.credit({
|
|
35
|
+
* gift_card_id: cardId,
|
|
36
|
+
* amount_minor: 5000,
|
|
37
|
+
* source: "purchase",
|
|
38
|
+
* source_ref: orderId,
|
|
39
|
+
* });
|
|
40
|
+
* await ledger.debit({
|
|
41
|
+
* gift_card_id: cardId,
|
|
42
|
+
* amount_minor: 1200,
|
|
43
|
+
* order_id: orderId,
|
|
44
|
+
* });
|
|
45
|
+
* var bal = await ledger.balance(cardId); // 3800
|
|
46
|
+
*
|
|
47
|
+
* Overdraft is refused at the primitive layer: debit > available
|
|
48
|
+
* throws `GIFT_CARD_LEDGER_INSUFFICIENT_BALANCE` and writes no
|
|
49
|
+
* row. Expire is operator-initiated burn — it caps at the current
|
|
50
|
+
* balance the same way an over-budget operator sweep should
|
|
51
|
+
* degrade gracefully rather than refusing.
|
|
52
|
+
*
|
|
53
|
+
* Surface:
|
|
54
|
+
* - credit({ gift_card_id, amount_minor, source, source_ref, occurred_at? })
|
|
55
|
+
* - debit({ gift_card_id, amount_minor, order_id, occurred_at? })
|
|
56
|
+
* - expire({ gift_card_id, amount_minor, reason, occurred_at? })
|
|
57
|
+
* - balance(gift_card_id)
|
|
58
|
+
* - history(gift_card_id, { limit?, cursor? })
|
|
59
|
+
* - transactionsForOrder(order_id)
|
|
60
|
+
* - bulkBalance({ gift_card_ids })
|
|
61
|
+
* - expiringBalance({ before, min_amount_minor }) — JOINs giftcards.expires_at
|
|
62
|
+
*
|
|
63
|
+
* Storage:
|
|
64
|
+
* - gift_card_ledger (migration 0081).
|
|
65
|
+
*
|
|
66
|
+
* @primitive giftCardLedger
|
|
67
|
+
* @related b.uuid.v7, b.guardUuid, shop.giftcards
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
var bShop;
|
|
71
|
+
function _b() {
|
|
72
|
+
if (!bShop) bShop = require("./index");
|
|
73
|
+
return bShop.framework;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
var KINDS = ["credit", "debit", "expire"];
|
|
77
|
+
var SOURCES = ["purchase", "refund_to_giftcard", "promotional", "manual"];
|
|
78
|
+
|
|
79
|
+
var MAX_SOURCE_REF_LEN = 128;
|
|
80
|
+
// Source_ref / reason are short correlation handles (originating
|
|
81
|
+
// order id, refund handle, campaign code, operator note). Refuse
|
|
82
|
+
// all control bytes including CR/LF — this is a single-line column
|
|
83
|
+
// where a newline would just be log-injection cover. Tab is also
|
|
84
|
+
// refused; correlation handles don't legitimately contain it.
|
|
85
|
+
var PRINTABLE_RE = /^[^\x00-\x1f\x7f]*$/;
|
|
86
|
+
|
|
87
|
+
var MAX_BULK_IDS = 500;
|
|
88
|
+
|
|
89
|
+
// ---- validators ---------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
function _uuid(s, label) {
|
|
92
|
+
try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
|
|
93
|
+
catch (e) { throw new TypeError("giftCardLedger: " + label + " — " + (e && e.message || "invalid UUID")); }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function _amountMinor(n, label) {
|
|
97
|
+
if (typeof n !== "number" || !Number.isInteger(n) || n <= 0) {
|
|
98
|
+
throw new TypeError("giftCardLedger: " + label + " must be a positive integer (minor units)");
|
|
99
|
+
}
|
|
100
|
+
return n;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function _source(s) {
|
|
104
|
+
if (typeof s !== "string" || SOURCES.indexOf(s) === -1) {
|
|
105
|
+
throw new TypeError("giftCardLedger: source must be one of " + SOURCES.join(", "));
|
|
106
|
+
}
|
|
107
|
+
return s;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function _sourceRef(s, label) {
|
|
111
|
+
if (s == null) return null;
|
|
112
|
+
if (typeof s !== "string") {
|
|
113
|
+
throw new TypeError("giftCardLedger: " + label + " must be a string");
|
|
114
|
+
}
|
|
115
|
+
if (!s.length) {
|
|
116
|
+
throw new TypeError("giftCardLedger: " + label + " must be a non-empty string when provided");
|
|
117
|
+
}
|
|
118
|
+
if (s.length > MAX_SOURCE_REF_LEN) {
|
|
119
|
+
throw new TypeError("giftCardLedger: " + label + " must be <= " + MAX_SOURCE_REF_LEN + " chars");
|
|
120
|
+
}
|
|
121
|
+
if (!PRINTABLE_RE.test(s)) {
|
|
122
|
+
throw new TypeError("giftCardLedger: " + label + " must not contain control bytes");
|
|
123
|
+
}
|
|
124
|
+
return s;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function _epochMs(ts, label) {
|
|
128
|
+
if (ts == null) return null;
|
|
129
|
+
if (typeof ts !== "number" || !Number.isInteger(ts) || ts < 0) {
|
|
130
|
+
throw new TypeError("giftCardLedger: " + label + " must be a non-negative integer epoch-ms");
|
|
131
|
+
}
|
|
132
|
+
return ts;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function _now() { return Date.now(); }
|
|
136
|
+
|
|
137
|
+
// ---- factory ------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
function create(opts) {
|
|
140
|
+
opts = opts || {};
|
|
141
|
+
var query = opts.query;
|
|
142
|
+
if (!query) {
|
|
143
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// The optional `giftcards` factory arg is accepted so callers can
|
|
147
|
+
// hand in a giftcards instance for future-facing composition
|
|
148
|
+
// (e.g. a debit-by-code shortcut). The ledger primitive itself
|
|
149
|
+
// operates on `gift_card_id` UUIDs — overdraft + balance logic is
|
|
150
|
+
// self-contained at the SQL tier and doesn't need to consult the
|
|
151
|
+
// giftcards primitive. The arg is held so a subsequent additive
|
|
152
|
+
// primitive can lift it without a surface change.
|
|
153
|
+
var giftcards = opts.giftcards || null;
|
|
154
|
+
void giftcards;
|
|
155
|
+
|
|
156
|
+
// O(1) current-balance read: the latest row by `occurred_at DESC`
|
|
157
|
+
// holds `balance_after_minor` as the denormalized snapshot. No SUM
|
|
158
|
+
// aggregation at read time. Falls through to 0 when no rows exist
|
|
159
|
+
// (a card that has never had a ledger row has zero ledger
|
|
160
|
+
// balance). Returns both the snapshot and the occurred_at so the
|
|
161
|
+
// write path can guarantee strict monotonicity (see
|
|
162
|
+
// `_resolveOccurredAt`).
|
|
163
|
+
async function _readLatest(giftCardId) {
|
|
164
|
+
var r = await query(
|
|
165
|
+
"SELECT balance_after_minor, occurred_at FROM gift_card_ledger " +
|
|
166
|
+
"WHERE gift_card_id = ?1 ORDER BY occurred_at DESC LIMIT 1",
|
|
167
|
+
[giftCardId],
|
|
168
|
+
);
|
|
169
|
+
if (!r.rows.length) return { balance: 0, occurred_at: null };
|
|
170
|
+
return { balance: r.rows[0].balance_after_minor, occurred_at: r.rows[0].occurred_at };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function _currentBalance(giftCardId) {
|
|
174
|
+
var latest = await _readLatest(giftCardId);
|
|
175
|
+
return latest.balance;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Two writes against the same card in the same millisecond would
|
|
179
|
+
// tie on `occurred_at` and make the "latest row" ambiguous. We
|
|
180
|
+
// bump the requested timestamp to `prior + 1` when it would
|
|
181
|
+
// collide (or land older than the prior row, which an
|
|
182
|
+
// out-of-order operator write could trigger). The result is a
|
|
183
|
+
// strictly-monotonic per-card `occurred_at` sequence, so the
|
|
184
|
+
// denormalized `balance_after_minor` snapshot is unambiguous on
|
|
185
|
+
// read. Operator-supplied backdated writes still land at the
|
|
186
|
+
// requested timestamp when there's no collision — only ties get
|
|
187
|
+
// adjusted.
|
|
188
|
+
function _resolveOccurredAt(requestedTs, latestTs) {
|
|
189
|
+
if (latestTs == null) return requestedTs;
|
|
190
|
+
if (requestedTs > latestTs) return requestedTs;
|
|
191
|
+
return latestTs + 1;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function _writeRow(giftCardId, kind, amountMinor, source, sourceRef, orderId, balanceAfter, ts) {
|
|
195
|
+
var id = _b().uuid.v7();
|
|
196
|
+
await query(
|
|
197
|
+
"INSERT INTO gift_card_ledger " +
|
|
198
|
+
"(id, gift_card_id, kind, amount_minor, source, source_ref, order_id, balance_after_minor, occurred_at) " +
|
|
199
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
|
200
|
+
[id, giftCardId, kind, amountMinor, source, sourceRef, orderId, balanceAfter, ts],
|
|
201
|
+
);
|
|
202
|
+
return id;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
KINDS: KINDS.slice(),
|
|
207
|
+
SOURCES: SOURCES.slice(),
|
|
208
|
+
|
|
209
|
+
credit: async function (input) {
|
|
210
|
+
if (!input || typeof input !== "object") {
|
|
211
|
+
throw new TypeError("giftCardLedger.credit: input object required");
|
|
212
|
+
}
|
|
213
|
+
var giftCardId = _uuid(input.gift_card_id, "gift_card_id");
|
|
214
|
+
var amount = _amountMinor(input.amount_minor, "amount_minor");
|
|
215
|
+
var source = _source(input.source);
|
|
216
|
+
var sourceRef = _sourceRef(input.source_ref, "source_ref");
|
|
217
|
+
var requested = _epochMs(input.occurred_at, "occurred_at");
|
|
218
|
+
if (requested == null) requested = _now();
|
|
219
|
+
|
|
220
|
+
var latest = await _readLatest(giftCardId);
|
|
221
|
+
var ts = _resolveOccurredAt(requested, latest.occurred_at);
|
|
222
|
+
var after = latest.balance + amount;
|
|
223
|
+
var id = await _writeRow(giftCardId, "credit", amount, source, sourceRef, null, after, ts);
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
id: id,
|
|
227
|
+
gift_card_id: giftCardId,
|
|
228
|
+
kind: "credit",
|
|
229
|
+
amount_minor: amount,
|
|
230
|
+
source: source,
|
|
231
|
+
source_ref: sourceRef,
|
|
232
|
+
balance_after_minor: after,
|
|
233
|
+
occurred_at: ts,
|
|
234
|
+
};
|
|
235
|
+
},
|
|
236
|
+
|
|
237
|
+
debit: async function (input) {
|
|
238
|
+
if (!input || typeof input !== "object") {
|
|
239
|
+
throw new TypeError("giftCardLedger.debit: input object required");
|
|
240
|
+
}
|
|
241
|
+
var giftCardId = _uuid(input.gift_card_id, "gift_card_id");
|
|
242
|
+
var amount = _amountMinor(input.amount_minor, "amount_minor");
|
|
243
|
+
var orderId = _uuid(input.order_id, "order_id");
|
|
244
|
+
var requested = _epochMs(input.occurred_at, "occurred_at");
|
|
245
|
+
if (requested == null) requested = _now();
|
|
246
|
+
|
|
247
|
+
var latest = await _readLatest(giftCardId);
|
|
248
|
+
if (amount > latest.balance) {
|
|
249
|
+
var insufficient = new Error("giftCardLedger.debit: amount exceeds available balance");
|
|
250
|
+
insufficient.code = "GIFT_CARD_LEDGER_INSUFFICIENT_BALANCE";
|
|
251
|
+
throw insufficient;
|
|
252
|
+
}
|
|
253
|
+
var ts = _resolveOccurredAt(requested, latest.occurred_at);
|
|
254
|
+
var after = latest.balance - amount;
|
|
255
|
+
var id = await _writeRow(giftCardId, "debit", amount, null, null, orderId, after, ts);
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
id: id,
|
|
259
|
+
gift_card_id: giftCardId,
|
|
260
|
+
kind: "debit",
|
|
261
|
+
amount_minor: amount,
|
|
262
|
+
order_id: orderId,
|
|
263
|
+
balance_after_minor: after,
|
|
264
|
+
occurred_at: ts,
|
|
265
|
+
};
|
|
266
|
+
},
|
|
267
|
+
|
|
268
|
+
expire: async function (input) {
|
|
269
|
+
if (!input || typeof input !== "object") {
|
|
270
|
+
throw new TypeError("giftCardLedger.expire: input object required");
|
|
271
|
+
}
|
|
272
|
+
var giftCardId = _uuid(input.gift_card_id, "gift_card_id");
|
|
273
|
+
var amount = _amountMinor(input.amount_minor, "amount_minor");
|
|
274
|
+
// `reason` is operator-supplied free-form. We require it
|
|
275
|
+
// explicitly (vs. an optional sourceRef) so an audit-trail row
|
|
276
|
+
// tagged 'expire' always carries the operator's justification.
|
|
277
|
+
if (input.reason == null || input.reason === "") {
|
|
278
|
+
throw new TypeError("giftCardLedger.expire: reason must be a non-empty string");
|
|
279
|
+
}
|
|
280
|
+
var reason = _sourceRef(input.reason, "reason");
|
|
281
|
+
var requested = _epochMs(input.occurred_at, "occurred_at");
|
|
282
|
+
if (requested == null) requested = _now();
|
|
283
|
+
|
|
284
|
+
var latest = await _readLatest(giftCardId);
|
|
285
|
+
// Expire caps at the current balance — operators running a
|
|
286
|
+
// scheduled sweep over computed "expiring before X" amounts
|
|
287
|
+
// should degrade gracefully rather than refusing when an
|
|
288
|
+
// interim debit has already drained the card. A capped expire
|
|
289
|
+
// returns the actual amount burned so the operator can
|
|
290
|
+
// reconcile.
|
|
291
|
+
var toBurn = amount > latest.balance ? latest.balance : amount;
|
|
292
|
+
if (toBurn === 0) {
|
|
293
|
+
// No-op write: persist a zero-amount row would violate the
|
|
294
|
+
// CHECK(amount_minor > 0) constraint. Surface a structured
|
|
295
|
+
// refusal so the caller can distinguish "already empty" from
|
|
296
|
+
// "actually burned N". A no-op expire is a valid outcome of
|
|
297
|
+
// a bulk sweep — we don't throw.
|
|
298
|
+
return {
|
|
299
|
+
id: null,
|
|
300
|
+
gift_card_id: giftCardId,
|
|
301
|
+
kind: "expire",
|
|
302
|
+
amount_minor: 0,
|
|
303
|
+
requested_minor: amount,
|
|
304
|
+
reason: reason,
|
|
305
|
+
balance_after_minor: latest.balance,
|
|
306
|
+
occurred_at: requested,
|
|
307
|
+
noop: true,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
var ts = _resolveOccurredAt(requested, latest.occurred_at);
|
|
311
|
+
var after = latest.balance - toBurn;
|
|
312
|
+
var id = await _writeRow(giftCardId, "expire", toBurn, null, reason, null, after, ts);
|
|
313
|
+
|
|
314
|
+
return {
|
|
315
|
+
id: id,
|
|
316
|
+
gift_card_id: giftCardId,
|
|
317
|
+
kind: "expire",
|
|
318
|
+
amount_minor: toBurn,
|
|
319
|
+
requested_minor: amount,
|
|
320
|
+
reason: reason,
|
|
321
|
+
balance_after_minor: after,
|
|
322
|
+
occurred_at: ts,
|
|
323
|
+
noop: false,
|
|
324
|
+
};
|
|
325
|
+
},
|
|
326
|
+
|
|
327
|
+
balance: async function (giftCardId) {
|
|
328
|
+
_uuid(giftCardId, "gift_card_id");
|
|
329
|
+
var b = await _currentBalance(giftCardId);
|
|
330
|
+
return { gift_card_id: giftCardId, balance_minor: b };
|
|
331
|
+
},
|
|
332
|
+
|
|
333
|
+
history: async function (giftCardId, opts2) {
|
|
334
|
+
_uuid(giftCardId, "gift_card_id");
|
|
335
|
+
opts2 = opts2 || {};
|
|
336
|
+
var limit = opts2.limit != null ? opts2.limit : 50;
|
|
337
|
+
if (typeof limit !== "number" || !Number.isInteger(limit) || limit < 1 || limit > 500) {
|
|
338
|
+
throw new TypeError("giftCardLedger.history: limit must be an integer in [1, 500]");
|
|
339
|
+
}
|
|
340
|
+
var cursor = opts2.cursor;
|
|
341
|
+
var sql = "SELECT id, gift_card_id, kind, amount_minor, source, source_ref, order_id, " +
|
|
342
|
+
"balance_after_minor, occurred_at FROM gift_card_ledger " +
|
|
343
|
+
"WHERE gift_card_id = ?1";
|
|
344
|
+
var params = [giftCardId];
|
|
345
|
+
if (cursor != null) {
|
|
346
|
+
if (typeof cursor !== "number" || !Number.isInteger(cursor) || cursor < 0) {
|
|
347
|
+
throw new TypeError("giftCardLedger.history: cursor must be a non-negative integer epoch-ms");
|
|
348
|
+
}
|
|
349
|
+
// Cursor is the `occurred_at` of the last row in the previous
|
|
350
|
+
// page — request rows STRICTLY OLDER so a page boundary
|
|
351
|
+
// landing on a tied timestamp doesn't double-return rows.
|
|
352
|
+
sql += " AND occurred_at < ?2";
|
|
353
|
+
params.push(cursor);
|
|
354
|
+
}
|
|
355
|
+
sql += " ORDER BY occurred_at DESC, id DESC LIMIT ?" + (params.length + 1);
|
|
356
|
+
params.push(limit);
|
|
357
|
+
var r = await query(sql, params);
|
|
358
|
+
var rows = r.rows;
|
|
359
|
+
var nextCursor = rows.length === limit ? rows[rows.length - 1].occurred_at : null;
|
|
360
|
+
return { rows: rows, next_cursor: nextCursor };
|
|
361
|
+
},
|
|
362
|
+
|
|
363
|
+
transactionsForOrder: async function (orderId) {
|
|
364
|
+
_uuid(orderId, "order_id");
|
|
365
|
+
var r = await query(
|
|
366
|
+
"SELECT id, gift_card_id, kind, amount_minor, source, source_ref, order_id, " +
|
|
367
|
+
"balance_after_minor, occurred_at FROM gift_card_ledger " +
|
|
368
|
+
"WHERE order_id = ?1 ORDER BY occurred_at ASC, id ASC",
|
|
369
|
+
[orderId],
|
|
370
|
+
);
|
|
371
|
+
return r.rows;
|
|
372
|
+
},
|
|
373
|
+
|
|
374
|
+
bulkBalance: async function (input) {
|
|
375
|
+
if (!input || typeof input !== "object") {
|
|
376
|
+
throw new TypeError("giftCardLedger.bulkBalance: input object required");
|
|
377
|
+
}
|
|
378
|
+
var ids = input.gift_card_ids;
|
|
379
|
+
if (!Array.isArray(ids)) {
|
|
380
|
+
throw new TypeError("giftCardLedger.bulkBalance: gift_card_ids must be an array");
|
|
381
|
+
}
|
|
382
|
+
if (ids.length === 0) return [];
|
|
383
|
+
if (ids.length > MAX_BULK_IDS) {
|
|
384
|
+
throw new TypeError("giftCardLedger.bulkBalance: gift_card_ids must be <= " + MAX_BULK_IDS + " entries");
|
|
385
|
+
}
|
|
386
|
+
// Validate every id at the call site — D1 will refuse the
|
|
387
|
+
// query on a non-UUID value but the operator-facing error is
|
|
388
|
+
// far better when the primitive surfaces "ids[3] is not a
|
|
389
|
+
// UUID" up front.
|
|
390
|
+
var validated = [];
|
|
391
|
+
for (var i = 0; i < ids.length; i += 1) {
|
|
392
|
+
validated.push(_uuid(ids[i], "gift_card_ids[" + i + "]"));
|
|
393
|
+
}
|
|
394
|
+
// Per-id subquery so the join lands the LATEST row per card.
|
|
395
|
+
// SQLite (and D1) support a correlated subquery against the
|
|
396
|
+
// same table for `MAX(occurred_at)` keyed off the card id —
|
|
397
|
+
// the `(gift_card_id, occurred_at DESC)` index drives both
|
|
398
|
+
// legs.
|
|
399
|
+
var placeholders = [];
|
|
400
|
+
for (var p = 0; p < validated.length; p += 1) {
|
|
401
|
+
placeholders.push("?" + (p + 1));
|
|
402
|
+
}
|
|
403
|
+
var sql =
|
|
404
|
+
"SELECT g.gift_card_id, g.balance_after_minor, g.occurred_at " +
|
|
405
|
+
"FROM gift_card_ledger g " +
|
|
406
|
+
"WHERE g.gift_card_id IN (" + placeholders.join(",") + ") " +
|
|
407
|
+
"AND g.occurred_at = (" +
|
|
408
|
+
" SELECT MAX(g2.occurred_at) FROM gift_card_ledger g2 " +
|
|
409
|
+
" WHERE g2.gift_card_id = g.gift_card_id" +
|
|
410
|
+
") " +
|
|
411
|
+
"ORDER BY g.gift_card_id ASC";
|
|
412
|
+
var r = await query(sql, validated);
|
|
413
|
+
// Build a lookup keyed by id so cards with no ledger rows at
|
|
414
|
+
// all still surface in the result set as `balance_minor: 0`.
|
|
415
|
+
// Operators sweeping a list of "all issued cards" expect a row
|
|
416
|
+
// back for every input id; a missing entry would be a silent
|
|
417
|
+
// skip.
|
|
418
|
+
var byId = Object.create(null);
|
|
419
|
+
for (var k = 0; k < r.rows.length; k += 1) {
|
|
420
|
+
var row = r.rows[k];
|
|
421
|
+
byId[row.gift_card_id] = row.balance_after_minor;
|
|
422
|
+
}
|
|
423
|
+
// Tie-break: when two rows land at the same `occurred_at`
|
|
424
|
+
// (operator backdating two credits to the same millisecond),
|
|
425
|
+
// the MAX(occurred_at) subquery matches both. Keep the one
|
|
426
|
+
// with the higher `balance_after_minor` since UUIDv7 secondary
|
|
427
|
+
// ordering would require a second subquery — and "the higher
|
|
428
|
+
// running balance" is the answer a sweep wants either way.
|
|
429
|
+
// Real-world collisions only happen on backdated operator
|
|
430
|
+
// writes; the in-flight `_now()` path is monotonic per-id.
|
|
431
|
+
var out = [];
|
|
432
|
+
for (var m = 0; m < validated.length; m += 1) {
|
|
433
|
+
var id = validated[m];
|
|
434
|
+
out.push({
|
|
435
|
+
gift_card_id: id,
|
|
436
|
+
balance_minor: Object.prototype.hasOwnProperty.call(byId, id) ? byId[id] : 0,
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
return out;
|
|
440
|
+
},
|
|
441
|
+
|
|
442
|
+
expiringBalance: async function (input) {
|
|
443
|
+
if (!input || typeof input !== "object") {
|
|
444
|
+
throw new TypeError("giftCardLedger.expiringBalance: input object required");
|
|
445
|
+
}
|
|
446
|
+
var before = _epochMs(input.before, "before");
|
|
447
|
+
if (before == null) {
|
|
448
|
+
throw new TypeError("giftCardLedger.expiringBalance: before is required");
|
|
449
|
+
}
|
|
450
|
+
var minAmount = input.min_amount_minor != null ? input.min_amount_minor : 1;
|
|
451
|
+
if (typeof minAmount !== "number" || !Number.isInteger(minAmount) || minAmount < 0) {
|
|
452
|
+
throw new TypeError("giftCardLedger.expiringBalance: min_amount_minor must be a non-negative integer");
|
|
453
|
+
}
|
|
454
|
+
// JOIN against `giftcards` so we filter on `expires_at`.
|
|
455
|
+
// `expires_at < ?before AND expires_at IS NOT NULL` returns
|
|
456
|
+
// cards whose deadline has passed (or will pass before the
|
|
457
|
+
// sweep horizon — operators pass `Date.now() + days_window`).
|
|
458
|
+
// The balance comes from the ledger's latest row per card.
|
|
459
|
+
// Cards with no ledger rows at all are excluded (a card that
|
|
460
|
+
// has never been credited has nothing to expire).
|
|
461
|
+
var sql =
|
|
462
|
+
"SELECT gc.id AS gift_card_id, gc.expires_at, l.balance_after_minor AS balance_minor " +
|
|
463
|
+
"FROM giftcards gc " +
|
|
464
|
+
"JOIN gift_card_ledger l ON l.gift_card_id = gc.id " +
|
|
465
|
+
"WHERE gc.expires_at IS NOT NULL " +
|
|
466
|
+
"AND gc.expires_at < ?1 " +
|
|
467
|
+
"AND l.occurred_at = (" +
|
|
468
|
+
" SELECT MAX(l2.occurred_at) FROM gift_card_ledger l2 " +
|
|
469
|
+
" WHERE l2.gift_card_id = gc.id" +
|
|
470
|
+
") " +
|
|
471
|
+
"AND l.balance_after_minor >= ?2 " +
|
|
472
|
+
"ORDER BY gc.expires_at ASC, gc.id ASC";
|
|
473
|
+
var r = await query(sql, [before, minAmount]);
|
|
474
|
+
return r.rows;
|
|
475
|
+
},
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
module.exports = {
|
|
480
|
+
create: create,
|
|
481
|
+
KINDS: KINDS,
|
|
482
|
+
SOURCES: SOURCES,
|
|
483
|
+
};
|
package/lib/index.js
CHANGED
|
@@ -95,4 +95,29 @@ module.exports = {
|
|
|
95
95
|
affiliates: require("./affiliates"),
|
|
96
96
|
mailingAudiences: require("./mailing-audiences"),
|
|
97
97
|
orderTimeline: require("./order-timeline"),
|
|
98
|
+
taxRates: require("./tax-rates"),
|
|
99
|
+
webhookSubscriptions: require("./webhook-subscriptions"),
|
|
100
|
+
storefrontPages: require("./storefront-pages"),
|
|
101
|
+
tenants: require("./tenants"),
|
|
102
|
+
apiKeys: require("./api-keys"),
|
|
103
|
+
barcodes: require("./barcodes"),
|
|
104
|
+
customerPortal: require("./customer-portal"),
|
|
105
|
+
subscriptionBilling: require("./subscription-billing"),
|
|
106
|
+
translations: require("./translations"),
|
|
107
|
+
couponStacking: require("./coupon-stacking"),
|
|
108
|
+
experiments: require("./experiments"),
|
|
109
|
+
printReceipts: require("./print-receipts"),
|
|
110
|
+
inventorySnapshots: require("./inventory-snapshots"),
|
|
111
|
+
productImport: require("./product-import"),
|
|
112
|
+
customerImport: require("./customer-import"),
|
|
113
|
+
codeMinter: require("./code-minter"),
|
|
114
|
+
storefrontForms: require("./storefront-forms"),
|
|
115
|
+
cartBulkOps: require("./cart-bulk-ops"),
|
|
116
|
+
carrierRates: require("./carrier-rates"),
|
|
117
|
+
operatorAuditLog: require("./operator-audit-log"),
|
|
118
|
+
cmsBlocks: require("./cms-blocks"),
|
|
119
|
+
giftCardLedger: require("./gift-card-ledger"),
|
|
120
|
+
discountAnalytics: require("./discount-analytics"),
|
|
121
|
+
searchFacets: require("./search-facets"),
|
|
122
|
+
dunning: require("./dunning"),
|
|
98
123
|
};
|