@blamejs/blamejs-shop 0.4.119 → 0.4.121

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.121 (2026-06-26) — **Vendor-invoice reconciliation values each PO line at its own cost, and a vendor's invoices must share one currency.** Two fixes to vendor-invoice reconciliation and reporting. When the same SKU appeared on more than one purchase order at different unit costs, reconcileAgainstPOs summed the received quantity across the POs but valued all of it at the LAST PO's unit cost, inflating the PO received value and corrupting the over-billing variance the operator relies on to catch a vendor over-bill before paying. Each PO line is now valued at its own unit cost and accumulated, so the received value and the variance are exact, and the per-unit figure shown in the line variance is a true weighted mean. Separately, a vendor's invoices may now only be recorded in one currency: the accounts-payable aging report and the vendor scorecard sum amounts per vendor into a single figure, which is meaningless across currencies, so an invoice whose currency differs from the vendor's existing invoices is refused at write time rather than silently producing an incommensurable total. **Fixed:** *PO reconciliation values each purchase-order line at its own unit cost* — reconcileAgainstPOs aggregated purchase-order lines per SKU, accumulating the received quantity across POs but overwriting the unit cost with each successive PO's — so when the same SKU was received on two POs at different prices, the received value was computed as the combined quantity times the last PO's cost. That inflated po_received_value_minor and the variance the operator uses to spot a vendor over-bill before payment. Each PO line is now valued at its own unit cost and the values accumulated, so the received value and variance are exact; the line-variance's per-unit cost is reported as the true weighted mean of the received value. · *A vendor's invoices must all be recorded in the same currency* — The accounts-payable aging report and the vendor scorecard sum each vendor's invoice amounts into a single figure, which is only meaningful within one currency — mixing currencies produced an incommensurable total with no currency tag to unwind it. Recording an invoice whose currency differs from the vendor's existing invoices is now refused with a clear error, so those per-vendor totals stay within a single currency by construction.
12
+
13
+ - v0.4.120 (2026-06-26) — **Splitting a discount across order lines no longer rounds a line's share negative.** When an order-level discount is allocated across the order's lines (proportionally, equally, or by quantity), each line's share was rounded independently with half-even rounding. Independently rounded shares can sum to MORE than the discount, which drove the leftover remainder negative and produced a negative discount on one line — and the checkout recorder, which rejects a negative share, then dropped the whole allocation breakdown, leaving nothing for a later per-line partial refund to draw on. Reversing an allocation for a refund had the same flaw and could persist a negative per-line refund (a charge-back on that line). The split now composes the framework's largest-remainder allocator: it floors each share and hands out the non-negative leftover one minor unit at a time, so every line's share is non-negative, the shares sum to the discount (or the refund) exactly, and no breakdown is dropped. The only visible change is which line absorbs a leftover cent — now the line with the largest fractional remainder rather than the highest subtotal. **Fixed:** *Per-line discount and refund shares are floored and distributed, never rounded negative* — The per-line discount split rounded each share half-even and independently, so the rounded shares could sum above the discount, send the remainder negative, and assign a negative discount to one line; the checkout path then dropped the breakdown (its recorder refuses a negative share), so a subsequent per-line refund had no recorded allocation to subtract. The reverse path likewise could persist a negative per-line refund. Both now compose the framework's largest-remainder allocator, which floors each share and distributes the non-negative leftover one minor unit at a time to the largest remainders — every share is non-negative, the shares sum exactly to the total, and the allocation is recorded. The leftover cent now lands on the largest-remainder line instead of the highest-subtotal line.
14
+
11
15
  - v0.4.119 (2026-06-26) — **Vendored framework refreshed to 0.15.32.** Updates the vendored blamejs framework to 0.15.32, taking in the 0.15.31 and 0.15.32 upstream releases. Both are internal test-suite tooling only: they extend the framework's guard suite with a re-verification gate that retires renamed suppression-marker tokens (so a re-verified suppression class can't inherit a stale approval) and update the matching in-source marker comments. No runtime code, public API, or wire format changed upstream, so there is no runtime or behavior change for this storefront and no operator action is required. **Changed:** *Vendored framework refreshed to 0.15.32* — Refreshes the vendored blamejs framework to 0.15.32 (taking in 0.15.31 and 0.15.32), upstream releases scoped to test-suite tooling — a re-verification gate for renamed suppression markers and the matching marker-comment updates. The shipped framework runtime, public API, and wire format are unchanged, so no operator action is required.
12
16
 
