mirror of
https://github.com/jambonz/jambonz-api-server.git
synced 2026-07-05 11:41:52 +00:00
ed51d8b13f
major merge of features from the hosted branch that was created temporarily during the initial launch of jambonz.org
335 lines
12 KiB
JavaScript
335 lines
12 KiB
JavaScript
const router = require('express').Router();
|
|
const {DbErrorBadRequest} = require('../../utils/errors');
|
|
const Account = require('../../models/account');
|
|
const {
|
|
createCustomer,
|
|
retrieveCustomer,
|
|
updateCustomer,
|
|
createSubscription,
|
|
retrieveSubscription,
|
|
updateSubscription,
|
|
retrieveInvoice,
|
|
payOutstandingInvoicesForCustomer,
|
|
attachPaymentMethod,
|
|
detachPaymentMethod,
|
|
retrievePaymentMethod,
|
|
retrieveUpcomingInvoice
|
|
} = require('../../utils/stripe-utils');
|
|
const {setupFreeTrial} = require('./utils');
|
|
const sysError = require('../error');
|
|
const actions = [
|
|
'upgrade-to-paid',
|
|
'downgrade-to-free',
|
|
'update-payment-method',
|
|
'update-quantities'
|
|
];
|
|
|
|
const handleError = async(logger, method, res, err) => {
|
|
if ('StatusError' === err.name) {
|
|
const text = await err.text();
|
|
let details;
|
|
if (text) {
|
|
details = JSON.parse(text);
|
|
logger.info({details}, `${method} failed`);
|
|
}
|
|
if (402 === err.statusCode && details) {
|
|
return res.status(err.statusCode).json(details);
|
|
}
|
|
return res.sendStatus(err.statusCode);
|
|
}
|
|
sysError(logger, res, err);
|
|
};
|
|
|
|
/**
|
|
* We handle 3 possible outcomes
|
|
* - the initial payment was successful
|
|
* - there was a card error on the initial payment (i.e. decline)
|
|
* - there is a requirement for additional authentication (e.g. SCA)
|
|
* see: https://stripe.com/docs/billing/migration/strong-customer-authentication
|
|
* @param {*} req
|
|
* @param {*} res
|
|
* @param {*} subscription
|
|
*/
|
|
const handleSubscriptionOutcome = async(req, res, subscription) => {
|
|
const logger = req.app.locals.logger;
|
|
const {account_sid} = subscription.metadata;
|
|
const {status, latest_invoice} = subscription;
|
|
const {payment_intent} = latest_invoice;
|
|
|
|
/* success case */
|
|
if ('active' == status && 'paid' === latest_invoice.status && 'succeeded' === payment_intent.status) {
|
|
await Account.activateSubscription(logger, account_sid, subscription.id, 'upgrade to paid plan');
|
|
return res.status(201).json({
|
|
status: 'success',
|
|
chargedAmount: latest_invoice.amount_paid,
|
|
currency: payment_intent.currency,
|
|
statementDescriptor: payment_intent.statement_descriptor
|
|
});
|
|
}
|
|
|
|
/* card error */
|
|
if ('incomplete' == status && 'open' === latest_invoice.status &&
|
|
'requires_payment_method' === payment_intent.status) {
|
|
return res.status(201).json({
|
|
status: 'card error',
|
|
subscription: subscription.id,
|
|
client_secret: payment_intent.client_secret,
|
|
reason: payment_intent.last_payment_error.message
|
|
});
|
|
}
|
|
|
|
/* more authentication required */
|
|
if ('incomplete' == status && 'open' === latest_invoice.status && 'requires_action' === payment_intent.status) {
|
|
return res.status(201).json({
|
|
status: 'action required',
|
|
subscription: subscription.id,
|
|
client_secret: payment_intent.client_secret
|
|
});
|
|
}
|
|
|
|
throw new Error(
|
|
`handleSubscriptionOutcome unexpected status ${status}:${latest_invoice.status}:${payment_intent.status}`);
|
|
};
|
|
|
|
/**
|
|
* Transition from free --> paid
|
|
* Create customer in Stripe, if needed
|
|
* Set the default payment method
|
|
* Create a subscription
|
|
* @param {*} req
|
|
* @param {*} res
|
|
*/
|
|
const upgradeToPaidPlan = async(req, res) => {
|
|
const logger = req.app.locals.logger;
|
|
const {account_sid, name, email} = req.user;
|
|
const {payment_method_id, products} = req.body;
|
|
const arr = await Account.retrieve(req.user.account_sid);
|
|
const account = arr[0];
|
|
|
|
/* retrieve stripe customer id locally, provision on Stripe if needed */
|
|
logger.debug({account}, 'upgradeToPaidPlan retrieved account');
|
|
let stripe_customer_id = account.stripe_customer_id;
|
|
if (!stripe_customer_id) {
|
|
logger.debug('upgradeToPaidPlan provisioning customer');
|
|
const customer = await createCustomer(logger, account_sid, email, name);
|
|
logger.debug(`upgradeToPaidPlan provisioned customer_id ${customer.id}`);
|
|
await Account.updateStripeCustomerId(account_sid, customer.id);
|
|
stripe_customer_id = customer.id;
|
|
}
|
|
|
|
/* attach the payment method to the customer and make it their default */
|
|
const pm = await attachPaymentMethod(logger, payment_method_id, stripe_customer_id);
|
|
const customer = await updateCustomer(logger, stripe_customer_id, {
|
|
invoice_settings: {
|
|
default_payment_method: req.body.payment_method_id,
|
|
}
|
|
});
|
|
logger.debug({customer}, 'successfully updated customer');
|
|
|
|
/* create a pending subscription -- will be activated on invoice.paid */
|
|
const account_subscription_sid = await Account.provisionPendingSubscription(logger, account_sid, products, pm);
|
|
|
|
/* create the subscription in Stripe */
|
|
const items = products.map((product) => {
|
|
return {
|
|
price: product.price_id,
|
|
quantity: product.quantity,
|
|
metadata: {
|
|
product_sid: product.product_sid
|
|
}
|
|
};
|
|
});
|
|
logger.debug({items}, 'creating subscription');
|
|
const subscription = await createSubscription(logger, stripe_customer_id,
|
|
{account_sid, account_subscription_sid}, items);
|
|
logger.debug({subscription}, 'created subscription');
|
|
|
|
await handleSubscriptionOutcome(req, res, subscription);
|
|
};
|
|
const downgradeToFreePlan = async(req, res) => {
|
|
const logger = req.app.locals.logger;
|
|
const {account_sid} = req.user;
|
|
try {
|
|
await setupFreeTrial(logger, account_sid);
|
|
return res.status(200).json({status: 'success'});
|
|
} catch (err) {
|
|
handleError(logger, 'downgradeToFreePlan', res, err);
|
|
}
|
|
};
|
|
const updatePaymentMethod = async(req, res) => {
|
|
const logger = req.app.locals.logger;
|
|
const {account_sid} = req.user;
|
|
try {
|
|
const {payment_method_id} = req.body;
|
|
const arr = await Account.retrieve(req.user.account_sid);
|
|
const account = arr[0];
|
|
if (!account.stripe_customer_id) {
|
|
throw new DbErrorBadRequest(`Account ${account_sid} is not provisioned in Stripe`);
|
|
}
|
|
const customer = await retrieveCustomer(logger, account.stripe_customer_id);
|
|
//logger.debug({customer}, 'retrieved customer');
|
|
|
|
/* attach the payment method to the customer */
|
|
const pm = await attachPaymentMethod(logger, payment_method_id, account.stripe_customer_id);
|
|
logger.debug({pm}, 'attached payment method to customer');
|
|
|
|
/* update last4 etc in our db */
|
|
await Account.updatePaymentInfo(logger, account_sid, pm);
|
|
|
|
/* make it the customer's default payment method */
|
|
await updateCustomer(logger, account.stripe_customer_id, {
|
|
invoice_settings: {
|
|
default_payment_method: req.body.payment_method_id,
|
|
}
|
|
});
|
|
|
|
/* detach the customer's old payment method */
|
|
const old_pm = customer.default_source || customer.invoice_settings.default_payment_method;
|
|
if (old_pm) await detachPaymentMethod(logger, old_pm);
|
|
|
|
/* if the customer has an unpaid invoice, try to pay it */
|
|
const success = await payOutstandingInvoicesForCustomer(logger, account.stripe_customer_id);
|
|
res.status(200).json({
|
|
status: success ? 'success' : 'failed to pay outstanding invoices'
|
|
});
|
|
} catch (err) {
|
|
handleError(logger, 'updatePaymentMethod', res, err);
|
|
}
|
|
};
|
|
const updateQuantities = async(req, res) => {
|
|
/**
|
|
* see https://stripe.com/docs/billing/subscriptions/upgrade-downgrade#immediate-payment
|
|
* and https://stripe.com/docs/billing/subscriptions/pending-updates
|
|
*/
|
|
const logger = req.app.locals.logger;
|
|
const {account_sid} = req.user;
|
|
const {products, dry_run} = req.body;
|
|
|
|
if (!products || !Array.isArray(products) ||
|
|
0 === products.length ||
|
|
products.find((p) => !p.price_id || !p.product_sid)) {
|
|
logger.info({products}, 'Subscription:updateQuantities invalid products');
|
|
return res.sendStatus(400);
|
|
}
|
|
|
|
try {
|
|
const account_subscription = await Account.getSubscription(req.user.account_sid);
|
|
if (!account_subscription || !account_subscription.stripe_subscription_id) {
|
|
logger.info(`Subscription:updateQuantities No active subscription found for account_sid ${account_sid}`);
|
|
return res.sendStatus(400);
|
|
}
|
|
const subscription_id = account_subscription.stripe_subscription_id;
|
|
|
|
const subscription = await retrieveSubscription(logger, subscription_id);
|
|
logger.debug({subscription}, 'retrieved existing subscription');
|
|
const pm = await retrievePaymentMethod(logger, account_subscription.stripe_payment_method_id);
|
|
logger.debug({pm}, 'retrieved existing payment method');
|
|
const items = products.map((product) => {
|
|
const existingItem = subscription.items.data.find((i) => i.price.id === product.price_id);
|
|
const obj = {
|
|
quantity: product.quantity,
|
|
};
|
|
return Object.assign(obj, existingItem ? {id: existingItem.id} : {price_id: product.price_id});
|
|
});
|
|
|
|
if (dry_run) {
|
|
const invoice = await retrieveUpcomingInvoice(logger, subscription.customer, subscription.id, items);
|
|
logger.debug({invoice}, 'dry run - upcoming invoice');
|
|
const dt = new Date(invoice.next_payment_attempt * 1000);
|
|
const sum = (acc, current) => acc + current.amount;
|
|
const prorated_cost = invoice.lines.data
|
|
.filter((l) => l.proration === true)
|
|
.reduce(sum, 0);
|
|
const monthly_cost = invoice.lines.data
|
|
.filter((l) => l.proration === false)
|
|
.reduce(sum, 0);
|
|
return res.status(201).json({
|
|
currency: invoice.currency,
|
|
prorated_cost,
|
|
monthly_cost,
|
|
next_invoice_date: dt.toDateString()
|
|
});
|
|
}
|
|
|
|
/* create a pending subscription */
|
|
await Account.provisionPendingSubscription(logger, account_sid, products,
|
|
pm, subscription_id);
|
|
|
|
/* update the subscription in Stripe */
|
|
const updated = await updateSubscription(logger, subscription_id, items);
|
|
logger.debug({updated}, 'updated subscription');
|
|
|
|
/* get latest invoice, to see if payment is needed */
|
|
const invoice = await retrieveInvoice(logger, updated.latest_invoice);
|
|
logger.debug({invoice}, 'latest invoice');
|
|
|
|
if ('paid' === invoice.status) {
|
|
logger.debug('activating pending subscription to new quantities since no invoice outstanding');
|
|
await Account.activateSubscription(logger, account_sid, subscription_id, 'selected new capacities');
|
|
return res.status(201).json({
|
|
status: 'success'
|
|
});
|
|
}
|
|
else {
|
|
return res.status(201).json({
|
|
status: 'failed',
|
|
reason: 'payment required'
|
|
});
|
|
}
|
|
} catch (err) {
|
|
handleError(logger, 'updatePaymentMethod', res, err);
|
|
}
|
|
};
|
|
|
|
/* create */
|
|
router.post('/', async(req, res) => {
|
|
const logger = req.app.locals.logger;
|
|
try {
|
|
const {action, payment_method_id, products} = req.body;
|
|
|
|
if (!actions.includes(action)) throw new DbErrorBadRequest('invalid or missing action');
|
|
if ('update-payment-method' === action && typeof payment_method_id !== 'string') {
|
|
throw new DbErrorBadRequest('missing payment_method_id');
|
|
}
|
|
if ('upgrade-to-paid' === action && (!Array.isArray(products) || 0 === products.length)) {
|
|
throw new DbErrorBadRequest('missing products');
|
|
}
|
|
if ('update-quantities' === action && (!Array.isArray(products) || 0 === products.length)) {
|
|
throw new DbErrorBadRequest('missing products');
|
|
}
|
|
|
|
switch (action) {
|
|
case 'upgrade-to-paid':
|
|
await upgradeToPaidPlan(req, res);
|
|
break;
|
|
case 'downgrade-to-free':
|
|
await downgradeToFreePlan(req, res);
|
|
break;
|
|
case 'update-payment-method':
|
|
await updatePaymentMethod(req, res);
|
|
break;
|
|
case 'update-quantities':
|
|
await updateQuantities(req, res);
|
|
break;
|
|
}
|
|
} catch (err) {
|
|
handleError(logger, 'POST /Subscription', res, err);
|
|
}
|
|
});
|
|
|
|
/* get */
|
|
router.get('/', async(req, res) => {
|
|
const logger = req.app.locals.logger;
|
|
const {account_sid} = req.user;
|
|
try {
|
|
const subscription = await Account.getSubscription(account_sid);
|
|
if (!subscription || !subscription.stripe_subscription_id) return res.sendStatus(404);
|
|
const sub = await retrieveSubscription(logger, subscription.stripe_subscription_id);
|
|
res.status(200).json(sub);
|
|
} catch (err) {
|
|
handleError(logger, 'GET /Subscription', res, err);
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|