@blamejs/blamejs-shop 0.4.30 → 0.4.31

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,8 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.4.x
10
10
 
11
+ - v0.4.31 (2026-06-13) — **A partial refund on a split-tender order no longer re-credits the gift card on top of the cash refund.** A refund-accounting fix. When an order was paid partly by gift card or redeemed loyalty and partly by cash, a partial refund of the cash slice returned the cash through the payment provider AND ALSO re-credited a proportional share to the gift card and loyalty balance — handing back more value than the refund. On a $50 order paid with a $20 gift card and $30 cash, a $30 refund returned $30 in cash plus $12 to the card: $42 for a $30 refund, and the over-credit landed on spendable balance. Refund accounting is now cash-first: a partial refund draws against the cash captured at checkout, and the gift-card and loyalty tenders are re-credited only for the portion of the cumulative refund that exceeds that cash. A full refund still returns every tender in full, exactly once. The gift-card and loyalty share each order was paid with is recorded at checkout so the refund path apportions correctly; orders placed before this release carry no recorded split and are treated as cash-only on partial refunds, with the full-refund path unchanged. No migration to apply. **Fixed:** *Partial refunds are cash-first on split-tender orders* — A partial refund returns value to the tender the customer was actually charged: the cash captured by the payment provider. The gift-card and loyalty balances are re-credited only once a refund exceeds the cash captured — so refunding the cash portion of a split-tender order returns just the cash, and the card is restored only by a refund that reaches into the credit-paid share or by a full refund. Previously a partial refund re-minted a proportional slice of the gift card and loyalty on every refund, returning more than the amount refunded; because that credit landed on spendable balance, the excess was real and re-usable. · *Reconcile gift-card balances touched by earlier split-tender partial refunds* — Operators who issued partial refunds on orders paid partly by gift card or loyalty before this release should review the affected gift-card balances and loyalty ledgers: those refunds may have credited value above the amount refunded. Full refunds were unaffected — they return each tender once. New refunds apportion correctly.
12
+
11
13
  - v0.4.30 (2026-06-11) — **Privacy exports and erasures now cover every customer-keyed table — including the guest-order claim audit, stock alerts' plaintext email, quotes, ratings, Q&A, operator notes, gift cards, and referrals.** A privacy-completeness release. A subject-access export now walks eight more customer-keyed domains, and erasure handles each with a stated basis: the guest-order claim audit's email hash — a verbatim copy of the lookup key the account erasure deliberately severs, which previously survived in full — is tombstoned under its own derivation while the order linkage stays under the audit basis; a stock-alert subscription's plaintext email is deleted outright; quotes, ratings, and Q&A keep their de-identified business records with the customer's free text and identity keys cleared; operator notes are deleted; gift cards and referral records stay under their accounting basis with the identity links severed. A stock-alert subscription made while signed in now links to the account, so it follows the customer through export and erasure rather than floating free. Every new domain reports in the export's completeness manifest and in the erasure's per-domain results — an unwired reader shows as absent, never silently dropped. No migration to apply. **Changed:** *Six more domains in the export, each with stated erasure semantics* — Quote requests and their negotiation messages, fulfillment ratings, product Q&A, operator customer notes, gift-card issue records, and referral activity (in both directions — as referrer and as referred) now stream into the subject-access export. Erasure treats each by its nature: the customer's quote message and Q&A identity are cleared in place with the de-identified business record retained, ratings and operator notes are deleted, and gift cards and referral accounting stay under their legal-obligation basis with the identity links severed and referred-email hashes tombstoned. Each domain reports its effect in the deletion result, and a deletion preview (dry run) touches nothing — verified per domain. **Fixed:** *Guest-order claim records join the export and erasure* — When a guest order attaches to an account on verified sign-in, the attachment is recorded with the buyer's email hash as the linking key. Those records are now part of the subject-access export, and an erasure tombstones the email hash under a derivation distinct from the account-level tombstone — so the two can never be cross-correlated — while the order linkage itself is retained under the same audit basis orders use. Previously the records were absent from the export, untouched by erasure, and invisible in the completeness manifest, and the surviving hash defeated the account erasure's deliberate severing of that key. · *Stock-alert subscriptions stop outliving an erasure* — A back-in-stock subscription stores the subscriber's email in plaintext — it has to send mail to it — but those rows survived an account erasure and never appeared in the export. The export now includes the customer's subscriptions (minus the bearer token hashes), erasure deletes the rows outright — freeing the address to subscribe again — and a subscription made while signed in is linked to the account so it follows the customer. Anonymous subscriptions keep their existing bounded lifetime and stay reachable by their unsubscribe token.
12
14
 
