@blamejs/blamejs-shop 0.0.61 → 0.0.64

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.
@@ -0,0 +1,508 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.planChanges
4
+ * @title Subscription plan changes — proration-aware upgrade / downgrade
5
+ *
6
+ * @intro
7
+ * Customer is on plan A, switches to plan B. The transition needs
8
+ * to answer three questions every operator dashboard / customer
9
+ * portal asks:
10
+ *
11
+ * 1. How much credit does the customer get for the unused
12
+ * portion of the current billing period on plan A?
13
+ * 2. What's the first prorated charge on plan B for the partial
14
+ * period between the change clock and the next billing date?
15
+ * 3. When does the change land — immediately (the customer
16
+ * upgraded and wants the new tier now) or at the next
17
+ * billing cycle (the customer downgraded and the operator
18
+ * defers the change to keep the current period whole)?
19
+ *
20
+ * `proposeChange` answers all three without persisting state.
21
+ * `executeChange` writes the transition + queues the proration
22
+ * adjustments via `subscriptionBilling` when injected. The
23
+ * scheduler-callable `applyScheduledChanges` walks every row whose
24
+ * `effective_at <= now AND status = 'pending'` and flips them to
25
+ * executed.
26
+ *
27
+ * Composition:
28
+ * var pc = bShop.planChanges.create({
29
+ * query: q,
30
+ * subscriptions: subs.subscriptions,
31
+ * subscriptionBilling: bill, // optional
32
+ * });
33
+ * var plan = await pc.proposeChange({
34
+ * subscription_id, new_plan_id, change_at,
35
+ * });
36
+ * await pc.executeChange({ subscription_id, new_plan_id, change_kind });
37
+ * await pc.cancelPendingChange({ subscription_id, reason });
38
+ * await pc.pendingChangeFor(subscription_id);
39
+ * await pc.historyForSubscription(subscription_id);
40
+ * await pc.applyScheduledChanges({ now: Date.now() });
41
+ *
42
+ * Proration math (minor units, integer-only):
43
+ *
44
+ * periodMs = current_period_end - current_period_start
45
+ * usedMs = effective_at - current_period_start
46
+ * remainingMs = current_period_end - effective_at
47
+ *
48
+ * proration_credit_minor = floor(from_plan.amount_minor * remainingMs / periodMs)
49
+ * first_charge_minor = floor(to_plan.amount_minor * remainingMs / periodMs)
50
+ *
51
+ * `next_billing_cycle` proration is zero on both sides — the
52
+ * outgoing plan rides out the period in full, the incoming plan
53
+ * starts clean at the next cycle.
54
+ *
55
+ * Currency mismatch between from/to plan throws — cross-currency
56
+ * migrations require an FX layer the caller composes outside this
57
+ * primitive (the same posture `subscriptionBilling.arpu` takes for
58
+ * cross-currency aggregation).
59
+ *
60
+ * @related b.guardUuid, b.uuid.v7
61
+ */
62
+
63
+ var bShop;
64
+ function _b() {
65
+ if (!bShop) bShop = require("./index");
66
+ return bShop.framework;
67
+ }
68
+
69
+ // ---- constants ----------------------------------------------------------
70
+
71
+ var CHANGE_KINDS = ["immediate", "next_billing_cycle"];
72
+ var STATUSES = ["proposed", "pending", "executed", "cancelled"];
73
+ var MAX_REASON_LEN = 280;
74
+
75
+ // Reuse the same control-byte / zero-width refusal posture as the
76
+ // sibling subscription-billing primitive — operator-authored prose
77
+ // can land in cancel_reason and replay into the customer profile
78
+ // screen, so the same "no direction-override / no invisible glyph"
79
+ // floor applies.
80
+ var CONTROL_BYTE_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
81
+ var ZERO_WIDTH_RE = new RegExp(
82
+ "[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
83
+ );
84
+
85
+ // ---- validators ---------------------------------------------------------
86
+
87
+ function _uuid(s, label) {
88
+ try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
89
+ catch (e) { throw new TypeError("planChanges: " + label + " — " + (e && e.message || "invalid UUID")); }
90
+ }
91
+
92
+ function _epochMs(n, label) {
93
+ if (typeof n !== "number" || !Number.isInteger(n) || n <= 0) {
94
+ throw new TypeError("planChanges: " + label + " must be a positive integer (epoch ms)");
95
+ }
96
+ return n;
97
+ }
98
+
99
+ function _epochMsOrNull(n, label) {
100
+ if (n == null) return null;
101
+ return _epochMs(n, label);
102
+ }
103
+
104
+ function _changeKind(s) {
105
+ if (typeof s !== "string" || CHANGE_KINDS.indexOf(s) === -1) {
106
+ throw new TypeError("planChanges: change_kind must be one of " + CHANGE_KINDS.join(", "));
107
+ }
108
+ return s;
109
+ }
110
+
111
+ function _optReason(s) {
112
+ if (s == null) return null;
113
+ if (typeof s !== "string" || !s.length) {
114
+ throw new TypeError("planChanges: reason must be a non-empty string when provided");
115
+ }
116
+ if (s.length > MAX_REASON_LEN) {
117
+ throw new TypeError("planChanges: reason must be <= " + MAX_REASON_LEN + " characters");
118
+ }
119
+ if (CONTROL_BYTE_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
120
+ throw new TypeError("planChanges: reason contains control / zero-width bytes");
121
+ }
122
+ return s;
123
+ }
124
+
125
+ function _now() { return Date.now(); }
126
+
127
+ // ---- proration math -----------------------------------------------------
128
+
129
+ // Pure function, exported under the factory + as a module-level
130
+ // surface for tests / external callers that want to reason about the
131
+ // math without instantiating the factory. Returns integer minor
132
+ // units. Inputs that produce a non-positive period collapse to
133
+ // (credit=0, charge=fromAmount=0 / toAmount on the new plan); the
134
+ // caller's validation upstream (proposeChange) refuses those shapes
135
+ // before they reach here, but the function stays defensive so a
136
+ // future direct caller can't divide-by-zero through it.
137
+ function _prorate(fromAmount, toAmount, periodStart, periodEnd, effectiveAt) {
138
+ var periodMs = periodEnd - periodStart;
139
+ if (periodMs <= 0) {
140
+ return { proration_credit_minor: 0, first_charge_minor: 0 };
141
+ }
142
+ var clampedEffective = effectiveAt;
143
+ if (clampedEffective < periodStart) clampedEffective = periodStart;
144
+ if (clampedEffective > periodEnd) clampedEffective = periodEnd;
145
+ var remainingMs = periodEnd - clampedEffective;
146
+ // Integer math throughout — minor units are integers, and the
147
+ // (amount * remaining / period) shape multiplies before dividing
148
+ // so a small remaining window doesn't truncate prematurely.
149
+ var credit = Math.floor((fromAmount * remainingMs) / periodMs);
150
+ var charge = Math.floor((toAmount * remainingMs) / periodMs);
151
+ return { proration_credit_minor: credit, first_charge_minor: charge };
152
+ }
153
+
154
+ // ---- factory ------------------------------------------------------------
155
+
156
+ function create(opts) {
157
+ opts = opts || {};
158
+ var query = opts.query;
159
+ if (!query) {
160
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
161
+ }
162
+ var subscriptionsHandle = opts.subscriptions;
163
+ if (!subscriptionsHandle || typeof subscriptionsHandle.get !== "function") {
164
+ throw new TypeError("planChanges.create: opts.subscriptions handle required");
165
+ }
166
+ // `subscriptionBilling` is optional. When wired, `executeChange`
167
+ // queues the proration adjustments through it
168
+ // (`recordInvoice({ amount_minor: first_charge_minor - proration_credit_minor })`)
169
+ // so the operator dashboard's invoice ledger reflects the
170
+ // transition. When absent, the row still persists; the operator
171
+ // wires the billing handle later or queues the adjustments via
172
+ // a parallel surface.
173
+ var billingHandle = opts.subscriptionBilling || null;
174
+
175
+ async function _getSubscription(subscriptionId) {
176
+ // Prefer the injected handle (production composition); fall back
177
+ // to a direct read so tests that pass a minimal handle
178
+ // ({ get: ... }) still see consistent rows.
179
+ var row = await subscriptionsHandle.get(subscriptionId);
180
+ if (row) return row;
181
+ var r = await query("SELECT * FROM subscriptions WHERE id = ?1", [subscriptionId]);
182
+ return r.rows[0] || null;
183
+ }
184
+
185
+ async function _getPlan(planId) {
186
+ var r = await query("SELECT * FROM subscription_plans WHERE id = ?1", [planId]);
187
+ return r.rows[0] || null;
188
+ }
189
+
190
+ async function _pendingFor(subscriptionId) {
191
+ var r = await query(
192
+ "SELECT * FROM subscription_plan_changes " +
193
+ "WHERE subscription_id = ?1 AND status IN ('proposed', 'pending') " +
194
+ "ORDER BY created_at DESC, id DESC LIMIT 1",
195
+ [subscriptionId],
196
+ );
197
+ return r.rows[0] || null;
198
+ }
199
+
200
+ async function _getById(id) {
201
+ var r = await query("SELECT * FROM subscription_plan_changes WHERE id = ?1", [id]);
202
+ return r.rows[0] || null;
203
+ }
204
+
205
+ return {
206
+ CHANGE_KINDS: CHANGE_KINDS.slice(),
207
+ STATUSES: STATUSES.slice(),
208
+ MAX_REASON_LEN: MAX_REASON_LEN,
209
+
210
+ // Exposed for tests + callers that want the pure math without
211
+ // round-tripping the factory.
212
+ prorate: _prorate,
213
+
214
+ proposeChange: async function (input) {
215
+ if (!input || typeof input !== "object") {
216
+ throw new TypeError("planChanges.proposeChange: input object required");
217
+ }
218
+ var subscriptionId = _uuid(input.subscription_id, "subscription_id");
219
+ var newPlanId = _uuid(input.new_plan_id, "new_plan_id");
220
+ var changeAt = _epochMsOrNull(input.change_at, "change_at");
221
+
222
+ var sub = await _getSubscription(subscriptionId);
223
+ if (!sub) {
224
+ var nf = new Error("planChanges.proposeChange: subscription " + subscriptionId + " not found");
225
+ nf.code = "SUBSCRIPTION_NOT_FOUND";
226
+ throw nf;
227
+ }
228
+ if (sub.plan_id === newPlanId) {
229
+ throw new TypeError("planChanges.proposeChange: new_plan_id is the same as the current plan");
230
+ }
231
+ var fromPlan = await _getPlan(sub.plan_id);
232
+ var toPlan = await _getPlan(newPlanId);
233
+ if (!fromPlan) {
234
+ var nfFrom = new Error("planChanges.proposeChange: from-plan " + sub.plan_id + " not found");
235
+ nfFrom.code = "PLAN_NOT_FOUND";
236
+ throw nfFrom;
237
+ }
238
+ if (!toPlan) {
239
+ var nfTo = new Error("planChanges.proposeChange: to-plan " + newPlanId + " not found");
240
+ nfTo.code = "PLAN_NOT_FOUND";
241
+ throw nfTo;
242
+ }
243
+ if (!toPlan.active) {
244
+ throw new TypeError("planChanges.proposeChange: to-plan " + newPlanId + " is archived");
245
+ }
246
+ if (fromPlan.currency !== toPlan.currency) {
247
+ throw new TypeError(
248
+ "planChanges.proposeChange: cross-currency change refused (" +
249
+ fromPlan.currency + " → " + toPlan.currency + ")"
250
+ );
251
+ }
252
+ if (sub.current_period_start == null || sub.current_period_end == null) {
253
+ throw new TypeError("planChanges.proposeChange: subscription has no current billing period");
254
+ }
255
+
256
+ var effectiveAt = changeAt == null ? _now() : changeAt;
257
+ // Decide kind. The caller can override by passing
258
+ // change_at >= current_period_end (next-cycle) or omitting it
259
+ // (immediate at Date.now()). The kind is derived, not passed —
260
+ // the operator's intent reads off the clock they pick.
261
+ var kind;
262
+ if (effectiveAt >= sub.current_period_end) {
263
+ kind = "next_billing_cycle";
264
+ effectiveAt = sub.current_period_end;
265
+ } else {
266
+ kind = "immediate";
267
+ }
268
+
269
+ var pror;
270
+ if (kind === "immediate") {
271
+ pror = _prorate(
272
+ fromPlan.amount_minor,
273
+ toPlan.amount_minor,
274
+ sub.current_period_start,
275
+ sub.current_period_end,
276
+ effectiveAt,
277
+ );
278
+ } else {
279
+ // next_billing_cycle — outgoing plan rides out the period in
280
+ // full, incoming plan starts clean at the next cycle. No
281
+ // proration applies on either side.
282
+ pror = { proration_credit_minor: 0, first_charge_minor: 0 };
283
+ }
284
+
285
+ return {
286
+ proration_credit_minor: pror.proration_credit_minor,
287
+ first_charge_minor: pror.first_charge_minor,
288
+ currency: fromPlan.currency,
289
+ effective_at: effectiveAt,
290
+ change_kind: kind,
291
+ from_plan_id: sub.plan_id,
292
+ to_plan_id: newPlanId,
293
+ };
294
+ },
295
+
296
+ executeChange: async function (input) {
297
+ if (!input || typeof input !== "object") {
298
+ throw new TypeError("planChanges.executeChange: input object required");
299
+ }
300
+ var subscriptionId = _uuid(input.subscription_id, "subscription_id");
301
+ var newPlanId = _uuid(input.new_plan_id, "new_plan_id");
302
+ // change_kind is optional on the input — when omitted, the
303
+ // primitive recomputes via proposeChange so the caller can pass
304
+ // (subscription_id, new_plan_id) alone and the row's kind
305
+ // derives off the clock. When passed, it's validated against
306
+ // the enum but the math is still recomputed against the live
307
+ // subscription period.
308
+ if (input.change_kind != null) _changeKind(input.change_kind);
309
+
310
+ // Refuse if a pending change already exists — the operator
311
+ // must cancel it first. Otherwise concurrent proposeChange
312
+ // calls would race a single subscription into a multi-pending
313
+ // state the scheduler couldn't disambiguate.
314
+ var existingPending = await _pendingFor(subscriptionId);
315
+ if (existingPending) {
316
+ var pErr = new Error(
317
+ "planChanges.executeChange: refused — subscription has a " +
318
+ existingPending.status + " change " + existingPending.id
319
+ );
320
+ pErr.code = "PLAN_CHANGE_REFUSED";
321
+ throw pErr;
322
+ }
323
+
324
+ var proposed = await this.proposeChange({
325
+ subscription_id: subscriptionId,
326
+ new_plan_id: newPlanId,
327
+ change_at: input.change_at,
328
+ });
329
+
330
+ var sub = await _getSubscription(subscriptionId);
331
+ var id = _b().uuid.v7();
332
+ var ts = _now();
333
+ // Status: `executed` when the effective clock is now-or-past,
334
+ // `pending` when the change is queued for a future clock
335
+ // (typically next_billing_cycle but also any future
336
+ // change_at). The scheduler's applyScheduledChanges walk flips
337
+ // pending → executed when the clock catches up.
338
+ var status = proposed.effective_at <= ts ? "executed" : "pending";
339
+ var executedAt = status === "executed" ? ts : null;
340
+
341
+ await query(
342
+ "INSERT INTO subscription_plan_changes " +
343
+ "(id, subscription_id, from_plan_id, to_plan_id, change_kind, status, " +
344
+ " proration_credit_minor, first_charge_minor, currency, effective_at, " +
345
+ " executed_at, cancelled_at, cancel_reason, created_at) " +
346
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, NULL, NULL, ?12)",
347
+ [
348
+ id, subscriptionId, sub.plan_id, newPlanId, proposed.change_kind, status,
349
+ proposed.proration_credit_minor, proposed.first_charge_minor, proposed.currency,
350
+ proposed.effective_at, executedAt, ts,
351
+ ],
352
+ );
353
+
354
+ if (status === "executed") {
355
+ await query(
356
+ "UPDATE subscriptions SET plan_id = ?1, updated_at = ?2 WHERE id = ?3",
357
+ [newPlanId, ts, subscriptionId],
358
+ );
359
+ if (billingHandle && typeof billingHandle.recordInvoice === "function") {
360
+ // Queue the proration adjustment as a single invoice row.
361
+ // The amount is the net (first_charge - credit); negative
362
+ // nets clamp to zero (the credit covers the partial-period
363
+ // charge in full — the unused remainder becomes a future-
364
+ // period credit the operator surfaces through a separate
365
+ // ledger entry outside this primitive's surface).
366
+ var net = proposed.first_charge_minor - proposed.proration_credit_minor;
367
+ if (net < 0) net = 0;
368
+ try {
369
+ // The `subscription_plans` table stores currency
370
+ // lowercase (`'usd'`); `subscriptionBilling.recordInvoice`
371
+ // expects uppercase ISO 4217. Normalize at the boundary
372
+ // so callers don't have to know the casing dialect each
373
+ // primitive picked.
374
+ await billingHandle.recordInvoice({
375
+ subscription_id: subscriptionId,
376
+ period_start: proposed.effective_at,
377
+ period_end: sub.current_period_end,
378
+ amount_minor: net,
379
+ currency: proposed.currency.toUpperCase(),
380
+ });
381
+ } catch (_e) {
382
+ // Drop-silent — by design. The billing handle is an
383
+ // optional composition; a recordInvoice failure must not
384
+ // crash executeChange (the plan transition itself
385
+ // landed). The caller observes the invoice gap through
386
+ // the billing handle's own ledger query.
387
+ }
388
+ }
389
+ }
390
+
391
+ return await _getById(id);
392
+ },
393
+
394
+ cancelPendingChange: async function (input) {
395
+ if (!input || typeof input !== "object") {
396
+ throw new TypeError("planChanges.cancelPendingChange: input object required");
397
+ }
398
+ var subscriptionId = _uuid(input.subscription_id, "subscription_id");
399
+ var reason = _optReason(input.reason);
400
+
401
+ var pending = await _pendingFor(subscriptionId);
402
+ if (!pending) {
403
+ var nf = new Error("planChanges.cancelPendingChange: no pending change for " + subscriptionId);
404
+ nf.code = "NO_PENDING_CHANGE";
405
+ throw nf;
406
+ }
407
+ // Defensive: `_pendingFor` already filters to proposed/pending,
408
+ // so this branch is unreachable through the public surface;
409
+ // it stays as a belt-and-braces refusal in case a caller
410
+ // bypasses _pendingFor via a future direct surface.
411
+ if (pending.status === "executed" || pending.status === "cancelled") {
412
+ var sErr = new Error(
413
+ "planChanges.cancelPendingChange: refused — change " +
414
+ pending.id + " is " + pending.status + " (terminal)"
415
+ );
416
+ sErr.code = "PLAN_CHANGE_STATE_REFUSED";
417
+ throw sErr;
418
+ }
419
+ var ts = _now();
420
+ await query(
421
+ "UPDATE subscription_plan_changes SET status = 'cancelled', cancelled_at = ?1, cancel_reason = ?2 " +
422
+ "WHERE id = ?3",
423
+ [ts, reason, pending.id],
424
+ );
425
+ return await _getById(pending.id);
426
+ },
427
+
428
+ pendingChangeFor: async function (subscriptionId) {
429
+ subscriptionId = _uuid(subscriptionId, "subscription_id");
430
+ return await _pendingFor(subscriptionId);
431
+ },
432
+
433
+ historyForSubscription: async function (subscriptionId) {
434
+ subscriptionId = _uuid(subscriptionId, "subscription_id");
435
+ var r = await query(
436
+ "SELECT * FROM subscription_plan_changes WHERE subscription_id = ?1 " +
437
+ "ORDER BY created_at DESC, id DESC",
438
+ [subscriptionId],
439
+ );
440
+ return r.rows;
441
+ },
442
+
443
+ // Scheduler-callable. Walks every row with `status = 'pending'`
444
+ // and `effective_at <= now`, flips them to executed, and updates
445
+ // the parent subscription row's plan_id. Queues the proration
446
+ // adjustment through `subscriptionBilling` when injected
447
+ // (drop-silent on recordInvoice failure, mirroring
448
+ // `executeChange`).
449
+ //
450
+ // Returns the list of executed change rows for the caller's
451
+ // logging / metrics. A cron wires this to a minute-cadence walk.
452
+ applyScheduledChanges: async function (input) {
453
+ if (!input || typeof input !== "object") {
454
+ throw new TypeError("planChanges.applyScheduledChanges: input object required");
455
+ }
456
+ var now = _epochMs(input.now, "now");
457
+ var due = await query(
458
+ "SELECT * FROM subscription_plan_changes " +
459
+ "WHERE status = 'pending' AND effective_at <= ?1 " +
460
+ "ORDER BY effective_at ASC, id ASC",
461
+ [now],
462
+ );
463
+ var executed = [];
464
+ for (var i = 0; i < due.rows.length; i += 1) {
465
+ var row = due.rows[i];
466
+ await query(
467
+ "UPDATE subscription_plan_changes SET status = 'executed', executed_at = ?1 WHERE id = ?2",
468
+ [now, row.id],
469
+ );
470
+ await query(
471
+ "UPDATE subscriptions SET plan_id = ?1, updated_at = ?2 WHERE id = ?3",
472
+ [row.to_plan_id, now, row.subscription_id],
473
+ );
474
+ if (billingHandle && typeof billingHandle.recordInvoice === "function") {
475
+ var net = row.first_charge_minor - row.proration_credit_minor;
476
+ if (net < 0) net = 0;
477
+ var subRow = await _getSubscription(row.subscription_id);
478
+ var periodEnd = subRow && subRow.current_period_end != null
479
+ ? subRow.current_period_end : row.effective_at;
480
+ try {
481
+ // Same casing-normalization the synchronous executeChange
482
+ // path runs — plans store currency lowercase, billing
483
+ // expects uppercase.
484
+ await billingHandle.recordInvoice({
485
+ subscription_id: row.subscription_id,
486
+ period_start: row.effective_at,
487
+ period_end: periodEnd,
488
+ amount_minor: net,
489
+ currency: String(row.currency).toUpperCase(),
490
+ });
491
+ } catch (_e) {
492
+ // Drop-silent — see executeChange.
493
+ }
494
+ }
495
+ executed.push(await _getById(row.id));
496
+ }
497
+ return executed;
498
+ },
499
+ };
500
+ }
501
+
502
+ module.exports = {
503
+ create: create,
504
+ CHANGE_KINDS: CHANGE_KINDS.slice(),
505
+ STATUSES: STATUSES.slice(),
506
+ MAX_REASON_LEN: MAX_REASON_LEN,
507
+ prorate: _prorate,
508
+ };