Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.
Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.
Path: blob/master/src/packages/util/purchases/cost-to-edit-license.ts
Views: 687
// See notes in packages/server/purchases/edit-license.ts for how this works.12import { cloneDeep } from "lodash";3import dayjs from "dayjs";4import type { PurchaseInfo } from "@cocalc/util/licenses/purchase/types";5import { compute_cost } from "@cocalc/util/licenses/purchase/compute-cost";6import { is_integer } from "@cocalc/util/type-checking";7import { LicenseIdleTimeouts } from "@cocalc/util/consts/site-license";8import type { Uptime } from "@cocalc/util/consts/site-license";9import { MAX } from "@cocalc/util/licenses/purchase/consts";10import { round2up } from "../misc";11import { CURRENT_VERSION } from "@cocalc/util/licenses/purchase/consts";1213export interface Changes {14end?: Date;15start?: Date;16quantity?: number;17custom_ram?: number;18custom_disk?: number;19custom_cpu?: number; // positive integer20custom_member?: boolean;21custom_uptime?: Uptime; // short, medium, day, always_running22}2324//const log = (...args) => console.log("costToEditLicense", ...args);25const log = (..._args) => {};2627export default function costToEditLicense(28info: PurchaseInfo,29changes: Changes,30now: Date = new Date(),31): { cost: number; modifiedInfo: PurchaseInfo } {32if (info.type != "quota") {33throw Error(34`bug -- editing a license of type "${info.type}" is not currently supported`,35);36}37const originalInfo = cloneDeep(info);38log({ info, changes });39if (info.start == null) {40throw Error("start must be set");41}42if (info.end == null) {43throw Error("end must be set");44}4546const recent = dayjs(now).subtract(5, "minutes").toDate();47// check constraints on the changes:48if (changes.start != null) {49if (info.start <= recent) {50throw Error(51"if you are going to change the start date, then the license can't have already started",52);53}54if (changes.end != null) {55if (changes.start > changes.end) {56throw Error(57"if you are changing both the start and end date, then start must be <= than end",58);59}60}61}62if (changes.end != null) {63if (changes.end < recent) {64throw Error(65"if you're changing the end date, then you can't change it to be in the past",66);67}68if (changes.start == null && changes.end < info.start) {69throw Error(70`you can't change the end date ${changes.end} to be before the start date ${info.start}`,71);72}73}7475if (changes.custom_uptime != null) {76if (77LicenseIdleTimeouts[changes.custom_uptime] == null &&78changes.custom_uptime != "always_running"79) {80throw Error(81`custom_uptime must be 'always_running' or one of ${JSON.stringify(82Object.keys(LicenseIdleTimeouts),83)}`,84);85}86}8788const origInfo = cloneDeep(info);89if (origInfo.start == null) {90throw Error("start must be set");91}92if (origInfo.end == null) {93throw Error("end must be set");94}95if (origInfo.start < now) {96// Change start date to right now, since we're only making a change97// during future time.98origInfo.start = now;99}100if (origInfo.end < origInfo.start) {101origInfo.end = origInfo.start;102}103104log("editLicense with start date updated:", { origInfo });105106// Make copy of data with modified params.107// modifiedInfo uses the current default pricing algorithm, since that the cost today108// to make this purchase, hence changing version below.109const modifiedInfo = { ...cloneDeep(origInfo), version: CURRENT_VERSION };110if (changes.start != null) {111modifiedInfo.start = changes.start;112}113if (changes.end != null) {114modifiedInfo.end = changes.end;115}116117if (modifiedInfo.start == null) {118throw Error("start must be set");119}120if (modifiedInfo.end == null) {121throw Error("end must be set");122}123if (modifiedInfo.start < now) {124// Change start date to right now, since we're only making a change125// during future time.126modifiedInfo.start = now;127}128if (modifiedInfo.end < modifiedInfo.start) {129modifiedInfo.end = modifiedInfo.start;130}131132let numChanges = 0;133if (changes.quantity != null && modifiedInfo.quantity != changes.quantity) {134assertIsPositiveInteger(changes.quantity, "quantity");135if (modifiedInfo.type != "quota") {136throw Error(137`you can only change the quantity of a quota upgrade license but this license has type '${modifiedInfo.type}'`,138);139}140numChanges += 1;141modifiedInfo.quantity = changes.quantity;142}143144if (145changes.custom_ram != null &&146modifiedInfo.custom_ram != changes.custom_ram147) {148assertIsPositiveInteger(changes.custom_ram, "custom_ram");149if (changes.custom_ram > MAX["ram"]) {150throw Error(`custom_ram must be at most ${MAX["ram"]}`);151}152if (modifiedInfo.type != "quota") {153throw Error(154`you can only change the custom_ram of a quota upgrade license but this license has type '${modifiedInfo.type}'`,155);156}157numChanges += 1;158modifiedInfo.custom_ram = changes.custom_ram;159}160161if (162changes.custom_cpu != null &&163modifiedInfo.custom_cpu != changes.custom_cpu164) {165assertIsPositiveInteger(changes.custom_cpu, "custom_cpu");166if (changes.custom_cpu > MAX["cpu"]) {167throw Error(`custom_ram must be at most ${MAX["ram"]}`);168}169if (modifiedInfo.type != "quota") {170throw Error(171`you can only change the custom_cpu of a quota upgrade license but this license has type '${modifiedInfo.type}'`,172);173}174numChanges += 1;175modifiedInfo.custom_cpu = changes.custom_cpu;176}177178if (179changes.custom_disk != null &&180modifiedInfo.custom_disk != changes.custom_disk181) {182assertIsPositiveInteger(changes.custom_disk, "custom_disk");183if (changes.custom_disk > MAX["disk"]) {184throw Error(`custom_ram must be at most ${MAX["disk"]}`);185}186if (modifiedInfo.type != "quota") {187throw Error(188`you can only change the custom_disk of a quota upgrade license but this license has type '${modifiedInfo.type}'`,189);190}191numChanges += 1;192modifiedInfo.custom_disk = changes.custom_disk;193}194195if (196changes.custom_member != null &&197!!modifiedInfo.custom_member != !!changes.custom_member198) {199if (typeof changes.custom_member != "boolean") {200throw Error("custom_member must be boolean");201}202if (modifiedInfo.type != "quota") {203throw Error(204`you can only change the custom_member of a quota upgrade license but this license has type '${modifiedInfo.type}'`,205);206}207numChanges += 1;208modifiedInfo.custom_member = changes.custom_member;209}210211if (212changes.custom_uptime != null &&213modifiedInfo.custom_uptime != changes.custom_uptime214) {215if (modifiedInfo.type != "quota") {216throw Error(217`you can only change the custom_uptime of a quota upgrade license but this license has type '${modifiedInfo.type}'`,218);219}220numChanges += 1;221modifiedInfo.custom_uptime = changes.custom_uptime;222}223224log({ modifiedInfo });225226// Determine price for the change227228// the value of the license the user currently owned. The pricing algorithm version is important here.229const currentValue = currentLicenseValue(origInfo);230231if (numChanges > 0 && modifiedInfo.type == "quota") {232// Delete modifiedInfo.cost_per_hour, since that would be the current233// rate, and also it is completely wrong since it does not take into234// account any of the changes! Also, it's impossible in general to235// account for changing all params (e.g., quantity is easy, but others are hard).236// Also, we want to force computation of the current going rate, not237// the old rate.238delete modifiedInfo.cost_per_hour;239}240241// Determine price for the modified license they would like to switch to.242// modifiedInfo uses the current default pricing algorithm, since that the cost today243// to make this purchase, hence changing version below.244const modifiedValue = currentLicenseValue(modifiedInfo);245// cost can be negative, when we give user a refund.246// **We round away from zero!** The reason is because247// if the user cancels a subscription for a refund and248// gets $X, then buys that same subscription again, we249// want the price to again be $X, and not $X+0.01, which250// could be really annoying and block the purchase.251const d = modifiedValue - currentValue;252const cost = (d < 0 ? -1 : 1) * round2up(Math.abs(d));253log({254cost,255currentValue,256modifiedValue,257origInfo,258changes,259modifiedInfo,260});261// In case of a subscription, we changed start to correctly compute the cost262// of the change. Set it back:263if (modifiedInfo.subscription != "no") {264modifiedInfo.start = originalInfo.start;265}266return { cost, modifiedInfo };267}268269function assertIsPositiveInteger(n: number, desc: string) {270if (!is_integer(n)) {271throw Error(`${desc} must be an integer`);272}273if (n <= 0) {274throw Error(`${desc} must be positive`);275}276}277278// this function assumes now <= start <= end!279function currentLicenseValue(info: PurchaseInfo): number {280if (info.type !== "quota") {281// We do not provide any prorated refund for ancient license types.282return 0;283}284if (info.end == null || info.start == null) {285// infinite value?286return 0;287}288289// Depending on cost_per_hour being set properly is a nightmare -- it can290// be very subtly wrong or have rounding issues, and this can expose us to abuse.291// Instead for NOW we are using the current value of the license.292// However, we never change our costs. When we do, we should293// keep our old cost params and use it to compute the value of the294// license, based on the date when it was last purchased.295// Perhaps we won't raise rates before switching to a full296// pay as you go model....297298// if (info.cost_per_hour) {299// // if this is set, we use it to compute the value300// // The value is cost_per_hour times the number of hours left until info.end.301// const end = dayjs(info.end);302// const start = dayjs(info.start);303// const hoursRemaining = end.diff(start, "hours", true);304// // the hoursRemaining can easily be *negative* if info.end is305// // in the past.306// // However the value of a license is never negative, so we max with 0.307// return Math.max(0, hoursRemaining * info.cost_per_hour);308// }309310// Compute value using the current rate.311// As mentioned above, we can keep old rates if/when we change the rate,312// and compute costs for refunds using that, when applicable.313const price = compute_cost(info);314return price.cost;315}316317318