@blamejs/blamejs-shop 0.4.114 → 0.4.116
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/sales-tax-filings.js +87 -19
- package/lib/storefront.js +38 -7
- 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.116 (2026-06-26) — **Sales-tax filings net partial refunds and derive owed tax from the jurisdiction's default rate.** Two correctness fixes to the sales-tax filing computation. A partial refund leaves its order in a paid / fulfilling / shipped / delivered status (there is no partially-refunded state), so the order still counts toward the filing for the period it sold in — but the filing counted the order's FULL tax and subtotal, ignoring the refunded portion, which over-stated collected tax and the amount remitted to the authority. A partial refund recorded inside the filing window now reduces that order's contribution to net taxable sales and net collected tax, apportioned across tax and subtotal by the order's own grand-total structure. Separately, the owed re-derivation attributed every order to the first rate row covering its date regardless of category, so a reduced category rate (e.g. food) defined alongside the jurisdiction's default rate in the same window could be applied to all revenue — and which rate won depended on the database's row order, making the owed figure wrong and non-deterministic. The owed re-derivation now models the jurisdiction's default (category-less) rate only; the collected figure still reflects the actual per-order tax each order was charged at its own rate. **Fixed:** *Partial refunds are netted out of a sales-tax filing instead of counting full tax* — A partial refund is recorded as a same-status order transition that does not change the order's status, subtotal, or tax columns, so the order remained in the filing with its pre-refund tax and subtotal counted in full — over-stating collected tax and over-remitting. computeFiling now sums the partial refunds recorded inside the filing window per order and nets each order's taxable subtotal and collected tax by the refunded share, apportioned across tax and subtotal by the order's grand total (half-even, integer minor units). Refunds are attributed to the period they occur in; an order with no in-window refund is unaffected, and recomputing a filing is idempotent. · *Owed tax is re-derived from the jurisdiction's default rate, deterministically* — The owed re-derivation took the first tax-rate row covering an order's date with no regard for category. Because a category-specific rate (such as a reduced food rate) can legally coexist with the jurisdiction's default rate in the same window, whichever row the database returned first claimed all revenue — yielding a wrong owed figure that flipped with the database's row order. The re-derivation now selects only the jurisdiction's default (category-less) rate, so the owed figure is deterministic and modelled on the jurisdiction's primary rate. The collected figure is unchanged: it remains the sum of the actual tax each order was charged at its own category-aware rate at order time.
|
|
12
|
+
|
|
13
|
+
- v0.4.115 (2026-06-26) — **Adding a bundle with a multi-quantity member no longer charges above the advertised bundle price.** A bundle's discounted price is split across its members and stored as a single integer per-unit price on each cart line, so a member's realized cost is its quantity times that per-unit price. When a member's quantity is greater than one and its allocated share is not divisible by that quantity, the leftover cents could not be returned without raising the per-unit price by a whole quantity-step — and the allocator did exactly that, pushing the realized cart subtotal a few cents ABOVE the bundle price the customer saw on the product page. The allocation now returns leftover cents only in whole quantity-steps and leaves any final sub-step residual off, so the realized subtotal is at most the advertised bundle price, never above it (a residual of fewer than the member's quantity in cents is dropped in the customer's favour in the cases a single integer per-unit price cannot represent exactly). Bundles whose members are all single-unit, and bundles whose discounted allocation divides evenly, are unaffected. **Fixed:** *Bundle with a quantity-greater-than-one member never charges above the quoted price* — The bundle-to-cart allocation converts each member's share of the discounted bundle price into a single integer per-unit price (floor) and returns the cents lost to that floor. A single-unit member absorbs the shortfall exactly; otherwise the cents are returned in whole quantity-sized steps. The final sub-step residual — strictly fewer cents than any remaining member's quantity — was being recovered by bumping a multi-unit member's per-unit price up by one, which raised that line's total by a whole quantity-step and pushed the realized subtotal above the quoted bundle price. That residual is now left off instead: the realized subtotal is at most the advertised price (a sub-quantity-cent undercharge in the customer's favour when an integer per-unit price cannot land on the quote exactly), and never exceeds it.
|
|
14
|
+
|
|
11
15
|
- v0.4.114 (2026-06-26) — **A mid-cycle subscription downgrade now issues the proration credit the customer is owed instead of dropping it.** When a subscription plan changed mid-billing-period to a cheaper plan, the credit for the outgoing plan's unused time exceeded the incoming plan's partial-period charge — and that net credit was silently discarded. The proration adjustment is recorded as a single invoice whose amount is the net of charge minus credit, and a negative net (the downgrade case) was clamped to zero with no mechanism to apply the remainder, so the customer lost the money they were owed. A mid-cycle downgrade now issues that owed credit to the customer's store-credit ledger. The plan-change component takes an optional storeCredit handle for this; when a downgrade owes a credit and no handle is wired, the change is refused (an immediate change throws before any state changes) or left pending (a scheduled change is skipped and retried on a later sweep) rather than applied with the credit lost. Upgrades and same-price changes are unchanged. **Fixed:** *Mid-cycle plan downgrade credit is issued to store credit, not dropped* — A subscription plan change splits the proration into a charge (billed through the invoice ledger) and a credit (the unused remainder of the outgoing plan). For a downgrade the credit exceeds the charge, and the invoice — which can only carry a charge — clamped the negative net to zero, so the owed remainder was silently lost. The plan-change component now issues that remainder (proration credit minus the partial-period charge) to the customer's hash-chained store-credit ledger, correlated to the change that produced it and gated on the same atomic transition claim that prevents double application. The credit is settled as part of winning that claim: if the store-credit write fails (a transient error), the plan transition is rolled back and the change is left reclaimable so a retry settles it cleanly — the credit is never finalized as lost. The proration invoice still records the (zero) charge, so a downgrade never overcharges. · *A downgrade with no credit vehicle is refused, not applied with the credit lost* — The plan-change component accepts an optional storeCredit handle. When a downgrade owes a credit but no handle is wired, an immediate change is refused before any row is written or plan transition is applied (the call throws), and a scheduled change is left pending and retried on a later sweep once a handle is available — never transitioned with the credit dropped. Operators composing the plan-change component should wire the store-credit handle to enable mid-cycle downgrades.
|
|
12
16
|
|
|
13
17
|
- v0.4.113 (2026-06-26) — **VAT-ID validation and EU reverse-charge now handle Greece's EL prefix and Switzerland's CHE prefix correctly.** Two related correctness fixes in the tax module's VAT handling. The bare VAT number returned by validateVatId was corrupted for Greek and Swiss IDs: it dropped the leading digit of a Greek number when the caller passed the ISO code "GR", and it left a stray "E" on a Swiss number — because the prefix was guessed by length instead of taken from the registry's actual prefix (Greece files its EU VAT under "EL", Switzerland under "CHE"). Separately, the intra-EU B2B reverse charge was always denied to Greek parties: the country was derived from the VAT-ID prefix ("EL") and checked against the EU member list, which is keyed by the ISO code ("GR"), with no alias normalization — so a Greek buyer or seller was treated as non-EU and charged standard VAT where EU law requires 0% with reverse charge. Both paths now canonicalize the EL/GR alias and strip the exact registry prefix. Every other country, and a genuine VAT-ID/country mismatch, behaves exactly as before. **Fixed:** *EU reverse-charge is no longer wrongly denied to Greek (EL) VAT IDs* — applyReverseCharge derives a party's country from the first characters of its VAT ID. Greek EU VAT IDs carry the prefix "EL" while Greece's ISO 3166-1 code is "GR", and the EU-membership check is keyed by ISO code — so a Greek seller, or a Greek buyer (whether the country was supplied as "GR" or "EL"), was classified as non-EU and refused the reverse charge, causing standard VAT to be invoiced on a transaction where EU law requires 0% with the buyer self-assessing. The derived prefix and the declared country are now canonicalized (EL is treated as GR) before the EU-membership and prefix-match checks. A genuine mismatch — a non-Greek VAT ID declared against a GR country, or vice versa — is still refused. · *validateVatId returns the exact bare number for Greek (EL) and Swiss (CHE) IDs* — The bare VAT number was produced by guessing the prefix length (2 vs 3 characters) rather than using the registry's actual prefix. For a Greek ID queried under the ISO code "GR" this dropped the first digit of the number; for a Swiss "CHE…" ID it left the "E" attached. The prefix is now taken from the registry — "EL" for Greece (ISO "GR"), "CHE" for Switzerland (ISO "CH"), and the two-letter ISO code for every other registry — so the returned number that prints on a reverse-charge invoice is exact. IDs whose registered number itself begins with letters (Austria's U, France's two-character lead, Spain's alphanumeric) are unaffected.
|
package/lib/asset-manifest.json
CHANGED
package/lib/sales-tax-filings.js
CHANGED
|
@@ -356,7 +356,7 @@ function create(opts) {
|
|
|
356
356
|
// without changing the primitive's contract.
|
|
357
357
|
async function _ordersInWindow(jurisdiction, periodStart, periodEnd) {
|
|
358
358
|
var sql =
|
|
359
|
-
"SELECT id, customer_id, currency, subtotal_minor, tax_minor, " +
|
|
359
|
+
"SELECT id, customer_id, currency, subtotal_minor, tax_minor, grand_total_minor, " +
|
|
360
360
|
" ship_to_json, status, created_at " +
|
|
361
361
|
"FROM orders " +
|
|
362
362
|
"WHERE created_at >= ?1 AND created_at < ?2 " +
|
|
@@ -370,30 +370,84 @@ function create(opts) {
|
|
|
370
370
|
catch (_e) { shipTo = null; }
|
|
371
371
|
if (!_shipToMatches(shipTo, jurisdiction)) continue;
|
|
372
372
|
out.push({
|
|
373
|
-
id:
|
|
374
|
-
customer_id:
|
|
375
|
-
currency:
|
|
376
|
-
subtotal_minor:
|
|
377
|
-
tax_minor:
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
373
|
+
id: row.id,
|
|
374
|
+
customer_id: row.customer_id,
|
|
375
|
+
currency: row.currency,
|
|
376
|
+
subtotal_minor: Number(row.subtotal_minor),
|
|
377
|
+
tax_minor: Number(row.tax_minor),
|
|
378
|
+
grand_total_minor: row.grand_total_minor == null ? 0 : Number(row.grand_total_minor),
|
|
379
|
+
status: row.status,
|
|
380
|
+
created_at: Number(row.created_at),
|
|
381
|
+
ship_to: shipTo,
|
|
381
382
|
});
|
|
382
383
|
}
|
|
383
384
|
return out;
|
|
384
385
|
}
|
|
385
386
|
|
|
387
|
+
// Total partial-refund cash refunded per order INSIDE the window. A
|
|
388
|
+
// partial refund is an `on_event = 'refund'` self-loop transition that
|
|
389
|
+
// leaves the order in its paid/fulfilling/shipped/delivered status (no
|
|
390
|
+
// partially_refunded state exists), so the order still counts toward the
|
|
391
|
+
// filing — its tax must be netted by the refunded portion or the filing
|
|
392
|
+
// over-states collected tax and over-remits. Refunds are attributed to
|
|
393
|
+
// the period they occur (occurred_at in-window), the standard
|
|
394
|
+
// returns-reduce-the-current-period convention; `amount_minor` is the
|
|
395
|
+
// refunded cash (grand-total terms), apportioned to tax vs subtotal at
|
|
396
|
+
// accumulation time using the order's own structure.
|
|
397
|
+
async function _refundsInWindow(periodStart, periodEnd) {
|
|
398
|
+
var byOrder = Object.create(null);
|
|
399
|
+
var r = await query(
|
|
400
|
+
"SELECT order_id, metadata_json FROM order_transitions " +
|
|
401
|
+
"WHERE on_event = 'refund' AND occurred_at >= ?1 AND occurred_at < ?2",
|
|
402
|
+
[periodStart, periodEnd],
|
|
403
|
+
);
|
|
404
|
+
for (var i = 0; i < r.rows.length; i += 1) {
|
|
405
|
+
var meta;
|
|
406
|
+
try { meta = JSON.parse(r.rows[i].metadata_json); }
|
|
407
|
+
catch (_e) { meta = null; }
|
|
408
|
+
var amt = meta && meta.amount_minor;
|
|
409
|
+
if (typeof amt === "number" && isFinite(amt) && amt > 0) {
|
|
410
|
+
byOrder[r.rows[i].order_id] = (byOrder[r.rows[i].order_id] || 0) + amt;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return byOrder;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Apportion `refunded` cash to the `part` (tax or subtotal) component of
|
|
417
|
+
// an order whose grand total is `total`, half-even, BigInt-safe (a
|
|
418
|
+
// window's order count times minor units can exceed 2^53). Returns the
|
|
419
|
+
// refunded share of `part`, never more than `part`.
|
|
420
|
+
function _apportionMinor(refunded, part, total) {
|
|
421
|
+
if (total <= 0 || refunded <= 0 || part <= 0) return 0;
|
|
422
|
+
var share = Number(
|
|
423
|
+
b.money.fromMinorUnits(BigInt(refunded), "USD")
|
|
424
|
+
.multiply([BigInt(part), BigInt(total)], { rounding: "half-even" })
|
|
425
|
+
.toMinorUnits()
|
|
426
|
+
);
|
|
427
|
+
return share > part ? part : share;
|
|
428
|
+
}
|
|
429
|
+
|
|
386
430
|
async function _ratesInWindow(jurisdiction, periodStart, periodEnd) {
|
|
387
|
-
// Pull
|
|
388
|
-
// window. The
|
|
389
|
-
//
|
|
390
|
-
//
|
|
431
|
+
// Pull the jurisdiction's DEFAULT (category IS NULL) rate rows that
|
|
432
|
+
// overlap the window. The owed re-derivation models the jurisdiction
|
|
433
|
+
// fallback rate only: an order carries no category here (orders are
|
|
434
|
+
// selected without a line-item/product join), so a category-specific
|
|
435
|
+
// rate could not be attributed to the right orders — and pulling
|
|
436
|
+
// category rows alongside the fallback let whichever row the DB
|
|
437
|
+
// returned first claim ALL revenue (a category rate is keyed per
|
|
438
|
+
// (jurisdiction, category) so it can legally coexist with the
|
|
439
|
+
// fallback in the same window), making owed both wrong and
|
|
440
|
+
// order-dependent. Restricting to the fallback rate makes owed
|
|
441
|
+
// deterministic; tax_collected_minor still reflects the actual
|
|
442
|
+
// per-order tax each order was charged at its own (category-aware)
|
|
443
|
+
// rate at order time.
|
|
391
444
|
if (!taxRatesApi) return [];
|
|
392
445
|
try {
|
|
393
446
|
var r = await query(
|
|
394
447
|
"SELECT id, jurisdiction, category, rate_bps, effective_from, effective_until " +
|
|
395
448
|
"FROM tax_rates " +
|
|
396
449
|
"WHERE jurisdiction = ?1 AND archived_at IS NULL " +
|
|
450
|
+
" AND category IS NULL " +
|
|
397
451
|
" AND effective_from < ?2 " +
|
|
398
452
|
" AND (effective_until IS NULL OR effective_until > ?3)",
|
|
399
453
|
[jurisdiction, periodEnd, periodStart],
|
|
@@ -486,6 +540,7 @@ function create(opts) {
|
|
|
486
540
|
|
|
487
541
|
var orders = await _ordersInWindow(raw.jurisdiction, Number(raw.period_start), Number(raw.period_end));
|
|
488
542
|
var rates = await _ratesInWindow(raw.jurisdiction, Number(raw.period_start), Number(raw.period_end));
|
|
543
|
+
var refunds = await _refundsInWindow(Number(raw.period_start), Number(raw.period_end));
|
|
489
544
|
|
|
490
545
|
// Resolve exemption per unique customer in a single pass so
|
|
491
546
|
// multiple orders from the same exempt customer don't refire the
|
|
@@ -528,13 +583,26 @@ function create(opts) {
|
|
|
528
583
|
|
|
529
584
|
for (var i = 0; i < orders.length; i += 1) {
|
|
530
585
|
var o = orders[i];
|
|
531
|
-
|
|
532
|
-
|
|
586
|
+
// Net any partial refund recorded inside the window. A refund of
|
|
587
|
+
// grand-total cash is apportioned to the order's subtotal and tax
|
|
588
|
+
// by its own structure, so the filing reflects NET taxable sales
|
|
589
|
+
// and NET collected tax — not the pre-refund figures.
|
|
590
|
+
var netSub = o.subtotal_minor;
|
|
591
|
+
var netTax = o.tax_minor;
|
|
592
|
+
var refunded = refunds[o.id] || 0;
|
|
593
|
+
if (refunded > 0 && o.grand_total_minor > 0) {
|
|
594
|
+
netSub = o.subtotal_minor - _apportionMinor(refunded, o.subtotal_minor, o.grand_total_minor);
|
|
595
|
+
netTax = o.tax_minor - _apportionMinor(refunded, o.tax_minor, o.grand_total_minor);
|
|
596
|
+
if (netSub < 0) netSub = 0;
|
|
597
|
+
if (netTax < 0) netTax = 0;
|
|
598
|
+
}
|
|
599
|
+
gross += netSub;
|
|
600
|
+
collected += netTax;
|
|
533
601
|
var isExempt = o.customer_id && exemptByCustomer[o.customer_id] === true;
|
|
534
602
|
if (isExempt) {
|
|
535
|
-
exempt +=
|
|
603
|
+
exempt += netSub;
|
|
536
604
|
} else {
|
|
537
|
-
taxable +=
|
|
605
|
+
taxable += netSub;
|
|
538
606
|
}
|
|
539
607
|
|
|
540
608
|
// Per-rate breakdown — only taxable contributions land in the
|
|
@@ -552,8 +620,8 @@ function create(opts) {
|
|
|
552
620
|
if (isExempt) {
|
|
553
621
|
bkt.taxable_minor += 0;
|
|
554
622
|
} else {
|
|
555
|
-
bkt.taxable_minor +=
|
|
556
|
-
bkt.tax_minor +=
|
|
623
|
+
bkt.taxable_minor += netSub;
|
|
624
|
+
bkt.tax_minor += netTax;
|
|
557
625
|
}
|
|
558
626
|
bkt.order_count += 1;
|
|
559
627
|
}
|
package/lib/storefront.js
CHANGED
|
@@ -21208,10 +21208,17 @@ function mount(router, deps) {
|
|
|
21208
21208
|
// Convert each member's line allocation to a uniform integer per-unit
|
|
21209
21209
|
// price (floor) and tally the cents lost to that floor across all
|
|
21210
21210
|
// lines. A single-unit line (the usual bundle shape) absorbs the whole
|
|
21211
|
-
// shortfall exactly
|
|
21212
|
-
// steps
|
|
21213
|
-
//
|
|
21214
|
-
//
|
|
21211
|
+
// shortfall exactly. Otherwise the cents are returned only in whole
|
|
21212
|
+
// qty-sized steps (each step is a per-unit +1 on a line, i.e. exactly
|
|
21213
|
+
// that line's quantity in cents), smallest-quantity lines first. A
|
|
21214
|
+
// final sub-step residual — strictly less than every remaining line's
|
|
21215
|
+
// quantity — CANNOT be returned without bumping a multi-unit line's
|
|
21216
|
+
// per-unit price, which would raise that line's realized total by a
|
|
21217
|
+
// whole quantity-step and push the realized subtotal ABOVE the quoted
|
|
21218
|
+
// bundle price the customer saw on the PDP. That residual is left off:
|
|
21219
|
+
// the realized subtotal is at most the quote, never above it (a
|
|
21220
|
+
// ≤ (min member qty − 1)-cent undercharge in the cases the integer
|
|
21221
|
+
// per-unit price cannot represent exactly — favouring the customer).
|
|
21215
21222
|
var lostCents = 0;
|
|
21216
21223
|
for (var u = 0; u < members.length; u += 1) {
|
|
21217
21224
|
var me = members[u];
|
|
@@ -21225,10 +21232,34 @@ function mount(router, deps) {
|
|
|
21225
21232
|
if (single) {
|
|
21226
21233
|
single.unit += lostCents;
|
|
21227
21234
|
} else {
|
|
21228
|
-
|
|
21229
|
-
|
|
21235
|
+
// Each whole step (a +1 on a member's per-unit price) returns
|
|
21236
|
+
// exactly that member's quantity in cents. Recover the MOST cents
|
|
21237
|
+
// possible WITHOUT exceeding lostCents — a greedy pass can strand
|
|
21238
|
+
// cents a different member would recover exactly (quantities 5 and
|
|
21239
|
+
// 7, residual 7: bumping the qty-7 line is exact, but spending 5 on
|
|
21240
|
+
// the qty-5 line first leaves an unrecoverable 2). This is bounded
|
|
21241
|
+
// coin-change over the member quantities: reach[v] holds a per-line
|
|
21242
|
+
// bump-count vector summing to exactly v cents, or null if v is not
|
|
21243
|
+
// reachable. The largest reachable v ≤ lostCents is applied; any
|
|
21244
|
+
// remainder below the smallest reachable step is genuinely
|
|
21245
|
+
// unrepresentable with one integer per-unit price and is dropped in
|
|
21246
|
+
// the customer's favour — never bumped up past the quoted price.
|
|
21247
|
+
var reach = new Array(lostCents + 1);
|
|
21248
|
+
reach[0] = byQty.map(function () { return 0; });
|
|
21249
|
+
for (var v = 1; v <= lostCents; v += 1) {
|
|
21250
|
+
reach[v] = null;
|
|
21251
|
+
for (var c = 0; c < byQty.length; c += 1) {
|
|
21252
|
+
if (byQty[c].qty <= v && reach[v - byQty[c].qty]) {
|
|
21253
|
+
reach[v] = reach[v - byQty[c].qty].slice();
|
|
21254
|
+
reach[v][c] += 1;
|
|
21255
|
+
break;
|
|
21256
|
+
}
|
|
21257
|
+
}
|
|
21230
21258
|
}
|
|
21231
|
-
|
|
21259
|
+
var bestSum = lostCents;
|
|
21260
|
+
while (bestSum > 0 && !reach[bestSum]) bestSum -= 1;
|
|
21261
|
+
var bumps = reach[bestSum] || byQty.map(function () { return 0; });
|
|
21262
|
+
for (var g = 0; g < byQty.length; g += 1) byQty[g].unit += bumps[g];
|
|
21232
21263
|
}
|
|
21233
21264
|
}
|
|
21234
21265
|
|
package/package.json
CHANGED