13
15
  - v0.4.29 (2026-06-11) — **A gift card can no longer pay for two orders at once — credits debit before any charge — and store-credit wallets, capped discounts, and the gift-card audit chain all hold under concurrency.** A money-integrity release closing five concurrency windows, each reproduced before fixing. The serious one: gift-card and loyalty credits were debited after the order existed, with failures captured for reconciliation — so two simultaneous checkouts presenting the same gift card both produced paid orders while the card was only debited once. Credits now debit before any charge: the database balance gate decides the race, the loser gets a clean re-quote, and a checkout that fails after the debit but before the order exists reverses the debit automatically. Store-credit wallets stop computing balances from a stale read — concurrent debits can no longer overdraw, and two grants landing in the same millisecond both count. Capped automatic discounts are reserved atomically before charging, so a last-redemption race refuses one buyer with a clear message instead of granting both. Every gift-card ledger entry — debits included — now participates in the per-card tamper-evidence hash chain, a uniqueness fence keeps concurrent writes from forking it, and a new verifyChain call recomputes a card's chain on demand. The payment idempotency cache absorbs same-key races instead of failing one of them. Upgrade applies two D1 migrations. **Fixed:** *Store-credit wallets hold under concurrent writes* — Wallet writes computed the new balance from a separately-read snapshot, so two concurrent debits could both fulfill against one balance — overdrawing the wallet — and two grants landing in the same millisecond could tie on their timestamp and silently drop one. Every wallet write now computes the live balance and a strictly-monotonic per-customer timestamp inside a single guarded insert: a debit that loses the race is refused as insufficient, both same-instant grants land and sum, and the scheduled expiry sweep keeps its degrade-gracefully cap. · *Capped automatic discounts are reserved before charging* — A rule's redemption caps were only read at quote time and counted after the order existed, so a single-use discount applied to every order that raced the last redemption. The applied rules are now claimed atomically before any charge — total cap and per-customer cap both enforced inside single guarded statements — and a refused claim fails the checkout closed with a clear message and re-quote, never a silently different price. A checkout that fails before its order exists releases its reservations, recording a redemption is idempotent per order, and a retried checkout reuses its own claim instead of double-reserving. · *Same-key payment calls absorb their race* — Two concurrent calls carrying the same idempotency key could both miss the replay cache and collide on its primary key, failing one of them with a constraint error. The cache claim is now conflict-aware: one call stores its response, the other defers to it and replays — and a same-key call carrying a different request body is still refused as a collision, racing or not. **Security:** *Gift-card and loyalty credits debit before any charge* — A checkout's gift-card and loyalty debits are now the first money movement, ahead of the payment intent and the order row, on the card-payment and PayPal paths alike. The database balance predicate is the cross-checkout double-spend gate: two carts presenting the same card race it directly, exactly one wins, and the loser's checkout rolls back cleanly — stock holds released, cart reusable, a clear message and re-quote, nothing charged. A checkout that dies between the debit and order creation reverses the debit (claim-guarded, exactly once). Once the order exists the debit is attached to it, so refunds and cancellations keep reversing credit proportionally exactly as before. · *Every gift-card ledger entry is chained, and the chain can't fork* — Debit rows — previously written outside the hash chain by the atomic overdraft guard — now carry the same parent and row hashes as credits and expirations, with the overdraft gate still enforced inside the insert. A per-card uniqueness fence (one child per chain tip) makes concurrent writes serialize instead of forking the chain or basing a balance on a stale snapshot; a writer that loses the race re-reads the tip and retries. A new verifyChain call recomputes a card's chain end to end and reports the first divergence, tolerating rows that predate the chain columns as a counted, unverifiable prefix.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.30",
