@blamejs/blamejs-shop 0.4.104 → 0.4.105
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 +2 -0
- package/README.md +1 -1
- package/SECURITY.md +11 -8
- package/lib/asset-manifest.json +1 -1
- package/lib/loyalty.js +407 -192
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.4.x
|
|
10
10
|
|
|
11
|
+
- v0.4.105 (2026-06-25) — **A loyalty points balance is now an exactly-once, tamper-evident running ledger instead of a stored counter updated alongside its history.** A customer's loyalty balance, lifetime points, and tier were held in a stored account row and updated in a statement separate from the transaction-history insert. On a backend without interactive transactions, a failure between those two writes could leave the balance and the history disagreeing, and there was no way to make a points credit exactly-once with a stored counter. Every loyalty mutation — earn, redeem, expire, adjust, credit, and the refund-time reverse/restore credits — is now a single guarded insert into the transaction ledger that also carries the new running balance, lifetime, and tier, so the balance change and its audit row are one write with no window to half-apply. The running balance is read from the ledger tip rather than a separate counter, redemptions refuse an overdraft inside that one statement, and concurrent writes to the same customer are serialized by a per-customer chain so two redemptions can never both spend the same points. Each ledger row is linked into a per-customer SHA3-512 hash chain, so the history is tamper-evident and can be verified end to end (the loyalty addition to the store-credit and gift-card ledger tamper-evidence). Balances, tiers, the points ratios, and the entire loyalty API are unchanged; the running-balance columns are added by a migration that anchors each existing customer to their current trusted balance without replaying history. The account row is kept as a denormalized mirror for the tier leaderboard and tier-expiry, refreshed after each write and never on a balance decision path. **Changed:** *Loyalty balance, lifetime, and tier are computed from a single guarded ledger write* — The loyalty balance, lifetime points, and tier are no longer a stored counter mutated next to a separate history insert. Each earn / redeem / expire / adjust / credit and each refund-time reverse or restore is now one guarded insert into the loyalty transaction ledger that records the event and carries the resulting running balance, lifetime, and tier in the same row — so a balance change and its audit entry are a single write that cannot half-apply, and a points credit is exactly-once and crash-safe. The current balance is read from the latest ledger row; a redemption's overdraft refusal lives inside that insert; and concurrent writes to one customer are serialized so two redemptions can't both spend the same balance. The customer's balances, tier, the points-per-dollar and redemption ratios, and the public loyalty API are all unchanged. · *Loyalty history is now a tamper-evident hash chain with a verifyChain audit* — Each loyalty transaction row is linked to the previous one for the same customer by a SHA3-512 hash chain, the same tamper-evidence the store-credit and gift-card ledgers already carry. A new verifyChain audit recomputes the chain to prove no past row was edited or re-ordered. A schema migration adds the running-balance and chain columns and anchors every existing customer to their current trusted balance — it copies the live balance verbatim rather than replaying history, so a balance is never recomputed (and never corrupted) during the upgrade. The migration applies automatically on deploy; no operator action is required.
|
|
12
|
+
|
|
11
13
|
- v0.4.104 (2026-06-25) — **Compare-and-swap atomic claims across the framework now decide won/lost through the shared row-count primitive instead of hand-rolled checks.** Throughout the framework, an atomic claim on a single-statement-per-request backend is a guarded write — UPDATE ... WHERE the row is still in its expected state, or a conditional INSERT — whose affected-row count decides whether this caller won the race (exactly one row) or lost and must no-op. That won/lost decision was hand-rolled at each site as a Number(result.rowCount || 0) === 1 check, which silently treats a result carrying no recognizable row-count as a lost race. Every such site (104 across commissions, redemption caps, inventory and stock-transfer claims, fulfillment and replenishment leases, gift-card and loyalty ledger writes, webhook delivery leases, and more) now routes that decision through the framework's shared row-count primitive, which owns the exactly-one-row check and the cross-adapter row-count field naming, and surfaces an indeterminate result instead of silently counting it as lost. On the production database path the affected-row count is always reported, so there is no behavior change; the change centralizes the interpretation and makes an otherwise-silent indeterminate result observable. No configuration changes and no schema changes. **Changed:** *Atomic compare-and-swap claims use the shared row-count primitive for their won/lost decision* — Compare-and-swap atomic claims — the guarded UPDATE / conditional INSERT pattern used wherever two requests can race the same row on a backend without interactive transactions — previously decided won-versus-lost with a hand-written row-count comparison at each call site, and a result that carried no recognizable row-count was silently taken as a lost race. These claims now make that decision through the framework's shared row-count primitive, which centralizes the exactly-one-row check and the adapter-specific row-count field naming and raises an error on an indeterminate result rather than treating it as a loss. Because the production database always reports the affected-row count, the won/lost outcome is unchanged on every real path; the benefit is one consistent, audited interpretation and a previously-silent indeterminate result now surfacing. A hand-rolled cross-adapter row-count extraction in the webhook delivery-retry lease is removed in favor of the same primitive.
|
|
12
14
|
|
|
13
15
|
- v0.4.103 (2026-06-25) — **A return refund that exceeds the order's remaining refundable balance is now refused cleanly, instead of failing at the payment provider.** The direct refund console refuses an over-refund up front: it compares the requested amount against the order's remaining refundable balance and returns a clear 'more than is still refundable' error before any money moves. The provider-backed return (RMA) refund path skipped that check — it issued the RMA's approved amount straight to the payment provider, relying on the provider to reject anything past the captured charge. The result was a late, opaque provider-failure error (and a returns workflow left mid-flight) instead of the clean, actionable refusal the console gives. The RMA refund path now applies the same remaining-balance guard before calling the provider: an approved amount greater than what remains refundable — including a return stacked on top of an earlier partial refund — is refused with the same clear message, the return is left in its received state so the operator can re-approve a corrected amount, and the payment provider is never dialed. A refund of an already-refunded return continues to return the existing conflict response. No configuration changes. **Fixed:** *Return (RMA) refunds honor the order's remaining-balance cap before reaching the payment provider* — A provider-backed return refund issued the return's approved amount directly to Stripe or PayPal with no remaining-balance check, so an amount larger than what was still refundable on the order — for example a return approved for more than the order total, or stacked on top of an earlier partial refund — failed only at the payment provider, surfacing as an opaque provider-failure error rather than the clean refusal the direct refund console returns. The return refund path now compares the approved amount against the order's remaining refundable balance first: an over-cap amount is refused with a clear message, the return stays in its received state so a corrected amount can be re-approved, and the provider is never called. A refund attempt on an already-refunded return still returns the existing conflict response.
|
package/README.md
CHANGED
|
@@ -81,7 +81,7 @@ Every primitive is composed on the vendored blamejs surface — no npm runtime d
|
|
|
81
81
|
| **`lib/quotes.js`** | Request-for-quote negotiation. A signed-in customer requests a quote from the cart (line quantities + an optional message); the operator responds from the console with per-line pricing and a validity window; the customer reviews and accepts or declines from `/account/quotes` or through a single-use capability link (`/quote/:token` — the token is stored only as a namespaced hash and compared timing-safe). Accepting converts through the normal order path — inventory holds are placed first and rolled back if order creation fails — and every status change replays a `b.fsm` lifecycle (requested → responded → accepted / declined / expired / withdrawn / converted), with expiry enforced at accept time so a stale price is never honored. A scheduled sweep also transitions quotes past their validity window to `expired` (so the console's expired filter reflects the real lifecycle), operators can reprice an open quote (the customer's existing link shows the new pricing) or convert a verbally-approved one to an order with a recorded reason, and the customer view renders the operator's per-line notes and the validity date. |
|
|
82
82
|
| **`lib/addresses.js`** | Per-customer address book at `/account/addresses` — add / edit / set default shipping or billing / remove. One-default-per-role invariant (promoting clears the prior). Every by-id route confirms the address belongs to the signed-in customer before acting (a guessed id returns 404). `b.guardUuid` ids, 2-char ISO country. |
|
|
83
83
|
| **`lib/returns.js`** | Self-serve RMAs. Customer requests a return against their own order at `/account/orders/:id/return` (items + reason, ownership-checked, lines built from the order's own records) and tracks status at `/account/returns`. Operators work `/admin/returns` — approve (refund amount) / mark received / refund / reject — over the pending → approved → received → refunded FSM; illegal transitions are 409, bad ids 404. |
|
|
84
|
-
| **`lib/loyalty.js`** + **`lib/loyalty-earn-rules.js`** + **`lib/loyalty-redemption.js`** | Customer rewards. `loyalty` owns the points balance, lifetime total, tier (bronze → platinum on operator-tunable thresholds), and an audited transaction ledger. `loyalty-earn-rules` defines how points are minted (per-dollar-spent, per-order, signup, birthday, …) keyed to lifecycle events; `loyalty-redemption` is the reward catalog customers spend points against. Customers see all of it at `/account/loyalty` — balance + tier, the earn rules in plain language, the reward catalog with a one-click Redeem, past redemptions, and the paginated earn/redeem ledger (login-gated). Paid orders award points automatically: the order FSM's paid transition fans out to the earn rules fire-and-forget, deduped on the order id so a re-delivered payment webhook never double-credits. At checkout a signed-in customer can spend points for a credit against the order total — valued by the redemption ratio (100 points = $1 default), capped at the order's worth and the balance, debited once behind a balance-guarded SQL decrement, stacking with any gift-card credit; surplus points stay in the balance. |
|
|
84
|
+
| **`lib/loyalty.js`** + **`lib/loyalty-earn-rules.js`** + **`lib/loyalty-redemption.js`** | Customer rewards. `loyalty` owns the points balance, lifetime total, tier (bronze → platinum on operator-tunable thresholds), and an audited transaction ledger — the balance, lifetime, and tier ride that ledger as a per-customer running snapshot written in one guarded row per event (exactly-once, with no separate counter to fall out of step), linked into a per-customer SHA3-512 hash chain like the gift-card and store-credit ledgers, with `loyalty.verifyChain(customerId)` to recompute it end to end. `loyalty-earn-rules` defines how points are minted (per-dollar-spent, per-order, signup, birthday, …) keyed to lifecycle events; `loyalty-redemption` is the reward catalog customers spend points against. Customers see all of it at `/account/loyalty` — balance + tier, the earn rules in plain language, the reward catalog with a one-click Redeem, past redemptions, and the paginated earn/redeem ledger (login-gated). Paid orders award points automatically: the order FSM's paid transition fans out to the earn rules fire-and-forget, deduped on the order id so a re-delivered payment webhook never double-credits. At checkout a signed-in customer can spend points for a credit against the order total — valued by the redemption ratio (100 points = $1 default), capped at the order's worth and the balance, debited once behind a balance-guarded SQL decrement, stacking with any gift-card credit; surplus points stay in the balance. |
|
|
85
85
|
| **`lib/referrals.js`** + **`lib/referral-leaderboard.js`** | Refer-a-friend with two-sided rewards. `referrals` issues each customer an 8-character code (32-glyph confusion-resistant alphabet via `b.crypto.generateBytes`) and tracks each referred friend through the funnel (invited → visited → joined → converted); referred emails are stored hash-only (`b.crypto.namespaceHash`), never plaintext. `referral-leaderboard` sits on top for top-referrer rankings + operator-tunable tier bonuses. Customers see all of it at `/account/referrals` — their code, an absolute shareable link, the friends they've referred (funnel stage + dates, no personal data), and an in-account leaderboard (rank + initials only). A public `/r/<code>` landing captures an inbound referral into a short-lived sealed first-party cookie (first-touch), which attributes the new customer to the referrer when they sign up by passkey / Google / Apple — self-referral and double-attribution guarded. When a referred customer's first order reaches paid, the order FSM's paid transition marks that referral converted and bumps the referrer's count fire-and-forget, deduped on the order id so a re-delivered webhook never double-counts; guest orders and a customer's later orders don't qualify. Issuing the payout itself (gift card / store credit / loyalty points) is an explicit operator action via the referrals reward API, so the reward instrument and any fraud review stay the operator's. |
|
|
86
86
|
| **`lib/product-compare.js`** | Side-by-side product comparison, no sign-in required. Each product page gets an "Add to compare" / "Remove from compare" control; `POST /compare/toggle` adds or removes the product and `GET /compare` renders the comparison table — image, price, availability, and the per-product attributes resolved through the catalog. The basket is keyed on the visitor's existing sealed session cookie (a signed-in shopper's customer id rides alongside), capped at four products (a fifth add is refused with a notice rather than silently dropping one), and is idempotent. Products that go archived or out of stock resolve out of the table with a remove control instead of breaking the render; an empty basket shows a friendly empty state. `POST /compare/clear` empties it. Linked from the footer on every page. |
|
|
87
87
|
| **`lib/bundles.js`** | Sell products together at a set price. A product page shows the bundles it belongs to as a "Bundle & save" offer — the member products, the bundle price, and the saving against the parts. `POST /cart/bundle` adds the whole bundle in one action: the price is recomputed from the live catalog and allocated across the member lines (proportional to list price, remainder on the last line) so the cart subtotal matches the quoted price. The add is atomic — an archived or out-of-stock member shows the bundle unavailable and adds nothing rather than a partial set; an unpriceable bundle (missing member price / currency mismatch) is shown unavailable, never a 500. The client sends only the bundle code; the server prices it. The offer renders identically from the edge worker and the container. |
|
package/SECURITY.md
CHANGED
|
@@ -319,15 +319,18 @@ node -e "
|
|
|
319
319
|
(success / failure / denied) and paginated. Opening the log is itself
|
|
320
320
|
recorded (an `audit.read` row), so reviewing the audit trail leaves
|
|
321
321
|
its own forensic mark.
|
|
322
|
-
- **The gift-card
|
|
323
|
-
and verifiable.** Every ledger entry — credits, debits, and
|
|
322
|
+
- **The gift-card, store-credit, and loyalty ledgers are hash-chained,
|
|
323
|
+
fork-proof, and verifiable.** Every ledger entry — credits, debits, and
|
|
324
324
|
expirations alike — links to its predecessor through a SHA3-512 chain,
|
|
325
|
-
keyed per card (gift cards) or per customer (store credit), so
|
|
326
|
-
row or deleting one from the middle breaks the linkage. A
|
|
327
|
-
fence (one child per chain tip) makes concurrent writes
|
|
328
|
-
than fork the chain
|
|
329
|
-
|
|
330
|
-
|
|
325
|
+
keyed per card (gift cards) or per customer (store credit, loyalty), so
|
|
326
|
+
editing a row or deleting one from the middle breaks the linkage. A
|
|
327
|
+
uniqueness fence (one child per chain tip) makes concurrent writes
|
|
328
|
+
serialize rather than fork the chain, and each loyalty / gift-card /
|
|
329
|
+
store-credit balance change is written in that same single guarded row,
|
|
330
|
+
so a points or balance mutation is exactly-once even on a backend without
|
|
331
|
+
interactive transactions. `giftCardLedger.verifyChain(cardId)` /
|
|
332
|
+
`storeCredit.verifyChain(customerId)` / `loyalty.verifyChain(customerId)`
|
|
333
|
+
recompute a ledger's chain end to end and report the first divergence — run them whenever a balance is
|
|
331
334
|
disputed; pass a trusted anchor (a row count + head hash from an earlier
|
|
332
335
|
snapshot) to also catch a truncation that deletes the most-recent rows,
|
|
333
336
|
which an append-only chain can't detect on its own. The overdraft
|
package/lib/asset-manifest.json
CHANGED
package/lib/loyalty.js
CHANGED
|
@@ -154,6 +154,51 @@ function _validateThresholds(thresholds) {
|
|
|
154
154
|
|
|
155
155
|
function _now() { return Date.now(); }
|
|
156
156
|
|
|
157
|
+
// ---- chain hashing ------------------------------------------------------
|
|
158
|
+
//
|
|
159
|
+
// loyalty_transactions is a per-customer running-balance hash chain (the
|
|
160
|
+
// gift-card-ledger / store-credit-ledger model). Each mutating row carries the
|
|
161
|
+
// running balance_after_points + lifetime_after_points + tier_after, written by
|
|
162
|
+
// the SAME guarded INSERT that records the event, so the balance change and the
|
|
163
|
+
// audit row are one write — the stored-column two-statement window (and the
|
|
164
|
+
// double-credit-on-revert hazard it forced) is structurally gone. A SHA3-512
|
|
165
|
+
// chain (prev_hash + row_hash, fenced by UNIQUE(customer_id, prev_hash)) makes
|
|
166
|
+
// the running snapshot tamper-evident and serializes concurrent writers.
|
|
167
|
+
//
|
|
168
|
+
// row_hash = SHA3-512(prev_hash || canonical-json(row-fields))
|
|
169
|
+
//
|
|
170
|
+
// order_id and restored_points are EXCLUDED from the hashed fields: both are
|
|
171
|
+
// mutated AFTER the row is inserted (linkRedemptionToOrder stamps order_id;
|
|
172
|
+
// reverse/restore advance restored_points), so hashing them would report a
|
|
173
|
+
// false tamper on every linked redemption and every restore.
|
|
174
|
+
var SHA3_512_HEX_LEN = 128;
|
|
175
|
+
var ZERO_HASH = "0".repeat(SHA3_512_HEX_LEN);
|
|
176
|
+
|
|
177
|
+
function _rowFieldsForHash(row) {
|
|
178
|
+
return {
|
|
179
|
+
id: row.id,
|
|
180
|
+
customer_id: row.customer_id,
|
|
181
|
+
transaction_type: row.transaction_type,
|
|
182
|
+
points: Number(row.points),
|
|
183
|
+
source: row.source == null ? null : row.source,
|
|
184
|
+
notes: row.notes == null ? null : row.notes,
|
|
185
|
+
balance_after_points: Number(row.balance_after_points),
|
|
186
|
+
lifetime_after_points: Number(row.lifetime_after_points),
|
|
187
|
+
tier_after: row.tier_after,
|
|
188
|
+
occurred_at: Number(row.occurred_at),
|
|
189
|
+
// order_id + restored_points EXCLUDED — both mutated post-insert.
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function _computeRowHash(prevHash, rowFields) {
|
|
194
|
+
var canonical = b.auditChain.canonicalize(rowFields, ["prev_hash", "row_hash"]);
|
|
195
|
+
var preimage = Buffer.concat([
|
|
196
|
+
Buffer.from(prevHash, "hex"),
|
|
197
|
+
Buffer.from(canonical, "utf8"),
|
|
198
|
+
]);
|
|
199
|
+
return b.crypto.sha3Hash(preimage);
|
|
200
|
+
}
|
|
201
|
+
|
|
157
202
|
// ---- factory ------------------------------------------------------------
|
|
158
203
|
|
|
159
204
|
function create(opts) {
|
|
@@ -205,17 +250,6 @@ function create(opts) {
|
|
|
205
250
|
return r.rows[0] || null;
|
|
206
251
|
}
|
|
207
252
|
|
|
208
|
-
async function _writeTx(customerId, type, points, source, orderId, notes, ts) {
|
|
209
|
-
var id = b.uuid.v7();
|
|
210
|
-
await query(
|
|
211
|
-
"INSERT INTO loyalty_transactions " +
|
|
212
|
-
"(id, customer_id, transaction_type, points, source, order_id, notes, occurred_at) " +
|
|
213
|
-
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
|
214
|
-
[id, customerId, type, points, source, orderId, notes, ts],
|
|
215
|
-
);
|
|
216
|
-
return id;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
253
|
async function _ensureAccountRow(customerId, ts) {
|
|
220
254
|
// INSERT OR IGNORE is idempotent — repeated calls land a single
|
|
221
255
|
// row. We don't bump `updated_at` on the existing-row path; the
|
|
@@ -228,23 +262,133 @@ function create(opts) {
|
|
|
228
262
|
);
|
|
229
263
|
}
|
|
230
264
|
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
//
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
265
|
+
// O(1) chain-tip read: the latest row by (customer_id, occurred_at DESC,
|
|
266
|
+
// id DESC) carries the running balance/lifetime/tier snapshot AFTER it.
|
|
267
|
+
// No SUM at read time. Falls through to a bronze zero-tip when the customer
|
|
268
|
+
// has no rows (a brand-new account anchors its chain from ZERO on the first
|
|
269
|
+
// write). A legacy/genesis NULL row_hash coerces to ZERO_HASH so the first
|
|
270
|
+
// hashed row anchors the chain (the lazy genesis anchor).
|
|
271
|
+
async function _readLatest(customerId) {
|
|
272
|
+
var r = await query(
|
|
273
|
+
"SELECT id, balance_after_points, lifetime_after_points, tier_after, occurred_at, row_hash " +
|
|
274
|
+
"FROM loyalty_transactions WHERE customer_id = ?1 " +
|
|
275
|
+
"ORDER BY occurred_at DESC, id DESC LIMIT 1",
|
|
276
|
+
[customerId],
|
|
277
|
+
);
|
|
278
|
+
if (!r.rows.length) {
|
|
279
|
+
return { id: null, balance: 0, lifetime: 0, tier: "bronze",
|
|
280
|
+
occurred_at: null, row_hash: ZERO_HASH };
|
|
281
|
+
}
|
|
282
|
+
var row = r.rows[0];
|
|
283
|
+
return {
|
|
284
|
+
id: row.id,
|
|
285
|
+
balance: Number(row.balance_after_points),
|
|
286
|
+
lifetime: Number(row.lifetime_after_points),
|
|
287
|
+
tier: row.tier_after,
|
|
288
|
+
occurred_at: Number(row.occurred_at),
|
|
289
|
+
row_hash: row.row_hash == null ? ZERO_HASH : row.row_hash,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Strict-monotonic per-customer occurred_at: two writes in the same ms (or a
|
|
294
|
+
// backdated write) would tie and make the tip ambiguous, so a colliding
|
|
295
|
+
// timestamp bumps to prior + 1. Non-colliding (future) timestamps land as
|
|
296
|
+
// requested.
|
|
297
|
+
function _resolveOccurredAt(requestedTs, latestTs) {
|
|
298
|
+
if (latestTs == null) return requestedTs;
|
|
299
|
+
if (requestedTs > latestTs) return requestedTs;
|
|
300
|
+
return latestTs + 1;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Re-derive the tip this many times when the chain-parent fence refuses an
|
|
304
|
+
// INSERT (a concurrent write claimed the slot). Each fence round lets exactly
|
|
305
|
+
// one racing writer win, so the cap is sized well beyond realistic same-
|
|
306
|
+
// customer fan-in; a genuine non-collision insert error re-throws on attempt
|
|
307
|
+
// one (the tip is unchanged), so a high cap never spins on a real failure.
|
|
308
|
+
var CHAIN_WRITE_ATTEMPTS = 64;
|
|
309
|
+
|
|
310
|
+
// One fenced write attempt off a freshly-read tip. Every event kind writes
|
|
311
|
+
// through here so every row joins the per-customer chain. The chain-parent
|
|
312
|
+
// fence (UNIQUE(customer_id, prev_hash)) makes the app-computed prev_hash /
|
|
313
|
+
// row_hash safe: a row derived from a stale tip collides instead of forking
|
|
314
|
+
// the chain or persisting a stale balance_after. The overdraft / underflow
|
|
315
|
+
// gate stays INSIDE the statement (correlated subquery against the live tip,
|
|
316
|
+
// never an app-side check) so a refusal is a zero-row no-op. Returns one of
|
|
317
|
+
// { written }, { refused } (guard said no), { collided } (tip moved — retry).
|
|
318
|
+
async function _attemptChainedWrite(customerId, d) {
|
|
319
|
+
var id = b.uuid.v7();
|
|
320
|
+
var rowHash = _computeRowHash(d.prevHash, {
|
|
321
|
+
id: id,
|
|
322
|
+
customer_id: customerId,
|
|
323
|
+
transaction_type: d.type,
|
|
324
|
+
points: d.points,
|
|
325
|
+
source: d.source,
|
|
326
|
+
notes: d.notes,
|
|
327
|
+
balance_after_points: d.balanceAfter,
|
|
328
|
+
lifetime_after_points: d.lifetimeAfter,
|
|
329
|
+
tier_after: d.tierAfter,
|
|
330
|
+
occurred_at: d.ts,
|
|
331
|
+
});
|
|
332
|
+
var balSub =
|
|
333
|
+
"COALESCE((SELECT balance_after_points FROM loyalty_transactions " +
|
|
334
|
+
"WHERE customer_id = ?2 ORDER BY occurred_at DESC, id DESC LIMIT 1), 0)";
|
|
335
|
+
try {
|
|
336
|
+
var res = await query(
|
|
337
|
+
"INSERT INTO loyalty_transactions " +
|
|
338
|
+
"(id, customer_id, transaction_type, points, source, order_id, notes, occurred_at, " +
|
|
339
|
+
"balance_after_points, lifetime_after_points, tier_after, prev_hash, row_hash) " +
|
|
340
|
+
"SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13 " +
|
|
341
|
+
"WHERE " + (d.guard ? d.guard.replace(/__BAL__/g, balSub) : "1"),
|
|
342
|
+
[id, customerId, d.type, d.points, d.source, d.orderId, d.notes, d.ts,
|
|
343
|
+
d.balanceAfter, d.lifetimeAfter, d.tierAfter, d.prevHash, rowHash],
|
|
344
|
+
);
|
|
345
|
+
if (Number(res.rowCount || 0) === 0) return { refused: true };
|
|
346
|
+
return { written: { id: id, points: d.points, balance: d.balanceAfter,
|
|
347
|
+
lifetime: d.lifetimeAfter, tier: d.tierAfter, occurred_at: d.ts } };
|
|
348
|
+
} catch (e) {
|
|
349
|
+
// State-agnostic collision detection — never error-message matching (the
|
|
350
|
+
// prod D1 bridge redacts the SQLite UNIQUE text to a generic HTTP 500).
|
|
351
|
+
// If the tip advanced past the parent we tried to chain off, a competing
|
|
352
|
+
// write claimed our slot — retry; otherwise the insert failed for another
|
|
353
|
+
// reason — re-throw.
|
|
354
|
+
var after = await _readLatest(customerId);
|
|
355
|
+
if (after.row_hash !== d.prevHash) return { collided: true };
|
|
356
|
+
throw e;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Run a chained write with bounded tip-contention retries. `derive(tip)` maps
|
|
361
|
+
// the freshly-read tip to the concrete row fields; each fence collision
|
|
362
|
+
// re-reads + re-derives so the values that land are always consistent with
|
|
363
|
+
// the parent they chain off. Returns the written row, or { refused: true }
|
|
364
|
+
// when the in-statement guard rejected it against the live tip.
|
|
365
|
+
async function _writeChained(customerId, derive) {
|
|
366
|
+
for (var attempt = 0; attempt < CHAIN_WRITE_ATTEMPTS; attempt += 1) {
|
|
367
|
+
var latest = await _readLatest(customerId);
|
|
368
|
+
var d = derive(latest);
|
|
369
|
+
d.prevHash = latest.row_hash;
|
|
370
|
+
var r = await _attemptChainedWrite(customerId, d);
|
|
371
|
+
if (r.collided) continue;
|
|
372
|
+
if (r.refused) return { refused: true };
|
|
373
|
+
return r.written;
|
|
374
|
+
}
|
|
375
|
+
var contention = new Error("loyalty: persistent chain-tip contention — retry the write");
|
|
376
|
+
contention.code = "LOYALTY_CONTENTION";
|
|
377
|
+
throw contention;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Best-effort refresh of the loyalty_accounts mirror from a freshly-written
|
|
381
|
+
// tip. The mirror is NOT authoritative and NOT on any mutation's decision
|
|
382
|
+
// path — balance() and every guard read the chain tip; the mirror exists only
|
|
383
|
+
// for the cross-customer tierLeaderboard and the operator tier_expires_at
|
|
384
|
+
// time-bound. Runs OUTSIDE the atomicity boundary: a crash here leaves money
|
|
385
|
+
// exactly-once correct and the next advancing write re-syncs it.
|
|
386
|
+
async function _refreshMirror(customerId, w, ts) {
|
|
387
|
+
await query(
|
|
388
|
+
"UPDATE loyalty_accounts SET balance_points = ?1, lifetime_points = ?2, " +
|
|
389
|
+
"tier = ?3, updated_at = ?4 WHERE customer_id = ?5",
|
|
390
|
+
[w.balance, w.lifetime, w.tier, ts, customerId],
|
|
391
|
+
);
|
|
248
392
|
}
|
|
249
393
|
|
|
250
394
|
return {
|
|
@@ -277,29 +421,31 @@ function create(opts) {
|
|
|
277
421
|
var orderId = input.order_id != null ? _uuid(input.order_id, "order_id") : null;
|
|
278
422
|
var notes = _notes(input.notes);
|
|
279
423
|
|
|
280
|
-
var
|
|
281
|
-
await _ensureAccountRow(customerId,
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
var
|
|
288
|
-
await
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
424
|
+
var requested = _now();
|
|
425
|
+
await _ensureAccountRow(customerId, requested);
|
|
426
|
+
// Balance + lifetime + tier all ride the SAME guarded chained INSERT off
|
|
427
|
+
// the freshly-read tip — there is no stored-column second statement to
|
|
428
|
+
// lose. earn advances lifetime and recomputes the tier off the new
|
|
429
|
+
// lifetime; `tier_changed` compares the chain's before/after tiers (both
|
|
430
|
+
// from the tip, no separate snapshot read).
|
|
431
|
+
var tierBefore;
|
|
432
|
+
var w = await _writeChained(customerId, function (latest) {
|
|
433
|
+
tierBefore = latest.tier;
|
|
434
|
+
var lifeAfter = latest.lifetime + points;
|
|
435
|
+
return {
|
|
436
|
+
type: "earn", points: points, source: source, orderId: orderId, notes: notes,
|
|
437
|
+
balanceAfter: latest.balance + points,
|
|
438
|
+
lifetimeAfter: lifeAfter,
|
|
439
|
+
tierAfter: computeTier(lifeAfter),
|
|
440
|
+
ts: _resolveOccurredAt(requested, latest.occurred_at),
|
|
441
|
+
};
|
|
442
|
+
});
|
|
443
|
+
await _refreshMirror(customerId, w, requested);
|
|
298
444
|
return {
|
|
299
|
-
balance:
|
|
300
|
-
lifetime:
|
|
301
|
-
tier:
|
|
302
|
-
tier_changed:
|
|
445
|
+
balance: w.balance,
|
|
446
|
+
lifetime: w.lifetime,
|
|
447
|
+
tier: w.tier,
|
|
448
|
+
tier_changed: w.tier !== tierBefore,
|
|
303
449
|
};
|
|
304
450
|
},
|
|
305
451
|
|
|
@@ -312,43 +458,41 @@ function create(opts) {
|
|
|
312
458
|
var orderId = input.order_id != null ? _uuid(input.order_id, "order_id") : null;
|
|
313
459
|
var notes = _notes(input.notes);
|
|
314
460
|
|
|
315
|
-
var
|
|
316
|
-
await _ensureAccountRow(customerId,
|
|
317
|
-
|
|
318
|
-
|
|
461
|
+
var requested = _now();
|
|
462
|
+
await _ensureAccountRow(customerId, requested);
|
|
463
|
+
// The overdraft refusal lives INSIDE the guarded INSERT (balSub + points
|
|
464
|
+
// >= 0, where points is the negative burn delta) — never an app-side
|
|
465
|
+
// pre-check — so two concurrent redemptions can't double-spend off a
|
|
466
|
+
// stale snapshot: the chain-parent fence serializes them and the loser
|
|
467
|
+
// re-reads the winner's smaller balance and is refused. Lifetime is not
|
|
468
|
+
// affected; redemption spends from the running balance only.
|
|
469
|
+
var w = await _writeChained(customerId, function (latest) {
|
|
470
|
+
return {
|
|
471
|
+
type: "redeem", points: -points, source: "redeem", orderId: orderId, notes: notes,
|
|
472
|
+
balanceAfter: latest.balance - points,
|
|
473
|
+
lifetimeAfter: latest.lifetime,
|
|
474
|
+
tierAfter: latest.tier,
|
|
475
|
+
ts: _resolveOccurredAt(requested, latest.occurred_at),
|
|
476
|
+
guard: "__BAL__ + ?4 >= 0",
|
|
477
|
+
};
|
|
478
|
+
});
|
|
479
|
+
if (w.refused) {
|
|
319
480
|
var ins = new Error("loyalty.redeem: insufficient balance");
|
|
320
481
|
ins.code = "LOYALTY_INSUFFICIENT_BALANCE";
|
|
321
482
|
throw ins;
|
|
322
483
|
}
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
// two concurrent redemptions can't double-spend. Lifetime is not
|
|
326
|
-
// affected — redemption spends from the running balance only.
|
|
327
|
-
var dec = await query(
|
|
328
|
-
"UPDATE loyalty_accounts SET balance_points = balance_points - ?1, " +
|
|
329
|
-
"updated_at = ?2 WHERE customer_id = ?3 AND balance_points >= ?1",
|
|
330
|
-
[points, ts, customerId],
|
|
331
|
-
);
|
|
332
|
-
if (!b.sql.casWon(dec).won) {
|
|
333
|
-
var raced = new Error("loyalty.redeem: insufficient balance");
|
|
334
|
-
raced.code = "LOYALTY_INSUFFICIENT_BALANCE";
|
|
335
|
-
throw raced;
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
var txId = await _writeTx(customerId, "redeem", -points, "redeem", orderId, notes, ts);
|
|
339
|
-
|
|
340
|
-
var after = await _readAccount(customerId);
|
|
484
|
+
await _refreshMirror(customerId, w, requested);
|
|
485
|
+
var acct = await _readAccount(customerId);
|
|
341
486
|
return {
|
|
342
|
-
balance:
|
|
343
|
-
lifetime:
|
|
344
|
-
tier:
|
|
345
|
-
tier_expires_at:
|
|
346
|
-
// Ledger row id of this burn — checkout debits points BEFORE its
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
|
|
351
|
-
tx_id: txId,
|
|
487
|
+
balance: w.balance,
|
|
488
|
+
lifetime: w.lifetime,
|
|
489
|
+
tier: w.tier,
|
|
490
|
+
tier_expires_at: acct ? acct.tier_expires_at : null,
|
|
491
|
+
// Ledger row id of this burn — checkout debits points BEFORE its order
|
|
492
|
+
// exists (the debit is the double-spend gate) and links the row to the
|
|
493
|
+
// order afterwards via linkRedemptionToOrder, or compensates via
|
|
494
|
+
// reverseRedemptionById when the checkout dies before the order exists.
|
|
495
|
+
tx_id: w.id,
|
|
352
496
|
};
|
|
353
497
|
},
|
|
354
498
|
|
|
@@ -396,13 +540,22 @@ function create(opts) {
|
|
|
396
540
|
);
|
|
397
541
|
if (!b.sql.casWon(claim).won) return { restored_points: 0 }; // lost the claim
|
|
398
542
|
await _ensureAccountRow(row.customer_id, ts);
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
await
|
|
405
|
-
|
|
543
|
+
// Forward-only credit-back: ONE chained +spent row off the live tip
|
|
544
|
+
// (lifetime untouched). The restored_points claim above is the
|
|
545
|
+
// serialization point, so a double-fire wins no claim and appends
|
|
546
|
+
// nothing — exactly-once. There is no revert anywhere, so the
|
|
547
|
+
// two-statement double-credit-on-revert hazard is structurally gone.
|
|
548
|
+
var w = await _writeChained(row.customer_id, function (latest) {
|
|
549
|
+
return {
|
|
550
|
+
type: "redeem", points: spent, source: "redeem-reversal", orderId: null,
|
|
551
|
+
notes: "restored ref=tx:" + row.id,
|
|
552
|
+
balanceAfter: latest.balance + spent,
|
|
553
|
+
lifetimeAfter: latest.lifetime,
|
|
554
|
+
tierAfter: latest.tier,
|
|
555
|
+
ts: _resolveOccurredAt(ts, latest.occurred_at),
|
|
556
|
+
};
|
|
557
|
+
});
|
|
558
|
+
await _refreshMirror(row.customer_id, w, ts);
|
|
406
559
|
return { restored_points: spent };
|
|
407
560
|
},
|
|
408
561
|
|
|
@@ -475,18 +628,24 @@ function create(opts) {
|
|
|
475
628
|
);
|
|
476
629
|
if (!b.sql.casWon(claim).won) continue; // lost the claim
|
|
477
630
|
await _ensureAccountRow(row.customer_id, ts);
|
|
478
|
-
//
|
|
479
|
-
// lifetime
|
|
480
|
-
//
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
)
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
631
|
+
// Forward-only credit-back: ONE chained +delta row off the live tip,
|
|
632
|
+
// BALANCE only (lifetime untouched — a redeem never moved it, so the
|
|
633
|
+
// restore mustn't either). The restored_points claim above is the
|
|
634
|
+
// serialization point; there is no revert, so no double-credit hazard.
|
|
635
|
+
// `source='redeem-reversal'` distinguishes it from the original burn so
|
|
636
|
+
// the scan above never re-reads it. _writeChained is awaited before the
|
|
637
|
+
// loop advances, so `delta`/`oid`/`ts` are stable inside the derive.
|
|
638
|
+
var wRestore = await _writeChained(row.customer_id, function (latest) {
|
|
639
|
+
return {
|
|
640
|
+
type: "redeem", points: delta, source: "redeem-reversal", orderId: oid,
|
|
641
|
+
notes: "restored ref=order:" + oid,
|
|
642
|
+
balanceAfter: latest.balance + delta,
|
|
643
|
+
lifetimeAfter: latest.lifetime,
|
|
644
|
+
tierAfter: latest.tier,
|
|
645
|
+
ts: _resolveOccurredAt(ts, latest.occurred_at),
|
|
646
|
+
};
|
|
647
|
+
});
|
|
648
|
+
await _refreshMirror(row.customer_id, wRestore, ts);
|
|
490
649
|
restoredTotal += delta;
|
|
491
650
|
}
|
|
492
651
|
return { restored_points: restoredTotal };
|
|
@@ -501,47 +660,38 @@ function create(opts) {
|
|
|
501
660
|
var source = _source(input.source);
|
|
502
661
|
var notes = _notes(input.notes);
|
|
503
662
|
|
|
504
|
-
var
|
|
505
|
-
await _ensureAccountRow(customerId,
|
|
506
|
-
//
|
|
507
|
-
//
|
|
508
|
-
//
|
|
509
|
-
//
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
// Positive adjustments also increment lifetime — operators
|
|
513
|
-
// crediting a customer for a service recovery should see that
|
|
514
|
-
// credit count toward tier. Negative adjustments do NOT
|
|
515
|
-
// decrement lifetime (otherwise a clawback could downgrade tier
|
|
516
|
-
// retroactively, which is a customer-facing surprise). The
|
|
517
|
-
// lifetime delta is therefore the positive part of `delta`.
|
|
663
|
+
var requested = _now();
|
|
664
|
+
await _ensureAccountRow(customerId, requested);
|
|
665
|
+
// Positive adjustments increment lifetime (operator goodwill counts
|
|
666
|
+
// toward tier) and recompute the tier; negative adjustments do NOT move
|
|
667
|
+
// lifetime (no retroactive tier downgrade) and are guarded so the balance
|
|
668
|
+
// can't go below zero (balSub + delta >= 0, delta negative) — the loser
|
|
669
|
+
// of a concurrent over-draw re-reads the live balance and is refused.
|
|
518
670
|
var lifetimeDelta = delta > 0 ? delta : 0;
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
);
|
|
532
|
-
if (
|
|
671
|
+
var tierBefore;
|
|
672
|
+
var w = await _writeChained(customerId, function (latest) {
|
|
673
|
+
tierBefore = latest.tier;
|
|
674
|
+
var lifeAfter = latest.lifetime + lifetimeDelta;
|
|
675
|
+
return {
|
|
676
|
+
type: "adjust", points: delta, source: source, orderId: null, notes: notes,
|
|
677
|
+
balanceAfter: latest.balance + delta,
|
|
678
|
+
lifetimeAfter: lifeAfter,
|
|
679
|
+
tierAfter: delta > 0 ? computeTier(lifeAfter) : latest.tier,
|
|
680
|
+
ts: _resolveOccurredAt(requested, latest.occurred_at),
|
|
681
|
+
guard: delta < 0 ? "__BAL__ + ?4 >= 0" : undefined,
|
|
682
|
+
};
|
|
683
|
+
});
|
|
684
|
+
if (w.refused) {
|
|
533
685
|
var ins = new Error("loyalty.adjust: adjustment would underflow balance");
|
|
534
686
|
ins.code = "LOYALTY_INSUFFICIENT_BALANCE";
|
|
535
687
|
throw ins;
|
|
536
688
|
}
|
|
537
|
-
await
|
|
538
|
-
|
|
539
|
-
var after = await _readAccount(customerId);
|
|
689
|
+
await _refreshMirror(customerId, w, requested);
|
|
540
690
|
return {
|
|
541
|
-
balance:
|
|
542
|
-
lifetime:
|
|
543
|
-
tier:
|
|
544
|
-
tier_changed:
|
|
691
|
+
balance: w.balance,
|
|
692
|
+
lifetime: w.lifetime,
|
|
693
|
+
tier: w.tier,
|
|
694
|
+
tier_changed: w.tier !== tierBefore,
|
|
545
695
|
};
|
|
546
696
|
},
|
|
547
697
|
|
|
@@ -564,21 +714,22 @@ function create(opts) {
|
|
|
564
714
|
var source = _source(input.source);
|
|
565
715
|
var notes = _notes(input.notes);
|
|
566
716
|
|
|
567
|
-
var
|
|
568
|
-
await _ensureAccountRow(customerId,
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
717
|
+
var requested = _now();
|
|
718
|
+
await _ensureAccountRow(customerId, requested);
|
|
719
|
+
// Forward-only +points chained row, BALANCE only — never moves lifetime
|
|
720
|
+
// and never recomputes tier (giving redeemed points back must not inflate
|
|
721
|
+
// the tier-driving total above what was actually earned).
|
|
722
|
+
var w = await _writeChained(customerId, function (latest) {
|
|
723
|
+
return {
|
|
724
|
+
type: "adjust", points: points, source: source, orderId: null, notes: notes,
|
|
725
|
+
balanceAfter: latest.balance + points,
|
|
726
|
+
lifetimeAfter: latest.lifetime,
|
|
727
|
+
tierAfter: latest.tier,
|
|
728
|
+
ts: _resolveOccurredAt(requested, latest.occurred_at),
|
|
729
|
+
};
|
|
730
|
+
});
|
|
731
|
+
await _refreshMirror(customerId, w, requested);
|
|
732
|
+
return { balance: w.balance, lifetime: w.lifetime, tier: w.tier };
|
|
582
733
|
},
|
|
583
734
|
|
|
584
735
|
expire: async function (input) {
|
|
@@ -592,55 +743,49 @@ function create(opts) {
|
|
|
592
743
|
}
|
|
593
744
|
var reason = _source(input.reason);
|
|
594
745
|
|
|
595
|
-
var
|
|
596
|
-
await _ensureAccountRow(customerId,
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
//
|
|
600
|
-
//
|
|
601
|
-
//
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
var toExpire = points > before.balance_points ? before.balance_points : points;
|
|
605
|
-
if (toExpire === 0) {
|
|
606
|
-
// No-op write the ledger row anyway so the audit trail
|
|
607
|
-
// records that the operator ran the sweep and found nothing.
|
|
608
|
-
await _writeTx(customerId, "expire", 0, reason, null, "", ts);
|
|
746
|
+
var requested = _now();
|
|
747
|
+
await _ensureAccountRow(customerId, requested);
|
|
748
|
+
// Cap at the LIVE balance on every attempt (an interim redemption may
|
|
749
|
+
// have shrunk it) — expire only what's there, never refusing. A zero-cap
|
|
750
|
+
// (empty balance) still writes a points:0 breadcrumb so the audit trail
|
|
751
|
+
// records the sweep ran. Lifetime is untouched. The chained row carries
|
|
752
|
+
// the new running balance, so there is no stored-column second statement.
|
|
753
|
+
var w = await _writeChained(customerId, function (latest) {
|
|
754
|
+
var toExpire = points > latest.balance ? latest.balance : points;
|
|
609
755
|
return {
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
756
|
+
type: "expire", points: -toExpire, source: reason, orderId: null, notes: "",
|
|
757
|
+
balanceAfter: latest.balance - toExpire,
|
|
758
|
+
lifetimeAfter: latest.lifetime,
|
|
759
|
+
tierAfter: latest.tier,
|
|
760
|
+
ts: _resolveOccurredAt(requested, latest.occurred_at),
|
|
761
|
+
// toExpire is capped at the same tip so the debit never underflows;
|
|
762
|
+
// the guard is belt-and-braces against a racing debit (which would
|
|
763
|
+
// instead collide on the fence and re-cap on retry).
|
|
764
|
+
guard: "__BAL__ + ?4 >= 0",
|
|
614
765
|
};
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
await query(
|
|
618
|
-
"UPDATE loyalty_accounts SET balance_points = balance_points - ?1, " +
|
|
619
|
-
"updated_at = ?2 WHERE customer_id = ?3 AND balance_points >= ?1",
|
|
620
|
-
[toExpire, ts, customerId],
|
|
621
|
-
);
|
|
622
|
-
await _writeTx(customerId, "expire", -toExpire, reason, null, "", ts);
|
|
623
|
-
|
|
624
|
-
var after = await _readAccount(customerId);
|
|
766
|
+
});
|
|
767
|
+
await _refreshMirror(customerId, w, requested);
|
|
625
768
|
return {
|
|
626
|
-
balance:
|
|
627
|
-
lifetime:
|
|
628
|
-
tier:
|
|
629
|
-
expired:
|
|
769
|
+
balance: w.balance,
|
|
770
|
+
lifetime: w.lifetime,
|
|
771
|
+
tier: w.tier,
|
|
772
|
+
expired: -w.points,
|
|
630
773
|
};
|
|
631
774
|
},
|
|
632
775
|
|
|
633
776
|
balance: async function (customerId) {
|
|
634
777
|
_uuid(customerId, "customer_id");
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
778
|
+
// Authoritative balance/lifetime/tier come from the chain tip (O(1) on
|
|
779
|
+
// the running index), NOT the advisory loyalty_accounts mirror. Only
|
|
780
|
+
// tier_expires_at — an operator time-bound the ledger doesn't carry — is
|
|
781
|
+
// read from the mirror.
|
|
782
|
+
var tip = await _readLatest(customerId);
|
|
783
|
+
var acct = await _readAccount(customerId);
|
|
639
784
|
return {
|
|
640
|
-
balance:
|
|
641
|
-
lifetime:
|
|
642
|
-
tier:
|
|
643
|
-
tier_expires_at:
|
|
785
|
+
balance: tip.balance,
|
|
786
|
+
lifetime: tip.lifetime,
|
|
787
|
+
tier: tip.tier,
|
|
788
|
+
tier_expires_at: acct ? acct.tier_expires_at : null,
|
|
644
789
|
};
|
|
645
790
|
},
|
|
646
791
|
|
|
@@ -652,8 +797,14 @@ function create(opts) {
|
|
|
652
797
|
throw new TypeError("loyalty.history: limit must be an integer in [1, 500]");
|
|
653
798
|
}
|
|
654
799
|
var cursor = opts2.cursor;
|
|
800
|
+
// Exclude the per-customer chain-genesis anchor (the migration backfill
|
|
801
|
+
// row, id 'genesis-<customer_id>', points 0) — it carries the running-
|
|
802
|
+
// balance snapshot for the chain, not a real customer transaction. A real
|
|
803
|
+
// uuid.v7 id never starts with 'genesis-', so this excludes only the
|
|
804
|
+
// anchor (filtering by id-prefix, not by source: an operator source could
|
|
805
|
+
// legitimately be 'chain-genesis').
|
|
655
806
|
var sql = "SELECT id, customer_id, transaction_type, points, source, order_id, notes, occurred_at " +
|
|
656
|
-
"FROM loyalty_transactions WHERE customer_id = ?1";
|
|
807
|
+
"FROM loyalty_transactions WHERE customer_id = ?1 AND id NOT LIKE 'genesis-%'";
|
|
657
808
|
var params = [customerId];
|
|
658
809
|
if (cursor != null) {
|
|
659
810
|
if (typeof cursor !== "number" || !Number.isInteger(cursor) || cursor < 0) {
|
|
@@ -700,6 +851,70 @@ function create(opts) {
|
|
|
700
851
|
var r = await query(sql, params);
|
|
701
852
|
return r.rows;
|
|
702
853
|
},
|
|
854
|
+
|
|
855
|
+
// Walk a customer's running-balance chain and recompute every row_hash to
|
|
856
|
+
// prove no row was edited or re-ordered after the fact (the loyalty leg of
|
|
857
|
+
// the money-ledger tamper-evidence). Mirrors gift-card-ledger.verifyChain:
|
|
858
|
+
// a NULL-row_hash legacy/genesis prefix is skipped until the first hashed
|
|
859
|
+
// row anchors the chain; a populated all-NULL ledger is `unanchored` (the
|
|
860
|
+
// shape a full rewrite produces), not "valid". An optional trusted
|
|
861
|
+
// { count, head } anchor additionally rules out a tail truncation. Verifies
|
|
862
|
+
// over _rowFieldsForHash — the SAME tuple the writer hashes (order_id +
|
|
863
|
+
// restored_points excluded), so a linked redemption or a restore can't
|
|
864
|
+
// false-tamper. O(n); operator-audit use, not hot-path.
|
|
865
|
+
verifyChain: async function (customerId, opts) {
|
|
866
|
+
_uuid(customerId, "customer_id");
|
|
867
|
+
opts = opts || {};
|
|
868
|
+
var anchor = null;
|
|
869
|
+
if (opts.anchor != null) {
|
|
870
|
+
if (typeof opts.anchor !== "object"
|
|
871
|
+
|| typeof opts.anchor.count !== "number" || !Number.isInteger(opts.anchor.count) || opts.anchor.count < 1
|
|
872
|
+
|| typeof opts.anchor.head !== "string" || opts.anchor.head.length !== SHA3_512_HEX_LEN) {
|
|
873
|
+
throw new TypeError("loyalty.verifyChain: anchor must be { count: positive integer, head: " + SHA3_512_HEX_LEN + "-hex-char string }");
|
|
874
|
+
}
|
|
875
|
+
anchor = opts.anchor;
|
|
876
|
+
}
|
|
877
|
+
var rows = (await query(
|
|
878
|
+
"SELECT * FROM loyalty_transactions WHERE customer_id = ?1 ORDER BY occurred_at ASC, id ASC",
|
|
879
|
+
[customerId],
|
|
880
|
+
)).rows;
|
|
881
|
+
var legacyPrefix = 0, anchored = false, prevHash = ZERO_HASH;
|
|
882
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
883
|
+
var row = rows[i];
|
|
884
|
+
if (!anchored && row.row_hash == null) { legacyPrefix += 1; continue; }
|
|
885
|
+
anchored = true;
|
|
886
|
+
var breakBase = { ok: false, rows_verified: i - legacyPrefix, legacy_prefix: legacyPrefix,
|
|
887
|
+
break_at: i, break_row_id: row.id };
|
|
888
|
+
if (row.row_hash == null) return Object.assign(breakBase, { reason: "unhashed row after chain anchor" });
|
|
889
|
+
if (row.prev_hash !== prevHash) {
|
|
890
|
+
return Object.assign(breakBase, { reason: "prev_hash mismatch", expected: prevHash, actual: row.prev_hash });
|
|
891
|
+
}
|
|
892
|
+
var computed = _computeRowHash(prevHash, _rowFieldsForHash(row));
|
|
893
|
+
if (computed !== row.row_hash) {
|
|
894
|
+
return Object.assign(breakBase, { reason: "row_hash mismatch", expected: computed, actual: row.row_hash });
|
|
895
|
+
}
|
|
896
|
+
prevHash = row.row_hash;
|
|
897
|
+
}
|
|
898
|
+
if (!anchored && rows.length > 0) {
|
|
899
|
+
return { ok: false, rows_verified: 0, legacy_prefix: legacyPrefix, break_at: 0,
|
|
900
|
+
break_row_id: rows[0].id, reason: "unanchored chain (no hashed row in a populated ledger)" };
|
|
901
|
+
}
|
|
902
|
+
if (anchor) {
|
|
903
|
+
if (rows.length < anchor.count) {
|
|
904
|
+
return { ok: false, rows_verified: rows.length - legacyPrefix, legacy_prefix: legacyPrefix,
|
|
905
|
+
anchor_checked: true, reason: "row count below anchor (possible tail truncation)",
|
|
906
|
+
expected_count: anchor.count, actual_count: rows.length };
|
|
907
|
+
}
|
|
908
|
+
var anchorRow = rows[anchor.count - 1];
|
|
909
|
+
if (anchorRow.row_hash !== anchor.head) {
|
|
910
|
+
return { ok: false, rows_verified: rows.length - legacyPrefix, legacy_prefix: legacyPrefix,
|
|
911
|
+
anchor_checked: true, reason: "anchor head mismatch (chain replaced below snapshot)",
|
|
912
|
+
expected: anchor.head, actual: anchorRow.row_hash };
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
return { ok: true, rows_verified: rows.length - legacyPrefix, legacy_prefix: legacyPrefix,
|
|
916
|
+
head: prevHash, anchor_checked: !!anchor };
|
|
917
|
+
},
|
|
703
918
|
};
|
|
704
919
|
}
|
|
705
920
|
|
package/package.json
CHANGED