@thezelijah/majik-subscription 1.0.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/README.md +336 -0
- package/dist/enums.d.ts +32 -0
- package/dist/enums.js +41 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +20 -0
- package/dist/majik-subscription.d.ts +431 -0
- package/dist/majik-subscription.js +1062 -0
- package/dist/types.d.ts +95 -0
- package/dist/types.js +2 -0
- package/dist/utils.d.ts +26 -0
- package/dist/utils.js +146 -0
- package/package.json +56 -0
|
@@ -0,0 +1,1062 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MajikSubscription = void 0;
|
|
4
|
+
exports.isMajikSubscriptionClass = isMajikSubscriptionClass;
|
|
5
|
+
exports.isMajikSubscriptionJSON = isMajikSubscriptionJSON;
|
|
6
|
+
const majik_money_1 = require("@thezelijah/majik-money");
|
|
7
|
+
const utils_1 = require("./utils");
|
|
8
|
+
const enums_1 = require("./enums");
|
|
9
|
+
/**
|
|
10
|
+
* Represents a subscription in the Majik system.
|
|
11
|
+
* Handles metadata, capacity, COS, and finance calculations (revenue, COS, profit, margins) for recurring subscriptions.
|
|
12
|
+
*/
|
|
13
|
+
class MajikSubscription {
|
|
14
|
+
/**
|
|
15
|
+
* Creates a new `MajikSubscription` instance.
|
|
16
|
+
* @param {SubscriptionID | undefined} id - Optional subscription ID. Auto-generated if undefined.
|
|
17
|
+
* @param {string | undefined} slug - Optional slug. Auto-generated from name if undefined.
|
|
18
|
+
* @param {string} name - Name of the subscription.
|
|
19
|
+
* @param {SubscriptionMetadata} metadata - Metadata including type, category, rate, description, COS, and capacity plan.
|
|
20
|
+
* @param {SubscriptionSettings} settings - Settings including status, visibility, and system flags.
|
|
21
|
+
* @param {ISODateString} [timestamp=new Date().toISOString()] - Optional creation timestamp.
|
|
22
|
+
* @param {ISODateString} [last_update=new Date().toISOString()] - Optional last update timestamp.
|
|
23
|
+
*/
|
|
24
|
+
constructor(id, slug, name, metadata, settings, timestamp = new Date().toISOString(), last_update = new Date().toISOString()) {
|
|
25
|
+
this.__type = "MajikSubscription";
|
|
26
|
+
this.__object = "class";
|
|
27
|
+
this.financeDirty = true;
|
|
28
|
+
this.id = id || (0, utils_1.autogenerateID)("mjksub");
|
|
29
|
+
this.slug = slug || (0, utils_1.generateSlug)(name);
|
|
30
|
+
this.name = name;
|
|
31
|
+
this.metadata = metadata;
|
|
32
|
+
this.settings = settings ?? {
|
|
33
|
+
status: enums_1.SubscriptionStatus.ACTIVE,
|
|
34
|
+
visibility: enums_1.SubscriptionVisibility.PRIVATE,
|
|
35
|
+
system: { isRestricted: false },
|
|
36
|
+
};
|
|
37
|
+
this.type = this.metadata.type;
|
|
38
|
+
this.category = this.metadata.category;
|
|
39
|
+
this.rate = this.metadata.rate;
|
|
40
|
+
this.status = this.settings.status;
|
|
41
|
+
this.timestamp = timestamp;
|
|
42
|
+
this.last_update = last_update;
|
|
43
|
+
}
|
|
44
|
+
/** Marks finance calculations as dirty for lazy recomputation */
|
|
45
|
+
markFinanceDirty() {
|
|
46
|
+
this.financeDirty = true;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Returns a zero-value MajikMoney object in the subscription currency.
|
|
50
|
+
* @param {string} [currencyCode] - Optional currency code. Defaults to subscription rate currency or PHP.
|
|
51
|
+
* @returns {MajikMoney} - A zero-value MajikMoney instance.
|
|
52
|
+
*/
|
|
53
|
+
DEFAULT_ZERO(currencyCode) {
|
|
54
|
+
const code = currencyCode || this.rate?.amount?.currency?.code || "PHP";
|
|
55
|
+
return majik_money_1.MajikMoney.fromMinor(0, code);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Initializes and creates a new `MajikSubscription` with default and null values.
|
|
59
|
+
* @param type - The type of service to initialize. Defaults to `TIME_BASED`. Use Enum `ServiceType`.
|
|
60
|
+
* @returns A new `MajikSubscription` instance.
|
|
61
|
+
*/
|
|
62
|
+
static initialize(name, type = enums_1.SubscriptionType.RECURRING, rate, category = "Other", descriptionText, skuID) {
|
|
63
|
+
if (!name || typeof name !== "string" || name.trim() === "") {
|
|
64
|
+
throw new Error("Name must be a valid non-empty string.");
|
|
65
|
+
}
|
|
66
|
+
if (!category || typeof category !== "string" || category.trim() === "") {
|
|
67
|
+
throw new Error("Category must be a valid non-empty string.");
|
|
68
|
+
}
|
|
69
|
+
// Set default values for optional parameters
|
|
70
|
+
const defaultMetadata = {
|
|
71
|
+
description: {
|
|
72
|
+
text: descriptionText || "A new subscription.",
|
|
73
|
+
},
|
|
74
|
+
type: type,
|
|
75
|
+
category: category,
|
|
76
|
+
rate: rate,
|
|
77
|
+
sku: skuID || undefined,
|
|
78
|
+
cos: [],
|
|
79
|
+
finance: (0, utils_1.createEmptySubscriptionFinance)(rate.amount.currency.code),
|
|
80
|
+
};
|
|
81
|
+
const defaultSettings = {
|
|
82
|
+
visibility: enums_1.SubscriptionVisibility.PRIVATE,
|
|
83
|
+
status: enums_1.SubscriptionStatus.ACTIVE,
|
|
84
|
+
system: {
|
|
85
|
+
isRestricted: false,
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
return new MajikSubscription(undefined, undefined, name || "My Subscription", defaultMetadata, defaultSettings, undefined, undefined);
|
|
89
|
+
}
|
|
90
|
+
/* ------------------ METADATA HELPERS ------------------ */
|
|
91
|
+
/**
|
|
92
|
+
* Updates the subscription name and regenerates the slug.
|
|
93
|
+
* @param {string} name - New subscription name.
|
|
94
|
+
* @returns {MajikSubscription} - Returns self for chaining.
|
|
95
|
+
*/
|
|
96
|
+
setName(name) {
|
|
97
|
+
this.name = name;
|
|
98
|
+
this.slug = (0, utils_1.generateSlug)(name);
|
|
99
|
+
this.updateTimestamp();
|
|
100
|
+
return this;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Updates the subscription rate.
|
|
104
|
+
* @param {SubscriptionRate} rate - New rate object.
|
|
105
|
+
* @returns {MajikSubscription} - Returns self for chaining.
|
|
106
|
+
*/
|
|
107
|
+
setRate(rate) {
|
|
108
|
+
this.rate = rate;
|
|
109
|
+
this.metadata.rate = rate;
|
|
110
|
+
this.updateTimestamp();
|
|
111
|
+
this.markFinanceDirty();
|
|
112
|
+
return this;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Updates the rate unit.
|
|
116
|
+
* @param {RateUnit} unit - New rate unit (e.g., per subscriber, per month).
|
|
117
|
+
* @returns {MajikSubscription} - Returns self for chaining.
|
|
118
|
+
*/
|
|
119
|
+
setRateUnit(unit) {
|
|
120
|
+
this.rate.unit = unit;
|
|
121
|
+
this.metadata.rate.unit = unit;
|
|
122
|
+
this.updateTimestamp();
|
|
123
|
+
this.markFinanceDirty();
|
|
124
|
+
return this;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Updates the numeric rate amount.
|
|
128
|
+
* @param {number} amount - New rate amount (must be positive).
|
|
129
|
+
* @returns {MajikSubscription} - Returns self for chaining.
|
|
130
|
+
* @throws Will throw an error if amount is non-positive.
|
|
131
|
+
*/
|
|
132
|
+
setRateAmount(amount) {
|
|
133
|
+
if (amount <= 0)
|
|
134
|
+
throw new Error("Rate Amount must be positive");
|
|
135
|
+
this.rate.amount = majik_money_1.MajikMoney.fromMajor(amount, this.rate.amount.currency.code);
|
|
136
|
+
this.metadata.rate.amount = majik_money_1.MajikMoney.fromMajor(amount, this.metadata.rate.amount.currency.code);
|
|
137
|
+
this.updateTimestamp();
|
|
138
|
+
this.markFinanceDirty();
|
|
139
|
+
return this;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Updates the subscription category.
|
|
143
|
+
* @param {string} category - New category name.
|
|
144
|
+
* @returns {MajikSubscription} - Returns self for chaining.
|
|
145
|
+
*/
|
|
146
|
+
setCategory(category) {
|
|
147
|
+
this.category = category;
|
|
148
|
+
this.metadata.category = category;
|
|
149
|
+
this.updateTimestamp();
|
|
150
|
+
return this;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Updates the HTML and plain text of the subscription description.
|
|
154
|
+
* @param {string} html - The new HTML description.
|
|
155
|
+
* @param {string} text - The new plain text description.
|
|
156
|
+
* @returns {MajikSubscription} - Returns self for chaining.
|
|
157
|
+
* @throws Will throw an error if either html or text is invalid.
|
|
158
|
+
*/
|
|
159
|
+
setDescription(html, text) {
|
|
160
|
+
if (!html || typeof html !== "string" || html.trim() === "")
|
|
161
|
+
throw new Error("HTML must be a valid non-empty string.");
|
|
162
|
+
if (!text || typeof text !== "string" || text.trim() === "")
|
|
163
|
+
throw new Error("Text must be a valid non-empty string.");
|
|
164
|
+
this.metadata.description.html = html;
|
|
165
|
+
this.metadata.description.text = text;
|
|
166
|
+
this.updateTimestamp();
|
|
167
|
+
return this;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Updates only the plain text of the subscription description.
|
|
171
|
+
* @param {string} text - The new plain text description.
|
|
172
|
+
* @returns {MajikSubscription} - Returns self for chaining.
|
|
173
|
+
* @throws Will throw an error if text is invalid.
|
|
174
|
+
*/
|
|
175
|
+
setDescriptionText(text) {
|
|
176
|
+
if (!text || typeof text !== "string" || text.trim() === "")
|
|
177
|
+
throw new Error("Description Text must be a valid non-empty string.");
|
|
178
|
+
this.metadata.description.text = text;
|
|
179
|
+
this.updateTimestamp();
|
|
180
|
+
return this;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Updates only the HTML of the subscription description.
|
|
184
|
+
* @param {string} html - The new HTML description.
|
|
185
|
+
* @returns {MajikSubscription} - Returns self for chaining.
|
|
186
|
+
* @throws Will throw an error if html is invalid.
|
|
187
|
+
*/
|
|
188
|
+
setDescriptionHTML(html) {
|
|
189
|
+
if (!html || typeof html !== "string" || html.trim() === "")
|
|
190
|
+
throw new Error("Description HTML must be a valid non-empty string.");
|
|
191
|
+
this.metadata.description.html = html;
|
|
192
|
+
this.updateTimestamp();
|
|
193
|
+
return this;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Updates SEO-friendly text of the subscription description.
|
|
197
|
+
* @param {string} text - SEO text.
|
|
198
|
+
* @returns {MajikSubscription} - Returns self for chaining.
|
|
199
|
+
*/
|
|
200
|
+
setDescriptionSEO(text) {
|
|
201
|
+
if (!text || typeof text !== "string" || text.trim() === "") {
|
|
202
|
+
this.metadata.description.seo = undefined;
|
|
203
|
+
this.updateTimestamp();
|
|
204
|
+
return this;
|
|
205
|
+
}
|
|
206
|
+
this.metadata.description.seo = text;
|
|
207
|
+
this.updateTimestamp();
|
|
208
|
+
return this;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Updates the Type of the Subscription.
|
|
212
|
+
* @param type - The new Type of the Subscription. Use Enum `SubscriptionType`.
|
|
213
|
+
* @throws Will throw an error if the `type` is not provided or is not a string.
|
|
214
|
+
*/
|
|
215
|
+
setType(type) {
|
|
216
|
+
if (!Object.values(enums_1.SubscriptionType).includes(type)) {
|
|
217
|
+
throw new Error("Invalid Subscription type.");
|
|
218
|
+
}
|
|
219
|
+
this.metadata.type = type;
|
|
220
|
+
this.type = type;
|
|
221
|
+
this.updateTimestamp();
|
|
222
|
+
return this;
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Returns SEO text if available; otherwise the plain text description.
|
|
226
|
+
* @returns {string} - SEO or plain text description.
|
|
227
|
+
*/
|
|
228
|
+
get seo() {
|
|
229
|
+
if (!!this.metadata.description.seo?.trim())
|
|
230
|
+
return this.metadata.description.seo;
|
|
231
|
+
return this.metadata.description.text;
|
|
232
|
+
}
|
|
233
|
+
/* ------------------ COS MANAGEMENT ------------------ */
|
|
234
|
+
/**
|
|
235
|
+
* Returns true if the subscription has at least one COS (cost breakdown) item.
|
|
236
|
+
*/
|
|
237
|
+
hasCostBreakdown() {
|
|
238
|
+
return Array.isArray(this.metadata.cos) && this.metadata.cos.length > 0;
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Adds a new COS (Cost of Subscription) item.
|
|
242
|
+
* @param {string} name - COS item name.
|
|
243
|
+
* @param {MajikMoney} unitCost - Cost per unit.
|
|
244
|
+
* @param {number} [quantity=1] - Number of units.
|
|
245
|
+
* @param {string} [unit] - Optional unit (e.g., "subscriber", "month").
|
|
246
|
+
* @returns {MajikSubscription} - Returns self for chaining.
|
|
247
|
+
*/
|
|
248
|
+
addCOS(name, unitCost, quantity = 1, unit) {
|
|
249
|
+
if (!name.trim())
|
|
250
|
+
throw new Error("COS name cannot be empty");
|
|
251
|
+
if (quantity <= 0)
|
|
252
|
+
throw new Error("COS quantity must be greater than zero");
|
|
253
|
+
this.assertCurrency(unitCost);
|
|
254
|
+
const newItem = {
|
|
255
|
+
id: (0, utils_1.autogenerateID)("mjksubcost"),
|
|
256
|
+
item: name,
|
|
257
|
+
quantity,
|
|
258
|
+
unitCost,
|
|
259
|
+
unit,
|
|
260
|
+
subtotal: unitCost.multiply(quantity),
|
|
261
|
+
};
|
|
262
|
+
this.metadata.cos.push(newItem);
|
|
263
|
+
this.updateTimestamp();
|
|
264
|
+
this.markFinanceDirty();
|
|
265
|
+
return this;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Pushes an existing COSItem into the metadata.
|
|
269
|
+
* @param {COSItem} item - COSItem to add.
|
|
270
|
+
* @returns {MajikSubscription} - Returns self for chaining.
|
|
271
|
+
* @throws Will throw an error if item is missing required properties or has currency mismatch.
|
|
272
|
+
*/
|
|
273
|
+
pushCOS(item) {
|
|
274
|
+
if (!item.id)
|
|
275
|
+
throw new Error("COS item must have an id");
|
|
276
|
+
if (!item.item?.trim())
|
|
277
|
+
throw new Error("COS item must have a name");
|
|
278
|
+
if (item.quantity <= 0)
|
|
279
|
+
throw new Error("COS quantity must be greater than zero");
|
|
280
|
+
this.assertCurrency(item.unitCost);
|
|
281
|
+
item.subtotal = item.unitCost.multiply(item.quantity);
|
|
282
|
+
this.metadata.cos.push(item);
|
|
283
|
+
this.updateTimestamp();
|
|
284
|
+
this.markFinanceDirty();
|
|
285
|
+
return this;
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Updates an existing COS item by ID.
|
|
289
|
+
* @param {string} id - COS item ID.
|
|
290
|
+
* @param {Partial<Pick<COSItem, "quantity" | "unitCost" | "unit">>} updates - Fields to update.
|
|
291
|
+
* @returns {MajikSubscription} - Returns self for chaining.
|
|
292
|
+
* @throws Will throw an error if the COS item does not exist or has invalid updates.
|
|
293
|
+
*/
|
|
294
|
+
updateCOS(id, updates) {
|
|
295
|
+
const item = this.metadata.cos.find((c) => c.id === id);
|
|
296
|
+
if (!item)
|
|
297
|
+
throw new Error(`COS item ${id} not found`);
|
|
298
|
+
if (updates.quantity !== undefined) {
|
|
299
|
+
if (updates.quantity <= 0)
|
|
300
|
+
throw new Error("Quantity must be positive");
|
|
301
|
+
item.quantity = updates.quantity;
|
|
302
|
+
}
|
|
303
|
+
if (updates.unitCost) {
|
|
304
|
+
this.assertCurrency(updates.unitCost);
|
|
305
|
+
item.unitCost = updates.unitCost;
|
|
306
|
+
}
|
|
307
|
+
if (!!updates?.item?.trim()) {
|
|
308
|
+
item.item = updates.item;
|
|
309
|
+
}
|
|
310
|
+
item.unit = updates.unit ?? item.unit;
|
|
311
|
+
item.subtotal = item.unitCost.multiply(item.quantity);
|
|
312
|
+
this.updateTimestamp();
|
|
313
|
+
this.markFinanceDirty();
|
|
314
|
+
return this;
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* Replaces all COS items with a new array.
|
|
318
|
+
* @param {COSItem[]} items - Array of COS items.
|
|
319
|
+
* @returns {MajikSubscription} - Returns self for chaining.
|
|
320
|
+
* @throws Will throw an error if items are missing required properties or have currency mismatch.
|
|
321
|
+
*/
|
|
322
|
+
setCOS(items) {
|
|
323
|
+
items.forEach((item) => {
|
|
324
|
+
if (!item.id ||
|
|
325
|
+
!item.item ||
|
|
326
|
+
!item.unitCost ||
|
|
327
|
+
item.quantity == null ||
|
|
328
|
+
!item.subtotal) {
|
|
329
|
+
throw new Error("Each COSItem must have id, item, unitCost, quantity, and subtotal");
|
|
330
|
+
}
|
|
331
|
+
this.assertCurrency(item.unitCost);
|
|
332
|
+
});
|
|
333
|
+
this.metadata.cos = [...items];
|
|
334
|
+
this.updateTimestamp();
|
|
335
|
+
this.markFinanceDirty();
|
|
336
|
+
return this;
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Removes a COS item by ID.
|
|
340
|
+
* @param {string} id - COS item ID.
|
|
341
|
+
* @returns {MajikSubscription} - Returns self for chaining.
|
|
342
|
+
* @throws Will throw an error if the COS item does not exist.
|
|
343
|
+
*/
|
|
344
|
+
removeCOS(id) {
|
|
345
|
+
const index = this.metadata.cos.findIndex((c) => c.id === id);
|
|
346
|
+
if (index === -1)
|
|
347
|
+
throw new Error(`COS item with id ${id} not found`);
|
|
348
|
+
this.metadata.cos.splice(index, 1);
|
|
349
|
+
this.updateTimestamp();
|
|
350
|
+
this.markFinanceDirty();
|
|
351
|
+
return this;
|
|
352
|
+
}
|
|
353
|
+
/** Clears all COS items. */
|
|
354
|
+
clearCostBreakdown() {
|
|
355
|
+
this.metadata.cos.length = 0;
|
|
356
|
+
this.updateTimestamp();
|
|
357
|
+
this.markFinanceDirty();
|
|
358
|
+
return this;
|
|
359
|
+
}
|
|
360
|
+
/* ------------------ CAPACITY MANAGEMENT ------------------ */
|
|
361
|
+
/**
|
|
362
|
+
* Returns true if the subscription has at least one Capacity Plan item.
|
|
363
|
+
*/
|
|
364
|
+
hasCapacity() {
|
|
365
|
+
return (Array.isArray(this.metadata.capacityPlan) &&
|
|
366
|
+
this.metadata.capacityPlan.length > 0);
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Returns the earliest (initial) YYYYMM from the capacity plan.
|
|
370
|
+
*/
|
|
371
|
+
get earliestCapacityMonth() {
|
|
372
|
+
if (!this.hasCapacity())
|
|
373
|
+
return null;
|
|
374
|
+
const supply = this.metadata.capacityPlan;
|
|
375
|
+
return supply.reduce((earliest, current) => current.month < earliest ? current.month : earliest, supply[0].month);
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Returns the most recent (latest) YYYYMM from the capacity plan.
|
|
379
|
+
*/
|
|
380
|
+
get latestCapacityMonth() {
|
|
381
|
+
if (!this.hasCapacity())
|
|
382
|
+
return null;
|
|
383
|
+
const supply = this.metadata.capacityPlan;
|
|
384
|
+
return supply.reduce((latest, current) => (current.month > latest ? current.month : latest), supply[0].month);
|
|
385
|
+
}
|
|
386
|
+
get capacity() {
|
|
387
|
+
return this.metadata?.capacityPlan || [];
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Returns the total capacity units across all months.
|
|
391
|
+
*/
|
|
392
|
+
get totalCapacity() {
|
|
393
|
+
const capacity = this.metadata.capacityPlan ?? [];
|
|
394
|
+
return capacity.reduce((sum, c) => sum + c.capacity + (c.adjustment ?? 0), 0);
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Returns the average capacity per month.
|
|
398
|
+
* Includes adjustments.
|
|
399
|
+
*/
|
|
400
|
+
get averageMonthlyCapacity() {
|
|
401
|
+
const capacity = this.metadata.capacityPlan ?? [];
|
|
402
|
+
if (capacity.length === 0)
|
|
403
|
+
return 0;
|
|
404
|
+
return this.totalCapacity / capacity.length;
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Returns the MonthlyCapacity entry with the highest supply.
|
|
408
|
+
* Includes adjustments.
|
|
409
|
+
*/
|
|
410
|
+
get maxSupplyMonth() {
|
|
411
|
+
const supply = this.metadata.capacityPlan ?? [];
|
|
412
|
+
if (supply.length === 0)
|
|
413
|
+
return null;
|
|
414
|
+
return supply.reduce((max, current) => {
|
|
415
|
+
const maxUnits = max.capacity + (max.adjustment ?? 0);
|
|
416
|
+
const currUnits = current.capacity + (current.adjustment ?? 0);
|
|
417
|
+
return currUnits > maxUnits ? current : max;
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Returns the MonthlyCapacity entry with the lowest supply.
|
|
422
|
+
* Includes adjustments.
|
|
423
|
+
*/
|
|
424
|
+
get minSupplyMonth() {
|
|
425
|
+
const supply = this.metadata.capacityPlan ?? [];
|
|
426
|
+
if (supply.length === 0)
|
|
427
|
+
return null;
|
|
428
|
+
return supply.reduce((min, current) => {
|
|
429
|
+
const minUnits = min.capacity + (min.adjustment ?? 0);
|
|
430
|
+
const currUnits = current.capacity + (current.adjustment ?? 0);
|
|
431
|
+
return currUnits < minUnits ? current : min;
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Generates and replaces the capacity plan automatically.
|
|
436
|
+
*
|
|
437
|
+
* @param months - Number of months to generate from the start date.
|
|
438
|
+
* @param amount - Base units for the first month.
|
|
439
|
+
* @param growthRate - Optional growth rate per month (e.g. 0.03 = +3%).
|
|
440
|
+
* @param startDate - Date | ISO date | YYYYMM. Defaults to current month.
|
|
441
|
+
* @returns {this} Updated subscription instance.
|
|
442
|
+
*/
|
|
443
|
+
generateCapacityPlan(months, amount, growthRate = 0, startDate) {
|
|
444
|
+
if (!Number.isInteger(months) || months <= 0) {
|
|
445
|
+
throw new Error("Months must be a positive integer");
|
|
446
|
+
}
|
|
447
|
+
if (!Number.isFinite(amount) || amount < 0) {
|
|
448
|
+
throw new Error("Amount must be a non-negative number");
|
|
449
|
+
}
|
|
450
|
+
if (growthRate < 0) {
|
|
451
|
+
throw new Error("Growth rate cannot be negative");
|
|
452
|
+
}
|
|
453
|
+
const start = (0, utils_1.normalizeStartDate)(startDate);
|
|
454
|
+
const supplyPlan = [];
|
|
455
|
+
let currentUnits = amount;
|
|
456
|
+
for (let i = 0; i < months; i++) {
|
|
457
|
+
const date = new Date(start.getFullYear(), start.getMonth() + i, 1);
|
|
458
|
+
const yyyy = date.getFullYear();
|
|
459
|
+
const mm = String(date.getMonth() + 1).padStart(2, "0");
|
|
460
|
+
const month = `${yyyy}-${mm}`;
|
|
461
|
+
supplyPlan.push({
|
|
462
|
+
month,
|
|
463
|
+
capacity: Math.round(currentUnits),
|
|
464
|
+
});
|
|
465
|
+
if (growthRate > 0) {
|
|
466
|
+
currentUnits *= 1 + growthRate;
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
return this.setCapacity(supplyPlan);
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Normalizes all supply plan entries to have the same unit amount.
|
|
473
|
+
*
|
|
474
|
+
* - Throws if supply plan is empty
|
|
475
|
+
* - If only one entry exists, does nothing
|
|
476
|
+
* - If multiple entries exist, sets all units to the provided amount
|
|
477
|
+
*
|
|
478
|
+
* @param amount - Unit amount to apply to all months
|
|
479
|
+
* @returns {this} Updated subscription instance
|
|
480
|
+
*/
|
|
481
|
+
normalizeCapacityUnits(amount) {
|
|
482
|
+
if (!Number.isFinite(amount) || amount < 0) {
|
|
483
|
+
throw new Error("Amount must be a non-negative number");
|
|
484
|
+
}
|
|
485
|
+
const supply = this.metadata.capacityPlan;
|
|
486
|
+
if (!supply || supply.length === 0) {
|
|
487
|
+
throw new Error("Supply plan is empty");
|
|
488
|
+
}
|
|
489
|
+
if (supply.length === 1) {
|
|
490
|
+
return this;
|
|
491
|
+
}
|
|
492
|
+
supply.forEach((s) => {
|
|
493
|
+
s.capacity = amount;
|
|
494
|
+
});
|
|
495
|
+
this.updateTimestamp();
|
|
496
|
+
this.markFinanceDirty();
|
|
497
|
+
return this;
|
|
498
|
+
}
|
|
499
|
+
recomputeCapacityPeriod(start, end, mode = enums_1.CapacityPeriodResizeMode.DEFAULT) {
|
|
500
|
+
if (!(0, utils_1.isValidYYYYMM)(start) || !(0, utils_1.isValidYYYYMM)(end)) {
|
|
501
|
+
throw new Error("Invalid YYYYMM period");
|
|
502
|
+
}
|
|
503
|
+
if (!this.hasCapacity()) {
|
|
504
|
+
throw new Error("No existing capacity plan to recompute");
|
|
505
|
+
}
|
|
506
|
+
if (start > end) {
|
|
507
|
+
throw new Error("Start month must be <= end month");
|
|
508
|
+
}
|
|
509
|
+
const newLength = (0, utils_1.monthsInPeriod)(start, end);
|
|
510
|
+
const oldPlan = [...this.metadata.capacityPlan];
|
|
511
|
+
const oldLength = oldPlan.length;
|
|
512
|
+
const newPlan = [];
|
|
513
|
+
if (mode === enums_1.CapacityPeriodResizeMode.DEFAULT) {
|
|
514
|
+
for (let i = 0; i < newLength; i++) {
|
|
515
|
+
const source = i < oldLength ? oldPlan[i] : oldPlan[oldLength - 1]; // extend using last known value
|
|
516
|
+
newPlan.push({
|
|
517
|
+
month: (0, utils_1.offsetMonthsToYYYYMM)(start, i),
|
|
518
|
+
capacity: source.capacity,
|
|
519
|
+
adjustment: source.adjustment,
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
if (mode === enums_1.CapacityPeriodResizeMode.DISTRIBUTE) {
|
|
524
|
+
const total = this.totalCapacity;
|
|
525
|
+
const base = Math.floor(total / newLength);
|
|
526
|
+
let remainder = total % newLength;
|
|
527
|
+
for (let i = 0; i < newLength; i++) {
|
|
528
|
+
const extra = remainder > 0 ? 1 : 0;
|
|
529
|
+
remainder--;
|
|
530
|
+
newPlan.push({
|
|
531
|
+
month: (0, utils_1.offsetMonthsToYYYYMM)(start, i),
|
|
532
|
+
capacity: base + extra,
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
this.metadata.capacityPlan = newPlan;
|
|
537
|
+
this.updateTimestamp();
|
|
538
|
+
this.markFinanceDirty();
|
|
539
|
+
return this;
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Sets the entire monthly capacity plan.
|
|
543
|
+
* @param {MonthlyCapacity[]} capacityPlan - Array of MonthlyCapacity.
|
|
544
|
+
* @returns {MajikSubscription} - Returns self for chaining.
|
|
545
|
+
* @throws Will throw an error if month format or capacity is invalid.
|
|
546
|
+
*/
|
|
547
|
+
setCapacity(capacityPlan) {
|
|
548
|
+
capacityPlan.forEach((s) => {
|
|
549
|
+
if (!(0, utils_1.isValidYYYYMM)(s.month))
|
|
550
|
+
throw new Error(`Invalid month: ${s.month}`);
|
|
551
|
+
if (typeof s.capacity !== "number")
|
|
552
|
+
throw new Error("Capacity must be a number");
|
|
553
|
+
});
|
|
554
|
+
this.metadata.capacityPlan = [...capacityPlan];
|
|
555
|
+
this.updateTimestamp();
|
|
556
|
+
this.markFinanceDirty();
|
|
557
|
+
return this;
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* Adds capacity for a specific month.
|
|
561
|
+
* @param {YYYYMM} month - YYYY-MM string.
|
|
562
|
+
* @param {number} units - Number of subscription units for the month.
|
|
563
|
+
* @param {number} [adjustment] - Optional adjustment to capacity.
|
|
564
|
+
* @returns {MajikSubscription} - Returns self for chaining.
|
|
565
|
+
* @throws Will throw an error if month already exists.
|
|
566
|
+
*/
|
|
567
|
+
addCapacity(month, units, adjustment) {
|
|
568
|
+
var _a;
|
|
569
|
+
if (!(0, utils_1.isValidYYYYMM)(month))
|
|
570
|
+
throw new Error("Invalid month");
|
|
571
|
+
(_a = this.metadata).capacityPlan ?? (_a.capacityPlan = []);
|
|
572
|
+
if (this.metadata.capacityPlan.some((s) => s.month === month)) {
|
|
573
|
+
throw new Error(`Month ${month} already exists. Use updateCapacityUnits or updateCapacityAdjustment`);
|
|
574
|
+
}
|
|
575
|
+
this.metadata.capacityPlan.push({ month, capacity: units, adjustment });
|
|
576
|
+
this.updateTimestamp();
|
|
577
|
+
this.markFinanceDirty();
|
|
578
|
+
return this;
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* Updates capacity units for a month.
|
|
582
|
+
* @param {YYYYMM} month - YYYY-MM string.
|
|
583
|
+
* @param {number} units - New subscription units.
|
|
584
|
+
* @returns {MajikSubscription} - Returns self for chaining.
|
|
585
|
+
* @throws Will throw an error if month does not exist.
|
|
586
|
+
*/
|
|
587
|
+
updateCapacityUnits(month, units) {
|
|
588
|
+
if (!(0, utils_1.isValidYYYYMM)(month))
|
|
589
|
+
throw new Error("Invalid month");
|
|
590
|
+
const plan = this.metadata.capacityPlan?.find((s) => s.month === month);
|
|
591
|
+
if (!plan)
|
|
592
|
+
throw new Error(`Month ${month} not found`);
|
|
593
|
+
plan.capacity = units;
|
|
594
|
+
this.updateTimestamp();
|
|
595
|
+
this.markFinanceDirty();
|
|
596
|
+
return this;
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* Updates capacity adjustment for a month.
|
|
600
|
+
* @param {YYYYMM} month - YYYY-MM string.
|
|
601
|
+
* @param {number} [adjustment] - Optional adjustment value.
|
|
602
|
+
* @returns {MajikSubscription} - Returns self for chaining.
|
|
603
|
+
* @throws Will throw an error if month does not exist.
|
|
604
|
+
*/
|
|
605
|
+
updateCapacityAdjustment(month, adjustment) {
|
|
606
|
+
if (!(0, utils_1.isValidYYYYMM)(month))
|
|
607
|
+
throw new Error("Invalid month");
|
|
608
|
+
const plan = this.metadata.capacityPlan?.find((s) => s.month === month);
|
|
609
|
+
if (!plan)
|
|
610
|
+
throw new Error(`Month ${month} not found`);
|
|
611
|
+
plan.adjustment = adjustment;
|
|
612
|
+
this.updateTimestamp();
|
|
613
|
+
this.markFinanceDirty();
|
|
614
|
+
return this;
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* Removes a month from the capacity plan.
|
|
618
|
+
* @param {YYYYMM} month - YYYY-MM string.
|
|
619
|
+
* @returns {MajikSubscription} - Returns self for chaining.
|
|
620
|
+
* @throws Will throw an error if month does not exist.
|
|
621
|
+
*/
|
|
622
|
+
removeCapacity(month) {
|
|
623
|
+
if (!(0, utils_1.isValidYYYYMM)(month))
|
|
624
|
+
throw new Error("Invalid month");
|
|
625
|
+
const index = this.metadata.capacityPlan?.findIndex((s) => s.month === month);
|
|
626
|
+
if (index === undefined || index === -1)
|
|
627
|
+
throw new Error(`Month ${month} not found`);
|
|
628
|
+
this.metadata.capacityPlan.splice(index, 1);
|
|
629
|
+
this.updateTimestamp();
|
|
630
|
+
this.markFinanceDirty();
|
|
631
|
+
return this;
|
|
632
|
+
}
|
|
633
|
+
/** Clears the entire capacity plan. */
|
|
634
|
+
clearCapacity() {
|
|
635
|
+
this.metadata.capacityPlan = [];
|
|
636
|
+
this.updateTimestamp();
|
|
637
|
+
this.markFinanceDirty();
|
|
638
|
+
return this;
|
|
639
|
+
}
|
|
640
|
+
/* ------------------ FINANCE HELPERS ------------------ */
|
|
641
|
+
/** Computes gross revenue across all months. */
|
|
642
|
+
computeGrossRevenue() {
|
|
643
|
+
const plan = this.metadata.capacityPlan ?? [];
|
|
644
|
+
return plan.reduce((acc, s) => acc.add(this.rate.amount.multiply(s.capacity + (s.adjustment ?? 0))), this.DEFAULT_ZERO());
|
|
645
|
+
}
|
|
646
|
+
/** Computes gross COS across all months. */
|
|
647
|
+
computeGrossCOS() {
|
|
648
|
+
const plan = this.metadata.capacityPlan ?? [];
|
|
649
|
+
const unitCOS = this.metadata.cos.reduce((acc, c) => acc.add(c.subtotal), this.DEFAULT_ZERO());
|
|
650
|
+
return plan.reduce((acc, s) => acc.add(unitCOS.multiply(s.capacity + (s.adjustment ?? 0))), this.DEFAULT_ZERO());
|
|
651
|
+
}
|
|
652
|
+
/** Computes gross profit (revenue - COS). */
|
|
653
|
+
computeGrossProfit() {
|
|
654
|
+
return this.computeGrossRevenue().subtract(this.computeGrossCOS());
|
|
655
|
+
}
|
|
656
|
+
/** Recomputes and stores aggregate finance info. */
|
|
657
|
+
recomputeFinance() {
|
|
658
|
+
if (!this.financeDirty)
|
|
659
|
+
return;
|
|
660
|
+
const grossRevenue = this.computeGrossRevenue();
|
|
661
|
+
const grossCOS = this.computeGrossCOS();
|
|
662
|
+
const grossProfit = this.computeGrossProfit();
|
|
663
|
+
const grossIncome = grossProfit;
|
|
664
|
+
const revenueMargin = grossRevenue.isZero()
|
|
665
|
+
? 0
|
|
666
|
+
: grossProfit.ratio(grossRevenue);
|
|
667
|
+
const cosMargin = grossRevenue.isZero() ? 0 : grossCOS.ratio(grossRevenue);
|
|
668
|
+
this.metadata.finance = {
|
|
669
|
+
revenue: {
|
|
670
|
+
gross: { value: grossRevenue, marginRatio: 1 },
|
|
671
|
+
net: { value: grossRevenue, marginRatio: 1 },
|
|
672
|
+
},
|
|
673
|
+
cos: {
|
|
674
|
+
gross: { value: grossCOS, marginRatio: cosMargin },
|
|
675
|
+
net: { value: grossCOS, marginRatio: cosMargin },
|
|
676
|
+
},
|
|
677
|
+
profit: {
|
|
678
|
+
gross: { value: grossProfit, marginRatio: revenueMargin },
|
|
679
|
+
net: { value: grossProfit, marginRatio: revenueMargin },
|
|
680
|
+
},
|
|
681
|
+
income: {
|
|
682
|
+
gross: { value: grossIncome, marginRatio: revenueMargin },
|
|
683
|
+
net: { value: grossIncome, marginRatio: revenueMargin },
|
|
684
|
+
},
|
|
685
|
+
};
|
|
686
|
+
this.financeDirty = false;
|
|
687
|
+
}
|
|
688
|
+
/* ------------------ AGGREGATE FINANCE GETTERS ------------------ */
|
|
689
|
+
get averageMonthlyRevenue() {
|
|
690
|
+
const months = this.metadata.capacityPlan?.length ?? 0;
|
|
691
|
+
if (months === 0)
|
|
692
|
+
return this.DEFAULT_ZERO();
|
|
693
|
+
return this.grossRevenue.divide(months);
|
|
694
|
+
}
|
|
695
|
+
get averageMonthlyProfit() {
|
|
696
|
+
const months = this.metadata.capacityPlan?.length ?? 0;
|
|
697
|
+
if (months === 0)
|
|
698
|
+
return this.DEFAULT_ZERO();
|
|
699
|
+
return this.grossProfit.divide(months);
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
702
|
+
* Returns total gross revenue across all months.
|
|
703
|
+
* @returns {MajikMoney} - Gross revenue.
|
|
704
|
+
*/
|
|
705
|
+
get grossRevenue() {
|
|
706
|
+
this.recomputeFinance();
|
|
707
|
+
return this.metadata.finance.revenue.gross.value;
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* Returns total gross cost of subscription (COS) across all months.
|
|
711
|
+
* @returns {MajikMoney} - Gross COS.
|
|
712
|
+
*/
|
|
713
|
+
get grossCost() {
|
|
714
|
+
this.recomputeFinance();
|
|
715
|
+
return this.metadata.finance.cos.gross.value;
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* Returns total gross profit across all months.
|
|
719
|
+
* @returns {MajikMoney} - Gross profit.
|
|
720
|
+
*/
|
|
721
|
+
get grossProfit() {
|
|
722
|
+
this.recomputeFinance();
|
|
723
|
+
return this.metadata.finance.profit.gross.value;
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* Returns net revenue (same as gross in this model).
|
|
727
|
+
* @returns {MajikMoney} - Net revenue.
|
|
728
|
+
*/
|
|
729
|
+
get netRevenue() {
|
|
730
|
+
this.recomputeFinance();
|
|
731
|
+
return this.metadata.finance.revenue.net.value;
|
|
732
|
+
}
|
|
733
|
+
/**
|
|
734
|
+
* Returns net profit (same as gross in this model).
|
|
735
|
+
* @returns {MajikMoney} - Net profit.
|
|
736
|
+
*/
|
|
737
|
+
get netProfit() {
|
|
738
|
+
this.recomputeFinance();
|
|
739
|
+
return this.metadata.finance.profit.net.value;
|
|
740
|
+
}
|
|
741
|
+
get unitCost() {
|
|
742
|
+
return this.metadata.cos.reduce((acc, c) => acc.add(c.subtotal), this.DEFAULT_ZERO());
|
|
743
|
+
}
|
|
744
|
+
get unitProfit() {
|
|
745
|
+
return this.rate.amount.subtract(this.unitCost);
|
|
746
|
+
}
|
|
747
|
+
get unitMargin() {
|
|
748
|
+
return this.rate.amount.isZero()
|
|
749
|
+
? 0
|
|
750
|
+
: this.unitProfit.ratio(this.rate.amount);
|
|
751
|
+
}
|
|
752
|
+
get price() {
|
|
753
|
+
return this.rate.amount.isZero() ? this.DEFAULT_ZERO() : this.rate.amount;
|
|
754
|
+
}
|
|
755
|
+
getMonthlySnapshot(month) {
|
|
756
|
+
return {
|
|
757
|
+
month,
|
|
758
|
+
revenue: this.getRevenue(month),
|
|
759
|
+
cogs: this.getCOS(month),
|
|
760
|
+
profit: this.getProfit(month),
|
|
761
|
+
margin: this.getMargin(month),
|
|
762
|
+
netRevenue: this.getNetRevenue(month),
|
|
763
|
+
netIncome: this.getNetIncome(month),
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
/**
|
|
767
|
+
* Calculates Net Revenue for a given month.
|
|
768
|
+
* @param month - YYYYMM
|
|
769
|
+
* @param discounts - Total discounts for the month (optional)
|
|
770
|
+
* @param returns - Total returns for the month (optional)
|
|
771
|
+
* @param allowances - Total allowances for the month (optional)
|
|
772
|
+
* @returns {MajikMoney} Net Revenue
|
|
773
|
+
*/
|
|
774
|
+
getNetRevenue(month, discounts, returns, allowances) {
|
|
775
|
+
if (!(0, utils_1.isValidYYYYMM)(month))
|
|
776
|
+
throw new Error("Invalid month");
|
|
777
|
+
let net = this.getRevenue(month);
|
|
778
|
+
if (discounts)
|
|
779
|
+
net = net.subtract(discounts);
|
|
780
|
+
if (returns)
|
|
781
|
+
net = net.subtract(returns);
|
|
782
|
+
if (allowances)
|
|
783
|
+
net = net.subtract(allowances);
|
|
784
|
+
return net;
|
|
785
|
+
}
|
|
786
|
+
/**
|
|
787
|
+
* Calculates Net Profit for a given month.
|
|
788
|
+
* @param month - YYYYMM
|
|
789
|
+
* @param operatingExpenses - Total operating expenses (optional)
|
|
790
|
+
* @param taxes - Total taxes (optional)
|
|
791
|
+
* @param discounts - Total discounts for the month (optional)
|
|
792
|
+
* @param returns - Total returns for the month (optional)
|
|
793
|
+
* @param allowances - Total allowances for the month (optional)
|
|
794
|
+
* @returns {MajikMoney} Net Profit
|
|
795
|
+
*/
|
|
796
|
+
getNetProfit(month, operatingExpenses, taxes, discounts, returns, allowances) {
|
|
797
|
+
if (!(0, utils_1.isValidYYYYMM)(month))
|
|
798
|
+
throw new Error("Invalid month");
|
|
799
|
+
let netRev = this.getNetRevenue(month, discounts, returns, allowances);
|
|
800
|
+
if (operatingExpenses)
|
|
801
|
+
netRev = netRev.subtract(operatingExpenses);
|
|
802
|
+
if (taxes)
|
|
803
|
+
netRev = netRev.subtract(taxes);
|
|
804
|
+
return netRev;
|
|
805
|
+
}
|
|
806
|
+
/**
|
|
807
|
+
* Alias for getNetProfit, same as Net Income
|
|
808
|
+
*/
|
|
809
|
+
getNetIncome(month, operatingExpenses, taxes, discounts, returns, allowances) {
|
|
810
|
+
if (!(0, utils_1.isValidYYYYMM)(month))
|
|
811
|
+
throw new Error("Invalid month");
|
|
812
|
+
return this.getNetProfit(month, operatingExpenses, taxes, discounts, returns, allowances);
|
|
813
|
+
}
|
|
814
|
+
/* ------------------ MONTHLY FINANCE ------------------ */
|
|
815
|
+
/**
|
|
816
|
+
* Returns revenue for a specific month.
|
|
817
|
+
* @param {YYYYMM} month - Month in YYYY-MM format.
|
|
818
|
+
* @returns {MajikMoney} - Monthly revenue.
|
|
819
|
+
*/
|
|
820
|
+
getRevenue(month) {
|
|
821
|
+
if (!(0, utils_1.isValidYYYYMM)(month))
|
|
822
|
+
throw new Error("Invalid month");
|
|
823
|
+
const plan = this.metadata.capacityPlan?.find((s) => s.month === month);
|
|
824
|
+
if (!plan)
|
|
825
|
+
return this.DEFAULT_ZERO();
|
|
826
|
+
return this.rate.amount.multiply(plan.capacity + (plan.adjustment ?? 0));
|
|
827
|
+
}
|
|
828
|
+
/**
|
|
829
|
+
* Returns all COS items.
|
|
830
|
+
* @returns {readonly COSItem[]} - Array of COS items.
|
|
831
|
+
*/
|
|
832
|
+
get cos() {
|
|
833
|
+
return this.metadata.cos;
|
|
834
|
+
}
|
|
835
|
+
/**
|
|
836
|
+
* Returns COS for a specific month.
|
|
837
|
+
* @param {YYYYMM} month - Month in YYYY-MM format.
|
|
838
|
+
* @returns {MajikMoney} - Monthly COS.
|
|
839
|
+
*/
|
|
840
|
+
getCOS(month) {
|
|
841
|
+
if (!(0, utils_1.isValidYYYYMM)(month))
|
|
842
|
+
throw new Error("Invalid month");
|
|
843
|
+
const plan = this.metadata.capacityPlan?.find((s) => s.month === month);
|
|
844
|
+
if (!plan)
|
|
845
|
+
return this.DEFAULT_ZERO();
|
|
846
|
+
const perUnitCOS = this.metadata.cos.reduce((acc, c) => acc.add(c.subtotal), this.DEFAULT_ZERO());
|
|
847
|
+
return perUnitCOS.multiply(plan.capacity + (plan.adjustment ?? 0));
|
|
848
|
+
}
|
|
849
|
+
/**
|
|
850
|
+
* Alias for Get COS. Retrieves COS for a given month.
|
|
851
|
+
* @param month - YYYYMM month.
|
|
852
|
+
* @returns {MajikMoney} COS for the month.
|
|
853
|
+
*/
|
|
854
|
+
getCost(month) {
|
|
855
|
+
return this.getCOS(month);
|
|
856
|
+
}
|
|
857
|
+
/**
|
|
858
|
+
* Returns profit for a specific month.
|
|
859
|
+
* @param {YYYYMM} month - Month in YYYY-MM format.
|
|
860
|
+
* @returns {MajikMoney} - Monthly profit.
|
|
861
|
+
*/
|
|
862
|
+
getProfit(month) {
|
|
863
|
+
if (!(0, utils_1.isValidYYYYMM)(month))
|
|
864
|
+
throw new Error("Invalid month");
|
|
865
|
+
return this.getRevenue(month).subtract(this.getCOS(month));
|
|
866
|
+
}
|
|
867
|
+
/**
|
|
868
|
+
* Returns profit margin (as a decimal) for a specific month.
|
|
869
|
+
* @param {YYYYMM} month - Month in YYYY-MM format.
|
|
870
|
+
* @returns {number} - Profit margin (0–1).
|
|
871
|
+
*/
|
|
872
|
+
getMargin(month) {
|
|
873
|
+
if (!(0, utils_1.isValidYYYYMM)(month))
|
|
874
|
+
throw new Error("Invalid month");
|
|
875
|
+
const revenue = this.getRevenue(month);
|
|
876
|
+
return revenue.isZero()
|
|
877
|
+
? 0
|
|
878
|
+
: this.getProfit(month)
|
|
879
|
+
.divideDecimal(revenue.toMajorDecimal())
|
|
880
|
+
.toNumber();
|
|
881
|
+
}
|
|
882
|
+
/* ------------------ SUBSCRIPTION-SPECIFIC METHODS ------------------ */
|
|
883
|
+
/**
|
|
884
|
+
* Applies a trial period to the subscription by reducing the capacity or revenue for the given number of months.
|
|
885
|
+
* @param {number} months - Number of months for the trial period.
|
|
886
|
+
* @returns {MajikSubscription} - Returns self for chaining.
|
|
887
|
+
* @throws {Error} - Throws if months is not a positive integer.
|
|
888
|
+
*/
|
|
889
|
+
applyTrial(months) {
|
|
890
|
+
if (!Number.isInteger(months) || months <= 0)
|
|
891
|
+
throw new Error("Trial months must be a positive integer");
|
|
892
|
+
if (!this.metadata.capacityPlan)
|
|
893
|
+
return this;
|
|
894
|
+
// Reduce the capacity for the first N months to 0
|
|
895
|
+
const sortedPlan = [...this.metadata.capacityPlan].sort((a, b) => a.month.localeCompare(b.month));
|
|
896
|
+
for (let i = 0; i < months && i < sortedPlan.length; i++) {
|
|
897
|
+
sortedPlan[i].adjustment =
|
|
898
|
+
(sortedPlan[i].adjustment ?? 0) - sortedPlan[i].capacity;
|
|
899
|
+
}
|
|
900
|
+
this.metadata.capacityPlan = sortedPlan;
|
|
901
|
+
this.updateTimestamp();
|
|
902
|
+
this.markFinanceDirty();
|
|
903
|
+
return this;
|
|
904
|
+
}
|
|
905
|
+
/**
|
|
906
|
+
* Computes the next billing date for the subscription.
|
|
907
|
+
* Assumes subscription billing is monthly and starts at the first month in the capacity plan.
|
|
908
|
+
* @returns {ISODateString | null} - Next billing date in ISO format, or null if no capacity plan exists.
|
|
909
|
+
*/
|
|
910
|
+
nextBillingDate() {
|
|
911
|
+
if (!this.metadata.capacityPlan || this.metadata.capacityPlan.length === 0)
|
|
912
|
+
return null;
|
|
913
|
+
// Find the next month after current date
|
|
914
|
+
const now = new Date();
|
|
915
|
+
const sortedMonths = [...this.metadata.capacityPlan].sort((a, b) => a.month.localeCompare(b.month));
|
|
916
|
+
for (const monthEntry of sortedMonths) {
|
|
917
|
+
const [year, month] = monthEntry.month.split("-").map(Number);
|
|
918
|
+
const firstOfMonth = new Date(year, month - 1, 1);
|
|
919
|
+
if (firstOfMonth > now)
|
|
920
|
+
return firstOfMonth.toISOString();
|
|
921
|
+
}
|
|
922
|
+
// If all months are in the past, return first month of next year
|
|
923
|
+
const lastMonth = sortedMonths[sortedMonths.length - 1];
|
|
924
|
+
const [lastYear, lastMonthNum] = lastMonth.month.split("-").map(Number);
|
|
925
|
+
const nextMonthDate = new Date(lastYear, lastMonthNum, 1);
|
|
926
|
+
return nextMonthDate.toISOString();
|
|
927
|
+
}
|
|
928
|
+
/**
|
|
929
|
+
* Forecasts revenue for the next N months based on current rate and capacity plan.
|
|
930
|
+
* @param {number} nextNMonths - Number of future months to forecast.
|
|
931
|
+
* @returns {MajikMoney} - Forecasted revenue as MajikMoney.
|
|
932
|
+
* @throws {Error} - Throws if nextNMonths is not a positive integer.
|
|
933
|
+
*/
|
|
934
|
+
forecastRevenue(nextNMonths) {
|
|
935
|
+
if (!Number.isInteger(nextNMonths) || nextNMonths <= 0)
|
|
936
|
+
throw new Error("nextNMonths must be a positive integer");
|
|
937
|
+
if (!this.metadata.capacityPlan || this.metadata.capacityPlan.length === 0)
|
|
938
|
+
return this.DEFAULT_ZERO();
|
|
939
|
+
const sortedPlan = [...this.metadata.capacityPlan].sort((a, b) => a.month.localeCompare(b.month));
|
|
940
|
+
let forecast = this.DEFAULT_ZERO();
|
|
941
|
+
for (let i = 0; i < nextNMonths; i++) {
|
|
942
|
+
const monthEntry = sortedPlan[i % sortedPlan.length]; // loop over plan if nextNMonths > plan length
|
|
943
|
+
const capacity = monthEntry.capacity + (monthEntry.adjustment ?? 0);
|
|
944
|
+
forecast = forecast.add(this.rate.amount.multiply(capacity));
|
|
945
|
+
}
|
|
946
|
+
return forecast;
|
|
947
|
+
}
|
|
948
|
+
/** Monthly Recurring Revenue (MRR) for a specific month or current month if not provided */
|
|
949
|
+
getMRR(month) {
|
|
950
|
+
if (!month) {
|
|
951
|
+
month = (0, utils_1.dateToYYYYMM)(new Date());
|
|
952
|
+
}
|
|
953
|
+
return this.getRevenue(month);
|
|
954
|
+
}
|
|
955
|
+
/** Annual Recurring Revenue (ARR) based on sum of next 12 months revenue
|
|
956
|
+
*
|
|
957
|
+
* Forecasts revenue for the next N months based on current rate and capacity plan.
|
|
958
|
+
* @param {number} months - Number of future months to forecast. Defaults to 12.
|
|
959
|
+
*/
|
|
960
|
+
getARR(months = 12) {
|
|
961
|
+
return this.forecastRevenue(months);
|
|
962
|
+
}
|
|
963
|
+
/* ------------------ UTILITIES ------------------ */
|
|
964
|
+
/**
|
|
965
|
+
* Validates the subscription instance.
|
|
966
|
+
* @param {boolean} [throwError=false] - Whether to throw an error on first invalid property.
|
|
967
|
+
* @returns {boolean} - True if valid, false if invalid and throwError is false.
|
|
968
|
+
* @throws {Error} - Throws error if throwError is true and a required field is missing/invalid.
|
|
969
|
+
*/
|
|
970
|
+
validateSelf(throwError = false) {
|
|
971
|
+
const requiredFields = [
|
|
972
|
+
{ field: this.id, name: "ID" },
|
|
973
|
+
{ field: this.timestamp, name: "Timestamp" },
|
|
974
|
+
{ field: this.name, name: "Subscription Name" },
|
|
975
|
+
{ field: this.metadata.description.text, name: "Description" },
|
|
976
|
+
{ field: this.metadata.rate.amount.toMajor(), name: "Rate Amount" },
|
|
977
|
+
{ field: this.metadata.rate.unit, name: "Rate Unit" },
|
|
978
|
+
];
|
|
979
|
+
for (const { field, name } of requiredFields) {
|
|
980
|
+
if (field === null || field === undefined || field === "") {
|
|
981
|
+
if (throwError)
|
|
982
|
+
throw new Error(`Validation failed: Missing or invalid property - ${name}`);
|
|
983
|
+
return false;
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
return true;
|
|
987
|
+
}
|
|
988
|
+
/**
|
|
989
|
+
* Converts the subscription to a plain object and generates a new ID.
|
|
990
|
+
* @returns {object} - Serialized subscription.
|
|
991
|
+
*/
|
|
992
|
+
finalize() {
|
|
993
|
+
return { ...this.toJSON(), id: (0, utils_1.autogenerateID)("mjksub") };
|
|
994
|
+
}
|
|
995
|
+
/**
|
|
996
|
+
* Converts the subscription instance to a plain JSON object.
|
|
997
|
+
* @returns {object} - Plain object representation.
|
|
998
|
+
*/
|
|
999
|
+
toJSON() {
|
|
1000
|
+
const preJSON = {
|
|
1001
|
+
__type: "MajikSubscription",
|
|
1002
|
+
__object: "json",
|
|
1003
|
+
id: this.id,
|
|
1004
|
+
slug: this.slug,
|
|
1005
|
+
name: this.name,
|
|
1006
|
+
category: this.category,
|
|
1007
|
+
rate: this.rate,
|
|
1008
|
+
status: this.status,
|
|
1009
|
+
type: this.type,
|
|
1010
|
+
timestamp: this.timestamp,
|
|
1011
|
+
last_update: this.last_update,
|
|
1012
|
+
metadata: this.metadata,
|
|
1013
|
+
settings: this.settings,
|
|
1014
|
+
};
|
|
1015
|
+
return (0, majik_money_1.serializeMoney)(preJSON);
|
|
1016
|
+
}
|
|
1017
|
+
/**
|
|
1018
|
+
* Parses a plain object or JSON string into a MajikSubscription instance.
|
|
1019
|
+
* @param {string | object} json - JSON string or object.
|
|
1020
|
+
* @returns {MajikSubscription} - Parsed subscription instance.
|
|
1021
|
+
* @throws {Error} - Throws if required properties are missing.
|
|
1022
|
+
*/
|
|
1023
|
+
static parseFromJSON(json) {
|
|
1024
|
+
const rawParse = typeof json === "string" ? JSON.parse(json) : structuredClone(json);
|
|
1025
|
+
const parsedData = (0, majik_money_1.deserializeMoney)(rawParse);
|
|
1026
|
+
if (!parsedData.id)
|
|
1027
|
+
throw new Error("Missing required property: 'id'");
|
|
1028
|
+
if (!parsedData.timestamp)
|
|
1029
|
+
throw new Error("Missing required property: 'timestamp'");
|
|
1030
|
+
if (!parsedData.metadata)
|
|
1031
|
+
throw new Error("Missing required property: 'metadata'");
|
|
1032
|
+
if (!parsedData.settings)
|
|
1033
|
+
throw new Error("Missing required property: 'settings'");
|
|
1034
|
+
return new MajikSubscription(parsedData.id, parsedData.slug, parsedData.name, parsedData.metadata, parsedData.settings, parsedData.timestamp, parsedData.last_update);
|
|
1035
|
+
}
|
|
1036
|
+
/**
|
|
1037
|
+
* Updates the last_update timestamp to current time.
|
|
1038
|
+
* Should be called whenever a property is modified.
|
|
1039
|
+
* @private
|
|
1040
|
+
*/
|
|
1041
|
+
updateTimestamp() {
|
|
1042
|
+
this.last_update = new Date().toISOString();
|
|
1043
|
+
}
|
|
1044
|
+
/**
|
|
1045
|
+
* Ensures the given MajikMoney object matches the subscription currency.
|
|
1046
|
+
* @param {MajikMoney} money - Money object to validate.
|
|
1047
|
+
* @private
|
|
1048
|
+
* @throws {Error} - Throws if currency does not match subscription rate.
|
|
1049
|
+
*/
|
|
1050
|
+
assertCurrency(money) {
|
|
1051
|
+
if (money.currency.code !== this.rate.amount.currency.code) {
|
|
1052
|
+
throw new Error("Currency mismatch with subscription rate");
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
exports.MajikSubscription = MajikSubscription;
|
|
1057
|
+
function isMajikSubscriptionClass(item) {
|
|
1058
|
+
return item.__object === "class";
|
|
1059
|
+
}
|
|
1060
|
+
function isMajikSubscriptionJSON(item) {
|
|
1061
|
+
return item.__object === "json";
|
|
1062
|
+
}
|