@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.
- package/CHANGELOG.md +4 -0
- package/lib/compliance-export.js +614 -0
- package/lib/error-log.js +525 -0
- package/lib/index.js +5 -0
- package/lib/invoice-renderer.js +618 -0
- package/lib/live-chat.js +714 -0
- package/lib/store-credit.js +565 -0
- package/package.json +1 -1
|
@@ -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