@blamejs/blamejs-shop 0.4.23 → 0.4.25
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/README.md +6 -1
- package/SECURITY.md +13 -0
- package/lib/admin.js +1328 -49
- package/lib/asset-manifest.json +5 -5
- package/lib/compliance-export.js +61 -7
- package/lib/customers.js +53 -0
- package/lib/cycle-counting.js +24 -4
- package/lib/gift-card-ledger.js +81 -10
- package/lib/giftcards.js +88 -0
- package/lib/inventory-allocations.js +33 -14
- package/lib/inventory-receive.js +116 -20
- package/lib/inventory-writeoffs.js +53 -64
- package/lib/loyalty-earn-rules.js +117 -0
- package/lib/loyalty.js +79 -0
- package/lib/newsletter.js +39 -2
- package/lib/operator-audit-log.js +20 -0
- package/lib/operator-inbox.js +202 -9
- package/lib/order.js +227 -27
- package/lib/payment.js +91 -18
- package/lib/quotes.js +107 -15
- package/lib/referrals.js +71 -0
- package/lib/security-middleware.js +33 -1
- package/lib/stock-transfers.js +185 -53
- package/lib/storefront.js +979 -126
- package/lib/translations.js +1 -0
- package/lib/webhook-receiver.js +15 -19
- package/lib/wishlist-alerts.js +37 -0
- package/package.json +1 -1
package/lib/asset-manifest.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.4.
|
|
2
|
+
"version": "0.4.25",
|
|
3
3
|
"assets": {
|
|
4
4
|
"css/admin.css": {
|
|
5
|
-
"integrity": "sha384-
|
|
6
|
-
"fingerprinted": "css/admin.
|
|
5
|
+
"integrity": "sha384-imfe0otYErcB8rr2h6KLSGTtStirysptpXETSPY4zLv3bZoIT75Lo1dOvkOav+xL",
|
|
6
|
+
"fingerprinted": "css/admin.6941d5151488a7c1.css"
|
|
7
7
|
},
|
|
8
8
|
"css/main.css": {
|
|
9
|
-
"integrity": "sha384-
|
|
10
|
-
"fingerprinted": "css/main.
|
|
9
|
+
"integrity": "sha384-z6x2cNhNfsxxs7OZi3ZIg84HAcsJIUyhH4RjZwuHCdf03Rr68dm9zxciLgThkGLh",
|
|
10
|
+
"fingerprinted": "css/main.cf0a6763075ece73.css"
|
|
11
11
|
},
|
|
12
12
|
"js/announcement.js": {
|
|
13
13
|
"integrity": "sha384-z4zcEMn+tScoVnYRE4nEf8N/oyvpxdpaxTNrT4QO/jURChid4+qjAvWkzatCaAPq",
|
package/lib/compliance-export.js
CHANGED
|
@@ -115,6 +115,48 @@ var STATUSES = Object.freeze([
|
|
|
115
115
|
"received", "processing", "fulfilled", "delivered", "dismissed",
|
|
116
116
|
]);
|
|
117
117
|
|
|
118
|
+
// Statutory response window per jurisdiction — the clock a supervisory
|
|
119
|
+
// authority measures the controller against once a subject files the
|
|
120
|
+
// request. The deadline is `requested_at + days`. GDPR Art. 12(3): one
|
|
121
|
+
// month from receipt (encoded as 30 days — the controller-defensible
|
|
122
|
+
// reading; extendable by two further months for complex requests, which an
|
|
123
|
+
// operator records out of band). CCPA Cal. Civ. Code §1798.130(a)(2): 45
|
|
124
|
+
// days (one 45-day extension permitted). LGPD Art. 19 §II / §3: 15 days for
|
|
125
|
+
// the full declaration. `other` carries no statutory clock — the operator's
|
|
126
|
+
// own SLA governs, so no deadline is surfaced rather than inventing one.
|
|
127
|
+
//
|
|
128
|
+
// This is the DSR-response analogue of b.breach.deadline (which encodes the
|
|
129
|
+
// US-state breach-NOTIFICATION statutes — a different clock with different
|
|
130
|
+
// citations); a subject-access response window has no entry in that
|
|
131
|
+
// registry, so the per-jurisdiction window lives here keyed to the same
|
|
132
|
+
// jurisdiction vocabulary the request rows already carry.
|
|
133
|
+
var DSR_RESPONSE_WINDOW = Object.freeze({
|
|
134
|
+
gdpr: Object.freeze({ days: 30, statute: "GDPR Art. 12(3) (one month from receipt)" }),
|
|
135
|
+
ccpa: Object.freeze({ days: 45, statute: "Cal. Civ. Code §1798.130(a)(2)" }),
|
|
136
|
+
lgpd: Object.freeze({ days: 15, statute: "LGPD Art. 19 §II" }),
|
|
137
|
+
other: null, // operator SLA governs — no statutory clock to surface
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
var MS_PER_DAY = b.constants.TIME.days(1);
|
|
141
|
+
|
|
142
|
+
// Compute the statutory response deadline for a DSR request. Returns null
|
|
143
|
+
// for a jurisdiction with no statutory clock (`other`) or a non-finite
|
|
144
|
+
// requested-at. The shape mirrors b.breach.deadline.forStates entries —
|
|
145
|
+
// `{ jurisdiction, days, due_by, statute }` — so a future operator clock
|
|
146
|
+
// (b.breach.deadline.createClock-style escalation) can adapt it without a
|
|
147
|
+
// reshape.
|
|
148
|
+
function _statutoryDeadline(jurisdiction, requestedAtMs) {
|
|
149
|
+
var win = DSR_RESPONSE_WINDOW[jurisdiction];
|
|
150
|
+
if (!win) return null;
|
|
151
|
+
if (typeof requestedAtMs !== "number" || !isFinite(requestedAtMs)) return null;
|
|
152
|
+
return {
|
|
153
|
+
jurisdiction: jurisdiction,
|
|
154
|
+
days: win.days,
|
|
155
|
+
due_by: requestedAtMs + (win.days * MS_PER_DAY),
|
|
156
|
+
statute: win.statute,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
|
|
118
160
|
var MAX_REASON_LEN = 4000;
|
|
119
161
|
var MAX_DISMISS_REASON_LEN = 4000;
|
|
120
162
|
var MAX_DELIVERY_METHOD_LEN = 64;
|
|
@@ -264,6 +306,7 @@ function _isEmptySection(section) {
|
|
|
264
306
|
|
|
265
307
|
function _hydrate(r) {
|
|
266
308
|
if (!r) return null;
|
|
309
|
+
var requestedAt = Number(r.requested_at);
|
|
267
310
|
return {
|
|
268
311
|
id: r.id,
|
|
269
312
|
customer_id: r.customer_id,
|
|
@@ -272,13 +315,19 @@ function _hydrate(r) {
|
|
|
272
315
|
scope: r.scope == null ? null : r.scope,
|
|
273
316
|
status: r.status,
|
|
274
317
|
requested_by: r.requested_by,
|
|
275
|
-
requested_at:
|
|
318
|
+
requested_at: requestedAt,
|
|
276
319
|
fulfilled_at: r.fulfilled_at == null ? null : Number(r.fulfilled_at),
|
|
277
320
|
delivered_at: r.delivered_at == null ? null : Number(r.delivered_at),
|
|
278
321
|
dismiss_reason: r.dismiss_reason == null ? null : r.dismiss_reason,
|
|
279
322
|
delivery_method: r.delivery_method == null ? null : r.delivery_method,
|
|
280
323
|
delivery_address: r.delivery_address == null ? null : r.delivery_address,
|
|
281
324
|
reason: r.reason == null ? null : r.reason,
|
|
325
|
+
// Derived: the statutory response deadline the supervisory authority
|
|
326
|
+
// measures against (computed from jurisdiction + requested_at, never
|
|
327
|
+
// persisted — so it always reflects the current registry). Null for a
|
|
328
|
+
// jurisdiction with no statutory clock. The admin DSR console surfaces
|
|
329
|
+
// `due_by` so an operator sees the wall before it elapses.
|
|
330
|
+
statutory_deadline: _statutoryDeadline(r.jurisdiction, requestedAt),
|
|
282
331
|
};
|
|
283
332
|
}
|
|
284
333
|
|
|
@@ -658,10 +707,15 @@ function create(opts) {
|
|
|
658
707
|
}
|
|
659
708
|
|
|
660
709
|
module.exports = {
|
|
661
|
-
create:
|
|
662
|
-
REQUEST_KINDS:
|
|
663
|
-
JURISDICTIONS:
|
|
664
|
-
SCOPES:
|
|
665
|
-
STATUSES:
|
|
666
|
-
SCOPE_SECTIONS:
|
|
710
|
+
create: create,
|
|
711
|
+
REQUEST_KINDS: REQUEST_KINDS,
|
|
712
|
+
JURISDICTIONS: JURISDICTIONS,
|
|
713
|
+
SCOPES: SCOPES,
|
|
714
|
+
STATUSES: STATUSES,
|
|
715
|
+
SCOPE_SECTIONS: SCOPE_SECTIONS,
|
|
716
|
+
// DSR statutory response-window registry + the per-request deadline
|
|
717
|
+
// calculator (the console reads the calculator's `due_by`; tests pin the
|
|
718
|
+
// per-jurisdiction windows).
|
|
719
|
+
DSR_RESPONSE_WINDOW: DSR_RESPONSE_WINDOW,
|
|
720
|
+
statutoryDeadline: _statutoryDeadline,
|
|
667
721
|
};
|
package/lib/customers.js
CHANGED
|
@@ -167,6 +167,23 @@ function create(opts) {
|
|
|
167
167
|
return b.crypto.namespaceHash(EMAIL_NAMESPACE, email);
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
// Monotonic upsert of the per-customer session-revocation boundary. The
|
|
171
|
+
// INSERT-OR-conflict keeps the LATER of the existing and incoming value
|
|
172
|
+
// so two concurrent revokes (erasure + a passkey-revoke firing together)
|
|
173
|
+
// can never roll the boundary backwards and resurrect a cookie one of
|
|
174
|
+
// them meant to kill. `MAX(...)` on the conflict path is the conservation
|
|
175
|
+
// guard; `excluded` references the row the INSERT tried to add.
|
|
176
|
+
async function _bumpSessionRevocation(id, at) {
|
|
177
|
+
await query(
|
|
178
|
+
"INSERT INTO customer_session_revocations (customer_id, sessions_valid_from, updated_at) " +
|
|
179
|
+
"VALUES (?1, ?2, ?2) " +
|
|
180
|
+
"ON CONFLICT(customer_id) DO UPDATE SET " +
|
|
181
|
+
"sessions_valid_from = MAX(customer_session_revocations.sessions_valid_from, excluded.sessions_valid_from), " +
|
|
182
|
+
"updated_at = excluded.updated_at",
|
|
183
|
+
[id, at],
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
170
187
|
var api = {
|
|
171
188
|
EMAIL_NAMESPACE: EMAIL_NAMESPACE,
|
|
172
189
|
|
|
@@ -600,6 +617,12 @@ function create(opts) {
|
|
|
600
617
|
);
|
|
601
618
|
emailHashCleared = Number((upd && upd.rowCount) || 0);
|
|
602
619
|
}
|
|
620
|
+
// Terminate every live sealed auth cookie for the erased account.
|
|
621
|
+
// Deleting the durable credentials stops a NEW session from being
|
|
622
|
+
// minted, but a cookie issued before erasure is stateless and stays
|
|
623
|
+
// valid for its 14-day TTL — so the anonymized account remains usable
|
|
624
|
+
// until then unless we move the revocation boundary forward now.
|
|
625
|
+
await _bumpSessionRevocation(id, ts);
|
|
603
626
|
return {
|
|
604
627
|
passkeys: Number((pkDel && pkDel.rowCount) || 0),
|
|
605
628
|
oauth_identities: Number((oaDel && oaDel.rowCount) || 0),
|
|
@@ -607,6 +630,36 @@ function create(opts) {
|
|
|
607
630
|
};
|
|
608
631
|
},
|
|
609
632
|
|
|
633
|
+
// Move the customer's session-revocation boundary to `at` (default
|
|
634
|
+
// now). Every sealed auth cookie whose `iat` predates the boundary —
|
|
635
|
+
// and every pre-`iat` cookie once a boundary exists — fails the
|
|
636
|
+
// revocation check on its next authenticated request and signs out.
|
|
637
|
+
// Idempotent and monotonic: a later bump only ever moves the boundary
|
|
638
|
+
// forward, so two concurrent revokes can't roll it backwards. Used by
|
|
639
|
+
// erasure (above), passkey-revoke, and explicit sign-out.
|
|
640
|
+
revokeSessionsForCustomer: async function (id, at) {
|
|
641
|
+
_uuid(id, "customer id");
|
|
642
|
+
var boundary = (at == null) ? _now() : at;
|
|
643
|
+
if (!Number.isInteger(boundary) || boundary < 0) {
|
|
644
|
+
throw new TypeError("customers.revokeSessionsForCustomer: at must be a non-negative integer epoch-ms or null");
|
|
645
|
+
}
|
|
646
|
+
await _bumpSessionRevocation(id, boundary);
|
|
647
|
+
return { customer_id: id, sessions_valid_from: boundary };
|
|
648
|
+
},
|
|
649
|
+
|
|
650
|
+
// The customer's session-revocation boundary (epoch-ms), or 0 when no
|
|
651
|
+
// revocation has ever been recorded (the default-allow state). The
|
|
652
|
+
// auth-cookie reader compares the cookie's `iat` against this: a cookie
|
|
653
|
+
// is live when the boundary is 0, or when its `iat` is >= the boundary.
|
|
654
|
+
sessionsValidFrom: async function (id) {
|
|
655
|
+
_uuid(id, "customer id");
|
|
656
|
+
var row = (await query(
|
|
657
|
+
"SELECT sessions_valid_from FROM customer_session_revocations WHERE customer_id = ?1 LIMIT 1",
|
|
658
|
+
[id],
|
|
659
|
+
)).rows[0];
|
|
660
|
+
return row ? Number(row.sessions_valid_from) : 0;
|
|
661
|
+
},
|
|
662
|
+
|
|
610
663
|
// Mutate a customer's editable profile fields. v1 covers display_name
|
|
611
664
|
// only — the one field a customer can safely change without a
|
|
612
665
|
// verification round trip.
|
package/lib/cycle-counting.js
CHANGED
|
@@ -576,6 +576,25 @@ function create(opts) {
|
|
|
576
576
|
throw new TypeError("cycle-counting.finalizeCount: count is " + header.status +
|
|
577
577
|
", only scheduled or in_progress counts can be finalized");
|
|
578
578
|
}
|
|
579
|
+
// Claim the (scheduled|in_progress) -> finalized transition atomically
|
|
580
|
+
// BEFORE computing variances or applying adjustments. Two concurrent
|
|
581
|
+
// finalizes both pass the read above, but only one UPDATE matches the
|
|
582
|
+
// expected status; the loser refuses, so the per-shelf adjustments are
|
|
583
|
+
// applied exactly once. (Without this guard both calls would write the
|
|
584
|
+
// variance adjustments through inventoryLocations.adjustStock, double-
|
|
585
|
+
// applying.) The final aggregate counts are stamped below once the loop
|
|
586
|
+
// has run.
|
|
587
|
+
var ts = _now();
|
|
588
|
+
var claim = await query(
|
|
589
|
+
"UPDATE cycle_counts SET status = 'finalized', finalized_at = ?1 " +
|
|
590
|
+
"WHERE slug = ?2 AND status IN ('scheduled', 'in_progress')",
|
|
591
|
+
[ts, input.slug],
|
|
592
|
+
);
|
|
593
|
+
if (Number(claim.rowCount || 0) !== 1) {
|
|
594
|
+
throw new TypeError("cycle-counting.finalizeCount: count " +
|
|
595
|
+
JSON.stringify(input.slug) + " is no longer scheduled or in_progress " +
|
|
596
|
+
"(finalized by a concurrent call)");
|
|
597
|
+
}
|
|
579
598
|
var lines = await _getLines(input.slug);
|
|
580
599
|
var varianceCount = 0;
|
|
581
600
|
var varianceValueMinor = 0;
|
|
@@ -616,11 +635,12 @@ function create(opts) {
|
|
|
616
635
|
}
|
|
617
636
|
}
|
|
618
637
|
}
|
|
619
|
-
|
|
638
|
+
// Status + finalized_at were already claimed above; stamp the computed
|
|
639
|
+
// aggregate variance totals onto the (now-finalized) header.
|
|
620
640
|
await query(
|
|
621
|
-
"UPDATE cycle_counts SET
|
|
622
|
-
"
|
|
623
|
-
[varianceCount, varianceValueMinor,
|
|
641
|
+
"UPDATE cycle_counts SET variance_count = ?1, variance_value_minor = ?2 " +
|
|
642
|
+
"WHERE slug = ?3",
|
|
643
|
+
[varianceCount, varianceValueMinor, input.slug],
|
|
624
644
|
);
|
|
625
645
|
return {
|
|
626
646
|
variance_count: varianceCount,
|
package/lib/gift-card-ledger.js
CHANGED
|
@@ -72,6 +72,13 @@ var b = require("./vendor/blamejs");
|
|
|
72
72
|
var KINDS = ["credit", "debit", "expire"];
|
|
73
73
|
var SOURCES = ["purchase", "refund_to_giftcard", "promotional", "manual"];
|
|
74
74
|
|
|
75
|
+
var SHA3_512_HEX_LEN = 128;
|
|
76
|
+
// Genesis anchor for a card's first ledger row. Each gift card is its own
|
|
77
|
+
// independent chain — the first row keyed by a `gift_card_id` links to
|
|
78
|
+
// ZERO, every subsequent row links to the prior row's row_hash for the
|
|
79
|
+
// SAME card.
|
|
80
|
+
var ZERO_HASH = "0".repeat(SHA3_512_HEX_LEN);
|
|
81
|
+
|
|
75
82
|
var MAX_SOURCE_REF_LEN = 128;
|
|
76
83
|
// Source_ref / reason are short correlation handles (originating
|
|
77
84
|
// order id, refund handle, campaign code, operator note). Refuse
|
|
@@ -130,6 +137,44 @@ function _epochMs(ts, label) {
|
|
|
130
137
|
|
|
131
138
|
function _now() { return Date.now(); }
|
|
132
139
|
|
|
140
|
+
// ---- chain hashing ------------------------------------------------------
|
|
141
|
+
//
|
|
142
|
+
// Mirrors operator-audit-log's composition exactly: the preimage is
|
|
143
|
+
//
|
|
144
|
+
// row_hash = SHA3-512(prev_hash || canonical-json(row-fields))
|
|
145
|
+
//
|
|
146
|
+
// where row-fields is every persisted ledger column except prev_hash +
|
|
147
|
+
// row_hash. `b.auditChain.canonicalize` (RFC 8785 walker, sorted keys,
|
|
148
|
+
// hash columns excluded) makes the preimage byte-identical across
|
|
149
|
+
// deployments + node versions. We deliberately do NOT use
|
|
150
|
+
// b.auditChain.computeRowHash / verifyChain — those expect camelCase
|
|
151
|
+
// rowHash / monotonicCounter / nonce columns this snake_case schema does
|
|
152
|
+
// not carry; composing canonicalize + sha3Hash directly is the
|
|
153
|
+
// snake_case-compatible path.
|
|
154
|
+
|
|
155
|
+
function _rowFieldsForHash(row) {
|
|
156
|
+
return {
|
|
157
|
+
id: row.id,
|
|
158
|
+
gift_card_id: row.gift_card_id,
|
|
159
|
+
kind: row.kind,
|
|
160
|
+
amount_minor: Number(row.amount_minor),
|
|
161
|
+
source: row.source == null ? null : row.source,
|
|
162
|
+
source_ref: row.source_ref == null ? null : row.source_ref,
|
|
163
|
+
order_id: row.order_id == null ? null : row.order_id,
|
|
164
|
+
balance_after_minor: Number(row.balance_after_minor),
|
|
165
|
+
occurred_at: Number(row.occurred_at),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function _computeRowHash(prevHash, rowFields) {
|
|
170
|
+
var canonical = b.auditChain.canonicalize(rowFields, ["prev_hash", "row_hash"]);
|
|
171
|
+
var preimage = Buffer.concat([
|
|
172
|
+
Buffer.from(prevHash, "hex"),
|
|
173
|
+
Buffer.from(canonical, "utf8"),
|
|
174
|
+
]);
|
|
175
|
+
return b.crypto.sha3Hash(preimage);
|
|
176
|
+
}
|
|
177
|
+
|
|
133
178
|
// ---- factory ------------------------------------------------------------
|
|
134
179
|
|
|
135
180
|
function create(opts) {
|
|
@@ -158,12 +203,21 @@ function create(opts) {
|
|
|
158
203
|
// `_resolveOccurredAt`).
|
|
159
204
|
async function _readLatest(giftCardId) {
|
|
160
205
|
var r = await query(
|
|
161
|
-
"SELECT balance_after_minor, occurred_at FROM gift_card_ledger " +
|
|
162
|
-
"WHERE gift_card_id = ?1 ORDER BY occurred_at DESC LIMIT 1",
|
|
206
|
+
"SELECT id, balance_after_minor, occurred_at, row_hash FROM gift_card_ledger " +
|
|
207
|
+
"WHERE gift_card_id = ?1 ORDER BY occurred_at DESC, id DESC LIMIT 1",
|
|
163
208
|
[giftCardId],
|
|
164
209
|
);
|
|
165
|
-
if (!r.rows.length) return { balance: 0, occurred_at: null };
|
|
166
|
-
return {
|
|
210
|
+
if (!r.rows.length) return { id: null, balance: 0, occurred_at: null, row_hash: ZERO_HASH };
|
|
211
|
+
return {
|
|
212
|
+
id: r.rows[0].id,
|
|
213
|
+
balance: r.rows[0].balance_after_minor,
|
|
214
|
+
occurred_at: r.rows[0].occurred_at,
|
|
215
|
+
// A legacy pre-chain row carries NULL row_hash — chain forward from
|
|
216
|
+
// ZERO so the first hashed row anchors the chain; verifyChain treats
|
|
217
|
+
// the unhashed legacy prefix as unverifiable (NULL row_hash break)
|
|
218
|
+
// rather than silently trusting it.
|
|
219
|
+
row_hash: r.rows[0].row_hash == null ? ZERO_HASH : r.rows[0].row_hash,
|
|
220
|
+
};
|
|
167
221
|
}
|
|
168
222
|
|
|
169
223
|
async function _currentBalance(giftCardId) {
|
|
@@ -187,13 +241,30 @@ function create(opts) {
|
|
|
187
241
|
return latestTs + 1;
|
|
188
242
|
}
|
|
189
243
|
|
|
190
|
-
|
|
244
|
+
// credit() + expire() write through here: they already read the prior
|
|
245
|
+
// row (balance + occurred_at), so chaining is a straight read-then-write
|
|
246
|
+
// — `prevHash` is that prior row's row_hash, and row_hash is computed
|
|
247
|
+
// over the fully-resolved field tuple before the INSERT. debit() does
|
|
248
|
+
// NOT use this path: its overdraft guard must stay a single atomic
|
|
249
|
+
// statement, so it chains separately (see below).
|
|
250
|
+
async function _writeRow(giftCardId, kind, amountMinor, source, sourceRef, orderId, balanceAfter, ts, prevHash) {
|
|
191
251
|
var id = b.uuid.v7();
|
|
252
|
+
var rowHash = _computeRowHash(prevHash, {
|
|
253
|
+
id: id,
|
|
254
|
+
gift_card_id: giftCardId,
|
|
255
|
+
kind: kind,
|
|
256
|
+
amount_minor: amountMinor,
|
|
257
|
+
source: source,
|
|
258
|
+
source_ref: sourceRef,
|
|
259
|
+
order_id: orderId,
|
|
260
|
+
balance_after_minor: balanceAfter,
|
|
261
|
+
occurred_at: ts,
|
|
262
|
+
});
|
|
192
263
|
await query(
|
|
193
264
|
"INSERT INTO gift_card_ledger " +
|
|
194
|
-
"(id, gift_card_id, kind, amount_minor, source, source_ref, order_id, balance_after_minor, occurred_at) " +
|
|
195
|
-
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
|
196
|
-
[id, giftCardId, kind, amountMinor, source, sourceRef, orderId, balanceAfter, ts],
|
|
265
|
+
"(id, gift_card_id, kind, amount_minor, source, source_ref, order_id, balance_after_minor, occurred_at, prev_hash, row_hash) " +
|
|
266
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
|
|
267
|
+
[id, giftCardId, kind, amountMinor, source, sourceRef, orderId, balanceAfter, ts, prevHash, rowHash],
|
|
197
268
|
);
|
|
198
269
|
return id;
|
|
199
270
|
}
|
|
@@ -216,7 +287,7 @@ function create(opts) {
|
|
|
216
287
|
var latest = await _readLatest(giftCardId);
|
|
217
288
|
var ts = _resolveOccurredAt(requested, latest.occurred_at);
|
|
218
289
|
var after = latest.balance + amount;
|
|
219
|
-
var id = await _writeRow(giftCardId, "credit", amount, source, sourceRef, null, after, ts);
|
|
290
|
+
var id = await _writeRow(giftCardId, "credit", amount, source, sourceRef, null, after, ts, latest.row_hash);
|
|
220
291
|
|
|
221
292
|
return {
|
|
222
293
|
id: id,
|
|
@@ -334,7 +405,7 @@ function create(opts) {
|
|
|
334
405
|
}
|
|
335
406
|
var ts = _resolveOccurredAt(requested, latest.occurred_at);
|
|
336
407
|
var after = latest.balance - toBurn;
|
|
337
|
-
var id = await _writeRow(giftCardId, "expire", toBurn, null, reason, null, after, ts);
|
|
408
|
+
var id = await _writeRow(giftCardId, "expire", toBurn, null, reason, null, after, ts, latest.row_hash);
|
|
338
409
|
|
|
339
410
|
return {
|
|
340
411
|
id: id,
|
package/lib/giftcards.js
CHANGED
|
@@ -406,6 +406,94 @@ function create(opts) {
|
|
|
406
406
|
return reversed;
|
|
407
407
|
},
|
|
408
408
|
|
|
409
|
+
// Credit a card's spend back PROPORTIONALLY to a partial refund — the
|
|
410
|
+
// refund-by-amount counterpart to reverseRedemption's all-or-nothing
|
|
411
|
+
// (cancel) release. A full refund (cancel / payment-failed) returns the
|
|
412
|
+
// whole spend; a partial refund must return only the share the refund
|
|
413
|
+
// covers, or a 10%-refunded order would re-mint the entire gift-card
|
|
414
|
+
// value as spendable while the customer keeps 90% of the goods.
|
|
415
|
+
//
|
|
416
|
+
// For each redemption against the order the target cumulative reversal is
|
|
417
|
+
// floor(amount_minor * refunded_minor / order_total_minor), clamped to
|
|
418
|
+
// amount_minor so rounding + a refund that exceeds the recorded total can
|
|
419
|
+
// never credit back more than the card actually paid. The credit is the
|
|
420
|
+
// delta between that target and what's already been reversed
|
|
421
|
+
// (reversed_minor), so a partial-then-final refund sequence converges
|
|
422
|
+
// exactly on the spend without ever over-crediting. reversed_minor is
|
|
423
|
+
// advanced under a guarded UPDATE keyed on the row's live value, so two
|
|
424
|
+
// concurrent reversals can't both credit the same slice (the second sees
|
|
425
|
+
// its expected-prior value no longer matches and re-derives the delta as
|
|
426
|
+
// zero). A redemption that reaches full reversal also stamps reversed_at
|
|
427
|
+
// so the existing (binary) reverseRedemption short-circuits it.
|
|
428
|
+
//
|
|
429
|
+
// Returns the list of redemptions that received a non-zero credit, each
|
|
430
|
+
// with the minor-units credited this call — the caller writes the
|
|
431
|
+
// matching ledger credits. An order that used no gift card, or one
|
|
432
|
+
// already reversed up to the requested proportion, returns [].
|
|
433
|
+
reverseRedemptionProRata: async function (orderId, input) {
|
|
434
|
+
_uuid(orderId, "order_id");
|
|
435
|
+
input = input || {};
|
|
436
|
+
var refundedMinor = input.refunded_minor;
|
|
437
|
+
var orderTotalMinor = input.order_total_minor;
|
|
438
|
+
if (!Number.isInteger(refundedMinor) || refundedMinor < 0) {
|
|
439
|
+
throw new TypeError("giftcards.reverseRedemptionProRata: refunded_minor must be a non-negative integer (minor units)");
|
|
440
|
+
}
|
|
441
|
+
if (!Number.isInteger(orderTotalMinor) || orderTotalMinor <= 0) {
|
|
442
|
+
throw new TypeError("giftcards.reverseRedemptionProRata: order_total_minor must be a positive integer (minor units)");
|
|
443
|
+
}
|
|
444
|
+
// A refund can't exceed the order total; clamp the fraction at 1 so a
|
|
445
|
+
// recorded over-refund (rounding at the provider, a manual adjustment)
|
|
446
|
+
// never targets more than the full spend.
|
|
447
|
+
var effRefunded = refundedMinor > orderTotalMinor ? orderTotalMinor : refundedMinor;
|
|
448
|
+
var rows = (await query(
|
|
449
|
+
"SELECT id, giftcard_id, amount_minor, reversed_minor FROM giftcard_redemptions " +
|
|
450
|
+
"WHERE order_id = ?1",
|
|
451
|
+
[orderId],
|
|
452
|
+
)).rows;
|
|
453
|
+
var credited = [];
|
|
454
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
455
|
+
var red = rows[i];
|
|
456
|
+
var amount = Number(red.amount_minor);
|
|
457
|
+
var already = Number(red.reversed_minor || 0);
|
|
458
|
+
// Proportional target in minor units. floor so we never round UP
|
|
459
|
+
// into crediting a fraction the refund didn't cover; clamp to the
|
|
460
|
+
// full spend so cumulative reversals can't exceed it.
|
|
461
|
+
var target = Math.floor((amount * effRefunded) / orderTotalMinor);
|
|
462
|
+
if (target > amount) target = amount;
|
|
463
|
+
var delta = target - already;
|
|
464
|
+
if (delta <= 0) continue; // already reversed to (or past) this proportion
|
|
465
|
+
var ts = _now();
|
|
466
|
+
var nowFull = target >= amount;
|
|
467
|
+
// Claim the slice: advance reversed_minor from its expected prior
|
|
468
|
+
// value so a concurrent reversal can't double-credit. Stamp
|
|
469
|
+
// reversed_at only when the redemption is now fully reversed (so the
|
|
470
|
+
// binary reverseRedemption treats it as done).
|
|
471
|
+
var claim = await query(
|
|
472
|
+
"UPDATE giftcard_redemptions SET reversed_minor = ?1, " +
|
|
473
|
+
"reversed_at = CASE WHEN ?2 = 1 THEN ?3 ELSE reversed_at END " +
|
|
474
|
+
"WHERE id = ?4 AND reversed_minor = ?5",
|
|
475
|
+
[target, nowFull ? 1 : 0, ts, red.id, already],
|
|
476
|
+
);
|
|
477
|
+
if (Number(claim.rowCount || 0) === 0) continue; // lost the claim to a concurrent reversal
|
|
478
|
+
// Restore the spendable balance on the card row. Capped at the card's
|
|
479
|
+
// issued_minor by the schema CHECK; the cumulative reversed_minor is
|
|
480
|
+
// bounded by amount_minor, so it can't exceed the face value.
|
|
481
|
+
// Reactivate a card drained to `redeemed` — it carries balance again.
|
|
482
|
+
await query(
|
|
483
|
+
"UPDATE giftcards SET balance_minor = balance_minor + ?1, " +
|
|
484
|
+
"status = CASE WHEN status = 'redeemed' THEN 'active' ELSE status END, " +
|
|
485
|
+
"updated_at = ?2 WHERE id = ?3",
|
|
486
|
+
[delta, ts, red.giftcard_id],
|
|
487
|
+
);
|
|
488
|
+
credited.push({
|
|
489
|
+
redemption_id: red.id,
|
|
490
|
+
gift_card_id: red.giftcard_id,
|
|
491
|
+
amount_minor: delta,
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
return credited;
|
|
495
|
+
},
|
|
496
|
+
|
|
409
497
|
"void": async function (id, opts2) {
|
|
410
498
|
opts2 = opts2 || {};
|
|
411
499
|
_uuid(id, "giftcard id");
|
|
@@ -369,25 +369,44 @@ function create(opts) {
|
|
|
369
369
|
" has no location_code — pin a location before commit");
|
|
370
370
|
}
|
|
371
371
|
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
//
|
|
375
|
-
//
|
|
376
|
-
//
|
|
377
|
-
// the
|
|
378
|
-
await locations.adjustStock({
|
|
379
|
-
sku: existing.sku,
|
|
380
|
-
location_code: existing.location_code,
|
|
381
|
-
delta: -existing.quantity,
|
|
382
|
-
reason: "hold-commit:" + existing.id + " order:" + input.order_id,
|
|
383
|
-
});
|
|
384
|
-
|
|
372
|
+
// Claim the held -> committed transition atomically BEFORE debiting the
|
|
373
|
+
// shelf. Two concurrent commits both pass the status read above, but only
|
|
374
|
+
// one UPDATE matches `status = 'held'`; the loser sees rowCount 0 and
|
|
375
|
+
// refuses, so the committed shelf is debited exactly once. (Without this
|
|
376
|
+
// guard both calls would debit the shelf, oversell.) The committed_order_id
|
|
377
|
+
// also lands now so the row's order pointer is set before the debit.
|
|
385
378
|
var ts = _monotonicTs();
|
|
386
|
-
await query(
|
|
379
|
+
var claim = await query(
|
|
387
380
|
"UPDATE inventory_holds SET status = 'committed', committed_at = ?1, " +
|
|
388
381
|
"committed_order_id = ?2 WHERE id = ?3 AND status = 'held'",
|
|
389
382
|
[ts, input.order_id, input.hold_id],
|
|
390
383
|
);
|
|
384
|
+
if (Number(claim.rowCount || 0) !== 1) {
|
|
385
|
+
throw new TypeError("inventory-allocations.commitHold: hold " + existing.id +
|
|
386
|
+
" is no longer held (committed by a concurrent call)");
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Debit the committed shelf through inventoryLocations.adjustStock. If the
|
|
390
|
+
// shelf write fails (insufficient stock, unknown location), release the
|
|
391
|
+
// claim back to 'held' so a retry or a manual release decides next — the
|
|
392
|
+
// hold must not strand in 'committed' with no shelf movement behind it.
|
|
393
|
+
try {
|
|
394
|
+
await locations.adjustStock({
|
|
395
|
+
sku: existing.sku,
|
|
396
|
+
location_code: existing.location_code,
|
|
397
|
+
delta: -existing.quantity,
|
|
398
|
+
reason: "hold-commit:" + existing.id + " order:" + input.order_id,
|
|
399
|
+
});
|
|
400
|
+
} catch (e) {
|
|
401
|
+
try {
|
|
402
|
+
await query(
|
|
403
|
+
"UPDATE inventory_holds SET status = 'held', committed_at = NULL, " +
|
|
404
|
+
"committed_order_id = NULL WHERE id = ?1 AND status = 'committed'",
|
|
405
|
+
[input.hold_id],
|
|
406
|
+
);
|
|
407
|
+
} catch (_e2) { /* drop-silent — the adjustStock error is the caller's signal */ }
|
|
408
|
+
throw e;
|
|
409
|
+
}
|
|
391
410
|
return _shapeHold(await _getHoldRow(input.hold_id));
|
|
392
411
|
}
|
|
393
412
|
|