13
17
  - v0.4.118 (2026-06-26) — **Changing an affiliate's commission kind now revalidates the commission value against the new kind's cap.** An affiliate's commission value is validated against its kind's ceiling when it is set: a percentage commission is capped at 100% (10000 basis points), while a flat per-order amount may be far larger. Changing only the commission kind — without resupplying the value — skipped that revalidation. Switching a flat per-order amount to a percentage therefore left the large flat value in place as a percentage rate: a $500-per-order flat amount became a 5,000,000% rate that computes a runaway commission on the next recorded order and feeds it straight into the pending-payout total. Updating the commission kind now revalidates the effective commission value (the resupplied one, or the stored one) against the new kind, so a change that would push the value over the new kind's cap is rejected with the same error as setting it directly, instead of being silently persisted. **Fixed:** *An affiliate commission-kind change revalidates the stored value against the new kind's cap* — Updating an affiliate re-checked the commission value against the (possibly new) kind's cap only when the value itself was part of the same update. A kind-only change left the stored value untouched and unvalidated, so switching a flat per-order amount to a percentage kept a value that is legal as a flat amount (up to the large flat ceiling) but thousands of times over the 100%-basis-point percentage cap — producing a grossly inflated commission on the next order and an inflated pending payout. The update path now revalidates the effective commission value against the effective kind whenever either the kind or the value changes, rejecting an over-cap combination rather than persisting it.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.119",
