@cocalc/server
Version:
CoCalc server functionality: functions used by either the hub and the next.js server
586 lines • 25.7 kB
JavaScript
"use strict";
/*
* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.
* License: AGPLv3 s.t. "Commons Clause" – see LICENSE.md for details
*/
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.StripeClient = exports.Stripe = void 0;
const hof_1 = require("async-await-utils/hof");
const awaiting_1 = require("awaiting");
const async_utils_1 = require("@cocalc/util/async-utils");
const message = __importStar(require("@cocalc/util/message"));
const upgrades_1 = require("@cocalc/util/upgrades");
const name_1 = __importDefault(require("@cocalc/util/stripe/name"));
const database_1 = require("@cocalc/database");
const stripe_1 = __importDefault(require("stripe"));
exports.Stripe = stripe_1.default;
const private_1 = __importDefault(require("@cocalc/server/accounts/profile/private"));
const connection_1 = __importDefault(require("./connection"));
const sales_tax_1 = require("./sales-tax");
const logger_1 = __importDefault(require("@cocalc/backend/logger"));
const logger = (0, logger_1.default)("stripe-client");
function get_string_field(mesg, name) {
if (mesg == null)
throw Error("invalid message; must not be null");
const x = mesg[name];
if (typeof x != "string")
throw Error(`mesg[${name}] must be a string`);
return x;
}
function get_nonnull_field(mesg, name) {
if (mesg == null)
throw Error("invalid message; must not be null");
const x = mesg[name];
if (x == null)
throw Error(`mesg[${name}] must be defined`);
return x;
}
class StripeClient {
constructor(client) {
if (!client.account_id) {
throw Error("account_id must be specified");
}
if (!client.database) {
// TODO: Importing this at the top level doesn't work with
// next.js right now for some reason. I'm sure this problem
// will vanish when @cocalc/database melts away or is rewritten
// in typescript...
client.database = (0, database_1.db)();
}
// Set defaults for some attributes of client, in case not specified
if (client.dbg == null) {
client.dbg = (f) => {
return (...args) => logger.debug(`Stripe(account_id=${client.account_id})`, f, ...args);
};
}
if (client.push_to_client == null) {
client.push_to_client = () => { }; // no op
}
if (client.error_to_client == null) {
client.error_to_client = () => { }; // no op
}
if (client.assert_user_is_in_group == null) {
client.assert_user_is_in_group = () => { }; // no op
}
this.client = client;
this.get_customer_id = (0, hof_1.reuseInFlight)(this.get_customer_id.bind(this));
}
dbg(f) {
return this.client.dbg(`stripe.${f}`);
}
// Returns the stripe customer id for this account from our database,
// or undefined if there is no known stripe customer id.
// Throws an error if something goes wrong.
// If called multiple times simultaneously, only does one DB query.
async get_customer_id() {
// If no customer info yet with stripe, then NOT an error; instead,
// customer_id is undefined (but will check every time in this case).
const dbg = this.dbg("get_customer_id");
dbg();
if (this.stripe_customer_id != null) {
dbg("using cached this.stripe_customer_id");
return this.stripe_customer_id;
}
const account_id = this.client.account_id;
if (account_id == null) {
throw Error("You must be signed in to use billing related functions.");
}
dbg("getting stripe_customer_id from database...");
const stripe_customer_id = await (0, async_utils_1.callback2)(this.client.database.get_stripe_customer_id, { account_id });
if (stripe_customer_id != null) {
// cache it, since it won't change.
this.stripe_customer_id = stripe_customer_id;
}
return stripe_customer_id;
}
// Raise an exception if user is not yet registered with stripe.
async need_customer_id() {
this.dbg("need_customer_id")();
const customer_id = await this.get_customer_id();
if (customer_id == null) {
throw Error("stripe customer not defined");
}
return customer_id;
}
async stripe_api_pager_options(mesg) {
return {
customer: await this.need_customer_id(),
limit: mesg.limit,
ending_before: mesg.ending_before,
starting_after: mesg.starting_after,
};
}
async get_customer(customer_id) {
const dbg = this.dbg("get_customer");
if (customer_id == null) {
dbg("getting customer id");
customer_id = await this.need_customer_id();
}
dbg("now getting stripe customer object");
return await (await (0, connection_1.default)()).customers.retrieve(customer_id);
}
async handle_mesg(mesg) {
try {
if (mesg.event.slice(0, 7) != "stripe_") {
throw Error("mesg event must start with stripe_");
}
const f = this[`mesg_${mesg.event.slice(7)}`];
if (f == null) {
throw Error(`no such message type ${mesg.event}`);
}
else {
let resp = await f.bind(this)(mesg);
if (resp == null) {
resp = {};
}
resp.id = mesg.id;
this.client.push_to_client(resp);
}
}
catch (err) {
let error;
if (err.stack != null) {
error = err.stack.split("\n")[0];
}
else {
error = `${err}`;
}
this.dbg("handle_mesg")("Error", error, err.stack);
this.client.error_to_client({ id: mesg.id, error });
}
}
async mesg_get_customer(_mesg) {
const dbg = this.dbg("mesg_get_customer");
dbg("get information from stripe: subscriptions, payment methods, etc.");
// note -- we explicitly put the "publishable_key" property there...
return message.stripe_customer({
stripe_publishable_key: (await (0, connection_1.default)()).publishable_key,
customer: await this.update_database(),
});
}
async mesg_create_source(mesg) {
const dbg = this.dbg("mesg_create_source");
dbg("create a payment method (credit card) in stripe for this user");
const token = get_string_field(mesg, "token");
dbg("looking up customer");
const customer_id = await this.get_customer_id();
if (customer_id == null) {
await this.create_new_stripe_customer_from_card_token(token);
}
else {
await this.add_card_to_existing_stripe_customer(token);
}
}
async create_new_stripe_customer_from_card_token(token) {
const dbg = this.dbg("create_new_stripe_customer_from_card_token");
dbg("create new stripe customer from card token");
// Check user is not anonymous. Our user interface never provides
// anything stripe related to anon users. However, an "abuser"
// might still try to trigger this. Anonymous accounts may be easier
// to create, e.g., no captcha, so we want to limit their potential for damage.
const { is_anonymous } = await (0, private_1.default)(this.client.account_id);
if (is_anonymous) {
throw Error("anonymous users are not allowed to create a stripe customer");
}
dbg("get identifying info about user");
const r = await (0, async_utils_1.callback2)(this.client.database.get_account, {
columns: ["email_address", "first_name", "last_name"],
account_id: this.client.account_id,
});
const email = r.email_address;
const description = (0, name_1.default)(r.first_name, r.last_name);
dbg(`they are ${description} with email ${email}`);
dbg("creating stripe customer");
const x = {
source: token,
description,
name: description,
email,
metadata: {
account_id: this.client.account_id,
},
};
const customer_id = (await (await (0, connection_1.default)()).customers.create(x))
.id;
dbg("success; now save customer_id to database");
await (0, async_utils_1.callback2)(this.client.database.set_stripe_customer_id, {
account_id: this.client.account_id,
customer_id,
});
await this.update_database();
}
async add_card_to_existing_stripe_customer(token) {
const dbg = this.dbg("add_card_to_existing_stripe_customer");
dbg("add card to existing stripe customer");
const customer_id = await this.need_customer_id();
await (await (0, connection_1.default)()).customers.createSource(customer_id, { source: token });
await this.update_database();
}
async mesg_delete_source(mesg) {
const dbg = this.dbg("mesg_delete_source");
dbg("delete a payment method for this user");
const card_id = get_string_field(mesg, "card_id");
const customer_id = await this.get_customer_id();
if (customer_id == null)
throw Error("no customer information so can't delete source");
await (await (0, connection_1.default)()).customers.deleteSource(customer_id, card_id);
await this.update_database();
}
async mesg_set_default_source(mesg) {
const dbg = this.dbg("mesg_set_default_source");
dbg("set a payment method for this user to be the default");
const card_id = get_string_field(mesg, "card_id");
const customer_id = await this.need_customer_id();
dbg("now setting the default source in stripe");
await (await (0, connection_1.default)()).customers.update(customer_id, {
default_source: card_id,
});
await this.update_database();
}
// update_database queries stripe for customer record, stores
// it in the database, and returns the new customer record
// if it exists
async update_database() {
this.dbg("update_database")();
const customer_id = await this.get_customer_id();
if (customer_id == null)
return;
return await (0, async_utils_1.callback2)(this.client.database.stripe_update_customer, {
account_id: this.client.account_id,
stripe: await (0, connection_1.default)(),
customer_id,
});
}
async mesg_update_source(mesg) {
const dbg = this.dbg("mesg_update_source");
dbg("modify a payment method");
const card_id = get_string_field(mesg, "card_id");
const info = get_nonnull_field(mesg, "info");
await (await (0, connection_1.default)()).sources.update(card_id, info);
await this.update_database();
}
async sales_tax(customer_id) {
return await (0, sales_tax_1.stripe_sales_tax)(customer_id, this.dbg("sales_tax"));
}
async mesg_create_subscription(mesg) {
const dbg = this.dbg("mesg_create_subscription");
dbg("create a subscription for this user, using some billing method");
const plan = get_string_field(mesg, "plan");
const schema = require("@cocalc/util/schema").PROJECT_UPGRADES.subscription[plan.split("-")[0]];
if (schema == null)
throw Error(`unknown plan -- '${plan}'`);
const customer_id = await this.need_customer_id();
const quantity = mesg.quantity ? mesg.quantity : 1;
dbg("determine applicable tax");
const tax_percent = await this.sales_tax(customer_id);
// CRITICAL regarding setting tax_percent below: if we don't just multiply by 100, since then sometimes
// stripe comes back with an error like this
// "Error: Invalid decimal: 8.799999999999999; must contain at maximum two decimal places."
const options = {
customer: customer_id,
payment_behavior: "error_if_incomplete",
items: [{ quantity, plan }],
coupon: mesg.coupon_id,
cancel_at_period_end: schema.cancel_at_period_end,
tax_percent: tax_percent
? Math.round(tax_percent * 100 * 100) / 100
: undefined,
};
dbg("add customer subscription to stripe");
await (await (0, connection_1.default)()).subscriptions.create(options);
dbg("added subscription; now save info in our database about it...");
await this.update_database();
if (options.coupon != null) {
dbg("add coupon to customer history");
const { coupon, coupon_history } = await this.validate_coupon(options.coupon);
// SECURITY NOTE: incrementing a counter... subject to attack?
// I.e., use a coupon more times than should be able to?
// NOT a big worry since we never issue or use coupons anyways...
coupon_history[coupon.id] += 1;
await (0, async_utils_1.callback2)(this.client.database.update_coupon_history, {
account_id: this.client.account_id,
coupon_history,
});
}
}
async mesg_cancel_subscription(mesg) {
const dbg = this.dbg("mesg_cancel_subscription");
dbg("cancel a subscription for this user");
const subscription_id = get_string_field(mesg, "subscription_id");
// TODO/SECURITY: We should check that this subscription actually
// belongs to this user. As it is, they could be cancelling somebody
// else's subscription!
dbg("cancel the subscription at stripe");
await (await (0, connection_1.default)()).subscriptions.update(subscription_id, {
cancel_at_period_end: mesg.at_period_end,
});
await this.update_database();
}
async mesg_update_subscription(mesg) {
const dbg = this.dbg("mesg_update_subscription");
dbg("edit a subscription for this user");
const subscription_id = get_string_field(mesg, "subscription_id");
dbg("Update the subscription.");
const changes = {
quantity: mesg.quantity,
plan: mesg.plan,
coupon: mesg.coupon_id,
};
await (await (0, connection_1.default)()).subscriptions.update(subscription_id, changes);
await this.update_database();
if (mesg.coupon_id != null) {
const { coupon, coupon_history } = await this.validate_coupon(mesg.coupon_id);
coupon_history[coupon.id] += 1;
await (0, async_utils_1.callback2)(this.client.database.update_coupon_history, {
account_id: this.client.account_id,
coupon_history,
});
}
}
async mesg_get_subscriptions(mesg) {
const dbg = this.dbg("mesg_get_subscriptions");
dbg("get a list of all the subscriptions that this customer has");
const options = await this.stripe_api_pager_options(mesg);
options.status = "all";
const subscriptions = await (await (0, connection_1.default)()).subscriptions.list(options);
return message.stripe_subscriptions({ subscriptions });
}
async mesg_get_coupon(mesg) {
const dbg = this.dbg("mesg_get_coupon");
dbg(`get the coupon with id=${mesg.coupon_id}`);
const coupon_id = get_string_field(mesg, "coupon_id");
const { coupon } = await this.validate_coupon(coupon_id);
return message.stripe_coupon({ coupon });
}
// Checks these coupon criteria:
// - Exists
// - Is valid
// - Used by this account less than the max per account (hard coded default is 1)
async validate_coupon(coupon_id) {
const dbg = this.dbg("validate_coupon");
dbg("retrieve the coupon");
const coupon = await (await (0, connection_1.default)()).coupons.retrieve(coupon_id);
dbg("check account coupon_history");
let coupon_history = await (0, async_utils_1.callback2)(this.client.database.get_coupon_history, {
account_id: this.client.account_id,
});
if (!coupon.valid)
throw Error("Sorry! This coupon has expired.");
if (coupon_history == null) {
coupon_history = {};
}
const times_used = coupon_history[coupon.id] != null ? coupon_history[coupon.id] : 0;
if (times_used >=
(coupon.metadata.max_per_account != null
? coupon.metadata.max_per_account
: 1)) {
throw Error("You've already used this coupon.");
}
coupon_history[coupon.id] = times_used;
return { coupon, coupon_history };
}
async mesg_get_charges(mesg) {
const dbg = this.dbg("mesg_get_charges");
dbg("get a list of charges for this customer");
const options = await this.stripe_api_pager_options(mesg);
const charges = await (await (0, connection_1.default)()).charges.list(options);
return message.stripe_charges({ charges });
}
async mesg_get_invoices(mesg) {
const dbg = this.dbg("mesg_get_invoices");
dbg("get a list of invoices for this customer");
const options = await this.stripe_api_pager_options(mesg);
const invoices = await (await (0, connection_1.default)()).invoices.list(options);
return message.stripe_invoices({ invoices });
}
// This is not actually used **YET**.
async mesg_admin_create_invoice_item(mesg) {
const dbg = this.dbg("mesg_admin_create_invoice_item");
dbg();
await (0, awaiting_1.callback)(this.client.assert_user_is_in_group.bind(this.client), "admin");
dbg("check for existing stripe customer_id");
const r = await (0, async_utils_1.callback2)(this.client.database.get_account, {
columns: [
"stripe_customer_id",
"email_address",
"first_name",
"last_name",
"account_id",
],
account_id: mesg.account_id,
email_address: mesg.email_address,
});
let customer_id = r.stripe_customer_id;
const email = r.email_address;
const description = (0, name_1.default)(r.first_name, r.last_name);
mesg.account_id = r.account_id;
const conn = await (0, connection_1.default)();
if (customer_id != null) {
dbg("already signed up for stripe -- sync local user account with stripe");
await (0, async_utils_1.callback2)(this.client.database.stripe_update_customer, {
account_id: mesg.account_id,
stripe: conn,
customer_id,
});
}
else {
dbg("create stripe entry for this customer");
const { is_anonymous } = await (0, private_1.default)(mesg.account_id);
if (is_anonymous) {
throw Error("anonymous users are not allowed to create a stripe customer");
}
const x = {
description,
email,
metadata: {
account_id: mesg.account_id,
},
};
const customer = await conn.customers.create(x);
customer_id = customer.id;
dbg("store customer id in our database");
await (0, async_utils_1.callback2)(this.client.database.set_stripe_customer_id, {
account_id: mesg.account_id,
customer_id,
});
}
if (!(mesg.amount != null && mesg.description != null)) {
dbg("no amount or no description, so not creating an invoice");
return;
}
dbg("now create the invoice item");
await conn.invoiceItems.create({
customer: customer_id,
amount: mesg.amount * 100,
currency: "usd",
description: mesg.description,
});
}
async mesg_get_available_upgrades(_mesg) {
const dbg = this.dbg("mesg_get_available_upgrades");
dbg("get stripe customer data");
const customer = await this.get_customer();
if (customer == null || customer.subscriptions == null) {
// no upgrades since not even a stripe account.
return message.available_upgrades({
total: {},
excess: {},
available: {},
});
}
const stripe_data = customer.subscriptions.data;
dbg("get user project upgrades");
const projects = await (0, async_utils_1.callback2)(this.client.database.get_user_project_upgrades, {
account_id: this.client.account_id,
});
const { excess, available } = (0, upgrades_1.available_upgrades)(stripe_data, projects);
const total = (0, upgrades_1.get_total_upgrades)(stripe_data);
return message.available_upgrades({
total,
excess,
available,
});
}
async mesg_remove_all_upgrades(mesg) {
const dbg = this.dbg("mesg_remove_all_upgrades");
dbg();
if (this.client.account_id == null)
throw Error("you must be signed in");
await (0, async_utils_1.callback2)(this.client.database.remove_all_user_project_upgrades, {
account_id: this.client.account_id,
projects: mesg.projects,
});
}
async mesg_sync_site_license_subscriptions() {
const dbg = this.dbg("mesg_sync_site_license_subscriptions");
dbg();
if (this.client.account_id == null)
throw Error("you must be signed in");
await this.client.database.sync_site_license_subscriptions(this.client.account_id);
}
async cancelEverything() {
const dbg = this.dbg("cancelEverything");
const customer_id = await this.get_customer_id();
dbg("customer_id = ", customer_id);
if (customer_id == null) {
// nothing to do, since no stripe info about this user.
return;
}
const conn = await (0, connection_1.default)();
dbg("Cancel the credit cards");
const customer = await this.get_customer(customer_id);
const payment_methods = customer.sources?.data;
if (payment_methods != null) {
for (const { id } of payment_methods) {
await conn.customers.deleteSource(customer_id, id);
}
}
dbg("Cancel the subscriptions (at period end)");
const subscriptions = customer.subscriptions?.data;
if (subscriptions != null) {
for (const { id } of subscriptions) {
await conn.subscriptions.update(id, {
cancel_at_period_end: true,
});
}
}
dbg("Sync the database to indicate that everything is canceled.");
await this.update_database();
}
async getPaymentMethods() {
const dbg = this.dbg("get_sources");
dbg("get a list of all the payment sources that this customer has");
const customer = await this.need_customer_id();
const conn = await (0, connection_1.default)();
await conn.paymentMethods.list({ customer, type: "card" });
}
async setDefaultSource(default_source) {
const conn = await (0, connection_1.default)();
await conn.customers.update(await this.need_customer_id(), {
default_source,
});
await this.update_database();
}
async deletePaymentMethod(id) {
const conn = await (0, connection_1.default)();
await conn.customers.deleteSource(await this.need_customer_id(), id);
await this.update_database();
}
async createPaymentMethod(token) {
await this.mesg_create_source({ token });
}
async cancelSubscription(id) {
// TODO/SECURITY: see comment in mesg_cancel_subscription
const conn = await (0, connection_1.default)();
await conn.subscriptions.update(id, { cancel_at_period_end: true });
await this.update_database();
}
}
exports.StripeClient = StripeClient;
//# sourceMappingURL=client.js.map