@blamejs/blamejs-shop 0.0.62 → 0.0.64

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,565 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.storeCredit
4
+ * @title Store-credit primitive — per-customer account-bound wallet
5
+ *
6
+ * @intro
7
+ * Per-customer store-credit wallet. Distinct from the `giftcards`
8
+ * + `giftCardLedger` primitives — gift cards are bearer
9
+ * credentials tied to a code (whoever holds the code can spend),
10
+ * store credit is account-bound (no code, follows the customer
11
+ * record). The two ledgers share the same shape because the
12
+ * read patterns are identical: O(1) current balance against a
13
+ * denormalized `balance_after_minor` snapshot, paginated history
14
+ * newest-first, transactions touching a given order, expiring-
15
+ * balance sweeps.
16
+ *
17
+ * Composition:
18
+ * var credit = bShop.storeCredit.create({ query: q });
19
+ * await credit.credit({
20
+ * customer_id: customerId,
21
+ * amount_minor: 2500,
22
+ * source: "refund",
23
+ * source_ref: refundId,
24
+ * expires_at: Date.now() + 365 * 86400 * 1000,
25
+ * });
26
+ * await credit.debit({
27
+ * customer_id: customerId,
28
+ * amount_minor: 1200,
29
+ * order_id: orderId,
30
+ * });
31
+ * var bal = await credit.balance(customerId); // 1300
32
+ *
33
+ * Overdraft is refused at the primitive layer: debit > available
34
+ * throws `STORE_CREDIT_INSUFFICIENT_BALANCE` and writes no row.
35
+ * Expire caps at the current balance (operator-initiated burn
36
+ * degrades gracefully when the credit has already been spent).
37
+ *
38
+ * `cleanupExpired` is the scheduler-callable sweep: it walks
39
+ * credit rows whose `expires_at < now` and whose deposited amount
40
+ * hasn't already been offset by a later expire entry, then writes
41
+ * an offsetting `expire` row for each. The sweep is idempotent —
42
+ * re-running it produces no new rows because the offsetting
43
+ * entries already exist.
44
+ *
45
+ * Surface:
46
+ * - credit({ customer_id, amount_minor, source, source_ref?, expires_at?, occurred_at? })
47
+ * - debit({ customer_id, amount_minor, order_id, occurred_at? })
48
+ * - expire({ customer_id, amount_minor, reason })
49
+ * - balance(customer_id)
50
+ * - history({ customer_id, cursor?, limit? })
51
+ * - transactionsForOrder(order_id)
52
+ * - expiringWithin({ customer_id, days })
53
+ * - bulkBalance({ customer_ids })
54
+ * - cleanupExpired({ now })
55
+ *
56
+ * Storage:
57
+ * - store_credit_ledger (migration 0094).
58
+ *
59
+ * @primitive storeCredit
60
+ * @related b.uuid.v7, b.guardUuid, shop.giftCardLedger
61
+ */
62
+
63
+ var bShop;
64
+ function _b() {
65
+ if (!bShop) bShop = require("./index");
66
+ return bShop.framework;
67
+ }
68
+
69
+ var KINDS = ["credit", "debit", "expire"];
70
+ var SOURCES = ["refund", "goodwill", "promotional", "manual", "loyalty_redemption"];
71
+
72
+ var MAX_SOURCE_REF_LEN = 128;
73
+ // source_ref / reason are short correlation handles. Refuse all
74
+ // control bytes (including CR/LF and tab) — log-injection cover has
75
+ // no legitimate place in a one-line correlation column.
76
+ var PRINTABLE_RE = /^[^\x00-\x1f\x7f]*$/;
77
+
78
+ var MAX_BULK_IDS = 500;
79
+ var MS_PER_DAY = 86400 * 1000;
80
+
81
+ // ---- validators ---------------------------------------------------------
82
+
83
+ function _uuid(s, label) {
84
+ try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
85
+ catch (e) { throw new TypeError("storeCredit: " + label + " — " + (e && e.message || "invalid UUID")); }
86
+ }
87
+
88
+ function _amountMinor(n, label) {
89
+ if (typeof n !== "number" || !Number.isInteger(n) || n <= 0) {
90
+ throw new TypeError("storeCredit: " + label + " must be a positive integer (minor units)");
91
+ }
92
+ return n;
93
+ }
94
+
95
+ function _source(s) {
96
+ if (typeof s !== "string" || SOURCES.indexOf(s) === -1) {
97
+ throw new TypeError("storeCredit: source must be one of " + SOURCES.join(", "));
98
+ }
99
+ return s;
100
+ }
101
+
102
+ function _sourceRef(s, label) {
103
+ if (s == null) return null;
104
+ if (typeof s !== "string") {
105
+ throw new TypeError("storeCredit: " + label + " must be a string");
106
+ }
107
+ if (!s.length) {
108
+ throw new TypeError("storeCredit: " + label + " must be a non-empty string when provided");
109
+ }
110
+ if (s.length > MAX_SOURCE_REF_LEN) {
111
+ throw new TypeError("storeCredit: " + label + " must be <= " + MAX_SOURCE_REF_LEN + " chars");
112
+ }
113
+ if (!PRINTABLE_RE.test(s)) {
114
+ throw new TypeError("storeCredit: " + label + " must not contain control bytes");
115
+ }
116
+ return s;
117
+ }
118
+
119
+ function _epochMs(ts, label) {
120
+ if (ts == null) return null;
121
+ if (typeof ts !== "number" || !Number.isInteger(ts) || ts < 0) {
122
+ throw new TypeError("storeCredit: " + label + " must be a non-negative integer epoch-ms");
123
+ }
124
+ return ts;
125
+ }
126
+
127
+ function _now() { return Date.now(); }
128
+
129
+ // ---- factory ------------------------------------------------------------
130
+
131
+ function create(opts) {
132
+ opts = opts || {};
133
+ var query = opts.query;
134
+ if (!query) {
135
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
136
+ }
137
+
138
+ // O(1) current-balance read: the latest row by `occurred_at DESC`
139
+ // holds `balance_after_minor` as the denormalized snapshot. No SUM
140
+ // aggregation at read time. Falls through to 0 when no rows exist
141
+ // (a customer that has never had a ledger row has zero credit).
142
+ // Returns both the snapshot and the occurred_at so the write path
143
+ // can guarantee strict monotonicity (see `_resolveOccurredAt`).
144
+ async function _readLatest(customerId) {
145
+ var r = await query(
146
+ "SELECT balance_after_minor, occurred_at FROM store_credit_ledger " +
147
+ "WHERE customer_id = ?1 ORDER BY occurred_at DESC LIMIT 1",
148
+ [customerId],
149
+ );
150
+ if (!r.rows.length) return { balance: 0, occurred_at: null };
151
+ return { balance: r.rows[0].balance_after_minor, occurred_at: r.rows[0].occurred_at };
152
+ }
153
+
154
+ async function _currentBalance(customerId) {
155
+ var latest = await _readLatest(customerId);
156
+ return latest.balance;
157
+ }
158
+
159
+ // Two writes against the same customer in the same millisecond
160
+ // would tie on `occurred_at` and make the "latest row" ambiguous.
161
+ // Bump the requested timestamp to `prior + 1` when it would
162
+ // collide (or land older than the prior row, which an
163
+ // out-of-order operator write could trigger). The result is a
164
+ // strictly-monotonic per-customer `occurred_at` sequence.
165
+ function _resolveOccurredAt(requestedTs, latestTs) {
166
+ if (latestTs == null) return requestedTs;
167
+ if (requestedTs > latestTs) return requestedTs;
168
+ return latestTs + 1;
169
+ }
170
+
171
+ async function _writeRow(customerId, kind, amountMinor, source, sourceRef, orderId, balanceAfter, expiresAt, ts) {
172
+ var id = _b().uuid.v7();
173
+ await query(
174
+ "INSERT INTO store_credit_ledger " +
175
+ "(id, customer_id, kind, amount_minor, source, source_ref, order_id, balance_after_minor, expires_at, occurred_at) " +
176
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
177
+ [id, customerId, kind, amountMinor, source, sourceRef, orderId, balanceAfter, expiresAt, ts],
178
+ );
179
+ return id;
180
+ }
181
+
182
+ return {
183
+ KINDS: KINDS.slice(),
184
+ SOURCES: SOURCES.slice(),
185
+
186
+ credit: async function (input) {
187
+ if (!input || typeof input !== "object") {
188
+ throw new TypeError("storeCredit.credit: input object required");
189
+ }
190
+ var customerId = _uuid(input.customer_id, "customer_id");
191
+ var amount = _amountMinor(input.amount_minor, "amount_minor");
192
+ var source = _source(input.source);
193
+ var sourceRef = _sourceRef(input.source_ref, "source_ref");
194
+ var expiresAt = _epochMs(input.expires_at, "expires_at");
195
+ var requested = _epochMs(input.occurred_at, "occurred_at");
196
+ if (requested == null) requested = _now();
197
+
198
+ var latest = await _readLatest(customerId);
199
+ var ts = _resolveOccurredAt(requested, latest.occurred_at);
200
+ var after = latest.balance + amount;
201
+ var id = await _writeRow(customerId, "credit", amount, source, sourceRef, null, after, expiresAt, ts);
202
+
203
+ return {
204
+ id: id,
205
+ customer_id: customerId,
206
+ kind: "credit",
207
+ amount_minor: amount,
208
+ source: source,
209
+ source_ref: sourceRef,
210
+ expires_at: expiresAt,
211
+ balance_after_minor: after,
212
+ occurred_at: ts,
213
+ };
214
+ },
215
+
216
+ debit: async function (input) {
217
+ if (!input || typeof input !== "object") {
218
+ throw new TypeError("storeCredit.debit: input object required");
219
+ }
220
+ var customerId = _uuid(input.customer_id, "customer_id");
221
+ var amount = _amountMinor(input.amount_minor, "amount_minor");
222
+ var orderId = _uuid(input.order_id, "order_id");
223
+ var requested = _epochMs(input.occurred_at, "occurred_at");
224
+ if (requested == null) requested = _now();
225
+
226
+ var latest = await _readLatest(customerId);
227
+ if (amount > latest.balance) {
228
+ var insufficient = new Error("storeCredit.debit: amount exceeds available balance");
229
+ insufficient.code = "STORE_CREDIT_INSUFFICIENT_BALANCE";
230
+ throw insufficient;
231
+ }
232
+ var ts = _resolveOccurredAt(requested, latest.occurred_at);
233
+ var after = latest.balance - amount;
234
+ var id = await _writeRow(customerId, "debit", amount, null, null, orderId, after, null, ts);
235
+
236
+ return {
237
+ id: id,
238
+ customer_id: customerId,
239
+ kind: "debit",
240
+ amount_minor: amount,
241
+ order_id: orderId,
242
+ balance_after_minor: after,
243
+ occurred_at: ts,
244
+ };
245
+ },
246
+
247
+ expire: async function (input) {
248
+ if (!input || typeof input !== "object") {
249
+ throw new TypeError("storeCredit.expire: input object required");
250
+ }
251
+ var customerId = _uuid(input.customer_id, "customer_id");
252
+ var amount = _amountMinor(input.amount_minor, "amount_minor");
253
+ // `reason` is operator-supplied free-form. Require it
254
+ // explicitly (vs. an optional sourceRef) so an audit-trail
255
+ // row tagged 'expire' always carries the operator's
256
+ // justification.
257
+ if (input.reason == null || input.reason === "") {
258
+ throw new TypeError("storeCredit.expire: reason must be a non-empty string");
259
+ }
260
+ var reason = _sourceRef(input.reason, "reason");
261
+ var requested = _epochMs(input.occurred_at, "occurred_at");
262
+ if (requested == null) requested = _now();
263
+
264
+ var latest = await _readLatest(customerId);
265
+ // Expire caps at the current balance — operators running a
266
+ // scheduled sweep over computed "expiring before X" amounts
267
+ // should degrade gracefully rather than refusing when an
268
+ // interim debit has already drained the wallet.
269
+ var toBurn = amount > latest.balance ? latest.balance : amount;
270
+ if (toBurn === 0) {
271
+ // No-op write: persisting a zero-amount row would violate
272
+ // the CHECK(amount_minor > 0) constraint. Surface a
273
+ // structured refusal so the caller can distinguish "already
274
+ // empty" from "actually burned N". A no-op expire is a
275
+ // valid outcome of a bulk sweep — don't throw.
276
+ return {
277
+ id: null,
278
+ customer_id: customerId,
279
+ kind: "expire",
280
+ amount_minor: 0,
281
+ requested_minor: amount,
282
+ reason: reason,
283
+ balance_after_minor: latest.balance,
284
+ occurred_at: requested,
285
+ noop: true,
286
+ };
287
+ }
288
+ var ts = _resolveOccurredAt(requested, latest.occurred_at);
289
+ var after = latest.balance - toBurn;
290
+ var id = await _writeRow(customerId, "expire", toBurn, null, reason, null, after, null, ts);
291
+
292
+ return {
293
+ id: id,
294
+ customer_id: customerId,
295
+ kind: "expire",
296
+ amount_minor: toBurn,
297
+ requested_minor: amount,
298
+ reason: reason,
299
+ balance_after_minor: after,
300
+ occurred_at: ts,
301
+ noop: false,
302
+ };
303
+ },
304
+
305
+ balance: async function (customerId) {
306
+ _uuid(customerId, "customer_id");
307
+ var bal = await _currentBalance(customerId);
308
+ return { customer_id: customerId, balance_minor: bal };
309
+ },
310
+
311
+ history: async function (input) {
312
+ if (!input || typeof input !== "object") {
313
+ throw new TypeError("storeCredit.history: input object required");
314
+ }
315
+ var customerId = _uuid(input.customer_id, "customer_id");
316
+ var limit = input.limit != null ? input.limit : 50;
317
+ if (typeof limit !== "number" || !Number.isInteger(limit) || limit < 1 || limit > 500) {
318
+ throw new TypeError("storeCredit.history: limit must be an integer in [1, 500]");
319
+ }
320
+ var cursor = input.cursor;
321
+ var sql = "SELECT id, customer_id, kind, amount_minor, source, source_ref, order_id, " +
322
+ "balance_after_minor, expires_at, occurred_at FROM store_credit_ledger " +
323
+ "WHERE customer_id = ?1";
324
+ var params = [customerId];
325
+ if (cursor != null) {
326
+ if (typeof cursor !== "number" || !Number.isInteger(cursor) || cursor < 0) {
327
+ throw new TypeError("storeCredit.history: cursor must be a non-negative integer epoch-ms");
328
+ }
329
+ // Cursor is the `occurred_at` of the last row in the
330
+ // previous page — request rows STRICTLY OLDER so a page
331
+ // boundary landing on a tied timestamp doesn't double-
332
+ // return rows.
333
+ sql += " AND occurred_at < ?2";
334
+ params.push(cursor);
335
+ }
336
+ sql += " ORDER BY occurred_at DESC, id DESC LIMIT ?" + (params.length + 1);
337
+ params.push(limit);
338
+ var r = await query(sql, params);
339
+ var rows = r.rows;
340
+ var nextCursor = rows.length === limit ? rows[rows.length - 1].occurred_at : null;
341
+ return { rows: rows, next_cursor: nextCursor };
342
+ },
343
+
344
+ transactionsForOrder: async function (orderId) {
345
+ _uuid(orderId, "order_id");
346
+ var r = await query(
347
+ "SELECT id, customer_id, kind, amount_minor, source, source_ref, order_id, " +
348
+ "balance_after_minor, expires_at, occurred_at FROM store_credit_ledger " +
349
+ "WHERE order_id = ?1 ORDER BY occurred_at ASC, id ASC",
350
+ [orderId],
351
+ );
352
+ return r.rows;
353
+ },
354
+
355
+ expiringWithin: async function (input) {
356
+ if (!input || typeof input !== "object") {
357
+ throw new TypeError("storeCredit.expiringWithin: input object required");
358
+ }
359
+ var customerId = _uuid(input.customer_id, "customer_id");
360
+ var days = input.days;
361
+ if (typeof days !== "number" || !Number.isInteger(days) || days < 0) {
362
+ throw new TypeError("storeCredit.expiringWithin: days must be a non-negative integer");
363
+ }
364
+ var now = _now();
365
+ var horizon = now + (days * MS_PER_DAY);
366
+
367
+ // Walk this customer's credit rows whose expires_at falls in
368
+ // the window (now, horizon]. A row whose expires_at has
369
+ // already passed is excluded — that's `cleanupExpired`'s
370
+ // domain. Match each credit row against the running sum of
371
+ // later `expire` entries to compute how much of the deposit
372
+ // remains exposed; only rows with non-zero remaining come
373
+ // back. Replay-derived rather than denormalized because
374
+ // expiring-balance is a per-credit-row question (the wallet
375
+ // can hold credits with different deadlines), not the
376
+ // single-balance question `balance()` answers.
377
+ var creditRows = (await query(
378
+ "SELECT id, amount_minor, source, source_ref, expires_at, occurred_at " +
379
+ "FROM store_credit_ledger " +
380
+ "WHERE customer_id = ?1 AND kind = 'credit' AND expires_at IS NOT NULL " +
381
+ "AND expires_at > ?2 AND expires_at <= ?3 " +
382
+ "ORDER BY expires_at ASC, occurred_at ASC",
383
+ [customerId, now, horizon],
384
+ )).rows;
385
+
386
+ if (!creditRows.length) return [];
387
+
388
+ // Aggregate expire-row burn for this customer post-`now` —
389
+ // the sum we'll consume against the FIFO-ordered (by
390
+ // expires_at) credit rows. Expire rows themselves don't
391
+ // carry expires_at; they're just balance reductions. We can't
392
+ // attribute an expire to a specific credit row at the schema
393
+ // level (no parent pointer), so we apply burn FIFO across
394
+ // credit rows whose deadline has already passed (impossible
395
+ // here — we filtered to expires_at > now) plus burn applied
396
+ // generically. The simpler model: ignore historical expires
397
+ // for the within-window question — those expires offset
398
+ // already-expired credits (handled by cleanupExpired).
399
+ // Customer's current total balance bounds how much of the
400
+ // window-resident credits remains spendable.
401
+ var totalBal = (await _readLatest(customerId)).balance;
402
+
403
+ // FIFO walk: each credit row contributes up to its
404
+ // amount_minor toward the running spendable balance, in
405
+ // expires_at order. Rows past the spendable bound are
406
+ // implicitly already-spent by post-credit debits — exclude.
407
+ var out = [];
408
+ var remaining = totalBal;
409
+ for (var i = 0; i < creditRows.length; i += 1) {
410
+ if (remaining <= 0) break;
411
+ var row = creditRows[i];
412
+ var slice = row.amount_minor > remaining ? remaining : row.amount_minor;
413
+ out.push({
414
+ credit_id: row.id,
415
+ amount_minor: slice,
416
+ source: row.source,
417
+ source_ref: row.source_ref,
418
+ expires_at: row.expires_at,
419
+ occurred_at: row.occurred_at,
420
+ });
421
+ remaining -= slice;
422
+ }
423
+ return out;
424
+ },
425
+
426
+ bulkBalance: async function (input) {
427
+ if (!input || typeof input !== "object") {
428
+ throw new TypeError("storeCredit.bulkBalance: input object required");
429
+ }
430
+ var ids = input.customer_ids;
431
+ if (!Array.isArray(ids)) {
432
+ throw new TypeError("storeCredit.bulkBalance: customer_ids must be an array");
433
+ }
434
+ if (ids.length === 0) return [];
435
+ if (ids.length > MAX_BULK_IDS) {
436
+ throw new TypeError("storeCredit.bulkBalance: customer_ids must be <= " + MAX_BULK_IDS + " entries");
437
+ }
438
+ // Validate every id up front — surface "ids[3] is not a UUID"
439
+ // at the call site rather than letting D1 reject the whole
440
+ // query with an opaque error.
441
+ var validated = [];
442
+ for (var i = 0; i < ids.length; i += 1) {
443
+ validated.push(_uuid(ids[i], "customer_ids[" + i + "]"));
444
+ }
445
+ // Per-id correlated subquery: the join lands the LATEST row
446
+ // per customer. `(customer_id, occurred_at DESC)` index drives
447
+ // both legs.
448
+ var placeholders = [];
449
+ for (var p = 0; p < validated.length; p += 1) {
450
+ placeholders.push("?" + (p + 1));
451
+ }
452
+ var sql =
453
+ "SELECT g.customer_id, g.balance_after_minor, g.occurred_at " +
454
+ "FROM store_credit_ledger g " +
455
+ "WHERE g.customer_id IN (" + placeholders.join(",") + ") " +
456
+ "AND g.occurred_at = (" +
457
+ " SELECT MAX(g2.occurred_at) FROM store_credit_ledger g2 " +
458
+ " WHERE g2.customer_id = g.customer_id" +
459
+ ") " +
460
+ "ORDER BY g.customer_id ASC";
461
+ var r = await query(sql, validated);
462
+ // Build a lookup keyed by id so customers with no rows still
463
+ // surface as `balance_minor: 0`. Operators sweeping a list
464
+ // expect a row for every input id; a silent skip would be a
465
+ // footgun.
466
+ var byId = Object.create(null);
467
+ for (var k = 0; k < r.rows.length; k += 1) {
468
+ var row = r.rows[k];
469
+ byId[row.customer_id] = row.balance_after_minor;
470
+ }
471
+ var out = [];
472
+ for (var m = 0; m < validated.length; m += 1) {
473
+ var id = validated[m];
474
+ out.push({
475
+ customer_id: id,
476
+ balance_minor: Object.prototype.hasOwnProperty.call(byId, id) ? byId[id] : 0,
477
+ });
478
+ }
479
+ return out;
480
+ },
481
+
482
+ cleanupExpired: async function (input) {
483
+ input = input || {};
484
+ var now = _epochMs(input.now, "now");
485
+ if (now == null) now = _now();
486
+
487
+ // Walk every credit row whose deadline has passed. For each,
488
+ // check whether a later `expire` row has already offset it —
489
+ // if so, skip (idempotent re-run produces no duplicates).
490
+ // Otherwise write an offsetting expire row capped at the
491
+ // wallet's current balance.
492
+ //
493
+ // The "already offset" check is per-customer rather than
494
+ // per-credit-row because expire rows have no parent pointer
495
+ // at the schema level. We sum the customer's expired-credit
496
+ // amounts (kind=credit AND expires_at <= now) and the
497
+ // customer's burn-expire amounts (kind=expire) — the delta
498
+ // is the still-unburned expiring credit for that customer.
499
+ // When the delta is zero, the sweep is a no-op for that
500
+ // customer.
501
+ var expiredByCustomer = (await query(
502
+ "SELECT customer_id, SUM(amount_minor) AS total " +
503
+ "FROM store_credit_ledger " +
504
+ "WHERE kind = 'credit' AND expires_at IS NOT NULL AND expires_at <= ?1 " +
505
+ "GROUP BY customer_id",
506
+ [now],
507
+ )).rows;
508
+
509
+ var processed = [];
510
+ for (var i = 0; i < expiredByCustomer.length; i += 1) {
511
+ var row = expiredByCustomer[i];
512
+ var customerId = row.customer_id;
513
+ var expiredTotal = row.total;
514
+
515
+ var burnRow = (await query(
516
+ "SELECT COALESCE(SUM(amount_minor), 0) AS total " +
517
+ "FROM store_credit_ledger " +
518
+ "WHERE customer_id = ?1 AND kind = 'expire'",
519
+ [customerId],
520
+ )).rows[0];
521
+ var alreadyBurned = burnRow ? burnRow.total : 0;
522
+ var pendingBurn = expiredTotal - alreadyBurned;
523
+
524
+ if (pendingBurn <= 0) {
525
+ // Already fully offset by prior expire rows (or by
526
+ // debits that drained the wallet below the expired
527
+ // amount — see cap below). Idempotent skip.
528
+ continue;
529
+ }
530
+
531
+ var latest = await _readLatest(customerId);
532
+ // Cap the burn at the wallet's current balance.
533
+ // Debits between the credit and the sweep may have spent
534
+ // the expired amount already; we never drive the balance
535
+ // negative. The expired credits were "first-out" from the
536
+ // operator's POV — but the schema doesn't track FIFO at
537
+ // row-level, so cap by current balance and let the audit
538
+ // trail reflect what was actually burned.
539
+ var toBurn = pendingBurn > latest.balance ? latest.balance : pendingBurn;
540
+ if (toBurn <= 0) {
541
+ // Wallet already empty — record nothing (no CHECK > 0
542
+ // violation). Operator can reconcile via history.
543
+ continue;
544
+ }
545
+ var ts = _resolveOccurredAt(now, latest.occurred_at);
546
+ var after = latest.balance - toBurn;
547
+ var id = await _writeRow(customerId, "expire", toBurn, null, "scheduled-expiry-sweep", null, after, null, ts);
548
+ processed.push({
549
+ id: id,
550
+ customer_id: customerId,
551
+ amount_minor: toBurn,
552
+ balance_after_minor: after,
553
+ occurred_at: ts,
554
+ });
555
+ }
556
+ return { processed: processed, swept_at: now };
557
+ },
558
+ };
559
+ }
560
+
561
+ module.exports = {
562
+ create: create,
563
+ KINDS: KINDS,
564
+ SOURCES: SOURCES,
565
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.0.62",
3
+ "version": "0.0.64",
4
4
  "description": "Open-source framework built on blamejs. Vendored stack, zero npm runtime deps, PQC-first crypto, security-on by default.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {