@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.
@@ -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
  };