@blamejs/blamejs-shop 0.4.88 → 0.4.90

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 CHANGED
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.4.x
10
10
 
11
+ - v0.4.90 (2026-06-24) — **Credit-account releases and aging now attribute payments to orders correctly, so cancelling a paid order can't erase another order's debt.** Two related accounting fixes for B2B credit accounts, no configuration or schema changes. On a credit account, payments are revolving — they pay down the oldest outstanding charge first across all of a customer's orders. Releasing the hold on a cancelled or returned order previously reduced the balance by that order's full charge without accounting for payments already applied to it, so on an account with more than one order, cancelling an order that a payment had effectively settled drove the balance below what was genuinely owed and silently erased another order's debt from the outstanding total and the aging report. A release now reduces the balance only by the order's still-unpaid amount (its charges minus the payments attributed to it, oldest-first), so a cancelled-but-paid order is a no-op and every other order's genuine debt is preserved. Separately, the aging report was pooling order-scoped releases together with order-agnostic payments into a single oldest-first settlement, so releasing a recent order could settle the oldest charge and report a genuinely past-due balance as current; the report now nets each order's releases against that order's own charges before applying payments oldest-first, so the past-due buckets reflect reality. The outstanding total is unchanged in every case — only the misattribution is corrected. Upgrade to pick it up. **Changed:** *Vendored blamejs framework refreshed to 0.15.20* — The vendored framework moves from 0.15.19 to 0.15.20, with no API or configuration change. It refreshes the bundled Mozilla public-suffix list (used for cookie-scope, same-site, and DMARC/BIMI domain classification), updates the bundled WebAuthn server library to 13.3.2 (improved passkey attestation certificate-path validation), and corrects the vendored-dependency SBOM version metadata so CVE scanners match advisories against the versions actually shipped. **Fixed:** *Releasing a credit-account order caps at its unpaid amount, not its full charge* — creditLimits.releaseHold now reduces the outstanding balance only by the target order's unpaid exposure — its charges and holds, minus its own prior releases, minus the revolving payments attributed to it under an oldest-order-first allocation — capped at the live balance. Previously it reduced the balance by the order's full net charge and ignored payments entirely (payments carry no order reference), so on a multi-order account, cancelling an order that a payment had already settled released credit that was never owed and erased another order's genuine debt from the outstanding total. A cancelled order whose charge has effectively been paid now releases nothing (the request is refused as having no outstanding amount); a genuinely unpaid order still releases its full exposure. The check is a single atomic statement, so it stays correct under concurrent releases and payments. · *Aging report nets order releases against their own order before applying payments* — creditLimits.agingReport no longer pools order-scoped releases with order-agnostic payments into one oldest-first drain. It first nets each order's releases against that order's own charges (a release reverses its own order's charge, never another's), then applies the revolving payment pool oldest-charge-first across what remains. Previously, releasing a recent order could settle the oldest charge in the pooled drain, masking a genuinely past-due balance as current and inverting the aging buckets that drive dunning and collections. The total outstanding is identical to before — only the bucket distribution is corrected — and it uses the same payment-attribution model as the release cap, so a release and the report can never disagree on an order's unpaid amount.
12
+
13
+ - v0.4.89 (2026-06-24) — **Store-credit wallet balances are now tamper-evident, and concurrent gift-card ledger writes retry reliably in production.** The store-credit ledger gains the same per-customer cryptographic hash chain the gift-card ledger already carries, so a balance can no longer be silently altered by a direct database edit. Each wallet row now links to the prior one with a SHA3-512 hash over its contents, and a new verifyChain check recomputes the chain and reports the first row that doesn't link — a denormalized balance snapshot was previously a value, not a proof, so a row edit that inflated a balance or a deletion that dropped a debit passed a balance read undetected. The same change makes the balance computation safe under concurrency through a chain-parent fence and a re-read-and-retry on contention. Separately, a latent reliability bug in the gift-card ledger's concurrent-write retry is fixed: it detected a write collision by matching the database's error text, but the production data-layer redacts that text to a generic error, so a genuine collision between two writes against the same gift card could surface as a failed request instead of retrying — both ledgers now detect a collision by re-reading the chain tip, which is robust regardless of how the error is reported. No configuration changes; the store-credit schema additions apply automatically on upgrade. **Added:** *Store-credit ledger is tamper-evident with a per-customer hash chain* — Every store-credit wallet row now carries prev_hash + row_hash columns forming a per-customer SHA3-512 chain: row_hash = SHA3-512(prev_hash || canonical-json(row fields)), anchored at a genesis sentinel for a wallet's first row. A direct database edit that inflates a balance snapshot, or a deletion that drops a debit, breaks the chain at that row. A new storeCredit.verifyChain(customer_id) walks the wallet and returns the first divergence (or confirms the chain is intact); a wallet whose hash columns have all been cleared reads as unanchored rather than trusting the unverifiable rows. Because deleting a chain's most-recent rows leaves the remaining prefix internally consistent — a truncation an append-only chain can't detect on its own — verifyChain optionally accepts a trusted anchor (a row count and head hash captured from an earlier snapshot) and reports a possible truncation when the chain has fallen below it. The balance and occurred-at values are now computed in the application and protected under concurrency by a chain-parent uniqueness fence — a write derived from a stale view of the wallet collides and is retried against the current tip, so the denormalized balance stays exact under concurrent credits, debits, expirations, and the scheduled expiry sweep. **Fixed:** *Concurrent gift-card and store-credit ledger writes retry reliably in production* — The ledger retries a write that loses a race against a concurrent write to the same entity. It previously recognized that collision by matching the database engine's constraint-violation text — but the production data-layer redacts that text to a generic error, so in production a genuine collision was not recognized and the write surfaced as a failed request instead of retrying (the path only worked under the in-memory test engine, where the full error text survives). Both the gift-card ledger and the new store-credit chain now detect a collision by re-reading the chain tip after a failed write: if the tip has advanced past the row the write tried to extend, a competing write claimed the slot and the write is retried; otherwise the original error is surfaced. The retry is now robust regardless of how the underlying error is reported.
14
+
11
15
  - v0.4.88 (2026-06-24) — **Refreshes the vendored framework to 0.15.19 and makes the vendored-dependency SBOM a committed, drift-guarded artifact.** Two supply-chain changes, no application configuration changes. The vendored blamejs framework moves from 0.15.16 to 0.15.19, picking up a set of upstream correctness and confidentiality fixes that ride inside the primitives the store already composes: a rejection on an established encrypted-API session is now returned inside the encrypted session envelope instead of disclosing which check failed in cleartext; stapled-OCSP response-freshness validation is restored; the DPoP proof's request-URI reconstruction now peer-gates forwarded scheme/host headers; the bundled public-suffix list is refreshed; and a family of credential/certificate verifiers continues to fail closed on an unparseable timestamp. Separately, the vendored-dependency SBOM (sbom.vendored.cdx.json) is now committed to the repository and mechanically projected from the vendored manifest, so its component versions can never silently fall behind what actually ships under lib/vendor/: a build gate fails on any drift, and the published, signed SBOM is the same verified file rather than one regenerated at publish time. Upgrade to pick both up. **Added:** *Vendored-dependency SBOM is a committed, drift-guarded artifact* — sbom.vendored.cdx.json — the CycloneDX 1.6 SBOM describing what ships under lib/vendor/ — is now committed to the repository and generated as a deterministic projection of lib/vendor/MANIFEST.json (the source of truth for the vendored version, license, and source). The vendor refresh regenerates it automatically, and a build gate fails if the committed SBOM drifts from the vendored manifest, so its component versions can never silently fall behind the vendored tree — the same protection the changelog, the release-notes rollup, and the asset-manifest already carry. The signed SBOM published as a release asset is now this same verified file rather than one regenerated at publish time, so the artifact downstream scanners consume is byte-reproducible from the committed source. **Changed:** *Vendored blamejs framework refreshed to 0.15.19* — The vendored framework moves from 0.15.16 to 0.15.19. The changes are upstream correctness and confidentiality fixes that ride inside the primitives the store already uses, with no API or configuration change: a rejection on an established encrypted-API session is now encrypted inside the session envelope rather than returned as a cleartext reason that disclosed which check failed; stapled-OCSP response-freshness enforcement (RFC 6960) is restored after a parsing regression had it reject every response; the DPoP htu reconstruction and the new request-host resolver only honor forwarded scheme/host headers from a declared trusted proxy; the bundled Mozilla public-suffix list (used for cookie-scope and same-site decisions) is refreshed to current upstream; and the credential/certificate validity-window verifiers continue to refuse a present-but-unparseable timestamp instead of skipping the check.
