UNPKG

smc-hub

Version:

CoCalc: Backend webserver component

433 lines (432 loc) 20.5 kB
"use strict"; /* * This file is part of CoCalc: Copyright © 2020 Sagemath, Inc. * License: AGPLv3 s.t. "Commons Clause" – see LICENSE.md for details */ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } return new (P || (P = Promise))(function (resolve, reject) { function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } step((generator = generator.apply(thisArg, _arguments || [])).next()); }); }; var __generator = (this && this.__generator) || function (thisArg, body) { var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g; return g = { next: verb(0), "throw": verb(1), "return": verb(2) }, typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g; function verb(n) { return function (v) { return step([n, v]); }; } function step(op) { if (f) throw new TypeError("Generator is already executing."); while (_) try { if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t; if (y = 0, t) op = [op[0] & 2, t.value]; switch (op[0]) { case 0: case 1: t = op; break; case 4: _.label++; return { value: op[1], done: false }; case 5: _.label++; y = op[1]; op = [0]; continue; case 7: op = _.ops.pop(); _.trys.pop(); continue; default: if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; } if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; } if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; } if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; } if (t[2]) _.ops.pop(); _.trys.pop(); continue; } op = body.call(thisArg, _); } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; } if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true }; } }; var __values = (this && this.__values) || function(o) { var s = typeof Symbol === "function" && Symbol.iterator, m = s && o[s], i = 0; if (m) return m.call(o); if (o && typeof o.length === "number") return { next: function () { if (o && i >= o.length) o = void 0; return { value: o && o[i++], done: !o }; } }; throw new TypeError(s ? "Object is not iterable." : "Symbol.iterator is not defined."); }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.sync_site_license_subscriptions = void 0; /* Ensure all (or just for given account_id) site license subscriptions are non-expired iff subscription in stripe is "active" or "trialing". This actually uses the "stripe_customer" field of the user account, so its important that *that* is valid. 2021-03-29: this also checks the other way around: for each un-expired license check if there is a subscription funding it. This additional sync is only run if there is no specific account_id set! */ var debug_1 = __importDefault(require("debug")); var L = debug_1.default("hub:sync-subscriptions"); var const_1 = require("./const"); var awaiting_1 = require("awaiting"); // wait this long after writing to the DB, to avoid overwhelming it... var WAIT_AFTER_UPDATE_MS = 20; // Get all license expire times from database at once, so we don't // have to query for each one individually, which would take a long time. // If account_id is given, we only get the licenses with that user // as a manager. // TODO: SCALABILITY WARNING function get_licenses(db, account_id, expires_unset) { var _a; if (expires_unset === void 0) { expires_unset = false; } return __awaiter(this, void 0, void 0, function () { var query, results, licenses, _b, _c, x; var e_1, _d; return __generator(this, function (_e) { switch (_e.label) { case 0: query = { select: ["id", "expires", "info"], table: "site_licenses", }; if (account_id != null && expires_unset) { throw new Error("setting the account_id requires expires_unset == false"); } if (account_id != null) { query.where = "$1 = ANY(managers)"; query.params = [account_id]; } else if (expires_unset) { query.where = "expires IS NULL"; } return [4 /*yield*/, db.async_query(query)]; case 1: results = _e.sent(); licenses = {}; try { for (_b = __values(results.rows), _c = _b.next(); !_c.done; _c = _b.next()) { x = _c.value; licenses[x.id] = { expires: x.expires, trial: ((_a = x.info) === null || _a === void 0 ? void 0 : _a.trial) === true }; } } catch (e_1_1) { e_1 = { error: e_1_1 }; } finally { try { if (_c && !_c.done && (_d = _b.return)) _d.call(_b); } finally { if (e_1) throw e_1.error; } } return [2 /*return*/, licenses]; } }); }); } // Get *all* stripe subscription data from the database. // TODO: SCALABILITY WARNING // TODO: Only the last 10 subs are here, I think, so an old sub might not get properly expired // for a user that has 10+ subs. Worry about this when there are such users; maybe there never will be. function get_subs(db, account_id) { var _a; return __awaiter(this, void 0, void 0, function () { var subs, ret, _b, _c, x, _d, _e, sub, license_id; var e_2, _f, e_3, _g; return __generator(this, function (_h) { switch (_h.label) { case 0: return [4 /*yield*/, db.async_query({ select: "stripe_customer#>'{subscriptions}' as sub", table: "accounts", where: account_id == null ? "stripe_customer_id IS NOT NULL" : { account_id: account_id }, timeout_s: const_1.TIMEOUT_S, })]; case 1: subs = _h.sent(); ret = {}; try { for (_b = __values(subs.rows), _c = _b.next(); !_c.done; _c = _b.next()) { x = _c.value; if (((_a = x.sub) === null || _a === void 0 ? void 0 : _a.data) == null) continue; try { for (_d = (e_3 = void 0, __values(x.sub.data)), _e = _d.next(); !_e.done; _e = _d.next()) { sub = _e.value; license_id = sub.metadata.license_id; if (license_id == null) { continue; // not a license } if (ret[license_id] == null) { ret[license_id] = []; } else { L("more than one subscription for license '" + license_id + "'"); } ret[license_id].push(sub); } } catch (e_3_1) { e_3 = { error: e_3_1 }; } finally { try { if (_e && !_e.done && (_g = _d.return)) _g.call(_d); } finally { if (e_3) throw e_3.error; } } } } catch (e_2_1) { e_2 = { error: e_2_1 }; } finally { try { if (_c && !_c.done && (_f = _b.return)) _f.call(_b); } finally { if (e_2) throw e_2.error; } } return [2 /*return*/, ret]; } }); }); } // there should only be one subscription per license id, but who knows ... function iter(subs) { var _a, _b, _i, license_id, sub_list, sub_list_1, sub_list_1_1, sub, e_4_1; var e_4, _c; return __generator(this, function (_d) { switch (_d.label) { case 0: _a = []; for (_b in subs) _a.push(_b); _i = 0; _d.label = 1; case 1: if (!(_i < _a.length)) return [3 /*break*/, 10]; license_id = _a[_i]; sub_list = subs[license_id]; _d.label = 2; case 2: _d.trys.push([2, 7, 8, 9]); sub_list_1 = (e_4 = void 0, __values(sub_list)), sub_list_1_1 = sub_list_1.next(); _d.label = 3; case 3: if (!!sub_list_1_1.done) return [3 /*break*/, 6]; sub = sub_list_1_1.value; return [4 /*yield*/, { license_id: license_id, sub: sub }]; case 4: _d.sent(); _d.label = 5; case 5: sub_list_1_1 = sub_list_1.next(); return [3 /*break*/, 3]; case 6: return [3 /*break*/, 9]; case 7: e_4_1 = _d.sent(); e_4 = { error: e_4_1 }; return [3 /*break*/, 9]; case 8: try { if (sub_list_1_1 && !sub_list_1_1.done && (_c = sub_list_1.return)) _c.call(sub_list_1); } finally { if (e_4) throw e_4.error; } return [7 /*endfinally*/]; case 9: _i++; return [3 /*break*/, 1]; case 10: return [2 /*return*/]; } }); } // returns true, if this subscription is actively funding function is_funding(sub) { // there are subs, which are "active" but the cancel_at time is in the past and hence are cancelled. // that's not in the stripe API but could happen to us here if the account's stripe info is no longer synced var cancelled = typeof sub.cancel_at === "number" ? new Date(sub.cancel_at * 1000) < new Date() : false; return (sub.status == "active" || sub.status == "trialing") && !cancelled; } // for each subscription status, we set the associated license status // in particular, we don't expect special cases like "trial" or other manual licenses function sync_subscriptions_to_licenses(db, licenses, subs, test_mode) { return __awaiter(this, void 0, void 0, function () { var n, _a, _b, _c, license_id, sub, expires, e_5_1; var e_5, _d; return __generator(this, function (_e) { switch (_e.label) { case 0: n = 0; _e.label = 1; case 1: _e.trys.push([1, 15, 16, 17]); _a = __values(iter(subs)), _b = _a.next(); _e.label = 2; case 2: if (!!_b.done) return [3 /*break*/, 14]; _c = _b.value, license_id = _c.license_id, sub = _c.sub; if (licenses[license_id] == null) { L("WARNING: no known license '" + license_id + "' for subscription '" + sub.id + "'"); } expires = licenses[license_id].expires; if (!is_funding(sub)) return [3 /*break*/, 8]; if (!(expires != null)) return [3 /*break*/, 7]; if (!test_mode) return [3 /*break*/, 3]; L("DRYRUN: set 'expires = null' where license_id='" + license_id + "'"); return [3 /*break*/, 5]; case 3: return [4 /*yield*/, db.async_query({ query: "UPDATE site_licenses", set: { expires: null }, where: { id: license_id }, })]; case 4: _e.sent(); _e.label = 5; case 5: return [4 /*yield*/, awaiting_1.delay(WAIT_AFTER_UPDATE_MS)]; case 6: _e.sent(); n += 1; _e.label = 7; case 7: return [3 /*break*/, 13]; case 8: if (!(expires == null || expires > new Date())) return [3 /*break*/, 13]; if (!test_mode) return [3 /*break*/, 9]; L("DRYRUN: set 'expires = " + new Date().toISOString() + "' where license_id='" + license_id + "'"); return [3 /*break*/, 11]; case 9: return [4 /*yield*/, db.async_query({ query: "UPDATE site_licenses", set: { expires: new Date() }, where: { id: license_id }, })]; case 10: _e.sent(); _e.label = 11; case 11: return [4 /*yield*/, awaiting_1.delay(WAIT_AFTER_UPDATE_MS)]; case 12: _e.sent(); n += 1; _e.label = 13; case 13: _b = _a.next(); return [3 /*break*/, 2]; case 14: return [3 /*break*/, 17]; case 15: e_5_1 = _e.sent(); e_5 = { error: e_5_1 }; return [3 /*break*/, 17]; case 16: try { if (_b && !_b.done && (_d = _a.return)) _d.call(_a); } finally { if (e_5) throw e_5.error; } return [7 /*endfinally*/]; case 17: return [2 /*return*/, n]; } }); }); } // this handles the case when the subscription, which is funding a license key, has been cancelled. // hence this checks all active licenses without an expiration, if there is still an associated subscription. // if not, the license is expired. // keep in mind there are special licenses like "trials", which aren't funded and might not have an expiration... function expire_cancelled_subscriptions(db, subs, test_mode) { return __awaiter(this, void 0, void 0, function () { var n, licenses, _a, _b, _i, license_id, funded, i, _c, _d, sub, msg; var e_6, _e; return __generator(this, function (_f) { switch (_f.label) { case 0: n = 0; return [4 /*yield*/, get_licenses(db, undefined, true)]; case 1: licenses = _f.sent(); _a = []; for (_b in licenses) _a.push(_b); _i = 0; _f.label = 2; case 2: if (!(_i < _a.length)) return [3 /*break*/, 10]; license_id = _a[_i]; funded = false; if (subs[license_id] != null) { i = 0; try { for (_c = (e_6 = void 0, __values(subs[license_id])), _d = _c.next(); !_d.done; _d = _c.next()) { sub = _d.value; if (is_funding(sub)) { funded = i; break; } i += 1; } } catch (e_6_1) { e_6 = { error: e_6_1 }; } finally { try { if (_d && !_d.done && (_e = _c.return)) _e.call(_c); } finally { if (e_6) throw e_6.error; } } } if (!(typeof funded === "number")) return [3 /*break*/, 3]; L("license_id '" + license_id + "' is funded by '" + subs[license_id][funded].id + "'"); return [3 /*break*/, 9]; case 3: msg = "license_id '" + license_id + "' is not funded by any subscription"; if (!licenses[license_id].trial) return [3 /*break*/, 4]; L(msg + ", but it is a trial"); return [3 /*break*/, 9]; case 4: L("" + msg); if (!test_mode) return [3 /*break*/, 5]; L("DRYRUN: set 'expires = " + new Date().toISOString() + "' where license_id='" + license_id + "'"); return [3 /*break*/, 7]; case 5: return [4 /*yield*/, db.async_query({ query: "UPDATE site_licenses", set: { expires: new Date() }, where: { id: license_id }, })]; case 6: _f.sent(); _f.label = 7; case 7: return [4 /*yield*/, awaiting_1.delay(WAIT_AFTER_UPDATE_MS)]; case 8: _f.sent(); n += 1; _f.label = 9; case 9: _i++; return [3 /*break*/, 2]; case 10: return [2 /*return*/, n]; } }); }); } // call this to sync subscriptions <-> site licenses. // if there is an account_id, it only syncs the given users' subscription to the license function sync_site_license_subscriptions(db, account_id, test_mode) { if (test_mode === void 0) { test_mode = false; } return __awaiter(this, void 0, void 0, function () { var licenses, subs, n, _a; return __generator(this, function (_b) { switch (_b.label) { case 0: test_mode = test_mode || !!process.env.DRYRUN; if (test_mode) L("DRYRUN TEST MODE -- UPDATE QUERIES ARE DISABLED"); return [4 /*yield*/, get_licenses(db, account_id)]; case 1: licenses = _b.sent(); return [4 /*yield*/, get_subs(db, account_id)]; case 2: subs = _b.sent(); return [4 /*yield*/, sync_subscriptions_to_licenses(db, licenses, subs, test_mode)]; case 3: n = _b.sent(); if (!(account_id == null)) return [3 /*break*/, 5]; _a = n; return [4 /*yield*/, expire_cancelled_subscriptions(db, subs, test_mode)]; case 4: n = _a + _b.sent(); _b.label = 5; case 5: return [2 /*return*/, n]; } }); }); } exports.sync_site_license_subscriptions = sync_site_license_subscriptions; //# sourceMappingURL=sync-subscriptions.js.map