CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutSign UpSign In
sagemathinc

Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.

GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/util/purchases/cost-to-edit-license.ts
Views: 687
1
// See notes in packages/server/purchases/edit-license.ts for how this works.
2
3
import { cloneDeep } from "lodash";
4
import dayjs from "dayjs";
5
import type { PurchaseInfo } from "@cocalc/util/licenses/purchase/types";
6
import { compute_cost } from "@cocalc/util/licenses/purchase/compute-cost";
7
import { is_integer } from "@cocalc/util/type-checking";
8
import { LicenseIdleTimeouts } from "@cocalc/util/consts/site-license";
9
import type { Uptime } from "@cocalc/util/consts/site-license";
10
import { MAX } from "@cocalc/util/licenses/purchase/consts";
11
import { round2up } from "../misc";
12
import { CURRENT_VERSION } from "@cocalc/util/licenses/purchase/consts";
13
14
export interface Changes {
15
end?: Date;
16
start?: Date;
17
quantity?: number;
18
custom_ram?: number;
19
custom_disk?: number;
20
custom_cpu?: number; // positive integer
21
custom_member?: boolean;
22
custom_uptime?: Uptime; // short, medium, day, always_running
23
}
24
25
//const log = (...args) => console.log("costToEditLicense", ...args);
26
const log = (..._args) => {};
27
28
export default function costToEditLicense(
29
info: PurchaseInfo,
30
changes: Changes,
31
now: Date = new Date(),
32
): { cost: number; modifiedInfo: PurchaseInfo } {
33
if (info.type != "quota") {
34
throw Error(
35
`bug -- editing a license of type "${info.type}" is not currently supported`,
36
);
37
}
38
const originalInfo = cloneDeep(info);
39
log({ info, changes });
40
if (info.start == null) {
41
throw Error("start must be set");
42
}
43
if (info.end == null) {
44
throw Error("end must be set");
45
}
46
47
const recent = dayjs(now).subtract(5, "minutes").toDate();
48
// check constraints on the changes:
49
if (changes.start != null) {
50
if (info.start <= recent) {
51
throw Error(
52
"if you are going to change the start date, then the license can't have already started",
53
);
54
}
55
if (changes.end != null) {
56
if (changes.start > changes.end) {
57
throw Error(
58
"if you are changing both the start and end date, then start must be <= than end",
59
);
60
}
61
}
62
}
63
if (changes.end != null) {
64
if (changes.end < recent) {
65
throw Error(
66
"if you're changing the end date, then you can't change it to be in the past",
67
);
68
}
69
if (changes.start == null && changes.end < info.start) {
70
throw Error(
71
`you can't change the end date ${changes.end} to be before the start date ${info.start}`,
72
);
73
}
74
}
75
76
if (changes.custom_uptime != null) {
77
if (
78
LicenseIdleTimeouts[changes.custom_uptime] == null &&
79
changes.custom_uptime != "always_running"
80
) {
81
throw Error(
82
`custom_uptime must be 'always_running' or one of ${JSON.stringify(
83
Object.keys(LicenseIdleTimeouts),
84
)}`,
85
);
86
}
87
}
88
89
const origInfo = cloneDeep(info);
90
if (origInfo.start == null) {
91
throw Error("start must be set");
92
}
93
if (origInfo.end == null) {
94
throw Error("end must be set");
95
}
96
if (origInfo.start < now) {
97
// Change start date to right now, since we're only making a change
98
// during future time.
99
origInfo.start = now;
100
}
101
if (origInfo.end < origInfo.start) {
102
origInfo.end = origInfo.start;
103
}
104
105
log("editLicense with start date updated:", { origInfo });
106
107
// Make copy of data with modified params.
108
// modifiedInfo uses the current default pricing algorithm, since that the cost today
109
// to make this purchase, hence changing version below.
110
const modifiedInfo = { ...cloneDeep(origInfo), version: CURRENT_VERSION };
111
if (changes.start != null) {
112
modifiedInfo.start = changes.start;
113
}
114
if (changes.end != null) {
115
modifiedInfo.end = changes.end;
116
}
117
118
if (modifiedInfo.start == null) {
119
throw Error("start must be set");
120
}
121
if (modifiedInfo.end == null) {
122
throw Error("end must be set");
123
}
124
if (modifiedInfo.start < now) {
125
// Change start date to right now, since we're only making a change
126
// during future time.
127
modifiedInfo.start = now;
128
}
129
if (modifiedInfo.end < modifiedInfo.start) {
130
modifiedInfo.end = modifiedInfo.start;
131
}
132
133
let numChanges = 0;
134
if (changes.quantity != null && modifiedInfo.quantity != changes.quantity) {
135
assertIsPositiveInteger(changes.quantity, "quantity");
136
if (modifiedInfo.type != "quota") {
137
throw Error(
138
`you can only change the quantity of a quota upgrade license but this license has type '${modifiedInfo.type}'`,
139
);
140
}
141
numChanges += 1;
142
modifiedInfo.quantity = changes.quantity;
143
}
144
145
if (
146
changes.custom_ram != null &&
147
modifiedInfo.custom_ram != changes.custom_ram
148
) {
149
assertIsPositiveInteger(changes.custom_ram, "custom_ram");
150
if (changes.custom_ram > MAX["ram"]) {
151
throw Error(`custom_ram must be at most ${MAX["ram"]}`);
152
}
153
if (modifiedInfo.type != "quota") {
154
throw Error(
155
`you can only change the custom_ram of a quota upgrade license but this license has type '${modifiedInfo.type}'`,
156
);
157
}
158
numChanges += 1;
159
modifiedInfo.custom_ram = changes.custom_ram;
160
}
161
162
if (
163
changes.custom_cpu != null &&
164
modifiedInfo.custom_cpu != changes.custom_cpu
165
) {
166
assertIsPositiveInteger(changes.custom_cpu, "custom_cpu");
167
if (changes.custom_cpu > MAX["cpu"]) {
168
throw Error(`custom_ram must be at most ${MAX["ram"]}`);
169
}
170
if (modifiedInfo.type != "quota") {
171
throw Error(
172
`you can only change the custom_cpu of a quota upgrade license but this license has type '${modifiedInfo.type}'`,
173
);
174
}
175
numChanges += 1;
176
modifiedInfo.custom_cpu = changes.custom_cpu;
177
}
178
179
if (
180
changes.custom_disk != null &&
181
modifiedInfo.custom_disk != changes.custom_disk
182
) {
183
assertIsPositiveInteger(changes.custom_disk, "custom_disk");
184
if (changes.custom_disk > MAX["disk"]) {
185
throw Error(`custom_ram must be at most ${MAX["disk"]}`);
186
}
187
if (modifiedInfo.type != "quota") {
188
throw Error(
189
`you can only change the custom_disk of a quota upgrade license but this license has type '${modifiedInfo.type}'`,
190
);
191
}
192
numChanges += 1;
193
modifiedInfo.custom_disk = changes.custom_disk;
194
}
195
196
if (
197
changes.custom_member != null &&
198
!!modifiedInfo.custom_member != !!changes.custom_member
199
) {
200
if (typeof changes.custom_member != "boolean") {
201
throw Error("custom_member must be boolean");
202
}
203
if (modifiedInfo.type != "quota") {
204
throw Error(
205
`you can only change the custom_member of a quota upgrade license but this license has type '${modifiedInfo.type}'`,
206
);
207
}
208
numChanges += 1;
209
modifiedInfo.custom_member = changes.custom_member;
210
}
211
212
if (
213
changes.custom_uptime != null &&
214
modifiedInfo.custom_uptime != changes.custom_uptime
215
) {
216
if (modifiedInfo.type != "quota") {
217
throw Error(
218
`you can only change the custom_uptime of a quota upgrade license but this license has type '${modifiedInfo.type}'`,
219
);
220
}
221
numChanges += 1;
222
modifiedInfo.custom_uptime = changes.custom_uptime;
223
}
224
225
log({ modifiedInfo });
226
227
// Determine price for the change
228
229
// the value of the license the user currently owned. The pricing algorithm version is important here.
230
const currentValue = currentLicenseValue(origInfo);
231
232
if (numChanges > 0 && modifiedInfo.type == "quota") {
233
// Delete modifiedInfo.cost_per_hour, since that would be the current
234
// rate, and also it is completely wrong since it does not take into
235
// account any of the changes! Also, it's impossible in general to
236
// account for changing all params (e.g., quantity is easy, but others are hard).
237
// Also, we want to force computation of the current going rate, not
238
// the old rate.
239
delete modifiedInfo.cost_per_hour;
240
}
241
242
// Determine price for the modified license they would like to switch to.
243
// modifiedInfo uses the current default pricing algorithm, since that the cost today
244
// to make this purchase, hence changing version below.
245
const modifiedValue = currentLicenseValue(modifiedInfo);
246
// cost can be negative, when we give user a refund.
247
// **We round away from zero!** The reason is because
248
// if the user cancels a subscription for a refund and
249
// gets $X, then buys that same subscription again, we
250
// want the price to again be $X, and not $X+0.01, which
251
// could be really annoying and block the purchase.
252
const d = modifiedValue - currentValue;
253
const cost = (d < 0 ? -1 : 1) * round2up(Math.abs(d));
254
log({
255
cost,
256
currentValue,
257
modifiedValue,
258
origInfo,
259
changes,
260
modifiedInfo,
261
});
262
// In case of a subscription, we changed start to correctly compute the cost
263
// of the change. Set it back:
264
if (modifiedInfo.subscription != "no") {
265
modifiedInfo.start = originalInfo.start;
266
}
267
return { cost, modifiedInfo };
268
}
269
270
function assertIsPositiveInteger(n: number, desc: string) {
271
if (!is_integer(n)) {
272
throw Error(`${desc} must be an integer`);
273
}
274
if (n <= 0) {
275
throw Error(`${desc} must be positive`);
276
}
277
}
278
279
// this function assumes now <= start <= end!
280
function currentLicenseValue(info: PurchaseInfo): number {
281
if (info.type !== "quota") {
282
// We do not provide any prorated refund for ancient license types.
283
return 0;
284
}
285
if (info.end == null || info.start == null) {
286
// infinite value?
287
return 0;
288
}
289
290
// Depending on cost_per_hour being set properly is a nightmare -- it can
291
// be very subtly wrong or have rounding issues, and this can expose us to abuse.
292
// Instead for NOW we are using the current value of the license.
293
// However, we never change our costs. When we do, we should
294
// keep our old cost params and use it to compute the value of the
295
// license, based on the date when it was last purchased.
296
// Perhaps we won't raise rates before switching to a full
297
// pay as you go model....
298
299
// if (info.cost_per_hour) {
300
// // if this is set, we use it to compute the value
301
// // The value is cost_per_hour times the number of hours left until info.end.
302
// const end = dayjs(info.end);
303
// const start = dayjs(info.start);
304
// const hoursRemaining = end.diff(start, "hours", true);
305
// // the hoursRemaining can easily be *negative* if info.end is
306
// // in the past.
307
// // However the value of a license is never negative, so we max with 0.
308
// return Math.max(0, hoursRemaining * info.cost_per_hour);
309
// }
310
311
// Compute value using the current rate.
312
// As mentioned above, we can keep old rates if/when we change the rate,
313
// and compute costs for refunds using that, when applicable.
314
const price = compute_cost(info);
315
return price.cost;
316
}
317
318