UNPKG

@cocalc/database

Version:

CoCalc: code for working with our PostgreSQL database

215 lines (214 loc) 8.83 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 __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! */ const debug_1 = __importDefault(require("debug")); const L = (0, debug_1.default)("hub:sync-subscriptions"); const const_1 = require("./const"); const awaiting_1 = require("awaiting"); // wait this long after writing to the DB, to avoid overwhelming it... const 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 async function get_licenses(db, account_id, expires_unset = false) { const 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"; } const results = await db.async_query(query); const licenses = {}; for (const x of results.rows) { licenses[x.id] = { expires: x.expires, trial: x.info?.trial === true }; } 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. async function get_subs(db, account_id) { const subs = await db.async_query({ select: "stripe_customer#>'{subscriptions}' as sub", table: "accounts", where: account_id == null ? "stripe_customer_id IS NOT NULL" : { account_id }, timeout_s: const_1.TIMEOUT_S, }); const ret = {}; for (const x of subs.rows) { if (x.sub?.data == null) continue; for (const sub of x.sub.data) { const 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); } } return ret; } // there should only be one subscription per license id, but who knows ... function* iter(subs) { for (const license_id in subs) { const sub_list = subs[license_id]; for (const sub of sub_list) { yield { license_id, sub }; } } } // 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 const 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 async function sync_subscriptions_to_licenses(db, licenses, subs, test_mode) { let n = 0; for (const { license_id, sub } of iter(subs)) { const license = licenses[license_id]; if (license == null) { L(`WARNING: no known license '${license_id}' for subscription '${sub.id}'`); } const expires = license?.expires; // we check, if the given subscription of that license is still funding it if (is_funding(sub)) { // make sure expires is not set if (expires != null) { if (test_mode) { L(`DRYRUN: set 'expires = null' where license_id='${license_id}'`); } else { await db.async_query({ query: "UPDATE site_licenses", set: { expires: null }, where: { id: license_id }, }); } await (0, awaiting_1.delay)(WAIT_AFTER_UPDATE_MS); n += 1; } } else { // status is something other than active, so make sure license *is* expired. // It will only un-expire when the subscription is active again. if (expires == null || expires > new Date()) { if (test_mode) { L(`DRYRUN: set 'expires = ${new Date().toISOString()}' where license_id='${license_id}'`); } else { await db.async_query({ query: "UPDATE site_licenses", set: { expires: new Date() }, where: { id: license_id }, }); } await (0, awaiting_1.delay)(WAIT_AFTER_UPDATE_MS); n += 1; } } } 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... async function expire_cancelled_subscriptions(db, subs, test_mode) { let n = 0; // this query already filters by expires == null const licenses = await get_licenses(db, undefined, true); for (const license_id in licenses) { let funded = false; if (subs[license_id] != null) { let i = 0; for (const sub of subs[license_id]) { if (is_funding(sub)) { funded = i; break; } i += 1; } } if (typeof funded === "number") { L(`license_id '${license_id}' is funded by '${subs[license_id][funded].id}'`); } else { const msg = `license_id '${license_id}' is not funded by any subscription`; // maybe trial without expiration? if (licenses[license_id]?.trial ?? false) { L(`${msg}, but it is a trial`); } else { L(`${msg}`); if (test_mode) { L(`DRYRUN: set 'expires = ${new Date().toISOString()}' where license_id='${license_id}'`); } else { await db.async_query({ query: "UPDATE site_licenses", set: { expires: new Date() }, where: { id: license_id }, }); } await (0, awaiting_1.delay)(WAIT_AFTER_UPDATE_MS); n += 1; } } } 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 async function sync_site_license_subscriptions(db, account_id, test_mode = false) { test_mode = test_mode || !!process.env.DRYRUN; if (test_mode) L(`DRYRUN TEST MODE -- UPDATE QUERIES ARE DISABLED`); const licenses = await get_licenses(db, account_id); const subs = await get_subs(db, account_id); let n = await sync_subscriptions_to_licenses(db, licenses, subs, test_mode); if (account_id == null) { n += await expire_cancelled_subscriptions(db, subs, test_mode); } return n; } exports.sync_site_license_subscriptions = sync_site_license_subscriptions; //# sourceMappingURL=sync-subscriptions.js.map