12
16
 
13
17
  - v0.4.87 (2026-06-22) — **Adding a bundle now charges the advertised bundle price even when a member item is already in the cart.** Fixes a pricing bug when adding a product bundle to a cart that already contains one of the bundle's items. Adding a bundle allocates the discounted bundle price across its members and writes each member to the cart at its allocated per-unit price. But when a member variant was already in the cart on its own, the add merged into the existing line and kept that line's standalone list price, so the cart subtotal came out above the advertised bundle price — the shopper was quoted the discounted bundle but charged closer to the full sum of parts. Adding a bundle now adds the bundle's units at the bundle price while preserving any units already in the cart: a fresh bundle add realizes exactly the advertised bundle price, and a bundle added on top of a standalone item keeps that item and adds the bundle at its discounted price (rather than dropping the item or extending the discount to it). No configuration changes; upgrade to pick it up. **Fixed:** *Bundle add honors the bundle price without disturbing existing cart units* — Adding a bundle now adds each member's units at the bundle's allocated per-unit price. On a fresh line the realized subtotal is exactly the advertised bundle price. When the variant is already in the cart, its existing units are kept at their value and the bundle's units are added at the bundle price, so the shopper is neither overcharged (the previous behavior charged the standalone list price for the bundle unit) nor has their existing quantity dropped. Adding the same bundle twice adds two bundles' worth of units. Direct add-to-cart of a standalone item is unchanged.
