@blamejs/blamejs-shop 0.4.113 → 0.4.114
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 +2 -0
- package/lib/asset-manifest.json +1 -1
- package/lib/plan-changes.js +155 -52
- package/package.json +1 -1
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.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
|
+
|
|
11
13
|
- 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.
|
|
12
14
|
|
|
13
15
|
- v0.4.112 (2026-06-26) — **The edge Stripe-webhook signature check now uses the framework's hardened verifier instead of a hand-rolled parse.** The Cloudflare edge worker verifies the Stripe webhook signature before forwarding a delivery to the container. That edge check previously parsed the `t=`/`v1=` signature header and compared the HMAC by hand. It now composes the framework's Stripe-signature verifier (HMAC-SHA256, the scheme Stripe publishes), which adds a bound on the signature-header size and on each `v1` hex length (so a malformed or oversized header is rejected cheaply), refuses a tolerance window below 30 seconds, matches across every `v1` signature a header carries, and compares in constant time. The container still re-verifies the same signature authoritatively, so this is a stricter front-line filter, not the only line of defense. Valid Stripe deliveries verify exactly as before; an unsigned, tampered, replayed, or oversized delivery is now refused at the edge by the same hardened code path the container uses. No configuration changes and no operator action required. **Changed:** *Edge Stripe-webhook verification composes the framework's hardened signature verifier* — The edge worker's inbound Stripe-signature pre-check no longer hand-parses the `t=`/`v1=` header and hand-compares the HMAC. It now calls the framework's Stripe verifier (HMAC-SHA256 over `<timestamp>.<body>`, the format Stripe documents), which applies a signature-header size cap, a per-`v1` hex-length cap, a minimum 30-second tolerance floor, matching across all `v1` signatures present, and a constant-time digest comparison — then forwards to the container, which re-verifies authoritatively (defense in depth). Valid deliveries are unaffected; unsigned, tampered, replayed, or oversized deliveries are refused at the edge before any container resource is touched. The framework's webhook module resolves its outbound HTTP client and delivery dispatcher only on the send path, so importing it solely to verify an inbound signature pulls no networking into the edge bundle.
|
package/lib/asset-manifest.json
CHANGED
package/lib/plan-changes.js
CHANGED
|
@@ -130,6 +130,8 @@ function _now() { return Date.now(); }
|
|
|
130
130
|
// caller's validation upstream (proposeChange) refuses those shapes
|
|
131
131
|
// before they reach here, but the function stays defensive so a
|
|
132
132
|
// future direct caller can't divide-by-zero through it.
|
|
133
|
+
function _max0(n) { return n < 0 ? 0 : n; }
|
|
134
|
+
|
|
133
135
|
function _prorate(fromAmount, toAmount, periodStart, periodEnd, effectiveAt) {
|
|
134
136
|
var periodMs = periodEnd - periodStart;
|
|
135
137
|
if (periodMs <= 0) {
|
|
@@ -168,6 +170,18 @@ function create(opts) {
|
|
|
168
170
|
// a parallel surface.
|
|
169
171
|
var billingHandle = opts.subscriptionBilling || null;
|
|
170
172
|
|
|
173
|
+
// `storeCredit` is the vehicle for the credit a mid-cycle DOWNGRADE
|
|
174
|
+
// leaves the customer holding: when the outgoing plan's unused
|
|
175
|
+
// remainder exceeds the incoming plan's partial-period charge, the
|
|
176
|
+
// difference is money the customer is owed. The proration invoice can
|
|
177
|
+
// only carry a charge (it clamps a negative net to zero), so without a
|
|
178
|
+
// credit vehicle that remainder would be silently dropped. When a
|
|
179
|
+
// downgrade owes a credit, it is issued to this handle's hash-chained
|
|
180
|
+
// ledger; if the handle is absent the change is refused (executeChange
|
|
181
|
+
// throws) or left pending (applyScheduledChanges) rather than applied
|
|
182
|
+
// with the credit lost.
|
|
183
|
+
var storeCreditHandle = opts.storeCredit || null;
|
|
184
|
+
|
|
171
185
|
async function _getSubscription(subscriptionId) {
|
|
172
186
|
// Prefer the injected handle (production composition); fall back
|
|
173
187
|
// to a direct read so tests that pass a minimal handle
|
|
@@ -198,6 +212,61 @@ function create(opts) {
|
|
|
198
212
|
return r.rows[0] || null;
|
|
199
213
|
}
|
|
200
214
|
|
|
215
|
+
// A downgrade taking effect now owes the customer a credit the invoice
|
|
216
|
+
// ledger cannot carry. Refuse if there is no vehicle to pay it.
|
|
217
|
+
function _assertCreditable(creditMinor) {
|
|
218
|
+
if (creditMinor > 0 && !storeCreditHandle) {
|
|
219
|
+
throw new TypeError(
|
|
220
|
+
"planChanges: a mid-cycle downgrade owes a " + creditMinor +
|
|
221
|
+
"-minor store credit, but no storeCredit handle was provided to " +
|
|
222
|
+
"planChanges.create — wire one or schedule the change to next_billing_cycle"
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Issue the credit a won downgrade owes to the customer's store-credit
|
|
228
|
+
// ledger. The won-transition claim makes this exactly-once; the caller
|
|
229
|
+
// pairs it with a revert-on-throw so a transient write failure cannot
|
|
230
|
+
// finalize a downgrade whose credit was never paid (the credit is the
|
|
231
|
+
// customer's money — it must be retryable, not lost). _assertCreditable
|
|
232
|
+
// (executeChange) / the pre-claim skip (applyScheduledChanges) guarantee
|
|
233
|
+
// storeCreditHandle is present whenever creditMinor > 0.
|
|
234
|
+
async function _issueDowngradeCredit(o) {
|
|
235
|
+
await storeCreditHandle.credit({
|
|
236
|
+
customer_id: o.customerId,
|
|
237
|
+
amount_minor: o.creditMinor,
|
|
238
|
+
source: "refund",
|
|
239
|
+
// Correlates the credit row to the plan-change that produced it.
|
|
240
|
+
source_ref: "subscription-downgrade:" + o.planChangeId,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Record the proration charge through the invoice ledger. Drop-silent —
|
|
245
|
+
// recordInvoice is an optional dashboard record, so a failure must not
|
|
246
|
+
// crash the transition that landed; the caller observes the gap through
|
|
247
|
+
// the billing ledger query. (The owed CREDIT, by contrast, is never
|
|
248
|
+
// dropped — see _issueDowngradeCredit + the callers' revert-on-throw.)
|
|
249
|
+
async function _recordProrationInvoice(o) {
|
|
250
|
+
if (!billingHandle || typeof billingHandle.recordInvoice !== "function") return;
|
|
251
|
+
try {
|
|
252
|
+
// Plans store currency lowercase ('usd'); recordInvoice expects
|
|
253
|
+
// uppercase ISO 4217. Normalize at the boundary.
|
|
254
|
+
await billingHandle.recordInvoice({
|
|
255
|
+
subscription_id: o.subscriptionId,
|
|
256
|
+
period_start: o.periodStart,
|
|
257
|
+
period_end: o.periodEnd,
|
|
258
|
+
amount_minor: o.chargeMinor,
|
|
259
|
+
currency: String(o.currency).toUpperCase(),
|
|
260
|
+
// Deterministic idempotency key — the change applies once, so a
|
|
261
|
+
// re-run dedupes against the UNIQUE processor_invoice_id rather
|
|
262
|
+
// than writing a second proration invoice.
|
|
263
|
+
processor_invoice_id: "planchange:" + o.planChangeId,
|
|
264
|
+
});
|
|
265
|
+
} catch (_e) {
|
|
266
|
+
// Drop-silent — by design.
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
201
270
|
return {
|
|
202
271
|
CHANGE_KINDS: CHANGE_KINDS.slice(),
|
|
203
272
|
STATUSES: STATUSES.slice(),
|
|
@@ -326,6 +395,12 @@ function create(opts) {
|
|
|
326
395
|
var sub = await _getSubscription(subscriptionId);
|
|
327
396
|
var id = b.uuid.v7();
|
|
328
397
|
var ts = _now();
|
|
398
|
+
// Split the proration into the charge (billed via the invoice
|
|
399
|
+
// ledger) and the credit (owed to the customer). Exactly one is
|
|
400
|
+
// non-zero: an upgrade charges the difference, a downgrade leaves
|
|
401
|
+
// the customer in credit for the outgoing plan's unused remainder.
|
|
402
|
+
var chargeMinor = _max0(proposed.first_charge_minor - proposed.proration_credit_minor);
|
|
403
|
+
var creditMinor = _max0(proposed.proration_credit_minor - proposed.first_charge_minor);
|
|
329
404
|
// Status: `executed` when the effective clock is now-or-past,
|
|
330
405
|
// `pending` when the change is queued for a future clock
|
|
331
406
|
// (typically next_billing_cycle but also any future
|
|
@@ -334,6 +409,13 @@ function create(opts) {
|
|
|
334
409
|
var status = proposed.effective_at <= ts ? "executed" : "pending";
|
|
335
410
|
var executedAt = status === "executed" ? ts : null;
|
|
336
411
|
|
|
412
|
+
// Fail closed: a downgrade taking effect now owes the customer a
|
|
413
|
+
// credit, and the store-credit ledger is the only vehicle for it.
|
|
414
|
+
// Refuse before any row is written rather than apply the transition
|
|
415
|
+
// and silently drop the credit. (A future-dated downgrade is left
|
|
416
|
+
// pending; applyScheduledChanges re-checks at execution time.)
|
|
417
|
+
if (status === "executed") _assertCreditable(creditMinor);
|
|
418
|
+
|
|
337
419
|
await query(
|
|
338
420
|
"INSERT INTO subscription_plan_changes " +
|
|
339
421
|
"(id, subscription_id, from_plan_id, to_plan_id, change_kind, status, " +
|
|
@@ -361,39 +443,41 @@ function create(opts) {
|
|
|
361
443
|
[newPlanId, ts, subscriptionId, sub.plan_id],
|
|
362
444
|
);
|
|
363
445
|
var wonTransition = Number((planUpd && planUpd.rowCount) || 0) > 0;
|
|
364
|
-
if (wonTransition
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
}
|
|
390
|
-
} catch (_e) {
|
|
391
|
-
// Drop-silent — by design. The billing handle is an
|
|
392
|
-
// optional composition; a recordInvoice failure must not
|
|
393
|
-
// crash executeChange (the plan transition itself
|
|
394
|
-
// landed). The caller observes the invoice gap through
|
|
395
|
-
// the billing handle's own ledger query.
|
|
446
|
+
if (wonTransition) {
|
|
447
|
+
if (creditMinor > 0) {
|
|
448
|
+
try {
|
|
449
|
+
await _issueDowngradeCredit({
|
|
450
|
+
customerId: sub.customer_id,
|
|
451
|
+
creditMinor: creditMinor,
|
|
452
|
+
planChangeId: id,
|
|
453
|
+
});
|
|
454
|
+
} catch (e) {
|
|
455
|
+
// Revert-on-throw: the credit is the customer's money, so a
|
|
456
|
+
// transient write failure must not finalize a downgrade it
|
|
457
|
+
// was never paid for. Roll the subscription back to the
|
|
458
|
+
// outgoing plan (guarded on the just-applied plan id so a
|
|
459
|
+
// concurrent change isn't clobbered) and void this change,
|
|
460
|
+
// then rethrow so the caller retries from a clean state.
|
|
461
|
+
await query(
|
|
462
|
+
"UPDATE subscriptions SET plan_id = ?1, updated_at = ?2 WHERE id = ?3 AND plan_id = ?4",
|
|
463
|
+
[sub.plan_id, _now(), subscriptionId, newPlanId],
|
|
464
|
+
);
|
|
465
|
+
await query(
|
|
466
|
+
"UPDATE subscription_plan_changes SET status = 'cancelled', cancelled_at = ?1, " +
|
|
467
|
+
"cancel_reason = ?2 WHERE id = ?3",
|
|
468
|
+
[_now(), "credit-settlement-failed", id],
|
|
469
|
+
);
|
|
470
|
+
throw e;
|
|
471
|
+
}
|
|
396
472
|
}
|
|
473
|
+
await _recordProrationInvoice({
|
|
474
|
+
subscriptionId: subscriptionId,
|
|
475
|
+
chargeMinor: chargeMinor,
|
|
476
|
+
currency: proposed.currency,
|
|
477
|
+
periodStart: proposed.effective_at,
|
|
478
|
+
periodEnd: sub.current_period_end,
|
|
479
|
+
planChangeId: id,
|
|
480
|
+
});
|
|
397
481
|
}
|
|
398
482
|
}
|
|
399
483
|
|
|
@@ -472,6 +556,14 @@ function create(opts) {
|
|
|
472
556
|
var executed = [];
|
|
473
557
|
for (var i = 0; i < due.rows.length; i += 1) {
|
|
474
558
|
var row = due.rows[i];
|
|
559
|
+
var chargeMinor = _max0(row.first_charge_minor - row.proration_credit_minor);
|
|
560
|
+
var creditMinor = _max0(row.proration_credit_minor - row.first_charge_minor);
|
|
561
|
+
// Fail closed: a due downgrade owes the customer a credit. If no
|
|
562
|
+
// store-credit vehicle is wired, leave the row PENDING (skip
|
|
563
|
+
// before claiming) so it applies on a later sweep once the handle
|
|
564
|
+
// exists — never transition it and drop the credit.
|
|
565
|
+
if (creditMinor > 0 && !storeCreditHandle) continue;
|
|
566
|
+
|
|
475
567
|
// Atomic claim: flip pending -> executed gated on the status.
|
|
476
568
|
// Two overlapping scheduler runs (cron overrun, retry, two
|
|
477
569
|
// workers) both SELECT the same due row; WITHOUT this guard both
|
|
@@ -491,31 +583,42 @@ function create(opts) {
|
|
|
491
583
|
"UPDATE subscriptions SET plan_id = ?1, updated_at = ?2 WHERE id = ?3",
|
|
492
584
|
[row.to_plan_id, now, row.subscription_id],
|
|
493
585
|
);
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
var periodEnd = subRow && subRow.current_period_end != null
|
|
499
|
-
? subRow.current_period_end : row.effective_at;
|
|
586
|
+
var subRow = await _getSubscription(row.subscription_id);
|
|
587
|
+
var periodEnd = subRow && subRow.current_period_end != null
|
|
588
|
+
? subRow.current_period_end : row.effective_at;
|
|
589
|
+
if (creditMinor > 0) {
|
|
500
590
|
try {
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
subscription_id: row.subscription_id,
|
|
506
|
-
period_start: row.effective_at,
|
|
507
|
-
period_end: periodEnd,
|
|
508
|
-
amount_minor: net,
|
|
509
|
-
currency: String(row.currency).toUpperCase(),
|
|
510
|
-
// Deterministic idempotency key — this change applies once,
|
|
511
|
-
// so a re-run dedupes against the UNIQUE processor_invoice_id
|
|
512
|
-
// rather than writing a second proration invoice.
|
|
513
|
-
processor_invoice_id: "planchange:" + row.id,
|
|
591
|
+
await _issueDowngradeCredit({
|
|
592
|
+
customerId: subRow ? subRow.customer_id : null,
|
|
593
|
+
creditMinor: creditMinor,
|
|
594
|
+
planChangeId: row.id,
|
|
514
595
|
});
|
|
515
596
|
} catch (_e) {
|
|
516
|
-
//
|
|
597
|
+
// Revert-on-throw, then carry on with the batch: the credit is
|
|
598
|
+
// the customer's money, so a transient write failure must not
|
|
599
|
+
// finalize this downgrade. Roll the plan back and return the row
|
|
600
|
+
// to 'pending' (guarded on the just-applied state) so a later
|
|
601
|
+
// sweep retries it cleanly; one bad row must not abort the run.
|
|
602
|
+
await query(
|
|
603
|
+
"UPDATE subscriptions SET plan_id = ?1, updated_at = ?2 WHERE id = ?3 AND plan_id = ?4",
|
|
604
|
+
[row.from_plan_id, now, row.subscription_id, row.to_plan_id],
|
|
605
|
+
);
|
|
606
|
+
await query(
|
|
607
|
+
"UPDATE subscription_plan_changes SET status = 'pending', executed_at = NULL " +
|
|
608
|
+
"WHERE id = ?1 AND status = 'executed'",
|
|
609
|
+
[row.id],
|
|
610
|
+
);
|
|
611
|
+
continue;
|
|
517
612
|
}
|
|
518
613
|
}
|
|
614
|
+
await _recordProrationInvoice({
|
|
615
|
+
subscriptionId: row.subscription_id,
|
|
616
|
+
chargeMinor: chargeMinor,
|
|
617
|
+
currency: row.currency,
|
|
618
|
+
periodStart: row.effective_at,
|
|
619
|
+
periodEnd: periodEnd,
|
|
620
|
+
planChangeId: row.id,
|
|
621
|
+
});
|
|
519
622
|
executed.push(await _getById(row.id));
|
|
520
623
|
}
|
|
521
624
|
return executed;
|
package/package.json
CHANGED