2
+ "version": "0.4.31",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-imfe0otYErcB8rr2h6KLSGTtStirysptpXETSPY4zLv3bZoIT75Lo1dOvkOav+xL",
package/lib/checkout.js CHANGED
@@ -1405,6 +1405,8 @@ function create(deps) {
1405
1405
  tax_minor: quote.totals.tax_minor,
1406
1406
  shipping_minor: quote.totals.shipping_minor,
1407
1407
  grand_total_minor: quote.totals.grand_total_minor,
1408
+ gift_card_applied_minor: gc ? gc.applied_minor : 0,
1409
+ loyalty_applied_minor: loy ? loy.applied_minor : 0,
1408
1410
  payment_intent_id: null,
1409
1411
  payment_provider: null, // credits covered the whole total — no provider charge to refund
1410
1412
  ship_to: input.ship_to,
@@ -1470,6 +1472,8 @@ function create(deps) {
1470
1472
  tax_minor: quote.totals.tax_minor,
1471
1473
  shipping_minor: quote.totals.shipping_minor,
1472
1474
  grand_total_minor: quote.totals.grand_total_minor,
1475
+ gift_card_applied_minor: gc ? gc.applied_minor : 0,
1476
+ loyalty_applied_minor: loy ? loy.applied_minor : 0,
1473
1477
  payment_intent_id: pi.id,
1474
1478
  payment_provider: "stripe", // refund surfaces route the refund dial by this
1475
1479
  ship_to: input.ship_to,
@@ -1794,6 +1798,8 @@ function create(deps) {
1794
1798
  tax_minor: quote.totals.tax_minor,
1795
1799
  shipping_minor: quote.totals.shipping_minor,
1796
1800
  grand_total_minor: quote.totals.grand_total_minor,
1801
+ gift_card_applied_minor: gc ? gc.applied_minor : 0,
1802
+ loyalty_applied_minor: loy ? loy.applied_minor : 0,
1797
1803
  payment_intent_id: null,
1798
1804
  payment_provider: null,
1799
1805
  ship_to: input.ship_to,
@@ -1835,6 +1841,8 @@ function create(deps) {
1835
1841
  tax_minor: quote.totals.tax_minor,
1836
1842
  shipping_minor: quote.totals.shipping_minor,
1837
1843
  grand_total_minor: quote.totals.grand_total_minor,
1844
+ gift_card_applied_minor: gc ? gc.applied_minor : 0,
1845
+ loyalty_applied_minor: loy ? loy.applied_minor : 0,
1838
1846
  payment_intent_id: ppOrder.id, // the PayPal order id (opaque); links the webhook + capture
1839
1847
  payment_provider: "paypal", // refund surfaces route the refund dial by this
1840
1848
  ship_to: input.ship_to,
package/lib/order.js CHANGED
@@ -420,6 +420,33 @@ function create(opts) {
420
420
  }
421
421
  }
422
422
 
423
+ // Recover the non-cash tender split (gift-card spend + redeemed loyalty,
424
+ // minor units) stamped on the order's init transition at create time. Cash
425
+ // captured = grand_total - gift - loyalty. Orders placed before the split
426
+ // was recorded return zeroes, so the cash-first refund math treats them as
427
+ // cash-only (a partial refund never re-credits a non-cash tender; the
428
+ // terminal full-refund edge still returns everything).
429
+ async function _orderTenderSplit(orderId) {
430
+ var split = { gift: 0, loyalty: 0 };
431
+ var row = (await query(
432
+ "SELECT metadata_json FROM order_transitions " +
433
+ "WHERE order_id = ?1 AND on_event = 'create' LIMIT 1",
434
+ [orderId],
435
+ )).rows[0];
436
+ if (row && row.metadata_json) {
437
+ try {
438
+ var m = JSON.parse(row.metadata_json);
439
+ if (m && Number.isInteger(m.gift_card_applied_minor) && m.gift_card_applied_minor > 0) {
440
+ split.gift = m.gift_card_applied_minor;
441
+ }
442
+ if (m && Number.isInteger(m.loyalty_applied_minor) && m.loyalty_applied_minor > 0) {
443
+ split.loyalty = m.loyalty_applied_minor;
444
+ }
445
+ } catch (_e) { /* malformed metadata → treat as cash-only (safe: never over-credits) */ }
446
+ }
447
+ return split;
448
+ }
449
+
423
450
  return {
424
451
  TERMINAL_STATES: TERMINAL_STATES,
425
452
 
@@ -442,6 +469,14 @@ function create(opts) {
442
469
  _nonNegInt(input.tax_minor, "tax_minor");
443
470
  _nonNegInt(input.shipping_minor, "shipping_minor");
444
471
  _nonNegInt(input.grand_total_minor, "grand_total_minor");
472
+ // Non-cash tender split (gift-card spend + redeemed loyalty, minor
473
+ // units). Stamped onto the init transition below so a later partial
474
+ // refund is cash-first: cash captured = grand_total - these. Optional,
475
+ // default 0 (a cash-only order).
476
+ var giftAppliedMinor = input.gift_card_applied_minor == null ? 0 : input.gift_card_applied_minor;
477
+ var loyaltyAppliedMinor = input.loyalty_applied_minor == null ? 0 : input.loyalty_applied_minor;
478
+ _nonNegInt(giftAppliedMinor, "gift_card_applied_minor");
479
+ _nonNegInt(loyaltyAppliedMinor, "loyalty_applied_minor");
445
480
  _shipTo(input.ship_to);
446
481
  // Which payment provider captured (or will capture) this order's
447
482
  // charge — refund surfaces route the refund dial by this column.
@@ -498,7 +533,14 @@ function create(opts) {
498
533
  // Initial transition row — from no-prior-state into pending. Its
499
534
  // metadata carries the stock-hold map (`{ stock_holds: { sku: qty } }`)
500
535
  // so the FSM can settle the holds without a dedicated column.
501
- var initMeta = Object.keys(heldBySku).length ? JSON.stringify({ stock_holds: heldBySku }) : "{}";
536
+ var initMetaObj = {};
537
+ if (Object.keys(heldBySku).length) initMetaObj.stock_holds = heldBySku;
538
+ // The non-cash tender split rides the same init transition so the refund
539
+ // path recovers the cash captured without re-deriving it from the credit
540
+ // ledgers. Recorded only when non-zero to keep the metadata lean.
541
+ if (giftAppliedMinor > 0) initMetaObj.gift_card_applied_minor = giftAppliedMinor;
542
+ if (loyaltyAppliedMinor > 0) initMetaObj.loyalty_applied_minor = loyaltyAppliedMinor;
543
+ var initMeta = JSON.stringify(initMetaObj);
502
544
  await query(
503
545
  "INSERT INTO order_transitions (id, order_id, from_state, to_state, on_event, reason, metadata_json, occurred_at) " +
504
546
  "VALUES (?1, ?2, '__init__', 'pending', 'create', ?3, ?5, ?4)",
@@ -930,23 +972,41 @@ function create(opts) {
930
972
  // operator recent-orders list after a partial refund, matching how a
931
973
  // real FSM transition touches it.
932
974
  await query("UPDATE orders SET updated_at = ?1 WHERE id = ?2", [ts, orderId]);
933
- // Return the credits this refund covers, in proportion to the refunded
934
- // total SO FAR (this slice included). Gift-card spend is re-minted and
935
- // loyalty (redeemed + earned) restored / clawed against the cumulative
936
- // refunded amount, so a partial-then-final refund sequence converges
937
- // exactly on the order total. Referral funnel reversal waits for the
938
- // terminal refund edge a partial refund doesn't void the order.
975
+ // Cash-first refund accounting. A partial refund returns value to the
976
+ // CASH tender the provider refund the operator just issued. The
977
+ // non-cash tenders (gift-card spend, redeemed loyalty) are re-credited
978
+ // only for the portion of the CUMULATIVE refund that exceeds the cash
979
+ // captured at checkout; without this, refunding the cash slice of a
980
+ // split-tender order would ALSO pro-rata re-mint the gift card, handing
981
+ // the customer back more than the operator refunded. The provider can
982
+ // never refund more than the cash it captured, so a cash-only partial
983
+ // refund leaves the non-cash tenders untouched here; they are returned
984
+ // by the terminal full-refund edge. Earned-loyalty clawback stays
985
+ // proportional to the FULL order total — points were earned on the whole
986
+ // order, and clawing them back is not part of the value-return path.
939
987
  var _ptTotal = Number(current.grand_total_minor) || 0;
940
988
  if (_ptTotal > 0) {
941
989
  var _ptRefunded = await this.refundedTotalMinor(orderId);
942
- await _settleGiftCards(orderId, _ptRefunded, _ptTotal);
990
+ var _split = await _orderTenderSplit(orderId);
991
+ var _cashCaptured = _ptTotal - _split.gift - _split.loyalty;
992
+ if (_cashCaptured < 0) _cashCaptured = 0;
993
+ var _nonCashBack = _ptRefunded - _cashCaptured;
994
+ if (_nonCashBack < 0) _nonCashBack = 0;
995
+ // Exhaust the gift tender before the loyalty tender for the
996
+ // above-cash remainder; each reverser clamps to its own tender total.
997
+ var _giftBack = _nonCashBack < _split.gift ? _nonCashBack : _split.gift;
998
+ var _loyaltyBack = _nonCashBack - _giftBack;
999
+ if (_loyaltyBack > _split.loyalty) _loyaltyBack = _split.loyalty;
1000
+ if (_split.gift > 0 && _giftBack > 0) {
1001
+ await _settleGiftCards(orderId, _giftBack, _split.gift);
1002
+ }
943
1003
  if (current.customer_id) {
944
1004
  var _ptCust = current.customer_id;
945
- if (loyalty && typeof loyalty.restoreRedemption === "function") {
1005
+ if (_split.loyalty > 0 && _loyaltyBack > 0 && loyalty && typeof loyalty.restoreRedemption === "function") {
946
1006
  Promise.resolve().then(function () {
947
1007
  return loyalty.restoreRedemption(orderId, {
948
- refunded_minor: _ptRefunded,
949
- order_total_minor: _ptTotal,
1008
+ refunded_minor: _loyaltyBack,
1009
+ order_total_minor: _split.loyalty,
950
1010
  });
951
1011
  }).catch(function () { /* drop-silent — loyalty ledger holds its own audit trail */ });
952
1012
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.4.30",
3
+ "version": "0.4.31",
4
4
  "description": "Open-source framework built on blamejs. Vendored stack, zero npm runtime deps, PQC-first crypto, security-on by default.",
5
5
  "main": "lib/index.js",
6
6
  "scripts": {