2
+ "version": "0.4.121",
3
3
  "assets": {
4
4
  "css/admin.css": {
5
5
  "integrity": "sha384-imfe0otYErcB8rr2h6KLSGTtStirysptpXETSPY4zLv3bZoIT75Lo1dOvkOav+xL",
@@ -181,18 +181,18 @@ function _validateLines(lines) {
181
181
 
182
182
  // ---- allocation math (pure) --------------------------------------------
183
183
 
184
- // Compute the per-line share via b.money's half-even rounded multiply.
185
- // Given total `T`, weights `[w0, w1, ...]` summing to `W`, each share
186
- // is `floor_half_even(T * wi / W)`. The shares may sum to slightly
187
- // less than `T` (rounding gives back fractions); the remainder is
188
- // applied to the line with the highest subtotal so the audit row's
189
- // `allocated_minor` values sum exactly to `T`.
184
+ // Compute the per-line share of `total` via b.money's largest-remainder
185
+ // allocator. Each share is `floor(total * wi / W)`, and the non-negative
186
+ // leftover is handed out one minor unit at a time to the lines with the
187
+ // largest fractional remainders, so the `allocated_minor` values sum to
188
+ // `total` EXACTLY and none is ever negative.
190
189
  //
191
- // "By quantity" weights use quantity; "equal" weights every line by
192
- // `1` (so the share is `T / N` rounded); "by_subtotal" /
193
- // "proportional" weight by subtotal. The remainder placement is
194
- // always by highest subtotal the operator's "the biggest line
195
- // absorbs the cent" mental model holds for every kind.
190
+ // "By quantity" weights use quantity; "equal" weights every line by `1`
191
+ // (so each gets `total / N` floored, leftover spread); "by_subtotal" /
192
+ // "proportional" weight by subtotal. Composing the framework allocator
193
+ // instead of independently rounding each share is what keeps the split
194
+ // conservation-safe a per-line half-even round can sum above `total`
195
+ // and force a negative share, which this avoids by construction.
196
196
  function _shares(total, lines, kind) {
197
197
  var weights = new Array(lines.length);
198
198
  var sumWeight = 0n;
@@ -220,32 +220,18 @@ function _shares(total, lines, kind) {
220
220
  }
221
221
  }
222
222
 
223
- var money = b.money;
224
- var totalMoney = money.fromMinorUnits(BigInt(total), ROUNDING_CURRENCY);
223
+ // Compose b.money's largest-remainder allocator: it floors each share and
224
+ // distributes the (always non-negative) leftover one minor unit at a time
225
+ // to the largest fractional remainders, so the shares sum to `total`
226
+ // EXACTLY and none is ever negative. The prior hand-rolled half-even
227
+ // multiply rounded each share independently and could push their sum ABOVE
228
+ // `total` — driving the remainder negative and a line's share below zero.
229
+ var allocated = b.money
230
+ .fromMinorUnits(BigInt(total), ROUNDING_CURRENCY)
231
+ .allocate(weights);
225
232
  var shares = new Array(lines.length);
226
- var allocated = 0n;
227
233
  for (var j = 0; j < lines.length; j += 1) {
228
- var share = totalMoney
229
- .multiply([BigInt(weights[j]), sumWeight])
230
- .toMinorUnits();
231
- shares[j] = share;
232
- allocated += share;
233
- }
234
-
235
- // Place the rounding remainder (positive or negative) on the line
236
- // with the highest subtotal_minor. Ties broken by original index so
237
- // the placement is deterministic across runtimes.
238
- var remainder = BigInt(total) - allocated;
239
- if (remainder !== 0n) {
240
- var bestIdx = 0;
241
- var bestSubtotal = lines[0].subtotal_minor;
242
- for (var m = 1; m < lines.length; m += 1) {
243
- if (lines[m].subtotal_minor > bestSubtotal) {
244
- bestSubtotal = lines[m].subtotal_minor;
245
- bestIdx = m;
246
- }
247
- }
248
- shares[bestIdx] = shares[bestIdx] + remainder;
234
+ shares[j] = allocated[j].toMinorUnits();
249
235
  }
250
236
  return shares;
251
237
  }
@@ -409,10 +395,9 @@ function create(opts) {
409
395
 
410
396
  // Reverse-walk an allocation: given a refund amount, distribute it
411
397
  // across the original breakdown proportional to each line's
412
- // `allocated_minor`. Half-even rounding via b.money; the rounding
413
- // remainder lands on the line with the largest original allocation
414
- // (the same "biggest line absorbs the cent" rule applied to the
415
- // reverse direction).
398
+ // `allocated_minor`, via the same b.money largest-remainder allocator
399
+ // as the forward split — so the per-line refund shares sum to `refund`
400
+ // exactly and none is negative.
416
401
  function _reverseShares(refund, breakdown) {
417
402
  var weights = new Array(breakdown.length);
418
403
  var sumWeight = 0n;
@@ -430,28 +415,17 @@ function create(opts) {
430
415
  sumWeight += 1n;
431
416
  }
432
417
  }
433
- var money = b.money;
434
- var refundMoney = money.fromMinorUnits(BigInt(refund), ROUNDING_CURRENCY);
418
+ // Same largest-remainder allocator as the forward split: floors each
419
+ // share and hands out the non-negative leftover unit-by-unit, so the
420
+ // per-line refund shares sum to `refund` exactly and none is negative
421
+ // (the prior half-even multiply could overshoot and persist a negative
422
+ // per-line refund — effectively a charge-back on that line).
423
+ var allocated = b.money
424
+ .fromMinorUnits(BigInt(refund), ROUNDING_CURRENCY)
425
+ .allocate(weights);
435
426
  var shares = new Array(breakdown.length);
436
- var allocated = 0n;
437
427
  for (var j = 0; j < breakdown.length; j += 1) {
438
- var share = refundMoney
439
- .multiply([BigInt(weights[j]), sumWeight])
440
- .toMinorUnits();
441
- shares[j] = share;
442
- allocated += share;
443
- }
444
- var remainder = BigInt(refund) - allocated;
445
- if (remainder !== 0n) {
446
- var bestIdx = 0;
447
- var bestAlloc = breakdown[0].allocated_minor;
448
- for (var m = 1; m < breakdown.length; m += 1) {
449
- if (breakdown[m].allocated_minor > bestAlloc) {
450
- bestAlloc = breakdown[m].allocated_minor;
451
- bestIdx = m;
452
- }
453
- }
454
- shares[bestIdx] = shares[bestIdx] + remainder;
428
+ shares[j] = allocated[j].toMinorUnits();
455
429
  }
456
430
  return shares;
457
431
  }
@@ -435,6 +435,33 @@ function create(opts) {
435
435
  }
436
436
  }
437
437
 
438
+ // Single currency per vendor: the AP aging report and the vendor
439
+ // scorecard sum amount_minor / paid_minor per vendor into one scalar,
440
+ // which is only meaningful within a single currency — mixing currencies
441
+ // would yield an incommensurable total with no currency tag to unwind it.
442
+ // Refuse a mismatched currency at write time rather than silently record
443
+ // one that corrupts those reports.
444
+ // Reject if ANY existing invoice for this vendor is in a different
445
+ // currency — not just a sampled one. An upgraded store may already hold
446
+ // mixed-currency rows (earlier releases allowed them); sampling a single
447
+ // row could match the new currency and let the mix grow, so probe for any
448
+ // `currency <> this` row. A vendor already carrying a mix is locked until
449
+ // the operator resolves it, which is the fail-closed behaviour the
450
+ // single-currency report invariant requires.
451
+ var mismatchCur = await query(
452
+ "SELECT currency FROM vendor_invoices WHERE vendor_slug = ?1 AND currency <> ?2 LIMIT 1",
453
+ [vendorSlug, currency],
454
+ );
455
+ if (mismatchCur.rows.length) {
456
+ var curErr = new Error(
457
+ "vendor-invoices.recordInvoice: vendor " + JSON.stringify(vendorSlug) +
458
+ " has an existing invoice in " + mismatchCur.rows[0].currency + "; this invoice is " +
459
+ currency + " — a vendor's invoices must share one currency",
460
+ );
461
+ curErr.code = "VENDOR_INVOICE_CURRENCY_MISMATCH";
462
+ throw curErr;
463
+ }
464
+
438
465
  var id = b.uuid.v7();
439
466
  var ts = _now();
440
467
 
@@ -745,16 +772,34 @@ function create(opts) {
745
772
  for (var k = 0; k < po.lines.length; k += 1) {
746
773
  var pl = po.lines[k];
747
774
  if (!poBySku[pl.sku]) {
748
- poBySku[pl.sku] = { received_qty: 0, unit_cost_minor: pl.unit_cost_minor, currency: pl.currency };
775
+ poBySku[pl.sku] = { received_qty: 0, received_value_minor: 0, unit_cost_minor: 0, currency: pl.currency };
749
776
  }
750
- poBySku[pl.sku].received_qty += Number(pl.quantity_received);
751
- // If multiple POs cost differently for the same SKU, keep the
752
- // weighted mean (operator-visible signal that the vendor
753
- // changed pricing across orders).
754
- poBySku[pl.sku].unit_cost_minor = pl.unit_cost_minor;
777
+ var recvQty = Number(pl.quantity_received);
778
+ poBySku[pl.sku].received_qty += recvQty;
779
+ // Value each PO line at its OWN unit cost and ACCUMULATE. The same
780
+ // SKU can legitimately appear on multiple POs at different prices, so
781
+ // a single per-SKU unit cost cannot represent the received value —
782
+ // the prior code summed quantity across POs but overwrote unit_cost
783
+ // with the last PO's, inflating the received value and corrupting the
784
+ // over-billing variance. The operator-visible weighted-mean unit cost
785
+ // is derived from this accumulated value below.
786
+ poBySku[pl.sku].received_value_minor += recvQty * pl.unit_cost_minor;
755
787
  }
756
788
  }
757
789
 
790
+ // Derive each SKU's operator-visible unit cost as the true weighted mean
791
+ // of its received value across all matched POs (floored to a whole minor
792
+ // unit). The VALUE math downstream uses received_value_minor directly so
793
+ // it stays exact; this per-unit figure is only for the line-variance
794
+ // display.
795
+ var poBucketKeys = Object.keys(poBySku);
796
+ for (var wm = 0; wm < poBucketKeys.length; wm += 1) {
797
+ var wmBucket = poBySku[poBucketKeys[wm]];
798
+ wmBucket.unit_cost_minor = wmBucket.received_qty > 0
799
+ ? Math.floor(wmBucket.received_value_minor / wmBucket.received_qty)
800
+ : 0;
801
+ }
802
+
758
803
  // Map invoice lines onto PO buckets. A line item without an `sku`
759
804
  // (service-line, freight) lands in unmatched_invoice_lines so the
760
805
  // operator can decide whether that bill component is a separate
@@ -796,7 +841,7 @@ function create(opts) {
796
841
  po_unit_cost: poBucket.unit_cost_minor,
797
842
  qty_variance: il.quantity - poBucket.received_qty,
798
843
  unit_cost_variance: il.unit_cost_minor - poBucket.unit_cost_minor,
799
- total_variance: il.total_minor - (poBucket.received_qty * poBucket.unit_cost_minor),
844
+ total_variance: il.total_minor - poBucket.received_value_minor,
800
845
  });
801
846
  }
802
847
 
@@ -817,8 +862,7 @@ function create(opts) {
817
862
 
818
863
  var poReceivedValueMinor = 0;
819
864
  for (var s = 0; s < poBySkuKeys.length; s += 1) {
820
- var bucket = poBySku[poBySkuKeys[s]];
821
- poReceivedValueMinor += bucket.received_qty * bucket.unit_cost_minor;
865
+ poReceivedValueMinor += poBySku[poBySkuKeys[s]].received_value_minor;
822
866
  }
823
867
 
824
868
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/blamejs-shop",
3
- "version": "0.4.119",
3
+ "version": "0.4.121",
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": {