@blamejs/blamejs-shop 0.4.120 → 0.4.122
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/lib/asset-manifest.json +1 -1
- package/lib/loyalty-earn-rules.js +7 -1
- package/lib/order.js +23 -10
- package/lib/vendor-invoices.js +53 -9
- 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.122 (2026-06-26) — **Loyalty points are clawed back against the goods subtotal they were earned on, not the order's grand total.** Loyalty points are earned on an order's goods value. When an order was refunded, the points clawback was computed as a proportion of the GRAND total instead — so refunding the full goods value while keeping non-refundable shipping or tax clawed back less than all of the earned points, letting a customer keep loyalty points on goods they had fully returned (a buy-then-return rewards-farming gap). The clawback now ratios against the DISCOUNTED goods value (the subtotal minus any order discount — the cash the customer actually paid for the goods), so returning the full goods value reverses all earned points even when shipping or tax is retained; the refund is applied goods-first (capped at the goods value). Separately, the clawback's points-times-amount product is now computed in BigInt, so a very large order can no longer drift the floored result by a point. Loyalty-tender restoration (returning points that were spent at checkout) still ratios against the cash refund and is unchanged. **Fixed:** *Earned-points clawback ratios against the discounted goods value, not the grand total* — Points are awarded on an order's goods value, but the refund clawback divided the refunded amount by the order's grand total. On an order carrying tax or shipping the grand total is larger than the goods value, so refunding the full goods value never reached a 100% clawback — the customer kept a slice of points on goods they fully returned. The refund path now passes the DISCOUNTED goods value — the subtotal minus any order discount, i.e. the cash actually paid for the goods — as the clawback base (the refund is treated goods-first and capped at that value), so a full goods return reverses all earned points even when non-refundable shipping or tax is kept, and a discounted order is no longer under-clawed. The terminal full-refund edge still claws 100%, and loyalty points spent as a checkout tender are still restored against the cash refunded. · *The proportional clawback computes points-times-amount in BigInt* — The clawback's points-awarded times refunded-amount product was computed with floating-point arithmetic. On a sufficiently large order the product can exceed the exact-integer range and drift the floored clawback by a point. It is now computed in BigInt — matching the framework's convention for money-by-quantity products — so the clawed amount is exact at any order size.
|
|
12
|
+
|
|
13
|
+
- 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.
|
|
14
|
+
|
|
11
15
|
- 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.
|
|
12
16
|
|
|
13
17
|
- 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.
|
package/lib/asset-manifest.json
CHANGED
|
@@ -808,7 +808,13 @@ function create(opts) {
|
|
|
808
808
|
var awarded = Number(row.points_awarded || 0);
|
|
809
809
|
if (awarded <= 0) continue;
|
|
810
810
|
var already = Number(row.clawed_points || 0);
|
|
811
|
-
|
|
811
|
+
// BigInt math — awarded (up to the 1e9 per-event cap) times effRefunded
|
|
812
|
+
// (minor units, unbounded) can exceed 2^53 on a large order, where a
|
|
813
|
+
// float multiply drifts and the floored claw is off by a point. BigInt
|
|
814
|
+
// division floors toward zero, matching Math.floor for these
|
|
815
|
+
// non-negative operands; the quotient is < awarded so it returns to a
|
|
816
|
+
// safe Number.
|
|
817
|
+
var target = Number((BigInt(awarded) * BigInt(effRefunded)) / BigInt(orderTotalMinor));
|
|
812
818
|
if (target > awarded) target = awarded;
|
|
813
819
|
var delta = target - already;
|
|
814
820
|
if (delta <= 0) continue;
|
package/lib/order.js
CHANGED
|
@@ -839,16 +839,24 @@ function create(opts) {
|
|
|
839
839
|
});
|
|
840
840
|
}).catch(function () { /* drop-silent — loyalty ledger holds its own audit trail */ });
|
|
841
841
|
}
|
|
842
|
-
// Loyalty points EARNED on the purchase — clawed back proportionally
|
|
843
|
-
//
|
|
844
|
-
//
|
|
845
|
-
|
|
842
|
+
// Loyalty points EARNED on the purchase — clawed back proportionally.
|
|
843
|
+
// The claw ratios against the DISCOUNTED goods value (subtotal minus
|
|
844
|
+
// any order discount) — the cash the customer actually paid for the
|
|
845
|
+
// goods the points were earned on, and the most a goods refund can
|
|
846
|
+
// return. A full-refund death edge refunds the whole order, so
|
|
847
|
+
// effRefunded (capped at the goods value) reaches it and the claw is
|
|
848
|
+
// 100% even when non-refundable shipping/tax is kept. Ratioing
|
|
849
|
+
// against the raw (pre-discount) subtotal would under-claw a
|
|
850
|
+
// discounted order. Detached + drop-silent; clawed_points makes the
|
|
851
|
+
// claw idempotent and convergent across partial slices.
|
|
852
|
+
var _revGoods = (Number(refreshed.subtotal_minor) || 0) - (Number(refreshed.discount_minor) || 0);
|
|
853
|
+
if (_revGoods > 0 && loyaltyEarnRules && typeof loyaltyEarnRules.reverseForEventProRata === "function") {
|
|
846
854
|
Promise.resolve().then(function () {
|
|
847
855
|
return loyaltyEarnRules.reverseForEventProRata({
|
|
848
856
|
customer_id: _revCustomer,
|
|
849
857
|
trigger_event_ref: "order:" + _revOrderId,
|
|
850
858
|
refunded_minor: _refTotal,
|
|
851
|
-
order_total_minor:
|
|
859
|
+
order_total_minor: _revGoods,
|
|
852
860
|
});
|
|
853
861
|
}).catch(function () { /* drop-silent — loyalty ledger holds its own audit trail */ });
|
|
854
862
|
}
|
|
@@ -1079,9 +1087,13 @@ function create(opts) {
|
|
|
1079
1087
|
// the customer back more than the operator refunded. The provider can
|
|
1080
1088
|
// never refund more than the cash it captured, so a cash-only partial
|
|
1081
1089
|
// refund leaves the non-cash tenders untouched here; they are returned
|
|
1082
|
-
// by the terminal full-refund edge. Earned-loyalty clawback
|
|
1083
|
-
//
|
|
1084
|
-
//
|
|
1090
|
+
// by the terminal full-refund edge. Earned-loyalty clawback ratios
|
|
1091
|
+
// against the DISCOUNTED goods value (subtotal minus any order discount)
|
|
1092
|
+
// — the cash paid for the goods the points were earned on — NOT the
|
|
1093
|
+
// grand total: refunding the full goods value claws all earned points
|
|
1094
|
+
// even when non-refundable shipping/tax is kept (the refund is
|
|
1095
|
+
// goods-first, capped at the goods value). Clawing is not part of the
|
|
1096
|
+
// value-return path above.
|
|
1085
1097
|
var _ptTotal = Number(current.grand_total_minor) || 0;
|
|
1086
1098
|
if (_ptTotal > 0) {
|
|
1087
1099
|
var _ptRefunded = await this.refundedTotalMinor(orderId);
|
|
@@ -1108,13 +1120,14 @@ function create(opts) {
|
|
|
1108
1120
|
});
|
|
1109
1121
|
}).catch(function () { /* drop-silent — loyalty ledger holds its own audit trail */ });
|
|
1110
1122
|
}
|
|
1111
|
-
|
|
1123
|
+
var _ptGoods = (Number(current.subtotal_minor) || 0) - (Number(current.discount_minor) || 0);
|
|
1124
|
+
if (_ptGoods > 0 && loyaltyEarnRules && typeof loyaltyEarnRules.reverseForEventProRata === "function") {
|
|
1112
1125
|
Promise.resolve().then(function () {
|
|
1113
1126
|
return loyaltyEarnRules.reverseForEventProRata({
|
|
1114
1127
|
customer_id: _ptCust,
|
|
1115
1128
|
trigger_event_ref: "order:" + orderId,
|
|
1116
1129
|
refunded_minor: _ptRefunded,
|
|
1117
|
-
order_total_minor:
|
|
1130
|
+
order_total_minor: _ptGoods,
|
|
1118
1131
|
});
|
|
1119
1132
|
}).catch(function () { /* drop-silent — loyalty ledger holds its own audit trail */ });
|
|
1120
1133
|
}
|
package/lib/vendor-invoices.js
CHANGED
|
@@ -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:
|
|
775
|
+
poBySku[pl.sku] = { received_qty: 0, received_value_minor: 0, unit_cost_minor: 0, currency: pl.currency };
|
|
749
776
|
}
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
//
|
|
753
|
-
//
|
|
754
|
-
|
|
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 -
|
|
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
|
-
|
|
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