@i4e/invest4edu-access-core 0.17.3 → 0.20.0
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/package.json +3 -2
- package/src/entitlement-schema.js +11 -0
- package/src/entitlement-store.js +161 -17
- package/src/entitlement.js +12 -2
- package/src/proration.js +155 -0
- package/src/subscription-lifecycle.js +8 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@i4e/invest4edu-access-core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"description": "Shared access-control primitives for NeoFindesk: tenant keystone, role capabilities, reportee tree, feature flags, and the unified access engine (registry schema, snapshot resolver, visibleWhen).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
"./grid-schema": "./src/grid-schema.js",
|
|
19
19
|
"./route-features": "./src/route-features.js",
|
|
20
20
|
"./subscription-lifecycle": "./src/subscription-lifecycle.js",
|
|
21
|
-
"./entitlement-store": "./src/entitlement-store.js"
|
|
21
|
+
"./entitlement-store": "./src/entitlement-store.js",
|
|
22
|
+
"./proration": "./src/proration.js"
|
|
22
23
|
},
|
|
23
24
|
"scripts": {
|
|
24
25
|
"test": "node --test test/"
|
|
@@ -33,6 +33,16 @@ export const SUBSCRIPTION_STATUSES = Object.freeze([
|
|
|
33
33
|
*/
|
|
34
34
|
export const QUOTA_TIMEZONE = "Asia/Kolkata";
|
|
35
35
|
|
|
36
|
+
/**
|
|
37
|
+
* The synthetic feature that carries a credits-mode plan's allowance.
|
|
38
|
+
*
|
|
39
|
+
* Credits mode needed no new machinery precisely because the allowance is an ordinary
|
|
40
|
+
* entitlement row on this code: it inherits cycle columns, effective-date versioning, trial
|
|
41
|
+
* precedence, as-of resolution and the race-free counter. A plan IS credits-mode when a row for
|
|
42
|
+
* this code resolves — there is no separate flag to drift out of step with the data.
|
|
43
|
+
*/
|
|
44
|
+
export const CREDITS_FEATURE_CODE = "SUBSCRIPTION.CREDITS";
|
|
45
|
+
|
|
36
46
|
/** Reasons an entitlement decision can carry. Stable strings — they surface in APIs and logs. */
|
|
37
47
|
export const ENTITLEMENT_REASONS = Object.freeze({
|
|
38
48
|
OK: "ok",
|
|
@@ -169,6 +179,7 @@ export const METER_CLIENT_DEF = Object.freeze({
|
|
|
169
179
|
|
|
170
180
|
export default {
|
|
171
181
|
PERIOD_GRANULARITIES,
|
|
182
|
+
CREDITS_FEATURE_CODE,
|
|
172
183
|
QUOTA_SCOPES,
|
|
173
184
|
OVERAGE_POLICIES,
|
|
174
185
|
SUBSCRIPTION_STATUSES,
|
package/src/entitlement-store.js
CHANGED
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
* outage into a customer-visible one. Absence is far more likely to be our gap than a customer
|
|
24
24
|
* genuinely owning nothing.
|
|
25
25
|
*/
|
|
26
|
-
import { resolveEntitlement, periodKey, subjectKeyFor } from "./entitlement.js";
|
|
26
|
+
import { resolveEntitlement, resolveEntitlementRow, periodKey, subjectKeyFor } from "./entitlement.js";
|
|
27
|
+
import { CREDITS_FEATURE_CODE } from "./entitlement-schema.js";
|
|
27
28
|
import { LIVE_STATUSES } from "./subscription-lifecycle.js";
|
|
28
29
|
|
|
29
30
|
/** Every failure path returns this, so callers never have to branch on "did the lookup work". */
|
|
@@ -96,15 +97,105 @@ export function createEntitlementStore({ getDb, toObjectId = (v) => v, logger =
|
|
|
96
97
|
const cycles = subscription.status === "trialing"
|
|
97
98
|
? ["trial", subscription.cycle]
|
|
98
99
|
: [subscription.cycle];
|
|
100
|
+
/**
|
|
101
|
+
* Fetched together: the feature's own rows AND the plan's credit-allowance rows. A plan IS
|
|
102
|
+
* credits-mode when a SUBSCRIPTION.CREDITS row resolves — the mode is the data, so there is
|
|
103
|
+
* no flag to drift out of step with it.
|
|
104
|
+
*/
|
|
99
105
|
const fetched = await db.collection("plan_entitlements").find({
|
|
100
106
|
plan_code: subscription.plan_code,
|
|
101
107
|
cycle: { $in: cycles },
|
|
102
|
-
feature_code: featureCode
|
|
108
|
+
feature_code: featureCode === CREDITS_FEATURE_CODE
|
|
109
|
+
? featureCode
|
|
110
|
+
: { $in: [featureCode, CREDITS_FEATURE_CODE] },
|
|
103
111
|
}).toArray();
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
112
|
+
const pick = (code) => {
|
|
113
|
+
const mine = fetched.filter((r) => r.feature_code === code);
|
|
114
|
+
const trial = mine.filter((r) => r.cycle === "trial");
|
|
115
|
+
return subscription.status === "trialing" && trial.length
|
|
116
|
+
? trial
|
|
117
|
+
: mine.filter((r) => r.cycle === subscription.cycle);
|
|
118
|
+
};
|
|
119
|
+
const rows = pick(featureCode);
|
|
120
|
+
const creditRows = featureCode === CREDITS_FEATURE_CODE ? [] : pick(CREDITS_FEATURE_CODE);
|
|
121
|
+
const creditsMode = creditRows.length > 0 && feature.is_meterable;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* CREDITS MODE — the feature's own row says whether it is INCLUDED (and may override the
|
|
125
|
+
* cost); the guard runs against the shared credit allowance, priced per use.
|
|
126
|
+
*
|
|
127
|
+
* cost = plan row's credit_cost > service's registry credit_cost > 1
|
|
128
|
+
*
|
|
129
|
+
* The per-plan override is your "premium plans give more usage" lever: eCAS can cost 2 on
|
|
130
|
+
* Discover and 1 on Grow without any per-feature matrix returning through the back door.
|
|
131
|
+
*/
|
|
132
|
+
if (creditsMode) {
|
|
133
|
+
const svcRow = resolveEntitlementRow(rows, featureCode, now);
|
|
134
|
+
// Not named in a credits plan → the same fail-open NOT_IN_PLAN as count mode. A plan
|
|
135
|
+
// that forgot to include a feature is a config gap, not a paywall.
|
|
136
|
+
if (svcRow && svcRow.unlocked !== false) {
|
|
137
|
+
const cost = Number(svcRow.credit_cost) > 0
|
|
138
|
+
? Number(svcRow.credit_cost)
|
|
139
|
+
: (Number(feature.credit_cost) > 0 ? Number(feature.credit_cost) : 1);
|
|
140
|
+
|
|
141
|
+
let packBalance = 0;
|
|
142
|
+
try {
|
|
143
|
+
const bal = await db.collection("feature_credits").aggregate([
|
|
144
|
+
{ $match: {
|
|
145
|
+
subject_type: subjectType,
|
|
146
|
+
subject_id: oid(subjectId),
|
|
147
|
+
feature_code: CREDITS_FEATURE_CODE,
|
|
148
|
+
$expr: { $lt: ["$consumed", "$purchased"] },
|
|
149
|
+
$or: [{ expires_at: null }, { expires_at: { $exists: false } }, { expires_at: { $gt: now } }],
|
|
150
|
+
} },
|
|
151
|
+
{ $group: { _id: null, n: { $sum: { $subtract: ["$purchased", "$consumed"] } } } },
|
|
152
|
+
]).toArray();
|
|
153
|
+
packBalance = bal[0]?.n || 0;
|
|
154
|
+
} catch (e) {
|
|
155
|
+
logger.warn(`[entitlement] credit-pack balance unreadable: ${e.message}`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const creditRow = creditRows[0];
|
|
159
|
+
const period = periodKey(creditRow.period_granularity || "month", {
|
|
160
|
+
now, subscriptionId: subscription._id, periodStart: subscription.period_start,
|
|
161
|
+
});
|
|
162
|
+
const subjectKey = subjectKeyFor(creditRow.quota_scope, identity) || "";
|
|
163
|
+
const usage = await db.collection("subscription_usage").findOne({
|
|
164
|
+
subscription_id: subscription._id,
|
|
165
|
+
feature_code: CREDITS_FEATURE_CODE,
|
|
166
|
+
period,
|
|
167
|
+
subject_key: subjectKey,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const result = await resolveEntitlement({
|
|
171
|
+
subscription,
|
|
172
|
+
entitlementRows: creditRows,
|
|
173
|
+
featureCode: CREDITS_FEATURE_CODE,
|
|
174
|
+
feature: { is_meterable: true },
|
|
175
|
+
used: usage?.used || 0,
|
|
176
|
+
credits: packBalance,
|
|
177
|
+
identity, bypass, now, cost,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
const effectiveCreditRow = creditRows[0]
|
|
181
|
+
? { ...creditRows[0], quota: result.quota, overage_policy: result.overage_policy || creditRows[0].overage_policy }
|
|
182
|
+
: null;
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
...result,
|
|
186
|
+
mode: "credits",
|
|
187
|
+
carriedSubjectType: subjectType,
|
|
188
|
+
carriedSubjectId: subjectId,
|
|
189
|
+
carriedSubscriptionId: subscription._id,
|
|
190
|
+
carriedPeriodStart: subscription.period_start || null,
|
|
191
|
+
carriedIdentity: identity,
|
|
192
|
+
carriedRow: effectiveCreditRow,
|
|
193
|
+
// consume() counts against THIS bucket, at THIS price — never recomputed.
|
|
194
|
+
carriedFeatureCode: CREDITS_FEATURE_CODE,
|
|
195
|
+
carriedCost: cost,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
}
|
|
108
199
|
|
|
109
200
|
/**
|
|
110
201
|
* À-la-carte credits — units bought singly. Summed across purchases that still have units
|
|
@@ -184,6 +275,31 @@ export function createEntitlementStore({ getDb, toObjectId = (v) => v, logger =
|
|
|
184
275
|
}
|
|
185
276
|
}
|
|
186
277
|
|
|
278
|
+
/**
|
|
279
|
+
* Per-service breakdown when a shared bucket pays — "where did my 500 credits go".
|
|
280
|
+
*
|
|
281
|
+
* Fire-and-forget and unguarded: it is reporting, not enforcement, and a failed attribution
|
|
282
|
+
* row must never undo a consumption the guard already allowed. Only written when the bucket
|
|
283
|
+
* differs from the feature (credits mode) — in count mode the guarded row IS the attribution.
|
|
284
|
+
*/
|
|
285
|
+
function recordAttribution({ db, entitlement, featureCode, bucketCode, subId, ident, n, units, now }) {
|
|
286
|
+
if (!db || bucketCode === featureCode) return;
|
|
287
|
+
try {
|
|
288
|
+
const row = entitlement?.carriedRow || {};
|
|
289
|
+
const period = periodKey(row.period_granularity || "month", {
|
|
290
|
+
now, subscriptionId: subId, periodStart: entitlement?.carriedPeriodStart,
|
|
291
|
+
});
|
|
292
|
+
const subjectKey = subjectKeyFor(row.quota_scope, ident) || "";
|
|
293
|
+
db.collection("subscription_usage").updateOne(
|
|
294
|
+
{ subscription_id: subId, feature_code: featureCode, period, subject_key: subjectKey },
|
|
295
|
+
{ $inc: { used: n, credits_spent: units }, $setOnInsert: { attribution: true } },
|
|
296
|
+
{ upsert: true },
|
|
297
|
+
).catch((e) => logger.warn(`[entitlement] attribution write failed: ${e.message}`));
|
|
298
|
+
} catch (e) {
|
|
299
|
+
logger.warn(`[entitlement] attribution skipped: ${e.message}`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
187
303
|
/**
|
|
188
304
|
* Consume one unit — race-free by construction.
|
|
189
305
|
*
|
|
@@ -210,6 +326,14 @@ export function createEntitlementStore({ getDb, toObjectId = (v) => v, logger =
|
|
|
210
326
|
if (entitlement && !entitlement.metered) return { consumed: false, reason: "not_metered" };
|
|
211
327
|
if (!db || !subId || !useRow) return { consumed: false, reason: "not_metered" };
|
|
212
328
|
|
|
329
|
+
/**
|
|
330
|
+
* Credits mode: the BUCKET is the plan's credit allowance, and one action costs `cost`
|
|
331
|
+
* units. Both were decided by the resolve that authorised this call — recomputing either
|
|
332
|
+
* here could draw from a bucket nobody checked, at a price nobody quoted.
|
|
333
|
+
*/
|
|
334
|
+
const bucketCode = entitlement?.carriedFeatureCode || featureCode;
|
|
335
|
+
const units = n * (Number(entitlement?.carriedCost) > 0 ? Number(entitlement.carriedCost) : 1);
|
|
336
|
+
|
|
213
337
|
// The SAME bucket that entitlementFor just checked — recomputing it from different inputs
|
|
214
338
|
// would count against a period nobody validated.
|
|
215
339
|
const start = periodStart !== undefined ? periodStart : entitlement?.carriedPeriodStart;
|
|
@@ -228,14 +352,24 @@ export function createEntitlementStore({ getDb, toObjectId = (v) => v, logger =
|
|
|
228
352
|
{
|
|
229
353
|
subject_type: entitlement.carriedSubjectType,
|
|
230
354
|
subject_id: oid(entitlement.carriedSubjectId),
|
|
231
|
-
feature_code:
|
|
232
|
-
|
|
355
|
+
feature_code: bucketCode,
|
|
356
|
+
/**
|
|
357
|
+
* The WHOLE cost must fit in one pack row, decided atomically. A 2-credit action
|
|
358
|
+
* against a pack with 1 left is refused rather than split across purchases —
|
|
359
|
+
* splitting would need a transaction across rows, and the boundary case (the last
|
|
360
|
+
* unit of one pack) is not worth that machinery. The refusal falls through to the
|
|
361
|
+
* plan counter, which answers honestly.
|
|
362
|
+
*/
|
|
363
|
+
$expr: { $lte: [{ $add: ["$consumed", units] }, "$purchased"] },
|
|
233
364
|
$or: [{ expires_at: null }, { expires_at: { $exists: false } }, { expires_at: { $gt: now } }],
|
|
234
365
|
},
|
|
235
|
-
{ $inc: { consumed:
|
|
366
|
+
{ $inc: { consumed: units } },
|
|
236
367
|
{ sort: { expires_at: 1, createdAt: 1 } },
|
|
237
368
|
);
|
|
238
|
-
if (hit)
|
|
369
|
+
if (hit) {
|
|
370
|
+
recordAttribution({ db, entitlement, featureCode, bucketCode, subId, ident, n, units, now });
|
|
371
|
+
return { consumed: true, fromCredit: true };
|
|
372
|
+
}
|
|
239
373
|
// No credit left after all — fall through and let the plan counter answer, which may well
|
|
240
374
|
// refuse. Better than silently succeeding on a balance that just went to zero.
|
|
241
375
|
}
|
|
@@ -244,7 +378,7 @@ export function createEntitlementStore({ getDb, toObjectId = (v) => v, logger =
|
|
|
244
378
|
const subjectKey = subjectKeyFor(useRow.quota_scope, ident) || "";
|
|
245
379
|
const filter = {
|
|
246
380
|
subscription_id: subId,
|
|
247
|
-
feature_code:
|
|
381
|
+
feature_code: bucketCode,
|
|
248
382
|
period,
|
|
249
383
|
subject_key: subjectKey,
|
|
250
384
|
};
|
|
@@ -255,15 +389,21 @@ export function createEntitlementStore({ getDb, toObjectId = (v) => v, logger =
|
|
|
255
389
|
// that record is the evidence for whether a quota is set correctly before anyone is refused.
|
|
256
390
|
if (!capped) {
|
|
257
391
|
await db.collection("subscription_usage").updateOne(
|
|
258
|
-
filter, { $inc: { used:
|
|
392
|
+
filter, { $inc: { used: units }, $setOnInsert: { ...filter } }, { upsert: true },
|
|
259
393
|
);
|
|
394
|
+
recordAttribution({ db, entitlement, featureCode, bucketCode, subId, ident, n, units, now });
|
|
260
395
|
return { consumed: true };
|
|
261
396
|
}
|
|
262
397
|
|
|
263
|
-
// Capped: try the GUARDED update first, without upsert.
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
398
|
+
// Capped: try the GUARDED update first, without upsert. Cost-aware — the whole cost must
|
|
399
|
+
// fit ($lte quota - units is exactly $lt quota when units is 1), and a balance never goes
|
|
400
|
+
// negative on an in-flight action.
|
|
401
|
+
const guarded = { ...filter, used: { $lte: Number(useRow.quota) - units } };
|
|
402
|
+
const hit = await db.collection("subscription_usage").updateOne(guarded, { $inc: { used: units } });
|
|
403
|
+
if (hit.matchedCount > 0) {
|
|
404
|
+
recordAttribution({ db, entitlement, featureCode, bucketCode, subId, ident, n, units, now });
|
|
405
|
+
return { consumed: true };
|
|
406
|
+
}
|
|
267
407
|
|
|
268
408
|
// No match means either "no row yet" (first use this period) or "quota reached". Distinguish
|
|
269
409
|
// by INSERTING — the unique index makes that safe under concurrency: exactly one racer wins
|
|
@@ -272,7 +412,11 @@ export function createEntitlementStore({ getDb, toObjectId = (v) => v, logger =
|
|
|
272
412
|
// Doing this as an upsert on the guarded filter instead would rely on a duplicate-key
|
|
273
413
|
// EXCEPTION to mean "quota exceeded" — correct by accident, and unreadable.
|
|
274
414
|
try {
|
|
275
|
-
|
|
415
|
+
// First use this period must ALSO fit — an allowance smaller than one action's cost is
|
|
416
|
+
// refused on action one, not discovered at minus-something.
|
|
417
|
+
if (Number(useRow.quota) - units < 0) return { consumed: false, reason: "quota_exceeded" };
|
|
418
|
+
await db.collection("subscription_usage").insertOne({ ...filter, used: units, createdAt: new Date() });
|
|
419
|
+
recordAttribution({ db, entitlement, featureCode, bucketCode, subId, ident, n, units, now });
|
|
276
420
|
return { consumed: true };
|
|
277
421
|
} catch (e) {
|
|
278
422
|
if (e?.code === 11000) return { consumed: false, reason: "quota_exceeded" };
|
package/src/entitlement.js
CHANGED
|
@@ -134,6 +134,12 @@ function withOverride(row, overrides = [], featureCode) {
|
|
|
134
134
|
export function resolveEntitlement({
|
|
135
135
|
subscription, entitlementRows = [], featureCode, feature = {},
|
|
136
136
|
used = 0, credits = 0, identity = {}, bypass = false, now = new Date(),
|
|
137
|
+
/**
|
|
138
|
+
* Units this ONE action costs. 1 for count-mode (used + 1 ≤ quota is exactly the old
|
|
139
|
+
* used < quota), the service's credit cost when a credits-mode plan is paying. Threading it
|
|
140
|
+
* through here is what lets one resolver serve both modes instead of a parallel copy.
|
|
141
|
+
*/
|
|
142
|
+
cost = 1,
|
|
137
143
|
} = {}) {
|
|
138
144
|
const base = {
|
|
139
145
|
quota: null, used, remaining: null, period: null, subject_key: null,
|
|
@@ -177,8 +183,11 @@ export function resolveEntitlement({
|
|
|
177
183
|
const granularity = row.period_granularity || "month";
|
|
178
184
|
const period = periodKey(granularity, { now, subscriptionId: subscription._id, periodStart: subscription.period_start });
|
|
179
185
|
const subject_key = subjectKeyFor(row.quota_scope, identity);
|
|
186
|
+
const unitCost = Number(cost) > 0 ? Number(cost) : 1;
|
|
180
187
|
const planRemaining = Math.max(0, Number(row.quota) - Number(used || 0));
|
|
181
|
-
|
|
188
|
+
// Cost-aware: the action is allowed only if the WHOLE cost fits. Refusing at balance < cost is
|
|
189
|
+
// the agreed rule — a balance never goes negative on an in-flight action.
|
|
190
|
+
const withinQuota = Number(used || 0) + unitCost <= Number(row.quota);
|
|
182
191
|
const policy = row.overage_policy || "block";
|
|
183
192
|
|
|
184
193
|
/**
|
|
@@ -192,7 +201,7 @@ export function resolveEntitlement({
|
|
|
192
201
|
* Credits do not reset with the period — they were paid for, so they last until used.
|
|
193
202
|
*/
|
|
194
203
|
const creditBalance = Math.max(0, Number(credits || 0));
|
|
195
|
-
const usingCredit = !withinQuota && creditBalance
|
|
204
|
+
const usingCredit = !withinQuota && creditBalance >= unitCost;
|
|
196
205
|
const remaining = planRemaining + creditBalance;
|
|
197
206
|
|
|
198
207
|
return {
|
|
@@ -210,6 +219,7 @@ export function resolveEntitlement({
|
|
|
210
219
|
period,
|
|
211
220
|
subject_key,
|
|
212
221
|
metered: true,
|
|
222
|
+
cost: unitCost,
|
|
213
223
|
};
|
|
214
224
|
}
|
|
215
225
|
|
package/src/proration.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Proration — what the unused part of a running term is worth when someone upgrades.
|
|
3
|
+
*
|
|
4
|
+
* Without it, an upgrade STACKS: the new term is appended to the current end date, so the days
|
|
5
|
+
* already paid for on the smaller plan sit behind the new one, unusable at the new tier. The
|
|
6
|
+
* customer quietly loses that value and nothing on screen says so.
|
|
7
|
+
*
|
|
8
|
+
* The POLICY is configuration, not code — mode, basis, rounding, cap and scope all arrive from
|
|
9
|
+
* the caller (platform default < plan < service), so changing how proration works is an edit
|
|
10
|
+
* rather than a deploy. This module is only the arithmetic, kept pure so it can be tested
|
|
11
|
+
* without a database, a clock or a subscription.
|
|
12
|
+
*
|
|
13
|
+
* ── The rules that are NOT configurable, because getting them wrong invents money ────────────
|
|
14
|
+
* · credit can never exceed what was actually paid for the current term
|
|
15
|
+
* · credit can never exceed the new plan's price (a purchase must not become negative)
|
|
16
|
+
* · rounding is toward the CUSTOMER paying more, never less — we do not credit value that was
|
|
17
|
+
* not paid for
|
|
18
|
+
* · a term that has not started, or has already ended, is worth nothing
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Every knob, with the conservative answer as the default. */
|
|
22
|
+
export const PRORATION_DEFAULTS = {
|
|
23
|
+
/**
|
|
24
|
+
* none — today's behaviour: no credit, the new term stacks on the old end date
|
|
25
|
+
* daily — unused days ÷ total days
|
|
26
|
+
* monthly — whole unused months only; a part-month counts for nothing
|
|
27
|
+
* points — no cash credit; the unused value is returned as reward points instead
|
|
28
|
+
*/
|
|
29
|
+
mode: "none",
|
|
30
|
+
/** Ceiling on the credit as a percentage of the new plan's price. 100 = up to the full price. */
|
|
31
|
+
cap_percent: 100,
|
|
32
|
+
/** Apply when the PLAN changes, and/or when only the billing cycle changes. */
|
|
33
|
+
on_plan_change: true,
|
|
34
|
+
on_cycle_change: false,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
38
|
+
|
|
39
|
+
/** Whole days between two instants, never negative. */
|
|
40
|
+
function daysBetween(from, to) {
|
|
41
|
+
const ms = new Date(to).getTime() - new Date(from).getTime();
|
|
42
|
+
return ms <= 0 ? 0 : Math.floor(ms / MS_PER_DAY);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Whole months remaining — a part-month is worth nothing under `monthly`. */
|
|
46
|
+
function wholeMonthsBetween(from, to) {
|
|
47
|
+
const a = new Date(from);
|
|
48
|
+
const b = new Date(to);
|
|
49
|
+
if (b <= a) return 0;
|
|
50
|
+
let months = (b.getFullYear() - a.getFullYear()) * 12 + (b.getMonth() - a.getMonth());
|
|
51
|
+
if (b.getDate() < a.getDate()) months -= 1;
|
|
52
|
+
return months > 0 ? months : 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Merge policy layers. Later wins, and only for keys it actually specifies — a plan that sets
|
|
57
|
+
* only `mode` must not silently reset the cap someone configured globally.
|
|
58
|
+
*/
|
|
59
|
+
export function resolveProrationPolicy(...layers) {
|
|
60
|
+
const out = { ...PRORATION_DEFAULTS };
|
|
61
|
+
for (const layer of layers) {
|
|
62
|
+
if (!layer || typeof layer !== "object") continue;
|
|
63
|
+
for (const k of Object.keys(PRORATION_DEFAULTS)) {
|
|
64
|
+
if (layer[k] !== undefined && layer[k] !== null) out[k] = layer[k];
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* What the unused remainder of the current term is worth, in PAISE.
|
|
72
|
+
*
|
|
73
|
+
* @param {object} a
|
|
74
|
+
* @param {number} a.paidAmount what was paid for the CURRENT term (paise, ex-tax)
|
|
75
|
+
* @param {Date} a.periodStart when the current term began
|
|
76
|
+
* @param {Date} a.periodEnd when it ends (null/absent ⇒ open-ended ⇒ nothing to prorate)
|
|
77
|
+
* @param {Date} a.now the moment of the change
|
|
78
|
+
* @param {number} a.newPlanPrice price of what they are moving to (paise, ex-tax) — the cap
|
|
79
|
+
* @param {boolean} a.isCycleChange true when the plan is unchanged and only the cycle differs
|
|
80
|
+
* @param {object} a.policy resolved policy (see resolveProrationPolicy)
|
|
81
|
+
* @returns {{credit:number, as_points:boolean, reason:string, days_remaining:number,
|
|
82
|
+
* days_total:number, mode:string}}
|
|
83
|
+
*/
|
|
84
|
+
export function prorate({
|
|
85
|
+
paidAmount = 0,
|
|
86
|
+
periodStart,
|
|
87
|
+
periodEnd,
|
|
88
|
+
now = new Date(),
|
|
89
|
+
newPlanPrice = 0,
|
|
90
|
+
isCycleChange = false,
|
|
91
|
+
policy = PRORATION_DEFAULTS,
|
|
92
|
+
} = {}) {
|
|
93
|
+
const p = resolveProrationPolicy(policy);
|
|
94
|
+
const nil = (reason) => ({
|
|
95
|
+
credit: 0, as_points: false, reason, mode: p.mode, days_remaining: 0, days_total: 0,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
if (p.mode === "none") return nil("proration is off");
|
|
99
|
+
if (isCycleChange && !p.on_cycle_change) return nil("cycle changes are not prorated");
|
|
100
|
+
if (!isCycleChange && !p.on_plan_change) return nil("plan changes are not prorated");
|
|
101
|
+
|
|
102
|
+
// An open-ended term (the provisioned default) has no remainder to value: nothing was paid for
|
|
103
|
+
// a period that does not end.
|
|
104
|
+
if (!periodEnd) return nil("term has no end date");
|
|
105
|
+
if (!(paidAmount > 0)) return nil("nothing was paid for the current term");
|
|
106
|
+
|
|
107
|
+
const daysTotal = daysBetween(periodStart, periodEnd);
|
|
108
|
+
const daysRemaining = daysBetween(now, periodEnd);
|
|
109
|
+
if (daysTotal <= 0) return nil("term length is zero");
|
|
110
|
+
if (daysRemaining <= 0) return nil("term has already ended");
|
|
111
|
+
|
|
112
|
+
let credit;
|
|
113
|
+
if (p.mode === "monthly") {
|
|
114
|
+
const monthsRemaining = wholeMonthsBetween(now, periodEnd);
|
|
115
|
+
const monthsTotal = wholeMonthsBetween(periodStart, periodEnd);
|
|
116
|
+
if (monthsTotal <= 0 || monthsRemaining <= 0) {
|
|
117
|
+
return { ...nil("less than a whole month remains"), days_remaining: daysRemaining, days_total: daysTotal };
|
|
118
|
+
}
|
|
119
|
+
credit = Math.floor((paidAmount * monthsRemaining) / monthsTotal);
|
|
120
|
+
} else {
|
|
121
|
+
// daily, and the basis for `points` too — the difference is only in how it is returned.
|
|
122
|
+
credit = Math.floor((paidAmount * daysRemaining) / daysTotal);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Never more than was paid. Floating-point and clock skew both make this reachable.
|
|
126
|
+
credit = Math.min(credit, paidAmount);
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The cap. Without it, a long unused term can exceed the new plan's price and the purchase
|
|
130
|
+
* goes to zero or negative — the customer upgrades for free, or we owe them money for taking
|
|
131
|
+
* a bigger plan. Applies to the cash credit only; points are a separate liability that does
|
|
132
|
+
* not have to fit inside this purchase.
|
|
133
|
+
*/
|
|
134
|
+
if (p.mode !== "points") {
|
|
135
|
+
const ceiling = Math.floor((newPlanPrice * Math.max(0, Math.min(100, p.cap_percent))) / 100);
|
|
136
|
+
if (credit > ceiling) {
|
|
137
|
+
credit = ceiling;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (credit <= 0) return { ...nil("nothing to credit"), days_remaining: daysRemaining, days_total: daysTotal };
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
credit,
|
|
145
|
+
as_points: p.mode === "points",
|
|
146
|
+
reason: p.mode === "points"
|
|
147
|
+
? `${daysRemaining} unused day(s) returned as points`
|
|
148
|
+
: `${daysRemaining} of ${daysTotal} day(s) unused`,
|
|
149
|
+
mode: p.mode,
|
|
150
|
+
days_remaining: daysRemaining,
|
|
151
|
+
days_total: daysTotal,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export default { prorate, resolveProrationPolicy, PRORATION_DEFAULTS };
|
|
@@ -70,6 +70,14 @@ export const SUBSCRIPTION_EVENT_TYPES = Object.freeze([
|
|
|
70
70
|
// Consumption that happened outside the software — a webinar attended, a VPD session held.
|
|
71
71
|
// There is no request behind it, so this event is the only record of who said it happened.
|
|
72
72
|
"usage_recorded",
|
|
73
|
+
/**
|
|
74
|
+
* The refund conversation, recorded as it happens rather than reconstructed afterwards.
|
|
75
|
+
*
|
|
76
|
+
* A refund is the one flow where WHO ASKED and WHO AGREED both matter later — a credit note
|
|
77
|
+
* shows money went back but not that anyone approved it. `refund_requested` is the customer's
|
|
78
|
+
* ask, `refund_rejected` closes it with a reason, and `refunded` is the money actually moving.
|
|
79
|
+
*/
|
|
80
|
+
"refund_requested", "refund_approved", "refund_rejected", "refunded",
|
|
73
81
|
]);
|
|
74
82
|
|
|
75
83
|
export default { SUBSCRIPTION_STATUSES, LIVE_STATUSES, TRANSITIONS, canTransition, SUBSCRIPTION_EVENT_TYPES };
|