smc-hub
Version:
CoCalc: Backend webserver component
576 lines (500 loc) • 19.4 kB
text/typescript
/*
* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.
* License: AGPLv3 s.t. "Commons Clause" – see LICENSE.md for details
*/
import { reuseInFlight } from "async-await-utils/hof";
import { callback } from "awaiting";
// STOPGAP FIX: relative dirs necessary for manage service
import { callback2 } from "smc-util/async-utils";
import { trunc_middle } from "smc-util/misc";
import * as message from "smc-util/message";
import {
available_upgrades,
get_total_upgrades,
} from "smc-util/upgrades";
import { PostgreSQL } from "../postgres/types";
import Stripe from "stripe";
import { get_stripe } from "./connection";
import { stripe_sales_tax } from "./sales-tax";
interface HubClient {
account_id: string;
dbg: (f: string) => Function;
database: PostgreSQL;
push_to_client: Function;
error_to_client: Function;
assert_user_is_in_group: Function;
}
type StripeCustomer = any;
type Message = any;
type Coupon = any;
type CouponHistory = any;
function get_string_field(mesg: Message, name: string): string {
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: Message, name: string): any {
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;
}
export class StripeClient {
private client: HubClient;
public conn: Stripe;
private stripe_customer_id?: string;
constructor(client: HubClient) {
this.client = client;
const conn = get_stripe();
if (conn == null) throw Error("stripe billing not configured");
this.conn = conn;
this.get_customer_id = reuseInFlight(this.get_customer_id.bind(this));
}
private dbg(f: string): Function {
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.
private async get_customer_id(): Promise<string | undefined> {
// 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 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.
public async need_customer_id(): Promise<string> {
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;
}
private async stripe_api_pager_options(mesg: Message): Promise<any> {
return {
customer: await this.need_customer_id(),
limit: mesg.limit,
ending_before: mesg.ending_before,
starting_after: mesg.starting_after,
};
}
private async get_customer(customer_id?: string): Promise<StripeCustomer> {
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 this.conn.customers.retrieve(customer_id);
}
public async handle_mesg(mesg: Message): Promise<void> {
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: any = await f.bind(this)(mesg);
if (resp == null) {
resp = {};
}
resp.id = mesg.id;
this.client.push_to_client(resp);
}
} catch (err) {
let error: string;
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 });
}
}
public async mesg_get_customer(_mesg: Message): Promise<Message> {
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: (this.conn as any).publishable_key,
customer: await this.update_database(),
});
}
public async mesg_create_source(mesg: Message): Promise<void> {
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);
}
}
private async create_new_stripe_customer_from_card_token(
token: string
): Promise<void> {
const dbg = this.dbg("create_new_stripe_customer_from_card_token");
dbg("create new stripe customer from card token");
dbg("get identifying info about user");
const r = await 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 = stripe_name(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: string = (await this.conn.customers.create(x)).id;
dbg("success; now save customer_id to database");
await callback2(this.client.database.set_stripe_customer_id, {
account_id: this.client.account_id,
customer_id,
});
await this.update_database();
}
private async add_card_to_existing_stripe_customer(
token: string
): Promise<void> {
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 this.conn.customers.createSource(customer_id, { source: token });
await this.update_database();
}
public async mesg_delete_source(mesg: Message): Promise<void> {
const dbg = this.dbg("mesg_delete_source");
dbg("delete a payment method for this user");
const card_id: string = 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 this.conn.customers.deleteSource(customer_id, card_id);
await this.update_database();
}
public async mesg_set_default_source(mesg: Message): Promise<void> {
const dbg = this.dbg("mesg_set_default_source");
dbg("set a payment method for this user to be the default");
const card_id: string = get_string_field(mesg, "card_id");
const customer_id: string = await this.need_customer_id();
dbg("now setting the default source in stripe");
await this.conn.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
public async update_database(): Promise<any> {
this.dbg("update_database")();
const customer_id = await this.get_customer_id();
if (customer_id == null) return;
return await callback2(this.client.database.stripe_update_customer, {
account_id: this.client.account_id,
stripe: this.conn,
customer_id,
});
}
public async mesg_update_source(mesg: Message): Promise<void> {
const dbg = this.dbg("mesg_update_source");
dbg("modify a payment method");
const card_id: string = get_string_field(mesg, "card_id");
const info: any = get_nonnull_field(mesg, "info");
await this.conn.sources.update(card_id, info);
await this.update_database();
}
public async sales_tax(customer_id: string): Promise<number> {
return await stripe_sales_tax(customer_id, this.dbg("sales_tax"));
}
public async mesg_create_subscription(mesg: Message): Promise<void> {
const dbg = this.dbg("mesg_create_subscription");
dbg("create a subscription for this user, using some billing method");
const plan: string = get_string_field(mesg, "plan");
const schema = require("smc-util/schema").PROJECT_UPGRADES.subscription[
plan.split("-")[0]
];
if (schema == null) throw Error(`unknown plan -- '${plan}'`);
const customer_id: string = 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" as "error_if_incomplete", // see https://github.com/sagemathinc/cocalc/issues/5234
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 this.conn.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 callback2(this.client.database.update_coupon_history, {
account_id: this.client.account_id,
coupon_history,
});
}
}
public async mesg_cancel_subscription(mesg: Message): Promise<void> {
const dbg = this.dbg("mesg_cancel_subscription");
dbg("cancel a subscription for this user");
const subscription_id: string = 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");
// This also returns the subscription, which lets
// us easily get the metadata of all projects associated to this subscription.
await this.conn.subscriptions.update(subscription_id, {
cancel_at_period_end: mesg.at_period_end,
});
await this.update_database();
}
public async mesg_update_subscription(mesg: Message): Promise<void> {
const dbg = this.dbg("mesg_update_subscription");
dbg("edit a subscription for this user");
const subscription_id: string = get_string_field(mesg, "subscription_id");
dbg("Update the subscription.");
const changes = {
quantity: mesg.quantity,
plan: mesg.plan,
coupon: mesg.coupon_id,
};
await this.conn.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 callback2(this.client.database.update_coupon_history, {
account_id: this.client.account_id,
coupon_history,
});
}
}
public async mesg_get_subscriptions(mesg: Message): Promise<Message> {
const dbg = this.dbg("mesg_get_subscriptions");
dbg("get a list of all the subscriptions that this customer has");
const customer_id: string = await this.need_customer_id();
const options = await this.stripe_api_pager_options(mesg);
options.status = "all";
options.customer = customer_id;
const subscriptions = await this.conn.subscriptions.list(options);
return message.stripe_subscriptions({ subscriptions });
}
public async mesg_get_coupon(mesg: Message): Promise<Message> {
const dbg = this.dbg("mesg_get_coupon");
dbg(`get the coupon with id=${mesg.coupon_id}`);
const coupon_id: string = 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)
private async validate_coupon(
coupon_id: string
): Promise<{ coupon: Coupon; coupon_history: CouponHistory }> {
const dbg = this.dbg("validate_coupon");
dbg("retrieve the coupon");
const coupon: Coupon = await this.conn.coupons.retrieve(coupon_id);
dbg("check account coupon_history");
let coupon_history: CouponHistory = await 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: number =
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 };
}
public async mesg_get_charges(mesg: Message): Promise<Message> {
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 this.conn.charges.list(options);
return message.stripe_charges({ charges });
}
public async mesg_get_invoices(mesg: Message): Promise<Message> {
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 this.conn.invoices.list(options);
return message.stripe_invoices({ invoices });
}
// This is not actually used **YET**.
public async mesg_admin_create_invoice_item(mesg: Message): Promise<void> {
const dbg = this.dbg("mesg_admin_create_invoice_item");
await callback(
this.client.assert_user_is_in_group.bind(this.client),
"admin"
);
dbg("check for existing stripe customer_id");
const r = await 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 = stripe_name(r.first_name, r.last_name);
mesg.account_id = r.account_id;
if (customer_id != null) {
dbg(
"already signed up for stripe -- sync local user account with stripe"
);
await callback2(this.client.database.stripe_update_customer, {
account_id: mesg.account_id,
stripe: this.conn,
customer_id,
});
} else {
dbg("create stripe entry for this customer");
const x = {
description,
email,
metadata: {
account_id: mesg.account_id,
},
};
const customer = await this.conn.customers.create(x);
customer_id = customer.id;
dbg("store customer id in our database");
await 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 this.conn.invoiceItems.create({
customer: customer_id,
amount: mesg.amount * 100,
currency: "usd",
description: mesg.description,
});
}
public async mesg_get_available_upgrades(_mesg: Message): Promise<Message> {
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 callback2(
this.client.database.get_user_project_upgrades,
{
account_id: this.client.account_id,
}
);
const { excess, available } = available_upgrades(stripe_data, projects);
const total = get_total_upgrades(stripe_data);
return message.available_upgrades({
total,
excess,
available,
});
}
public async mesg_remove_all_upgrades(mesg: Message): Promise<void> {
const dbg = this.dbg("mesg_remove_all_upgrades");
dbg();
if (this.client.account_id == null) throw Error("you must be signed in");
await callback2(this.client.database.remove_all_user_project_upgrades, {
account_id: this.client.account_id,
projects: mesg.projects,
});
}
public async mesg_sync_site_license_subscriptions(): Promise<void> {
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
);
}
}
export function stripe_name(first_name, last_name): string {
return trunc_middle(`${first_name ?? ""} ${last_name ?? ""}`, 200);
}