@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 +4 -0
- package/SECURITY.md +14 -9
- package/lib/asset-manifest.json +1 -1
- package/lib/credit-limits.js +129 -40
- package/lib/gift-card-ledger.js +72 -15
- package/lib/store-credit.js +340 -89
- package/lib/vendor/MANIFEST.json +13 -11
- package/lib/vendor/blamejs/CHANGELOG.md +2 -0
- package/lib/vendor/blamejs/api-snapshot.json +2 -2
- package/lib/vendor/blamejs/lib/vendor/MANIFEST.json +17 -17
- package/lib/vendor/blamejs/lib/vendor/public-suffix-list.dat +22 -3
- package/lib/vendor/blamejs/lib/vendor/public-suffix-list.data.js +1297 -1292
- package/lib/vendor/blamejs/lib/vendor/simplewebauthn-server.cjs +18 -18
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.15.20.json +27 -0
- package/lib/vendor/blamejs/scripts/vendor-update.sh +29 -2
- package/lib/vendor/blamejs/test/layer-0-primitives/vendor-manifest.test.js +46 -0
- package/package.json +1 -1
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
|
|
323
|
-
Every ledger entry — credits, debits, and
|
|
324
|
-
its predecessor through a
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
chain
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
package/lib/asset-manifest.json
CHANGED
package/lib/credit-limits.js
CHANGED
|
@@ -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
|
-
//
|
|
620
|
-
//
|
|
621
|
-
// in ONE atomic statement.
|
|
622
|
-
//
|
|
623
|
-
//
|
|
624
|
-
//
|
|
625
|
-
//
|
|
626
|
-
//
|
|
627
|
-
//
|
|
628
|
-
//
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
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
|
-
|
|
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
|
|
763
|
-
//
|
|
764
|
-
//
|
|
765
|
-
//
|
|
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
|
-
|
|
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
|
|
842
|
+
"WHERE customer_id = ?1 AND kind = 'payment'",
|
|
776
843
|
[customerId],
|
|
777
844
|
)).rows[0];
|
|
778
|
-
var toSettle =
|
|
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 <
|
|
788
|
-
var
|
|
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 -
|
|
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
|
-
//
|
|
800
|
-
//
|
|
801
|
-
//
|
|
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;
|
package/lib/gift-card-ledger.js
CHANGED
|
@@ -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
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
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:
|
|
556
|
-
rows_verified:
|
|
557
|
-
legacy_prefix:
|
|
558
|
-
|
|
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
|
|