@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.
- package/CHANGELOG.md +6 -0
- package/lib/compliance-export.js +614 -0
- package/lib/email-campaigns.js +844 -0
- package/lib/error-log.js +525 -0
- package/lib/geolocation.js +651 -0
- package/lib/gift-registry.js +820 -0
- package/lib/index.js +15 -0
- package/lib/invoice-renderer.js +618 -0
- package/lib/live-chat.js +714 -0
- package/lib/loyalty-redemption.js +673 -0
- package/lib/plan-changes.js +508 -0
- package/lib/refund-policy.js +965 -0
- package/lib/sms-dispatcher.js +7 -1
- package/lib/stock-transfers.js +777 -0
- package/lib/store-credit.js +565 -0
- package/lib/storefront-dashboards.js +863 -0
- package/lib/vendors.js +797 -0
- package/package.json +1 -1
|
@@ -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
|
+
};
|