package/SECURITY.md CHANGED
@@ -319,15 +319,20 @@ 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 ledger is hash-chained, fork-proof, and verifiable.**
323
- Every ledger entry — credits, debits, and expirations alike — links to
324
- its predecessor through a per-card SHA3-512 chain, so a direct edit or
325
- deletion of a row breaks the linkage. A uniqueness fence (one child per
326
- chain tip) makes concurrent writes serialize rather than fork the
327
- chain, and `giftCardLedger.verifyChain(cardId)` recomputes a card's
328
- chain end to end and reports the first divergence — run it whenever a
329
- card's balance is disputed. The overdraft refusal stays inside the same
330
- guarded insert, so the integrity device never weakens the balance gate.
322
+ - **The gift-card and store-credit ledgers are hash-chained, fork-proof,
323
+ and verifiable.** Every ledger entry — credits, debits, and
324
+ expirations alike — links to its predecessor through a SHA3-512 chain,
325
+ keyed per card (gift cards) or per customer (store credit), so editing a
326
+ row or deleting one from the middle breaks the linkage. A uniqueness
327
+ fence (one child per chain tip) makes concurrent writes serialize rather
328
+ than fork the chain. `giftCardLedger.verifyChain(cardId)` /
329
+ `storeCredit.verifyChain(customerId)` recompute a ledger's chain end to
330
+ end and report the first divergence run them whenever a balance is
331
+ disputed; pass a trusted anchor (a row count + head hash from an earlier
332
+ snapshot) to also catch a truncation that deletes the most-recent rows,
333
+ which an append-only chain can't detect on its own. The overdraft
334
+ refusal stays inside the same guarded insert, so the integrity device
335
+ never weakens the balance gate.
331
336
  - **Privacy exports hold the whole record; erasure states a basis per
332
337
  domain.** A subject-access export walks every table that keys a row
