@cocalc/server
Version:
CoCalc server functionality: functions used by either the hub and the next.js server
332 lines • 13.5 kB
JavaScript
;
/*
* 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.setPurchaseMetadata = exports.unitAmount = exports.chargeUserForLicense = void 0;
const logger_1 = require("@cocalc/backend/logger");
const connection_1 = __importDefault(require("@cocalc/server/stripe/connection"));
const consts_1 = require("@cocalc/util/licenses/purchase/consts");
const timecalcs_1 = require("@cocalc/util/stripe/timecalcs");
const product_id_1 = require("./product-id");
const product_metadata_1 = require("./product-metadata");
const product_name_1 = require("./product-name");
const logger = (0, logger_1.getLogger)("licenses-charge");
async function chargeUserForLicense(stripe, info) {
logger.debug("getting product_id");
const product_id = await stripeGetProduct(info);
if (info.subscription == "no") {
return await stripePurchaseProduct(stripe, product_id, info);
}
else {
return await stripeCreateSubscription(stripe, product_id, info);
}
}
exports.chargeUserForLicense = chargeUserForLicense;
function unitAmount(info) {
if (info.cost == null)
throw Error("cost must be defined");
return Math.round(info.cost.cost_per_unit * 100);
}
exports.unitAmount = unitAmount;
async function stripeCreatePrice(info) {
const product = (0, product_id_1.getProductId)(info);
// Add the pricing info:
// - if sub then we set the price for monthly and yearly
// and build in the 25% discount since subscriptions are
// self-service by default.
// - if number of days, we set price for that many days.
if (info.cost == null)
throw Error("cost must be defined");
const conn = await (0, connection_1.default)();
if (info.subscription == "no") {
// create the one-time cost
await conn.prices.create({
currency: "usd",
unit_amount: unitAmount(info),
product,
});
}
else {
await stripeCreatePriceSubscriptions({ product, conn, info });
}
}
/** subscription prices:
* - for "quota" licenses, the online discount is baked into the price.
* i.e. see in compute_cost(...) the discounted_cost is the total price with discounts,
* while all other costs don't have the online discount.
* - dedicated resources do not have an online discount.
*/
async function stripeCreatePriceSubscriptions({ conn, info, product, }) {
if (info.cost == null)
throw Error("cost must be defined");
const { type } = info;
const common = {
currency: "usd",
product,
};
if (type === "quota") {
// create the two recurring subscription costs. Build
// in the self-service discount, which is:
// COSTS.online_discount
await conn.prices.create({
...common,
unit_amount: Math.round(consts_1.COSTS.online_discount * info.cost.cost_sub_month * 100),
recurring: { interval: "month" },
});
await conn.prices.create({
...common,
unit_amount: Math.round(consts_1.COSTS.online_discount * info.cost.cost_sub_year * 100),
recurring: { interval: "year" },
});
}
else if (type === "disk" || type === "vm") {
// there are no vm subscriptions – at this point in time – but if there are some,
// we would handle them just like the dedicated disks
await conn.prices.create({
...common,
unit_amount: Math.round(100 * info.cost.cost_sub_month),
recurring: { interval: "month" },
});
await conn.prices.create({
...common,
unit_amount: Math.round(100 * info.cost.cost_sub_year),
recurring: { interval: "year" },
});
}
}
async function stripeGetProduct(info) {
const product_id = (0, product_id_1.getProductId)(info);
// check to see if the product has already been created; if not, create it.
if (!(await stripeProductExists(product_id))) {
// now we have to create the product.
const metadata = (0, product_metadata_1.getProductMetadata)(info);
const name = (0, product_name_1.getProductName)(info);
let statement_descriptor = "COCALC LIC ";
if (info.subscription != "no") {
statement_descriptor += "SUB";
}
else {
if (info.type === "disk")
throw new Error("disk do not have a period");
const n = (0, timecalcs_1.getDays)(info);
statement_descriptor += `${n}${n < 100 ? " " : ""}DAYS`;
}
// Hard limit of 22 characters. Deleting part of "DAYS" is ok, as
// this is for credit card, and just having "COCALC" is mainly what is needed.
// See https://github.com/sagemathinc/cocalc/issues/5712
statement_descriptor = statement_descriptor.slice(0, 22);
const conn = await (0, connection_1.default)();
await conn.products.create({
id: product_id,
name,
metadata,
statement_descriptor,
});
await stripeCreatePrice(info);
}
return product_id;
}
async function stripeProductExists(product_id) {
try {
const conn = await (0, connection_1.default)();
await conn.products.retrieve(product_id);
return true;
}
catch (_) {
return false;
}
}
/**
* rough outline, of what I think this does/should do:
* - a product is a single purchase, e.g. license for a specific interval --
* there is also a subscription function, see below.
* - A "price" is created, which is also parametrized by the number of days,
* but not the number of projects.
* - This product price is without an online discount (no idea why), but instead
* briefly a coupon is created and added to the user's account at stripe.
* - the above is only for type==quota licenses, not VMs!
* - The invoice is created, with the desired price, quantity, etc.
* - When issuing the invoice to be paid, stripe calculates the discount
* (which introduces rounding errors between what we show the user and what happens at stripe)
*/
async function stripePurchaseProduct(stripe, product_id, info) {
const { quantity } = info;
logger.debug("stripePurchaseProduct", product_id, quantity);
if (info.type === "disk")
throw new Error("can only deal with VMs and quota licenses");
const customer = await stripe.need_customer_id();
const conn = await (0, connection_1.default)();
logger.debug("stripePurchaseProduct: get price");
const prices = await conn.prices.list({
product: product_id,
type: "one_time",
active: true,
});
let price = prices.data[0]?.id;
if (price == null) {
logger.debug("stripePurchaseProduct: missing -- try to create it");
await stripeCreatePrice(info);
const prices = await conn.prices.list({
product: product_id,
type: "one_time",
active: true,
});
price = prices.data[0]?.id;
if (price == null) {
logger.debug("stripePurchaseProduct: still missing -- give up");
throw Error(`price for one-time purchase missing -- product_id="${product_id}"`);
}
}
logger.debug("stripePurchaseProduct: got price", JSON.stringify(price));
if (info.start == null || info.end == null) {
throw Error("start and end must be defined");
}
const period = {
start: Math.round(new Date(info.start).valueOf() / 1000),
end: Math.round(new Date(info.end).valueOf() / 1000),
};
// gets automatically put on the invoice created below.
await conn.invoiceItems.create({ customer, price, quantity, period });
// TODO: improve later to handle case of *multiple* items on one invoice
// TODO: tax_percent is DEPRECATED but not gone (see stripeCreateSubscription below).
const tax_percent = await stripe.sales_tax(customer);
const options = {
customer,
auto_advance: true,
collection_method: "charge_automatically",
tax_percent: tax_percent
? Math.round(tax_percent * 100 * 100) / 100
: undefined,
};
logger.debug("stripePurchaseProduct options=", JSON.stringify(options));
// coupons are only for quota license upgrades, not dedicated VMs
if (info.type === "quota") {
const coupon = await getSelfServiceDiscountCoupon(conn);
await conn.customers.update(customer, { coupon });
}
const invoice_id = (await conn.invoices.create(options)).id;
await conn.invoices.finalizeInvoice(invoice_id, {
auto_advance: true,
});
const invoice = await conn.invoices.pay(invoice_id, {
payment_method: info.payment_method,
});
if (info.type === "quota") {
// remove coupon so it isn't automatically applied
await conn.customers.deleteDiscount(customer);
}
await stripe.update_database();
if (!invoice.paid) {
// We void it so user doesn't get charged later. Of course,
// we plan to rewrite this to keep trying and once they pay it
// somehow, then they get their license. But that's a TODO!
await conn.invoices.voidInvoice(invoice_id);
throw Error("created invoice but not able to pay it -- invoice has been voided; please try again when you have a valid payment method on file");
}
return { type: "invoice", id: invoice_id };
}
/**
* similar to the function above, this creates a subscription.
* - *two* prices are created, the monthly and yearly price.
* - there is a price for each possible configuration, but not the quantity.
* - most importantly, the online discount is baked into the price directly.
* i.e. no coupons.
*/
async function stripeCreateSubscription(stripe, product_id, info) {
const { quantity, subscription } = info;
const customer = await stripe.need_customer_id();
const conn = await (0, connection_1.default)();
const prices = await conn.prices.list({
product: product_id,
type: "recurring",
active: true,
});
let price = undefined;
for (const x of prices.data) {
if (subscription.startsWith(x.recurring?.interval ?? "none")) {
price = x?.id;
break;
}
}
if (price == null) {
await stripeCreatePrice(info);
const prices = await conn.prices.list({
product: product_id,
type: "recurring",
active: true,
});
for (const x of prices.data) {
if (subscription.startsWith(x.recurring?.interval ?? "none")) {
price = x?.id;
break;
}
}
if (price == null) {
logger.debug("stripePurchaseProduct: still missing -- give up");
throw Error(`price for subscription purchase missing -- product_id="${product_id}", subscription="${subscription}"`);
}
}
// TODO: will need to improve to handle case of *multiple* items on one subscription
// CRITICAL: 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."
// TODO: tax_percent is DEPRECATED -- https://stripe.com/docs/billing/migration/taxes
// but fortunately it still works so we can rewrite this later.
const tax_percent = await stripe.sales_tax(customer);
const options = {
customer,
// see https://github.com/sagemathinc/cocalc/issues/5234 for
// why this payment_behavior.
payment_behavior: "error_if_incomplete",
items: [{ price, quantity }],
tax_percent: tax_percent
? Math.round(tax_percent * 100 * 100) / 100
: undefined,
};
const { id } = await conn.subscriptions.create(options);
await stripe.update_database();
return { type: "subscription", id };
}
// Gets a coupon that matches the current online discount.
const knownCoupons = {};
async function getSelfServiceDiscountCoupon(conn) {
const percent_off = Math.round(100 * (1 - consts_1.COSTS.online_discount));
const id = `coupon_self_service_${percent_off}`;
if (knownCoupons[id]) {
return id;
}
try {
await conn.coupons.retrieve(id);
}
catch (_) {
// coupon doesn't exist, so we have to create it.
await conn.coupons.create({
id,
percent_off,
name: "Self-service discount",
duration: "forever",
});
}
knownCoupons[id] = true;
return id;
}
async function setPurchaseMetadata(purchase, metadata) {
const conn = await (0, connection_1.default)();
switch (purchase.type) {
case "subscription":
await conn.subscriptions.update(purchase.id, { metadata });
break;
case "invoice":
await conn.invoices.update(purchase.id, { metadata });
break;
default:
throw new Error(`unexpected purchase type ${purchase.type}`);
}
}
exports.setPurchaseMetadata = setPurchaseMetadata;
//# sourceMappingURL=charge.js.map