UNPKG

@cocalc/server

Version:

CoCalc server functionality: functions used by either the hub and the next.js server

332 lines 13.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 __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