333
338
  by the customer — identity, orders, subscriptions, addresses, saved
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.88",
2
+ "version": "0.4.90",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-imfe0otYErcB8rr2h6KLSGTtStirysptpXETSPY4zLv3bZoIT75Lo1dOvkOav+xL",
@@ -256,6 +256,44 @@ function create(opts) {
256
256
  var _BAL_SUB = "COALESCE((SELECT balance_after_minor FROM credit_transactions " +
257
257
  "WHERE customer_id = ?2 ORDER BY occurred_at DESC LIMIT 1), 0)";
258
258
 
259
+ // The UNPAID exposure of ONE order (?4) — what releaseHold may release
260
+ // without consuming balance that belongs to OTHER orders' genuine unpaid
261
+ // charges. Computed PER CHARGE/HOLD ROW (not collapsed per order), row-
262
+ // for-row identical to the agingReport recompute, so the two surfaces can
263
+ // never disagree even when an order carries more than one charge/hold row
264
+ // (a hold plus a capture, or an injected duplicate). The attribution
265
+ // model: a RELEASE nets only against its OWN order's rows, oldest-first;
266
+ // a PAYMENT is revolving (order_id=NULL) and drains the post-release row
267
+ // remainders global oldest-first. So each row's unpaid = its amount,
268
+ // minus the release applied to it (its own order's release filling that
269
+ // order's rows oldest-first), minus the FIFO slice of the payment pool
270
+ // that overlaps the row's position [cum_rem_before, cum_rem_before+rem) on
271
+ // the global oldest-first number line. An order's unpaid is the SUM of its
272
+ // rows' unpaid. Expressed as one correlated subquery (nested CTEs + window
273
+ // functions) so it composes inside the SAME single-statement atomic write
274
+ // as _BAL_SUB: a concurrent payment/release commits first and this
275
+ // subquery re-evaluates against it, so race-safety is preserved with no
276
+ // app-side read-then-write. References ?2 (customer_id) + ?4 (order_id),
277
+ // both bound by _appendTxnAtomic.
278
+ var _UNPAID_SUB =
279
+ "COALESCE((WITH " +
280
+ "pay AS (SELECT COALESCE(SUM(amount_minor), 0) AS p " +
281
+ " FROM credit_transactions WHERE customer_id = ?2 AND kind = 'payment'), " +
282
+ "rel AS (SELECT order_id, COALESCE(SUM(amount_minor), 0) AS r " +
283
+ " FROM credit_transactions WHERE customer_id = ?2 AND kind = 'release' GROUP BY order_id), " +
284
+ "rows0 AS (SELECT id, order_id, occurred_at, amount_minor, " +
285
+ " COALESCE(SUM(amount_minor) OVER (PARTITION BY order_id ORDER BY occurred_at, id " +
286
+ " ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING), 0) AS cum_own_before " +
287
+ " FROM credit_transactions WHERE customer_id = ?2 AND kind IN ('charge', 'hold')), " +
288
+ "rel_applied AS (SELECT r0.id, r0.order_id, r0.occurred_at, " +
289
+ " r0.amount_minor - MAX(0, MIN(r0.amount_minor, COALESCE(rel.r, 0) - r0.cum_own_before)) AS rem " +
290
+ " FROM rows0 r0 LEFT JOIN rel ON rel.order_id = r0.order_id), " +
291
+ "rows1 AS (SELECT order_id, rem, " +
292
+ " COALESCE(SUM(rem) OVER (ORDER BY occurred_at, id ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING), 0) AS cum_rem_before " +
293
+ " FROM rel_applied) " +
294
+ "SELECT COALESCE(SUM(rem - MAX(0, MIN(cum_rem_before + rem, (SELECT p FROM pay)) - MAX(cum_rem_before, 0))), 0) " +
295
+ "FROM rows1 WHERE order_id = ?4), 0)";
296
+
259
297
  // Append a ledger transaction ATOMICALLY. The new balance, the
260
298
  // strictly-monotonic occurred_at, and the operation's invariant
261
299
  // (credit limit / sufficient balance / outstanding charge) are all
@@ -616,27 +654,28 @@ function create(opts) {
616
654
  throw closed;
617
655
  }
618
656
 
619
- // Net exposure to this order — charge + hold sums minus prior
620
- // release sums — capped at the live balance, computed and written
621
- // in ONE atomic statement. The racy read-sum-then-write this
622
- // replaced let two concurrent releases (or a release racing a
623
- // payment) both read the same remaining/balance and both write,
624
- // double-releasing the order and corrupting the running balance.
625
- // The guard refuses when nothing is outstanding (never charged /
626
- // already fully released) or the balance is drained, so a release
627
- // can't drive the ledger negative. Capping at _BAL_SUB stays
628
- // defensive against operator-induced ledger drift.
629
- var remainingSub = "COALESCE((SELECT SUM(CASE " +
630
- "WHEN kind IN ('charge', 'hold') THEN amount_minor " +
631
- "WHEN kind = 'release' THEN -amount_minor ELSE 0 END) " +
632
- "FROM credit_transactions WHERE customer_id = ?2 AND order_id = ?4), 0)";
633
- var toReleaseExpr = "CASE WHEN " + remainingSub + " < " + _BAL_SUB +
634
- " THEN " + remainingSub + " ELSE " + _BAL_SUB + " END";
657
+ // UNPAID exposure of this order — its net charge minus the FIFO
658
+ // payments attributed to it (_UNPAID_SUB) — capped at the live
659
+ // balance, computed and written in ONE atomic statement. Subtracting
660
+ // the payment attribution is the fix for the multi-order over-release:
661
+ // the cap used to be the order's GROSS net charge and never subtracted
662
+ // payments (payments are order-agnostic, order_id=NULL), so releasing a
663
+ // conceptually-paid order in a multi-order account consumed balance
664
+ // that belonged to OTHER orders' genuine unpaid charges and erased
665
+ // their debt. Keeping it a single correlated-subquery statement
666
+ // preserves the original race-safety (a concurrent release/payment
667
+ // commits first and _UNPAID_SUB / _BAL_SUB re-evaluate against it). The
668
+ // guard refuses when the order is fully paid / released / never
669
+ // charged, or the balance is drained, so a release can't drive the
670
+ // ledger negative. Capping at _BAL_SUB stays defensive against
671
+ // operator-induced ledger drift.
672
+ var toReleaseExpr = "CASE WHEN " + _UNPAID_SUB + " < " + _BAL_SUB +
673
+ " THEN " + _UNPAID_SUB + " ELSE " + _BAL_SUB + " END";
635
674
  var written = await _appendTxnAtomic(
636
675
  customerId, "release", orderId, null, requested,
637
676
  toReleaseExpr, // amount actually released
638
677
  _BAL_SUB + " - (" + toReleaseExpr + ")", // new balance
639
- remainingSub + " > 0 AND " + _BAL_SUB + " > 0", // nothing outstanding / drained -> refuse
678
+ _UNPAID_SUB + " > 0 AND " + _BAL_SUB + " > 0", // fully paid/released/drained -> refuse
640
679
  [],
641
680
  );
642
681
  if (!written) {
@@ -759,23 +798,80 @@ function create(opts) {
759
798
  }
760
799
  var terms = acct.payment_terms_days;
761
800
 
762
- // Walk all charge/hold rows oldest-first. Apply payments +
763
- // releases FIFO against the oldest outstanding amounts. What
764
- // remains in each charge row after settlement is the
765
- // outstanding slice for that age bucket.
801
+ // Walk all charge/hold rows oldest-first, then settle in two passes
802
+ // that honor what each settlement kind actually reverses:
803
+ //
804
+ // 1. A RELEASE is intrinsically order-scoped — it reverses ITS
805
+ // order's charge — so it nets only against that order's OWN
806
+ // charge slices (oldest-first). Pooling releases with payments
807
+ // into one global FIFO drain (the prior behavior) let a release
808
+ // of a RECENT order settle the OLDEST charge, masking a genuinely
809
+ // past-due balance as current and inverting the aging buckets.
810
+ // 2. A PAYMENT is revolving (order_id=NULL) — it pays down the
811
+ // OLDEST outstanding charge first across ALL orders. It drains
812
+ // AFTER the per-order release netting, over whatever net slices
813
+ // remain.
814
+ //
815
+ // This is the SAME attribution model releaseHold's _UNPAID_SUB cap
816
+ // uses (own-order releases, then global-FIFO payments), so the report
817
+ // and a release can never disagree on an order's unpaid exposure. The
818
+ // total is unchanged (charges − releases − payments); only the bucket
819
+ // distribution is corrected. The (occurred_at, id) ordering matches the
820
+ // ledger's monotonic stamping and _UNPAID_SUB's FIFO key.
766
821
  var chargeRows = (await query(
767
- "SELECT amount_minor, occurred_at, order_id FROM credit_transactions " +
822
+ "SELECT id, amount_minor, occurred_at, order_id FROM credit_transactions " +
768
823
  "WHERE customer_id = ?1 AND kind IN ('charge', 'hold') " +
769
- "ORDER BY occurred_at ASC",
824
+ "ORDER BY occurred_at ASC, id ASC",
770
825
  [customerId],
771
826
  )).rows;
772
827
 
773
- var settleRow = (await query(
828
+ // Per-order release totals — netted against that order's own charges.
829
+ var relRows = (await query(
830
+ "SELECT order_id, COALESCE(SUM(amount_minor), 0) AS rel FROM credit_transactions " +
831
+ "WHERE customer_id = ?1 AND kind = 'release' GROUP BY order_id",
832
+ [customerId],
833
+ )).rows;
834
+ var relByOrder = Object.create(null);
835
+ for (var ri = 0; ri < relRows.length; ri += 1) {
836
+ relByOrder[relRows[ri].order_id] = relRows[ri].rel;
837
+ }
838
+
839
+ // Order-agnostic payment pool — drains FIFO AFTER release netting.
840
+ var paySettleRow = (await query(
774
841
  "SELECT COALESCE(SUM(amount_minor), 0) AS total FROM credit_transactions " +
775
- "WHERE customer_id = ?1 AND kind IN ('payment', 'release')",
842
+ "WHERE customer_id = ?1 AND kind = 'payment'",
776
843
  [customerId],
777
844
  )).rows[0];
778
- var toSettle = settleRow ? settleRow.total : 0;
845
+ var toSettle = paySettleRow ? paySettleRow.total : 0;
846
+
847
+ var slices = [];
848
+ for (var si = 0; si < chargeRows.length; si += 1) {
849
+ slices.push({
850
+ order_id: chargeRows[si].order_id,
851
+ occurred_at: chargeRows[si].occurred_at,
852
+ remaining: chargeRows[si].amount_minor,
853
+ });
854
+ }
855
+ // Pass 1: net each order's releases against its OWN charge slices,
856
+ // oldest-first (slices are already in global oldest-first order).
857
+ for (var p1 = 0; p1 < slices.length; p1 += 1) {
858
+ var sRel = slices[p1];
859
+ var rel = relByOrder[sRel.order_id] || 0;
860
+ if (rel > 0) {
861
+ var rd = rel > sRel.remaining ? sRel.remaining : rel;
862
+ sRel.remaining -= rd;
863
+ relByOrder[sRel.order_id] = rel - rd;
864
+ }
865
+ }
866
+ // Pass 2: drain the order-agnostic payment pool FIFO across the
867
+ // remaining net slices, oldest-first.
868
+ for (var p2 = 0; p2 < slices.length && toSettle > 0; p2 += 1) {
869
+ var sPay = slices[p2];
870
+ if (sPay.remaining <= 0) continue;
871
+ var pd = toSettle > sPay.remaining ? sPay.remaining : toSettle;
872
+ sPay.remaining -= pd;
873
+ toSettle -= pd;
874
+ }
779
875
 
780
876
  var buckets = {};
781
877
  buckets[AGE_BUCKETS.CURRENT] = 0;
@@ -784,22 +880,15 @@ function create(opts) {
784
880
  buckets[AGE_BUCKETS.D90] = 0;
785
881
 
786
882
  var total = 0;
787
- for (var i = 0; i < chargeRows.length; i += 1) {
788
- var row = chargeRows[i];
789
- var remaining = row.amount_minor;
790
- if (toSettle > 0) {
791
- var settled = toSettle > remaining ? remaining : toSettle;
792
- remaining -= settled;
793
- toSettle -= settled;
794
- }
883
+ for (var i = 0; i < slices.length; i += 1) {
884
+ var remaining = slices[i].remaining;
795
885
  if (remaining <= 0) continue;
796
- var ageMs = now - row.occurred_at;
886
+ var ageMs = now - slices[i].occurred_at;
797
887
  var ageDays = Math.floor(ageMs / MS_PER_DAY);
798
- // Bucket by days-past-due so the report reads the same
799
- // regardless of the account's payment terms — a net-15 and
800
- // a net-60 account both report `current` against not-yet-
801
- // due charges, then advance through 30 / 60 / 90+ buckets
802
- // measured from the due date.
888
+ // Bucket by days-past-due so the report reads the same regardless of
889
+ // the account's payment terms — a net-15 and a net-60 account both
890
+ // report `current` against not-yet-due charges, then advance through
891
+ // 30 / 60 / 90+ buckets measured from the due date.
803
892
  var pastDue = ageDays - terms;
804
893
  var bucket;
805
894
  if (pastDue <= 0) bucket = AGE_BUCKETS.CURRENT;
@@ -242,15 +242,14 @@ function create(opts) {
242
242
  }
243
243
 
244
244
  // How many times a writer re-derives the chain tip when the parent fence
245
- // refuses its INSERT (another write landed first). Each retry re-reads
246
- // the tip, so contention resolves in one extra round trip per racing
247
- // writer; five attempts is far beyond any realistic burst.
248
- var CHAIN_WRITE_ATTEMPTS = 5;
249
-
250
- function _isChainFenceCollision(err) {
251
- var msg = (err && err.message) || "";
252
- return /UNIQUE constraint failed/i.test(msg) && /(prev_hash|chain_parent)/i.test(msg);
253
- }
245
+ // refuses its INSERT (another write landed first). Each fence round lets
246
+ // exactly ONE racing writer win, so a burst of N concurrent writes to the
247
+ // SAME card needs up to N rounds — the cap is sized well beyond any
248
+ // realistic same-card fan-in so a legitimate burst is never dropped with
249
+ // GIFT_CARD_LEDGER_CONTENTION. A genuine non-collision insert error
250
+ // re-throws on the first attempt (see _attemptChainedWrite), so a high cap
251
+ // never spins on a real failure.
252
+ var CHAIN_WRITE_ATTEMPTS = 64;
254
253
 
255
254
  // One fenced write attempt against a tip the caller just read. EVERY
256
255
  // event kind writes through here so every row participates in the
@@ -295,7 +294,16 @@ function create(opts) {
295
294
  if (Number(res.rowCount || 0) === 0) return { refused: true };
296
295
  return { written: { id: id, balance_after_minor: d.after, occurred_at: d.ts } };
297
296
  } catch (e) {
298
- if (_isChainFenceCollision(e)) return { collided: true };
297
+ // State-agnostic collision detection never error-message matching:
298
+ // the production D1 service-binding redacts the SQLite "UNIQUE
299
+ // constraint failed" text to a generic "HTTP 500", so a message regex
300
+ // is blind in prod (it would only match under node:sqlite, where the
301
+ // full text survives). Re-read the tip — if it advanced past the
302
+ // parent we tried to chain off, a competing write claimed our slot (a
303
+ // genuine fence collision) and we retry; if the tip is unchanged the
304
+ // insert failed for another reason, so re-throw.
305
+ var after = await _readLatest(giftCardId);
306
+ if (after.row_hash !== latest.row_hash) return { collided: true };
299
307
  throw e;
300
308
  }
301
309
  }
@@ -483,8 +491,26 @@ function create(opts) {
483
491
  // leaves behind) fails: an all-NULL chain is unanchored, not valid.
484
492
  // An empty ledger (zero rows) is the only no-anchor case that passes.
485
493
  // O(n) per card; operator-audit use, not hot-path.
486
- verifyChain: async function (giftCardId) {
494
+ verifyChain: async function (giftCardId, opts) {
487
495
  _uuid(giftCardId, "gift_card_id");
496
+ opts = opts || {};
497
+ // Optional trusted anchor (opts.anchor = { count, head } from a prior
498
+ // snapshot's total_rows + last_hash) — the ONLY way to detect a TAIL
499
+ // TRUNCATION or whole-chain replacement, since an append-only chain
500
+ // stays internally consistent after its tail is removed. The row at the
501
+ // snapshot's position must still carry the snapshot's head hash (it
502
+ // survives later valid appends). Without an anchor, truncation is not
503
+ // ruled out and anchor_checked:false is reported (operator-audit-log
504
+ // closes the same gap with signed checkpoints).
505
+ var anchor = null;
506
+ if (opts.anchor != null) {
507
+ if (typeof opts.anchor !== "object"
508
+ || typeof opts.anchor.count !== "number" || !Number.isInteger(opts.anchor.count) || opts.anchor.count < 1
509
+ || typeof opts.anchor.head !== "string" || opts.anchor.head.length !== SHA3_512_HEX_LEN) {
510
+ throw new TypeError("giftCardLedger.verifyChain: anchor must be { count: positive integer, head: " + SHA3_512_HEX_LEN + "-hex-char string }");
511
+ }
512
+ anchor = opts.anchor;
513
+ }
488
514
  var r = await query(
489
515
  "SELECT * FROM gift_card_ledger WHERE gift_card_id = ?1 " +
490
516
  "ORDER BY occurred_at ASC, id ASC",
@@ -551,11 +577,42 @@ function create(opts) {
551
577
  reason: "unanchored chain (no hashed row in a populated ledger)",
552
578
  };
553
579
  }
580
+ // Internal walk is clean. With a trusted anchor, also rule out a tail
581
+ // truncation below the snapshot: the row at the snapshot's position
582
+ // must still carry the snapshot's head hash, and the chain must not be
583
+ // shorter than the snapshot.
584
+ if (anchor) {
585
+ if (rows.length < anchor.count) {
586
+ return {
587
+ ok: false,
588
+ rows_verified: rows.length - legacyPrefix,
589
+ legacy_prefix: legacyPrefix,
590
+ anchor_checked: true,
591
+ reason: "row count below anchor (possible tail truncation)",
592
+ expected_count: anchor.count,
593
+ actual_count: rows.length,
594
+ };
595
+ }
596
+ var anchorRow = rows[anchor.count - 1];
597
+ if (!anchorRow || anchorRow.row_hash !== anchor.head) {
598
+ return {
599
+ ok: false,
600
+ rows_verified: rows.length - legacyPrefix,
601
+ legacy_prefix: legacyPrefix,
602
+ anchor_checked: true,
603
+ reason: "anchor row hash mismatch (possible tail truncation or divergence)",
604
+ expected: anchor.head,
605
+ actual: anchorRow ? anchorRow.row_hash : null,
606
+ };
607
+ }
608
+ }
554
609
  return {
555
- ok: true,
556
- rows_verified: rows.length - legacyPrefix,
557
- legacy_prefix: legacyPrefix,
558
- last_hash: prevHash,
610
+ ok: true,
611
+ rows_verified: rows.length - legacyPrefix,
612
+ legacy_prefix: legacyPrefix,
613
+ total_rows: rows.length,
614
+ last_hash: prevHash,
615
+ anchor_checked: anchor != null,
559
616
  };
560
617
  },
561
618