agmission/Development/server/controllers/subscription.js

1720 lines
68 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict';
const assert = require('assert'),
ObjectId = require('mongodb').ObjectId,
moment = require('moment'),
env = require('../helpers/env'),
utils = require('../helpers/utils'),
cardUtil = require('../helpers/card_util'),
{ Errors, Fields, DEFAULT_LANG, TrialTypes } = require('../helpers/constants'),
{ AppError, AppAuthError, AppParamError, AppMembershipError, AppInputError } = require('../helpers/app_error'),
{ getCountryName, getBillingAddressFromCustomer, updateBillingAddress } = require('../helpers/user_helper'),
debug = require('debug')('agm:subscription'),
{ stripe } = require('../helpers/subscription_util'),
{ SubStatus, SubType, SubFields, Events, InvStatus, ChrgStatus, isTaxableCountry, isValidTaxStatus, isFinalSubStatus } = require('../model/subscription'),
{ Customer, Vehicle, BillPeriod, SubEvent } = require('../model'),
subUtil = require('../helpers/subscription_util'),
cache = require('../helpers/mem_cache'),
mailer = require('../helpers/mailer'),
{ agmMailSig } = require('../helpers/mailer'),
errorHandler = require('error-handler').errorHandler;
/**
* Handle all stripe webhook events
*/
async function stripeWebhooks_post(req, res) {
const validSubStatuses = [SubStatus.ACTIVE, SubStatus.TRIALING];
let event = req.body;
// Replace this endpoint secret with your endpoint's unique secret. If you are testing with the CLI, find the secret by running 'stripe listen'
// If you are using an endpoint defined with the API or dashboard, look in your webhook settings at https://dashboard.stripe.com/webhooks
const endpointSecret = env.STRIPE_WH_SEC;
// Only verify the event if you have an endpoint secret defined. Otherwise use the basic event deserialized with JSON.parse
if (endpointSecret) {
const signature = req.headers['stripe-signature']; // Get the signature sent by Stripe
try {
event = stripe.webhooks.constructEvent(req.body, signature, endpointSecret);
} catch (err) {
debug(`⚠️ Webhook signature verification failed.`, err.message);
return res.sendStatus(400);
}
}
try {
// debug(`Stripe Event - ${event.type} - ${event.id}`);
const eventData = event.data.object;
// Make sure to process only interested events
if (Object.values(Events).includes(event.type)) {
/*
Check the events DB to ensure the idempotent of proccessing for each Stripe event. Ref: https://stripe.com/docs/webhooks
Ensure no duplicated events reprocessed if it was handled successfully.
*/
const dbEvent = await SubEvent.findOne({ eventId: event.id }); // Check whether eventId is unique or need to change the filter here?
if (!dbEvent) {
const stripeCustId = await _resolveStripeCustId(eventData, event.type);
if (stripeCustId) {
// The applicator account and ms info within AGM local db.
let dbCustomer = await findApplicatorByCustId(stripeCustId);
if (dbCustomer) {
switch (event.type) {
case Events.CUST_SUB_TRIAL_WILL_END:
await handleTrialWillEnd(eventData, dbCustomer, req);
break;
case Events.CUST_SUB_DELETED:
dbCustomer = await updateCustSubStatus(eventData, dbCustomer);
if (dbCustomer && dbCustomer.membership) {
await emailCurSubcriptions(dbCustomer, dbCustomer.membership.subscriptions, req);
}
break;
case Events.CUST_SUB_CREATED:
dbCustomer = await updateCustSubStatus(eventData, dbCustomer);
if (dbCustomer) {
if (dbCustomer && dbCustomer.membership && validSubStatuses.includes(eventData.status)) {
await emailCurSubcriptions(dbCustomer, dbCustomer.membership.subscriptions, req);
}
await updateSubBillPeriod(dbCustomer, eventData);
}
break;
case Events.CUST_SUB_UPDATED:
dbCustomer = await updateCustSubStatus(eventData, dbCustomer);
if (dbCustomer) {
// For a just activated subscription?
if (event.data.previous_attributes.status && validSubStatuses.includes(eventData.status)) {
await emailCurSubcriptions(dbCustomer, dbCustomer.membership.subscriptions, req);
}
// For a just revnewed subscription?
if (event.data.previous_attributes.current_period_start && event.data.previous_attributes.current_period_end) {
await updateSubBillPeriod(dbCustomer, eventData);
}
}
break;
case Events.INV_UPCOMING:
/* Send customer email to notify the customer (Ref: https://docs.stripe.com/billing/subscriptions/trials#compliance)
* 1. Trail ending remider the next auto renewal and charge; giving options to cancel for compliance (link)
* 2. The next auto renewal and charge
*/
await handleUpcomingInvoice(eventData, dbCustomer, req);
break;
case Events.INV_PM_FAILED:
/*
Occurs when a payment failed. => Update membership's subscription status; Email to notify the to update with a working card.
Confirmed /update-pm URL (in email) for users to handle the 'Update Payment Method' in the FE.
*/
await handleCustInvoiceChange(event.type, eventData, dbCustomer, req);
break;
case Events.INV_FIN_FAILED:
/*
Occurs if Stripe cant finalize an invoice. Subscriptions remain active if invoices cant be finalized, which means that users may still be able to access your product while youre not able to collect payments.
This might rarely happens, mostly after the customer billing address was changed and Stripe found it could not auto calculate tax based on the address.
In addition, because we control the customer registration info from beginning (registration form). Then initially, we would somehow help ensure the billing address entered by the customer is valid to bill.
*/
await handleInvoiceFinalizeFailed(eventData, dbCustomer, req);
break;
// case Events.CHARGE_SUCCEEDED:
// /* Occurs when a payment was paid successfully */
// break;
case Events.CHARGE_RFD_UPDATED:
/** Handle failed refund manually with the customer via an alternative way.
* 1. Email to admin or Billing department about this. (done)
* 2. May be, try email and notify the customer on this as well (later on)
*/
if (eventData && eventData.status === ChrgStatus.FAILED) {
await handleRefundFailed(eventData, dbCustomer);
}
break;
default: // Unexpected event type
break;
}
// Invalidate session cache for users of this customer (applicator)
if (dbCustomer) {
dbCustomer._id && (invalidateSessionsbyPuid(dbCustomer._id.toHexString()));
// Log the successfully handled/proccessed event to storage (DB). TODO: UPDATE the mantainer worker to clear/archive old entries.
if (dbCustomer.membership && dbCustomer.membership.custId) {
await SubEvent.create({ eventId: event.id, type: event.type, createdAt: event.created, handledAt: moment.utc().unix(), custId: dbCustomer.membership.custId });
}
}
}
}
}
}
} catch (err) {
debug(err);
} finally {
// Always return a 200 response to acknowledge receipt of the event
res.send().end();
}
async function _resolveStripeCustId(whEvD, evType) {
if (!whEvD || !evType) return null;
let custId = whEvD.customer;
if (!custId) {
if (evType.startsWith('charge.') && whEvD.charge) {
const charge = await stripe.charges.retrieve(whEvD.charge);
charge && (custId = charge.customer);
}
}
return custId;
}
/**
* Verify that the customer have a payment method so we can bill them. And
* Email customer to advise for resolving invalid payment method to avoid charge issue later when the subscription passed the trials period.
*
* @param {*} subscription the Stripe subscription
* @param {*} applicator the customer/applicator object
* @param {*} req the request object for baseUrl extraction
*/
async function handleTrialWillEnd(subscription, applicator, req) {
if (subscription && !subscription.cancel_at_period_end) {
const { paymentMethod } = await getSubDetails(subscription.id);
if (!paymentMethod || paymentMethod && (utils.isEmptyObj(paymentMethod.card) || cardUtil.isExpired(paymentMethod.card.exp_month, paymentMethod.card.exp_year))) {
if (applicator) {
// Ensure req object is valid for mailer functions
req = utils.ensureValidReqObject(req);
// Use req.locals to pass data and let decorator extract baseUrl from request
req.locals = {
name: applicator.contact,
lang: applicator.lang || DEFAULT_LANG
};
if (paymentMethod && paymentMethod.card) {
req.locals.pmType = utils.capitalize(paymentMethod.card.brand);
req.locals.pmEnding = paymentMethod.card.last4;
}
await mailer.sendUpdatePaymentEmail(req, applicator.username);
}
}
}
}
async function handleUpcomingInvoice(invoice, applicator, req) {
if (invoice && invoice.collection_method == "charge_automatically") {
const { productNames, paymentMethod, subscription } = await getSubDetails(invoice.subscription);
if (applicator) {
// Ensure req object is valid for mailer functions
req = utils.ensureValidReqObject(req);
req.locals = {
name: applicator.contact,
prodsName: productNames,
chargeAmount: invoice.total * 1e-2,
userId: applicator._id.toHexString(),
lang: applicator.lang || DEFAULT_LANG
};
if (paymentMethod && paymentMethod.card) {
req.locals.cardType = paymentMethod.card.brand;
req.locals.cardEnding = paymentMethod.card.last4;
if (subscription && subscription.status === SubStatus.TRIALING) {
req.locals.upRenewalDate = utils.timestampToDate(invoice.next_payment_attempt);
await mailer.sendSubTrialEndingRemindEmail(req, applicator.username);
} else {
req.locals.upPaymentDate = utils.timestampToDate(invoice.next_payment_attempt);
await mailer.sendSubRenewalRemindEmail(req, applicator.username);
}
}
}
}
}
async function getSubDetails(subId) {
const subscription = await stripe.subscriptions.retrieve(subId, { expand: ['default_payment_method', 'customer', 'items.data.price.product'] });
if (!subscription) return {};
// Ref: https://docs.stripe.com/api/subscriptions/create?lang=node. How to get a sub's payment method.
let pm = subscription.default_payment_method;
if (!pm) {
const pmId = subscription.default_source || subscription.customer.invoice_settings.default_payment_method || subscription.customer.default_source;
if (pmId) pm = await stripe.paymentMethods.retrieve(pmId);
}
const productNames = subscription.items && (subscription.items.data.map(it => it.price.product.name).join(','));
return ({ productNames, paymentMethod: pm, subscription });
}
}
/**
* Invalidate cached sessions for a given customer (applicator) userId
* @param {*} puid the applicator userId
*/
function invalidateSessionsbyPuid(puid) {
if (!puid) return;
const sessions = cache.filterByKind(puid);
for (let i = 0; i < sessions.length; i++) {
sessions[i] && (sessions[i][1].ts = 0);
}
}
async function findApplicatorByCustId(custId) {
assert(custId, AppInputError.create());
const applicator = await Customer.findOne({ "membership.custId": custId }).populate({ path: 'Country', select: 'code name -_id' }).lean();
if (env.PRODUCTION && !applicator) {
// Critical error - sending admin an email for investigation
const textLines = ["Dear AGM Admin,\n",
`Please check why is the Applicator missing for this Stripe customer ? #${custId}.\n`,
...agmMailSig
]
await errorHandler.mailErrorToAdmin(textLines.join('\n'));
} else {
return applicator;
}
return null;
}
async function updateCustSubStatus(subscription, applicator) {
if (subscription && subscription.id) {
if (applicator) {
return await updateCustSubscriptions(applicator._id, [subscription]);
}
}
return null;
}
async function handleCustInvoiceChange(eventType, invoice, applicator, req) {
if (!(eventType && invoice && invoice.id && invoice.customer && invoice.subscription && invoice.payment_intent)) return;
const subscription = await stripe.subscriptions.retrieve(invoice.subscription);
if (subscription && applicator) {
// Get the failed Payment method from the invoice.last_payment_error.payment_intent.source
const invPaymentIntent = await stripe.paymentIntents.retrieve(invoice.payment_intent);
if (invPaymentIntent && !utils.isEmptyObj(invPaymentIntent.last_payment_error)) {
let custPM = invPaymentIntent.last_payment_error.payment_method;
if (!custPM && !utils.isEmptyObj(invPaymentIntent.last_payment_error.source) && invPaymentIntent.last_payment_error.source.id) {
custPM = await stripe.customers.retrievePaymentMethod(applicator.membership.custId, invPaymentIntent.last_payment_error.source.id);
}
if (custPM && custPM.card && Events.INV_PM_FAILED === eventType) {
// Ensure req object is valid for mailer functions
req = utils.ensureValidReqObject(req);
req.locals = {
name: utils.capitalize(applicator.contact),
pmType: utils.capitalize(custPM.card.brand),
pmEnding: custPM.card.last4,
pmPiId: invoice.payment_intent,
lang: applicator.lang || DEFAULT_LANG
};
await mailer.sendUpdatePaymentEmail(req, applicator.username);
}
}
await updateCustSubscriptions(applicator._id, [subscription]);
}
}
async function _handleTaxFinalizationFailed(invoice) {
const textLines = [
'Dear Billing Department,\n',
`An invoice (created: ${utils.timestampToDate(invoice.created)}) was failed to finalized with 'automatic_tax[status]=failed'.`,
`Invoice id: '${invoice.id}'. For more info, try search the id in the Dashboard.`,
'Please check (https://status.stripe.com) and resolve with Stripe Support directly.\n',
...agmMailSig
];
const _contentOps = {
subject: '[Agm-Billing-Errors] Unexpected Invoice Finalization Failed',
text: textLines.join('\n')
};
await mailer.sendTextMail(_contentOps, env.AGN_BILL_MGT_EMAIL, env.AGM_ADM_EMAIL);
}
async function _handleOtherInvoiceFinalizationError(invoice) {
if (!invoice || utils.isEmptyObj(invoice['last_finalization_error'])) return;
// For now, notify (Billing/Agm Team) Admin to do take correction actions.
let hasCardError = invoice.last_finalization_error.type === 'card_error';
const textLines = [
`Dear ${hasCardError ? 'Billing Department' : 'Agm Admin'},\n`,
`An invoice (created: ${utils.timestampToDate(invoice.created)}) was failed to finalized with 'last_finalization_error=${invoice.last_finalization_error.type}'.`,
`Invoice id: '${invoice.id}'`,
(hasCardError ? 'For more info, try search the id in the Dashboard.' : `The last_finalization_error: ${JSON.stringify(invoice.last_finalization_error)}`) + '\n',
'Please check and resolve accordingly.\n',
...agmMailSig
];
const contentOps = {
subject: '[Agm-Billing-Errors] Unexpected Invoice Finalization Error',
text: textLines.join('\n')
};
if (hasCardError)
await mailer.sendTextMail(contentOps, env.AGN_BILL_MGT_EMAIL);
else
await mailer.sendTextMail(contentOps, env.AGN_BILL_MGT_EMAIL, env.AGM_ADM_EMAIL);
}
async function handleInvoiceFinalizeFailed(invoice, applicator, req) {
/* To determine why the invoice finalization failed, look at the Invoice object's last_finalization_error field, which provides more information
about the failure, including how to proceed.
*/
if (!invoice || utils.isEmptyObj(invoice['last_finalization_error'])) return;
if (!utils.isEmptyObj(invoice.automatic_tax)) {
if (invoice.automatic_tax[SubFields.STATUS] == 'requires_location_inputs') {
// Notify the customer to update current billing address
if (invoice.customer) {
if (applicator) {
// Get the billing address using the helper function
let billingAddress = getBillingAddressFromCustomer(applicator);
// Add country information to the address if available
if (billingAddress && applicator.Country) {
billingAddress.Country = getCountryName(applicator.Country || applicator.country);
}
// Ensure req object is valid for mailer functions
req = utils.ensureValidReqObject(req);
req.locals = {
name: utils.capitalize(applicator.contact),
userId: applicator._id.toHexString(),
address: billingAddress,
lang: applicator.lang || DEFAULT_LANG
};
await mailer.sendUpdateBillingAddressEmail(req, applicator.username);
}
}
// ... Then, FE-BE to update Billing Address. FE verify the b.address until valid.
} else if (invoice.automatic_tax[SubFields.STATUS] == 'failed') {
/*(From Stripe docs at https://stripe.com/docs/billing/subscriptions/overview) => retry the request later.
Based on the latest Q&A on this on Stripe Dev Discord threads, they suggested to have human involvement to check
(https://status.stripe.com) and resolve with Stripe directly.*/
await _handleTaxFinalizationFailed(invoice);
}
} else {
await _handleOtherInvoiceFinalizationError(invoice);
}
}
async function updateSubBillPeriod(user, sub, session) {
if (!user || !sub) return;
const bpObj = {
userId: user._id,
custId: sub.customer, subType: sub.metadata[SubFields.TYPE], periodStart: sub.current_period_start, periodEnd: sub.current_period_end,
lookupKey: sub.items.data[0].price.lookup_key
};
if (bpObj.userId && bpObj.custId && bpObj.subType && bpObj.periodStart && bpObj.periodEnd)
await BillPeriod.updateOne(bpObj, { $setOnInsert: sub }, { upsert: true, session });
}
async function apiConfig_get(req, res) {
res.json({ config: env.STRIPE_PUB_KEY });
}
async function createPaymentUser(user) {
const username = user?.username;
if (!username) AppParamError.throw(Errors.USER_NOT_FOUND);
const custsRS = await stripe.customers.search({
query: `email:"${username}"`,
});
if (!utils.isEmptyArray(custsRS.data)) {
return custsRS.data[0];
} else {
// Get the billing address using the helper function
const billingAddress = getBillingAddressFromCustomer(user);
return await stripe.customers.create({
...(user?.name && { name: user.name.trim() }),
email: username,
...(!utils.isEmptyObj(billingAddress) && { address: _toStripeAddress(billingAddress) })
});
}
}
/**
* Resolve the applicator user membership. Create a new Stripe customer if not existed.
* TODO: passing more data like: billing address (initial values for tax), email from the username, etc.
*
* @param {*} user The applicator user
* @returns the membership object
*/
async function resolvePaymentUser(user) {
if (utils.isEmptyObj(user)) return null;
if (utils.isEmptyObj(user.membership) || !user.membership.custId) {
const paymentUser = await createPaymentUser(user);
const membership = { ...user.membership, custId: paymentUser.id };
await Customer.updateOne({ _id: user._id }, { $set: { membership: membership } });
return membership;
}
return user.membership;
}
/**
* Get all Payment Methods of a Applicator by Stripe Customer Id (card)
* @param {*} req custId: the applicator Stripe customer Id
* @param {*} res List of Stripe PM Methods of the customer
*/
async function paymentMethods_get(req, res) {
const custId = req.params && req.params.custId;
if (!custId) AppParamError.throw();
const paymentMethods = await stripe.customers.listPaymentMethods(custId, { type: 'card' });
res.json(paymentMethods.data);
}
/**
* Get Stripe customer default Payment Method
* @param {*} req custId: params.custId is the Stripe customer Id
* @param {*} res null or the default Payment Method object
*/
async function getCustDefaultPaymentMethod_get(req, res) {
const custId = req.params && req.params.custId;
if (!custId) AppParamError.throw();
const stripeCust = await stripe.customers.retrieve(req.params.custId);
if (!stripeCust || stripeCust.deleted) AppParamError.throw(Errors.APP_VENDOR_NOT_FOUND);
const defPmId = stripeCust && stripeCust.invoice_settings?.[SubFields.DEFAULT_PAYMENT_METHOD];
let defPM = null;
if (defPmId) {
defPM = await stripe.paymentMethods.retrieve(defPmId);
}
res.json(defPM);
}
// async function resolvePaymentMethod(pmId) {
// if (!pmId) AppMembershipError.throw(Errors.INVALID_PAYMENT_METHOD);
// const pmMethod = await stripe.paymentMethods.retrieve(pmId);
// if (!pmMethod || !pmMethod.card || cardUtil.isExpired(pmMethod.exp_month, pmMethod.exp_year))
// AppMembershipError.throw(Errors.PAYMENT_EXPIRED);
// return pmMethod;
// }
/**
* Get all prices of provided services or items
* [ { lookupKey, priceUSD, type('essential | enterprise | addon')} ]
* @return {*} prices list array in format of [ { lookupKey, priceUSD(value * 100), type('essential | enterprise | addon')} ]
*/
async function getPrices_get(req, res) {
let prices = [];
const stripePrices = await stripe.prices.search({
query: 'active:\'true\' AND currency:\'usd\' AND -lookup_key:null',
});
if (stripePrices && !utils.isEmptyArray(stripePrices.data)) {
prices = stripePrices.data.map(it => (
{
lookupKey: it.lookup_key, priceUSD: it.unit_amount, type: it[SubFields.METADATA][SubFields.TIER],
[SubFields.MAX_VEHICLES]: Number(it[SubFields.METADATA][SubFields.MAX_VEHICLES] || 0),
[SubFields.MAX_ACRES]: it[SubFields.METADATA][SubFields.MAX_ACRES] || 0,
[SubFields.LEVEL]: Number(it[SubFields.METADATA][SubFields.LEVEL] || 0)
}
));
}
res.json(prices);
}
/**
* Get a Stripe coupon by ID
* @return {object} the Stripe coupon object with detail info or Stripe exeption for missing_resources
*/
async function getCoupon_get(req, res) {
// TODO: Might need to consider returning only needed fields
const coupon = await stripe.coupons.retrieve(req.params.coupon);
res.json(coupon);
}
/**
* Retrieve Billing address of a customer with Stripe location verification result in .tax.
* Client can use verify whether the address is valid (properly recognized and taxable) via .valid bases the value of tax.automatic_tax: true if 'supported' or 'not_collecting'.
* @param {*} req userId: the applicator userId
* @param {*} res Billing Address object, valid: true means recognizable (Stripe location verification result).
*/
async function getBillAddress_get(req, res) {
const userId = req.params.userId;
assert(userId, AppParamError.create());
const customer = await Customer.findOne({ _id: ObjectId(userId) }).lean();
assert(customer, AppAuthError.create());
// Get billing address using helper function
let billingAddress = getBillingAddressFromCustomer(customer);
if (billingAddress && customer.membership && customer.membership.custId) {
const stripeCust = await stripe.customers.retrieve(customer.membership.custId, { expand: [SubFields.TAX] });
(stripeCust && stripeCust.tax) && (billingAddress.valid = isValidTaxStatus(stripeCust.tax.automatic_tax));
}
res.json(billingAddress);
}
function _validateAddress(address) {
assert(address, AppParamError.create());
assert(address.country, AppParamError.create());
}
function _toAddress(address) {
if (!address) return address;
const _address = { ...address };
if (_address['postal_code']) {
_address.postalCode = _address['postal_code'];
delete _address['postal_code'];
}
return _address;
}
function _toStripeAddress(address) {
if (!address) return address;
const stripeAddress = {
line1: address?.line1, line2: address?.line2, city: address?.city, state: address?.state, postal_code: address?.postalCode,
country: address?.country && (typeof address.country === 'object' && address.country.code ? address.country.code : address.country)
};
return stripeAddress;
}
/**
* Update customer (Applicator billing address) and return the tax status (location verfification from Stripe).
* If there are draft invoices, try to finalize once the address updated and Stripe location verification is valid.
* @param {*} req { userId, address } in the body.
* @param {*} returns Billing Address object, valid: true means recognizable (Stripe location verification result).
*/
async function updateBillAddress_put(req, res) {
const address = req.body, userId = req.params.userId;
assert(userId, AppParamError.create());
_validateAddress(address);
const _address = _toAddress(address);
let customer = await Customer.findOne({ _id: ObjectId(userId) }).lean();
assert(customer, AppAuthError.create());
// TODO: need to review this validation later if allowing billing addresses in more than one country.
assert(customer.country === _address.country, AppError.create(Errors.INVALID_ADDRESS_COUNTRY));
// Use the reusable helper function to update billing addresses
const addressUpdated = updateBillingAddress(
customer.addresses || [],
_address,
address._id
);
// Only update database if there are actual changes
if (JSON.stringify(customer.addresses) !== JSON.stringify(addressUpdated)) {
customer = await Customer.findOneAndUpdate(
{ _id: customer._id },
{ $set: { addresses: addressUpdated } },
{ runValidators: true, new: true, lean: true }
);
}
const stripeAddr = _toStripeAddress(address);
const updateStripeOps = { address: stripeAddr, expand: [SubFields.TAX] };
if (address.name) updateStripeOps.name = address.name;
const stripeCust = await stripe.customers.update(customer.membership.custId, updateStripeOps);
// Get the billing address using the helper function
const custBARes = getBillingAddressFromCustomer(customer);
custBARes.valid = isValidTaxStatus(stripeCust.tax.automatic_tax);
// Check for the case of updated with a valid address to finalize all pending draft invoices
if (isTaxableCountry(customer.country) && custBARes.valid) {
await finalizeCustDraftInvoices(customer.membership.custId);
}
res.json(custBARes);
}
function _validateSubscribingInput(input) {
input.package && assert(env.PRICES[input.package], AppParamError.create());
input.addons && !utils.isEmptyArray(input.addons) && (
input.addons.map(it => assert(env.PRICES[it.price], AppParamError.create()))
);
}
function _isEmptySubsInput(input) {
if (!input.package && (utils.isEmptyArray(input.addons) || input.addons.every(it => it.quantity == 0)))
return true;
return false;
}
function _validateTrialSubscribing(input, membership) {
if (!input || !membership) AppInputError.throw();
const trials = membership?.trials;
if (input.trial) {
assert(!utils.isEmptyObj(trials) && Object.values(TrialTypes).includes(trials.type) && trials.type !== TrialTypes.NONE
&& (trials.type === TrialTypes.BY_DATE && trials.byDate || trials.type === TrialTypes.DAYS && utils.isNumber(trials.trialDays) && trials.trialDays > 0),
AppError.create(Errors.TRIALS_NOT_ENABLED));
}
if (!utils.isEmptyObj(trials)) subUtil.validateTrial(trials, Errors.TRIALS_EXPIRED);
}
/**
* Update customer's subscription based on the current selection
* @param {*} req Input Parameter in { pmId:strin (Optional when cancel all), defaultPM: true/false, package, addons[ { price<lookup key>, quantity }], prorateTS: <proration moment timestamp>,
* coupon: <string, the discount coupon>
*
* @returns list of Stripe updated subscription objects
*
* May 30th, 2023
* Allow optional pmId when unsubcribing all
*/
async function updateSubscriptions_post(req, res) {
// Assumed that we use 2 subscriptions (1 for package, 1 for addons)
// Ref - Update Subscription: https://stripe.com/docs/api/subscriptions/update
// Ref - Subscription life cycle: https://stripe.com/docs/billing/subscriptions/overview
const input = req.body;
_validateSubscribingInput(input);
const userInfo = req.userInfo;
if (!userInfo) AppAuthError.throw();
const customer = await Customer.findOne({ _id: ObjectId(userInfo.puid) }).lean();
const membership = customer.membership;
if (!customer || !membership || !membership.custId) AppAuthError.throw();
// Validate trials if in trial mode (input.trial is 1)
input.trial && (_validateTrialSubscribing(input, membership));
const subscriptions = await listCustomerSubscriptions(membership.custId, { expand: ['data.latest_invoice.payment_intent'] });
if (!utils.isEmptyArray(subscriptions.data) && subscriptions.data.some(sub => [SubStatus.INCOMPLETE, SubStatus.PAST_DUE, SubStatus.UNPAID].includes(sub.status))) {
return res.json(subscriptions.data); // Return the existing subs w/ open invoices for resolving
} else {
const paymentMethods = await stripe.customers.listPaymentMethods(membership.custId, { type: 'card' });
const hasPM = paymentMethods && !utils.isEmptyArray(paymentMethods.data);
const subOps = { automatic_tax: { enabled: isTaxableCountry(customer.country) } }; // Controlling subscription additional options
if (input.coupon) subOps.coupon = input.coupon;
const trials = membership.trials;
if (input.trial && trials) {
if (TrialTypes.BY_DATE === trials.type) {
trials.byDate && (subOps.trial_end = moment.utc(trials.byDate).unix());
} else if (TrialTypes.DAYS === trials.type) {
const endTrialDate = (trials.startDate ? moment.utc(trials.startDate) : input.prorateTS ? moment.utc(input.prorateTS * 1e3) : moment.utc()).add(trials.trialDays, "days");
endTrialDate && (subOps.trial_end = endTrialDate.unix());
}
// Always cancel at the end of the trial period.
if (subOps.trial_end && !input.pmId) subOps.cancel_at_period_end = true;
// Force to cancel when trial ended if there is no Payment Method.
subOps.trial_settings = { end_behavior: { missing_payment_method: 'cancel' } };
}
// !(hasAnySubs && emptySubs) => Requiring pmId
if (!(!utils.isEmptyArray(subscriptions.data) && _isEmptySubsInput(input))) {
!(input.trial) && (assert(input.pmId, AppParamError.create()));
if (input.pmId) {
if (!hasPM || hasPM && utils.isEmptyArray(paymentMethods.data.filter(it => it.id === input.pmId))) {
await stripe.paymentMethods.attach(input.pmId, { customer: membership.custId });
}
if (input.defaultPM || !hasPM) {
await stripe.customers.update(membership.custId, { invoice_settings: { default_payment_method: input.pmId } });
} else {
subOps.default_payment_method = input.pmId;
}
}
}
const prorateTS = input.prorateTS && utils.isNumber(input.prorateTS) ? Number(input.prorateTS) : Math.floor(new Date() * 1e-3);
const packageSubs = subscriptions.data && subscriptions.data.filter(it => it.metadata && it.metadata[SubFields.TYPE] === SubType.PACKAGE);
if (!utils.isEmptyArray(packageSubs)) {
const packageSub = packageSubs[0]; // The subscription for current AGM main package
if (input.package) {
const pkgItems = packageSub.items.data.filter(it => it.price.lookup_key === input.package);
if (utils.isEmptyArray(pkgItems)) {
// Upgrade or downgrade the package
await updateSubscription(packageSub, [{ price: env.PRICES[input.package] }], subOps, SubType.PACKAGE, prorateTS);
}
} else {
// Cancel the subscribed package
await cancelSubscription(packageSub.id, prorateTS);
}
} else if (input.package) {
await createSubscription(membership.custId, [{ price: env.PRICES[input.package] }], SubType.PACKAGE, subOps);
}
// Assuming that we use one subscription for all addons with monthly billing cycle.
const addonsSubs = subscriptions.data && subscriptions.data.filter(it => it.metadata && it.metadata[SubFields.TYPE] === SubType.ADDON);
if (!utils.isEmptyArray(input.addons)) {
const inputAddons = [];
for (let i = 0; i < input.addons.length; i++) {
const inAdo = input.addons[i];
if (inAdo && env.PRICES[inAdo.price] && inAdo.price) {
inputAddons.push({ price: env.PRICES[inAdo.price], quantity: inAdo[SubFields.QUANTITY] === undefined ? 1 : inAdo[SubFields.QUANTITY] });
}
}
if (utils.isEmptyArray(addonsSubs)) {
const addOnsWqty = (!utils.isEmptyArray(inputAddons) && inputAddons.filter(it => it.quantity)) || [];
if (addOnsWqty.length) {
await createSubscription(membership.custId, inputAddons, SubType.ADDON, subOps);
}
} else {
// Detect whether there is any changes in addons to proceed with upgrade/downgrade/cancel
const addonSub = addonsSubs[0];
// Check and update the addons subscription's items with quantities accordingly.
const addonsSet = utils.arrayToObject(inputAddons, SubFields.PRICE) || {};
let updatedItems = [], subItem, item, everZeroQty = false;
for (let i = 0; i < addonSub.items.data.length && Object.values(addonsSet).length; i++) {
subItem = addonSub.items.data[i];
item = addonsSet[subItem.price.id];
if (!item || (item && item.quantity != subItem.quantity)) {
if (item.quantity) {
updatedItems.push(({ price: subItem.price.id, quantity: item.quantity }));
}
if (item && item.quantity === 0) {
everZeroQty = true;
}
}
if (item) {
delete addonsSet[subItem.price.id];
}
}
if (Object.values(addonsSet).length) {
updatedItems = updatedItems.concat(Object.values(addonsSet).filter(it => it.quantity));
}
if (everZeroQty || !utils.isEmptyArray(updatedItems)) {
await updateSubscription(addonSub, updatedItems, subOps, SubType.ADDON, prorateTS);
}
}
}
else if (!utils.isEmptyArray(addonsSubs)) {
// None addons selected, go ahead to unsubcribe all addons within the addon subscription
await cancelSubscription(addonsSubs[0].id, prorateTS);
}
const updatedSubs = await listCustomerSubscriptions(membership.custId, { expand: ['data.latest_invoice.payment_intent'] });
res.json(updatedSubs.data);
}
}
async function listCustomerSubscriptions(custId, ops) {
let _ops = { customer: custId };
!utils.isEmptyObj(ops) && (_ops = Object.assign({}, _ops, ops));
return await stripe.subscriptions.list(_ops);
}
async function createSubscription(custId, items, type, subOps) {
if (utils.isEmptyArray(items) || !custId) return null;
let params = {
metadata: { type: type },
cancel_at_period_end: true,
expand: ['latest_invoice.payment_intent']
};
if (!utils.isEmptyObj(subOps))
params = Object.assign({}, params, subOps);
params.customer = custId;
params.items = items;
return await stripe.subscriptions.create(params);
}
async function updateSubscription(subscription, updatedItems, subOps, type, prorateTS) {
await cancelSubscription(subscription.id, prorateTS);
if (!utils.isEmptyArray(updatedItems)) {
const newSub = await createSubscription(subscription.customer, updatedItems, type, subOps);
}
}
async function cancelSubscription(subId, prorateTS) {
/** Advise from Stripe Dev on Stripe Developers Discord channel **
* So if you really wanted to solve this I'd do something like this:
- Use the upcoming Invoices API to sum up all the prorations (Without tax) you want to refund for.
- Use the preview credit note API (https://stripe.com/docs/api/credit_notes/preview) with that proration amount as an invoice line item
which will calculate the automatic tax and do the rounding for you so you know the exact total amount.
- Generate the real credit note using the same proration amount and the calculated total you got from previewing the credit note.
REF: https://stripe.com/docs/refunds?dashboard-or-api=api#issuing
*/
const sub = await stripe.subscriptions.retrieve(subId);
assert(sub, AppMembershipError.create());
// Prorate or Credit Notes (auto taxable) for active subscription
if (sub.status === SubStatus.ACTIVE) {
// Set proration date to this moment (UTC) TS
const prorationDate = prorateTS && utils.isNumber(prorateTS) ? prorateTS : Math.floor(Date.now() * 1e-3);
const autoTaxed = (sub.automatic_tax && sub.automatic_tax.enabled) || false;
// See what the next invoice would look like if the price was unsubscribed and the proration date
const items = [{
quantity: 0,
id: sub.items.data[0].id
}];
// List upcomming invoices if the subscription is canceled with proration.
const invoices = await stripe.invoices.retrieveUpcoming({
automatic_tax: { enabled: autoTaxed },
customer: sub.customer,
subscription: sub.id,
subscription_items: items,
subscription_proration_date: prorationDate
});
// List all paid invoices for the original charge. Refunds can only be sent back to the original payment method used in a charge.
const paidInvoices = await stripe.invoices.list({
customer: sub.customer,
subscription: sub.id,
status: 'paid',
});
assert(paidInvoices && paidInvoices.data.length, AppMembershipError.create(Errors.PAID_INVOICES_NOT_FOUND));
// Calculate the proration cost
let remainCents = 0;
for (let i = 0; i < invoices.lines.data.length; i++) {
const invoiceItem = invoices.lines.data[i];
if (invoiceItem.period.start == prorationDate) {
remainCents += invoiceItem.amount;
}
}
const refundAmount = (remainCents < 0) ? remainCents * -1 : remainCents;
if (autoTaxed) {
// Create a credit notes for refunding an item of the last paid invoice of the subscription
const line1 = paidInvoices.data[0].lines.data[0];
const cnOps = {
invoice: paidInvoices.data[0].id,
lines: [
{ type: 'invoice_line_item', invoice_line_item: line1.id, amount: refundAmount }
]
};
// Use credit note preview to (avoid rounding issue) get the total amount w/ partial tax for credit note.
const creditNotePreview = await stripe.creditNotes.preview(cnOps);
const creditNote = await stripe.creditNotes.create({
...cnOps,
refund_amount: creditNotePreview.amount
});
} else {
const refund = await stripe.refunds.create({
charge: paidInvoices.data[0].charge,
amount: refundAmount
});
}
}
return await stripe.subscriptions.del(subId);
}
/**
* Send customer an email mentioning about the current subscriptions.
* @param {*} customer the customer object
* @param {*} subs the AgMission subscriptions list
* @param {*} req the request object for baseUrl extraction
* @returns
*/
async function emailCurSubcriptions(customer, subs, req) {
if (!customer || !customer.username) return;
// Ensure req object is valid for mailer functions
req = utils.ensureValidReqObject(req);
req.locals = { name: customer.contact || customer.username, lang: customer.lang || DEFAULT_LANG };
// Build model data object for the email template to display package and addons
for (let i = 0; i < subs.length; i++) {
if (isFinalSubStatus(subs[i].status)) continue;
const item = subs[i].items[0],
dateStr = utils.timestampToDate(subs[i].periodStart),
trialEndStr = subs[i].trialEnd && utils.timestampToDate(subs[i].trialEnd);
if (subs[i].type === SubType.PACKAGE) {
req.locals.package = [
...req.locals.package || [],
{ name: item.price, billCycle: subs[i].recurring.interval, startDate: dateStr, status: subs[i].status, trialEnd: trialEndStr }
];
} else if (subs[i].type === SubType.ADDON) {
req.locals.addon = [
...req.locals.addon || [],
{ name: item.price, quantity: item.quantity, billCycle: subs[i].recurring.interval, startDate: dateStr, status: subs[i].status, trialEnd: trialEndStr }
];
}
}
await mailer.sendCurSubcriptionsEmail(req, customer.username);
}
/**
* List customer's subscriptions
* @param {*} req the query contains: custId, status, billInfo=true/false (to expand to the latest_invoice and then payment_intent)
* @param {*} res list of subscribed Stripe subscriptions of the customer
*/
async function getCustSubscriptions_get(req, res) {
const input = req.query;
assert(input && input.custId, AppParamError.create());
const ops = input.status ? { status: input.status } : {};
input.billInfo && (ops.expand = ['data.latest_invoice.payment_intent']);
const subscriptions = await listCustomerSubscriptions(input.custId, ops);
res.json(subscriptions.data);
}
/**
* Get a preview of the upcoming invoice for a customer at any time.
* * @param {*} req Input Parameter in { custId:string, package, addons[ { price<lookup key>, quantity }], prorateTS: <proration moment timestamp>,
* coupon: <string, the discount coupon>
*
* Ref: https://stripe.com/docs/api/invoices/upcoming, https://stripe.com/docs/api/invoices/line_item
*/
async function retrieveNextInvoices_post(req, res) {
const input = req.body;
_validateSubscribingInput(input);
assert(input && input.custId, AppParamError.create());
let invoices = [];
let packageSub, addonSub, pkgSubItems = [], adoSubItems = [];
const subscriptions = await listCustomerSubscriptions(input.custId);
const packageSubs = subscriptions.data && subscriptions.data.filter(it => it.metadata && it.metadata[SubFields.TYPE] === SubType.PACKAGE);
if (!utils.isEmptyArray(packageSubs)) {
packageSub = packageSubs[0]; // The subscription for current AGM main package
if (input.package) {
const pkgItems = packageSub.items.data.filter(it => it.price.lookup_key === input.package);
if (utils.isEmptyArray(pkgItems)) {
// Upgrade or downgrade the package
pkgSubItems.push(...
[
{ id: packageSub.items.data[0].id, deleted: true },
{ price: env.PRICES[input.package], deleted: false }
]
);
}
} else {
// Cancel the subscribed package ?
pkgSubItems.push({ id: packageSub.items.data[0].id, quantity: 0 });
}
} else {
if (input.package) {
// Subscribing for the new package ?
pkgSubItems.push({ price: env.PRICES[input.package] });
}
}
const addonsSubs = subscriptions.data && subscriptions.data.filter(it => it.metadata && it.metadata[SubFields.TYPE] === SubType.ADDON);
const inputAddons = input.addons && input.addons.map(it => ({ price: env.PRICES[it.price], quantity: it[SubFields.QUANTITY] === undefined ? 1 : it[SubFields.QUANTITY] }));
if (!utils.isEmptyArray(addonsSubs)) {
addonSub = addonsSubs[0]; // The subscription for all monthly addons
// Check and update the addons subscription's items with quantities accordingly.
const addonsSet = utils.arrayToObject(inputAddons, SubFields.PRICE) || {};
if (!Object.values(addonsSet).length) {
adoSubItems.push(...addonSub.items.data.map(it => ({ id: it.id, quantity: 0 })));
} else {
let subItem, item;
for (let i = 0; i < addonSub.items.data.length; i++) {
subItem = addonSub.items.data[i];
if ((item = (addonsSet[subItem.price.id]))) {
if (item.quantity != subItem.quantity) {
if (item.quantity) {
adoSubItems.push(...
[
{ id: subItem.id, deleted: true },
{ price: item.price, quantity: item.quantity, deleted: false }
]
);
} else {
adoSubItems.push({ id: subItem.id, quantity: 0 });
}
}
delete addonsSet[subItem.price.id];
}
}
if (Object.values(addonsSet).length) {
adoSubItems = adoSubItems.concat(Object.values(addonsSet).filter(it => it.quantity));
}
}
} else {
if (!utils.isEmptyArray(input.addons)) {
adoSubItems.push(...inputAddons.filter(it => it.quantity));
}
}
// Retrieve upcoming invoices
if (packageSub || !utils.isEmptyArray(pkgSubItems))
invoices = invoices.concat(await retreiveNextInvoices(input.custId, pkgSubItems, packageSub, input.prorateTS, input.coupon));
if (addonSub || !utils.isEmptyArray(adoSubItems))
invoices = invoices.concat(await retreiveNextInvoices(input.custId, adoSubItems, addonSub, input.prorateTS, input.coupon));
res.json(invoices);
}
/**
* Get a preview of upcoming invoices for the customer.
* Ref: https://stripe.com/docs/api/invoices/upcoming?lang=node
* dt: ISO 8601 datetime string, use only for DEBUG or TEST purpose.
* @param {*} custId the Stripe customer Id
* @param {*} subItems when there is changes in price items or quantities
* @param {*} subscription the existing subscription
* @param {*} prorateTS the proration timestamp
* @param {*} coupon the discount coupon
* @returns List of upcoming (not yet created) invoices
*/
async function retreiveNextInvoices(custId, subItems, subscription, prorateTS, coupon) {
let invoices = [];
const pkgOps = {
customer: custId,
automatic_tax: { enabled: true }
};
if (subscription)
pkgOps.subscription = subscription.id;
if (!utils.isEmptyArray(subItems)) {
pkgOps.subscription_items = subItems;
if (subscription) {
pkgOps.subscription_proration_behavior = 'always_invoice';
const _prorateTS = utils.isNumber(prorateTS) ? prorateTS : Math.floor(new Date() * 1e-3);
pkgOps.subscription_proration_date = _prorateTS;
}
}
const skipPreview = (subscription && subscription.cancel_at_period_end === true && utils.isEmptyArray(subItems));
if (!skipPreview) {
if (coupon && (subscription || !utils.isEmptyArray(subItems)))
pkgOps.coupon = coupon;
invoices = await stripe.invoices.retrieveUpcoming(pkgOps);
}
return invoices;
}
/**
* Update Ag-Nav db on corresponding Stripe customer's subscriptions when there has been subscription changes.
* @param {*} appId the applicator user Id
* @param {*} subscriptions the Stripe subscriptions list
* @param {*} replaceAllExisting whether to simply replace the current subscriptions list with the given list, default=false.
*/
async function updateCustSubscriptions(appId, subscriptions, replaceAllExisting = false) {
const customer = await Customer.findOne({ _id: ObjectId(appId) });
assert(customer, AppAuthError.create());
const membership = customer.membership;
assert(membership, AppAuthError.create());
const newTrialSub = !utils.isEmptyArray(subscriptions) && subscriptions.find(s => s.status === SubStatus.TRIALING);
if (replaceAllExisting || utils.isEmptyArray(membership.subscriptions)) {
const subs = [];
if (!utils.isEmptyArray(subscriptions)) {
for (let i = 0; i < subscriptions.length; i++) {
if (!isFinalSubStatus(subscriptions[i].status)) {
const mSub = _toMembershipSubscription(subscriptions[i]);
if (mSub)
subs.push(mSub);
}
}
}
customer.membership.subscriptions = subs;
} else if (!utils.isEmptyArray(subscriptions)) {
const subsSet = utils.arrayToObject(subscriptions, SubFields.ID);
let item;
for (let i = membership.subscriptions.length - 1; i >= 0 && membership.subscriptions.length; i--) {
const sub = membership.subscriptions[i];
if ((item = subsSet[sub.id])) {
// Synchronize with DB subs if any is matched
if (isFinalSubStatus(item.status)) {
membership.subscriptions.splice(i, 1);
} else {
membership.subscriptions[i] = _toMembershipSubscription(item);
}
delete subsSet[sub.id];
} else if (sub && isFinalSubStatus(sub.status)) {
// Remove any non-valid (canceled) DB subscriptions if any
membership.subscriptions.splice(i, 1);
}
}
const newSubs = Object.values(subsSet);
if (newSubs.length) {
for (let i = 0; i < newSubs.length; i++) {
const sub = _toMembershipSubscription(newSubs[i]);
membership.subscriptions.push(sub);
}
}
}
// Updating the last trial dates
if (newTrialSub) {
const trials = membership.trials || {};
const trialEndDate = new Date(newTrialSub.trial_end * 1e3);
if (!trials.lastEndDate || trialEndDate > trials.lastEndDate)
trials.lastEndDate = trialEndDate;
const trialStartDate = new Date(newTrialSub.trial_start * 1e3);
if (!trials.lastStartDate || (trialStartDate > trials.lastStartDate && trialStartDate > trials.lastEndDate))
trials.lastStartDate = trialStartDate;
}
// Make mongoose detect changes in nested objects
customer.markModified('membership');
const updatedCust = await customer.save();
return updatedCust.toObject();
}
/**
* Convert Stripe subscrition to AgMission subscription membership object
* @param {*} sub Stripe subscription
* @returns a subscription object in AgNav db format
*/
function _toMembershipSubscription(sub) {
if (!sub || !(sub.items && sub.items.data)) return sub;
const firstItem = sub.items.data[0];
const mSub = {
id: sub.id,
type: sub.metadata.type,
status: sub.status,
periodStart: sub.current_period_start,
periodEnd: sub.current_period_end,
items: sub.items.data.map(it => ({ price: it.price.lookup_key, quantity: it.quantity || 1, metadata: it.price.metadata })),
recurring: {
interval: firstItem.price.recurring.interval,
intervalCount: firstItem.price.recurring.interval_count
},
cancelAtPeriodEnd: sub.cancel_at_period_end,
trialEnd: sub.trial_end
};
return mSub;
}
/**
* Get all open (including latest) invoices for the users to pay to resume the subscription
* @param {*} req params within the body { [ unpaidSubs: [subscription id] }
* @return [*] list of open invoices to pay (with the most recently created invoices appearing first)
*/
async function resolveUnpaidSubcriptions_post(req, res) {
let allOpenInvoices = [];
/* Unpaid subscription resume process
* 1. (Front End) Collect new payment information .
2. (Back End) Turn automatic collection back on by setting auto advance to true on draft invoices.
Finalize, then pay the open invoices. Pay the most recent invoice before its due date to update the status of the subscription to active.
3. (Front End call Back End) Pay all the invoices to resume the subscriptions to make the subscription status change to active
4. (Front End) Verify the response and decide to proceed the provision if the subscription is active (success)
*/
const input = req.body;
assert(input.unpaidSubs && !utils.isEmptyArray(input.unpaidSubs), AppParamError.create());
const unpaidSubs = [];
for (let i = 0; i < input.unpaidSubs.length; i++) {
const sub = await stripe.subscriptions.retrieve(input.unpaidSubs[i]);
if (sub && [SubStatus.UNPAID, SubStatus.PAST_DUE].includes(sub.status))
unpaidSubs.push(sub);
}
for (let i = 0; i < unpaidSubs.length; i++) {
const sub = unpaidSubs[i];
const draftInvoices = [];
for await (const draftInv of stripe.invoices.list({
customer: sub.customer,
subscription: sub.id,
status: InvStatus.DRAFT,
created: {
gte: sub.created
}
})) {
draftInvoices.push(draftInv);
};
// Turn automatic collection back and finalize them all
for (let i = 0; i < draftInvoices.length; i++) {
const invoice = draftInvoices[i];
if (InvStatus.DRAFT === invoice.status) {
await stripe.invoices.update(invoice.id, { auto_advance: true });
await stripe.invoices.finalizeInvoice(invoice.id);
}
}
const openInvoices = [];
for await (const openInv of stripe.invoices.list({
customer: sub.customer,
subscription: sub.id,
status: InvStatus.OPEN,
created: {
gte: sub.created
}
})) {
openInvoices.push(openInv);
};
allOpenInvoices = [...openInvoices];
}
res.json(allOpenInvoices);
}
/**
* Pay an invoice with a payment method (normally a card type PM Id). Will first check the invoice and only attempt to pay if the invoice status is 'open'.
* @param {*} req the params object { invIds:[invoice id], pmId } within the request body
* @rerurn [*] List of the paid invoices after attempted to pay them with the pmId
*/
async function payInvoice_post(req, res) {
let allPaidInvoices = [];
const input = req.body;
assert(input.invIds && input.pmId && !utils.isEmptyArray(input.invIds), AppParamError.create());
for (let i = 0; i < input.invIds.length; i++) {
if (input.invIds[i]) {
const inv = await stripe.invoices.retrieve(input.invIds[i]);
if (inv && inv.status === InvStatus.OPEN) {
const piRes = await stripe.invoices.pay(input.invIds[i], { payment_method: input.pmId });
if (piRes)
allPaidInvoices.push(piRes);
}
}
}
res.json(allPaidInvoices);
}
async function finalizeCustDraftInvoices(custId) {
/**
* Resolve the error: 'Error: When `automatic_tax[enabled]=true`, enough customer location information must be provided to accurately determine tax rates for the customer' first then do this to resume charging for all pending draft invoices.
* IMPORTANT:
* When this error happened, all drafts invoices would not able to finalize and collect automatically while the subscription is still being in 'active' status.
*
* TODO: Only do this after:
* 1. The customer updated his/her billing address.
* Or 2. AgNav updated customer's billing address.
*
* BE to evaluate whether this address is valid or not. the proceed with this step. Once these draft invoices are resumed with auto collection and finalized, the collection (charge) process will take place automatically.
*
* Then their Stripe billing address was updated and valid.
*/
let openInvoices = [];
const draftInvoices = await stripe.invoices.list({
customer: custId,
status: InvStatus.DRAFT,
});
// Turn on automatic collection back and finalize them all.
for (let i = 0; i < draftInvoices.data.length; i++) {
const invoice = draftInvoices.data[i];
if (InvStatus.DRAFT === invoice.status) {
await stripe.invoices.update(invoice.id, { auto_advance: true });
const openInv = await stripe.invoices.finalizeInvoice(invoice.id);
openInv && (openInvoices.push(openInv));
}
}
return openInvoices;
}
/**
* Finalize all customer draft invoices
* @param {*} req
* @param {*} res list of open invoices (ready and to be auto collected/charged ASAP)
*/
async function finalizeCustDraftInvoices_post(req, res) {
const input = req.body;
assert(input && input.custId, AppParamError.create());
const openInvoices = await finalizeCustDraftInvoices(input.custId);
res.json(openInvoices);
}
/**
* Set the default payment method (card) for subscriptions
* @param {*} req { subIds:[subscription id], pmId }. Only subscriptions with status other than 'canceled and incomplete_expired' will be updated.
* The Payment Method must be a valid one. (Belong to and or not yet attached to the customer of the subscription)
* @param {*} res List of updated subscriptions
*/
async function setSubsPaymentMethod_post(req, res) {
const input = req.body;
assert(input.subIds && input.pmId && !utils.isEmptyArray(input.subIds), AppParamError.create());
const updatedSubs = await setSubsPaymentMethod(input.pmId, input.subIds);
res.json(updatedSubs);
}
async function setSubsPaymentMethod(pmId, subIds) {
assert(pmId && !utils.isEmptyArray(subIds), AppInputError.create());
let updatedSubs = [];
for (let i = 0; i < subIds.length; i++) {
let sub;
try {
sub = await stripe.subscriptions.retrieve(subIds[i]);
} catch (error) { }
if (sub && !isFinalSubStatus(sub.status)) {
await attachPmIdToCustomer(pmId, sub.customer);
const updatedSub = await stripe.subscriptions.update(subIds[i], { default_payment_method: pmId });
updatedSubs.push(updatedSub);
}
}
return updatedSubs;
}
async function attachPmIdToCustomer(pmId, custId) {
assert(pmId && custId, AppParamError.create());
let pmMethod = await stripe.paymentMethods.retrieve(pmId);
if (!pmMethod || pmMethod.customer && pmMethod.customer != custId) AppParamError.throw(Errors.INVALID_PAYMENT_METHOD);
if (!pmMethod.customer)
return await stripe.paymentMethods.attach(pmId, { customer: custId });
return pmMethod;
}
async function setCustDefaultPaymentMethod(pmId, custId, isDefault = true) {
if (!pmId || !custId) return;
await stripe.customers.update(custId, { invoice_settings: { default_payment_method: isDefault ? pmId : null } });
if (isDefault) {
// Set this pm as the default PM to all active subscriptions.
const curSubs = await stripe.subscriptions.list({ customer: custId });
if (!utils.isEmptyArray(curSubs.data)) {
await setSubsPaymentMethod(pmId, curSubs.data.map(sub => sub.id));
}
}
}
async function deleteCustPaymentMethod(req, res) {
const custId = req.params.custId;
const input = req.body;
assert(input && input.pmId && custId, AppParamError.create());
const custPmMethods = await stripe.customers.listPaymentMethods(custId, { type: 'card' });
if (utils.isEmptyArray(custPmMethods.data) || !custPmMethods.data.some(pm => pm.id === input.pmId)) AppParamError.throw(Errors.CUST_PM_NOT_FOUND);
const customer = res.locals.customer;
if (custPmMethods.data.length === 1 || (customer && customer.invoice_settings && customer.invoice_settings[SubFields.DEFAULT_PAYMENT_METHOD] === input.pmId))
AppParamError.throw(Errors.RM_LAST_DEFAULT_PM_NOT_ALLOW);
// Assure not allow deleting the one being used in (mostly active) subscription(s)
const curSubs = await stripe.subscriptions.list({ customer: custId });
if (!utils.isEmptyArray(curSubs.data)) {
if (curSubs.data.some(sub => sub.default_payment_method === input.pmId || sub.default_source === input.pmId)) AppParamError.throw(Errors.RM_ACTIVE_PM_NOT_ALLOW);
}
// Detach the pm. Once detached, it will never be reusable.
const removedPmMethod = await stripe.paymentMethods.detach(input.pmId);
res.json(removedPmMethod);
}
async function hasApplVendor(req, res, next) {
const custId = req.params && req.params.custId;
assert(custId, AppParamError.create());
const customer = await stripe.customers.retrieve(custId);
if (!customer) AppParamError.throw(Errors.APP_VENDOR_NOT_FOUND);
else res.locals.customer = customer;
const applicator = await findApplicatorByCustId(custId);
if (!applicator) AppParamError.throw(Errors.LOCAL_VENDOR_NOT_FOUND);
else res.locals.applVendor = applicator;
(next && typeof next === 'function') && (await next());
}
/**
* Update Customer Payment Method info.
* @param {*} req params in body { type: 'card', (optional)name: <Full Name>, (optional)card: {expMonth: expiry month, expYear: expiry month },(optional) setDefault: true/false }
* @param {*} res the updated Stripe Payment Method object
*/
async function updateCustPaymentMethod_put(req, res) {
const custId = req.params && req.params.custId;
const input = req.body;
assert(custId && !utils.isEmptyObj(input) && input.pmId, AppParamError.create());
const pmMethod = await stripe.paymentMethods.retrieve(input.pmId);
if (!pmMethod || pmMethod.customer !== custId) AppParamError.throw(Errors.INVALID_PAYMENT_METHOD);
let pmParams = {};
if (input.name && pmMethod[SubFields.BILLING_DETAILS][SubFields.NAME] != input.name) pmParams = { ...pmParams, ...{ [SubFields.BILLING_DETAILS]: { [[SubFields.NAME]]: input.name } } };
if (!utils.isEmptyObj(input.card)) {
assert(!Number.isNaN(input.card.expMonth) && !Number.isNaN(input.card.expYear), AppParamError.create());
const expMonth = Number(input.card.expMonth), expYear = Number(input.card.expYear);
if (cardUtil.isExpired(expMonth, expYear)) AppParamError.throw(Errors.PAYMENT_EXPIRED);
pmParams = {
...pmParams, ...{ [SubFields.CARD]: { [SubFields.EXP_MONTH]: expMonth, [SubFields.EXP_YEAR]: expYear } }
};
}
const updatedCustPm = await stripe.paymentMethods.update(input.pmId, pmParams);
if (undefined !== input.setDefault && utils.stringToBoolean(input.setDefault))
await setCustDefaultPaymentMethod(input.pmId, custId, input.setDefault);
res.json(updatedCustPm);
}
/**
* Add (attach) a Stripe Payment method to a customer
* @param {*} req { pmId: <the newly non-attached payment method id>, (optional)setDefault: true <to also set this PM as a default one> }
* @param {*} res the Stripe Payment method object
*/
async function addCustPaymentMethod_post(req, res) {
const custId = req.params && req.params.custId;
const input = req.body;
assert(custId && input && input.pmId, AppParamError.create());
const pm = await attachPmIdToCustomer(input.pmId, custId);
if (undefined !== input.setDefault && utils.stringToBoolean(input.setDefault))
await setCustDefaultPaymentMethod(input.pmId, custId, input.setDefault);
res.json(pm);
}
/**
* Handle a failed refund
* @param {*} refund Stripe refund object
* @returns The Stripe charge object of the refund
*/
async function handleRefundFailed(refund, applicator) {
if (!refund) return;
const textLines = [
'Dear Billing Department,\n',
`A refund (created: ${utils.timestampToDate(refund.created)}) was failed with reason: '${refund.failure_reason}'.`,
(refund.charge ? `Original charge id: '${refund.charge}'. For more info, try search the charge id in the Dashboard.` : 'Original charge is not found.') + '\n',
];
if (applicator) {
textLines.push(
'Customer Information:',
...utils.objToStrArray(applicator, ['name', 'contact', 'username']),
'\n'
);
}
textLines.push('Please arrange an alternative way to refund to the customer.\n', ...agmMailSig);
const _contentOps = {
subject: '[Agmission-Billing] Errors - Unexpected Failed Refund',
text: textLines.join('\n')
};
await mailer.sendTextMail(_contentOps, env.AGN_BILL_MGT_EMAIL, env.AGM_ADM_EMAIL);
}
/**
* Get invoices of a given customer
* @param {*} req input params: { custId: <mandantory>, byTime: <'3m'/'6m', 'year number'>}
* @param {*} res list of Stripe invoices and refund charges { invoices:[], charges:[] }
*/
async function customerInvoices_post(req, res) {
const input = req.body;
assert(input && input.custId, AppParamError.create());
const invoices = [];
let invoiceQuery = `customer:'${input.custId}'`;
let timeCriteria;
if (input.byTime) {
timeCriteria = subUtil.timeToQueryRange(input.byTime, 'created');
if (timeCriteria) {
invoiceQuery += ' AND ' + timeCriteria;
}
}
// Use auto-paging feature to get all invoices (Node 10+)
for await (const invoice of stripe.invoices.search({
query: invoiceQuery
})) {
invoices.push(invoice);
}
let chargesQuery = `customer:'${input.custId}' AND -refunded:null AND status:'succeeded'`;
if (timeCriteria) {
chargesQuery += ' AND ' + timeCriteria;
}
const charges = [];
// Use auto-paging feature to get all charges (Node 10+)
for await (const charge of stripe.charges.search({
query: chargesQuery
})) {
charges.push(charge);
}
res.json({ invoices: invoices, charges: charges });
}
/**
* Get the (default latest) charges by a customer/applicator
* @param {*} req params: { custId, refunded:true(for fully-refunded charges)/false(for partially-refunded charges)/null(for non-refunded charges)
* status:(default 'succeeded'), limit (default 1, max 30) }
* @param {*} res list of Stripe charges
*/
async function getCustomerCharges_post(req, res) {
const input = req.body;
assert(input && input.custId, AppParamError.create());
const _linmit = Math.min(input['limit'] || 1, 30);
let searchQuery = `customer:'${input.custId}'`;
if (input[SubFields.REFUNDED] != undefined) {
assert(['true', 'false'].includes(String(input.refunded)), AppParamError.create());
searchQuery += ` AND refunded:'${input.refunded}'`;
} else {
searchQuery += ` AND refunded:null`;
}
if (input[SubFields.STATUS] !== undefined) {
assert(Object.values(ChrgStatus).includes(input.status), AppParamError.create());
searchQuery += ` AND status:'${input.status}'`;
} else {
searchQuery += ` AND status:'${ChrgStatus.SUCCEEDED}'`;
}
const charges = await stripe.charges.search({
query: searchQuery,
limit: _linmit
});
res.json(charges.data);
}
/**
* Get customer/applicator usages
* @param {*} req params: { byPuid: [string, The applicator UserId], fromTS, toTS } The applicator userId
* @param {*} res Usage Object { ttArea: [number, Total sprayable areas of all jobs in ha], numOfAC: [number, numbers of AC],
* jobUsages: [array, [obj:[jobId, createdAt, updateDate, totalSprayed (in ha)], Job usage list]] }
*/
async function getCustomerUsages_post(req, res) {
const input = req.body;
assert(input && input.byPuid && utils.isObjectId(input.byPuid), AppParamError.create());
const byPuid = ObjectId(input.byPuid);
const ttSprArea = await subUtil.calcTotalAreaByUser(byPuid, input.fromTS, input.toTS);
const numOfAC = await Vehicle.countDocuments({ parent: byPuid, [Fields.MARKED_DELETE]: { $in: [false, null] } });
const retObj = { ttArea: ttSprArea, numOfAC: numOfAC };
if (input.fromTS || input.toTS) {
const jobUsages = await subUtil.getJobUsageByTime(byPuid, input.fromTS, input.toTS);
if (utils.isEmptyArray(jobUsages && jobUsages.length)) retObj.jobUsages = jobUsages;
}
res.json(retObj);
}
/**
* Get Billing Periods of a customer's subscription(s)
* @param {*} req params: { custId, subTypes: [string] (Optional)}
* @param {*} res list of the subscription's billing periods [array, { custId, subType, periodEnd, periodStart }]
*/
async function getSubBillPeriods_post(req, res) {
const input = req.body;
assert(input.custId && input.custId, AppParamError.create());
const subTypes = !utils.isEmptyArray(input.subTypes) ? input.subTypes : [SubType.PACKAGE];
const billPeriods = await BillPeriod.find({ custId: input.custId, subType: { $in: subTypes } }, '-_id -__v');
res.json(billPeriods);
}
function _toStripeSubSettings(subsSettings) {
const retSubSettings = [];
if (utils.isEmptyArray(subsSettings)) return retSubSettings;
const subFieldsMap = {
"cancelAtPeriodEnd": "cancel_at_period_end"
};
for (let i = 0; i < subsSettings.length; i++) {
const subSettings = subsSettings[i];
if (subSettings.subId || Object.keys(subSettings).length > 1) {
const updateItem = { subId: subSettings.subId, settings: {} };
for (const [key, value] of Object.entries(subFieldsMap)) {
if (subSettings.hasOwnProperty(key)) {
updateItem.settings[value] = subSettings[key];
}
}
Object.keys(updateItem.settings).length > 0 && (retSubSettings.push(updateItem));
}
}
return retSubSettings;
}
/**
* Set Subscription settings
* @param {*} req params: { subsSettings: [ { subId: string, cancelAtPeriodEnd: boolean } ] }
* @param {*} res list of updated Stripe subscriptions
*/
async function setSubsSettings_post(req, res) {
const input = req.body;
assert(!utils.isEmptyArray(input.subsSettings), AppParamError.create());
const updatedSubs = [];
const stripeSubSetttings = _toStripeSubSettings(input.subsSettings);
for (let i = 0; i < stripeSubSetttings.length; i++) {
updatedSubs.push(await stripe.subscriptions.update(stripeSubSetttings[i].subId, stripeSubSetttings[i].settings));
}
res.json(updatedSubs);
}
module.exports = {
apiConfig_get, createPaymentUser, resolvePaymentUser, paymentMethods_get, getPrices_get, getCustSubscriptions_get, updateSubscriptions_post,
retrieveNextInvoices_post, payInvoice_post, resolveUnpaidSubcriptions_post, setSubsPaymentMethod_post, setSubsSettings_post, finalizeCustDraftInvoices_post,
customerInvoices_post, getCustomerCharges_post, getBillAddress_get, updateBillAddress_put, getCustomerUsages_post, getSubBillPeriods_post, getCoupon_get,
getCustDefaultPaymentMethod_get, deleteCustPaymentMethod, updateCustPaymentMethod_put, addCustPaymentMethod_post,
hasApplVendor, findApplicatorByCustId, updateCustSubStatus, updateSubBillPeriod, updateCustSubscriptions,
stripeWebhooks_post
}