agmission/Development/server/controllers/subscription.js

5332 lines
226 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, PromoModes, PromoEligibility, CouponDuration, StripeErrorTypes } = 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, IntentStatus, SubType, SubFields, Events, InvStatus, ChrgStatus, isTaxableCountry, isValidTaxStatus, isFinalSubStatus, BillReasons } = require('../model/subscription'),
{ Customer, Vehicle, BillPeriod, SubEvent } = require('../model'),
SubscriptionHistory = require('../model/subscription_history'),
Settings = require('../model/setting'),
subUtil = require('../helpers/subscription_util'),
cache = require('../helpers/mem_cache'),
mailer = require('../helpers/mailer'),
{ agmMailSig } = require('../helpers/mailer'),
errorHandler = require('error-handler').errorHandler;
/**
* SECURITY: Sanitize subscription object to remove sensitive discount/coupon data
* Removes discount from subscription level and nested invoice objects
* @param {Object} subscription - Stripe subscription object
* @returns {Object} Sanitized subscription without discount fields
*/
function sanitizeSubscription(subscription) {
if (!subscription) return subscription;
const cleaned = { ...subscription };
// Remove subscription-level discount
delete cleaned.discount;
// Remove discount from latest_invoice if present
if (cleaned.latest_invoice && typeof cleaned.latest_invoice === 'object') {
const cleanedInvoice = { ...cleaned.latest_invoice };
delete cleanedInvoice.discount;
delete cleanedInvoice.discounts; // Also remove discounts array if present
cleaned.latest_invoice = cleanedInvoice;
}
// Remove discount from invoice line items if present
if (cleaned.items && cleaned.items.data) {
cleaned.items.data = cleaned.items.data.map(item => {
const cleanedItem = { ...item };
delete cleanedItem.discount;
delete cleanedItem.discounts;
return cleanedItem;
});
}
return cleaned;
}
/**
* Sanitize a Stripe invoice (upcoming preview) to remove sensitive coupon/discount data.
* Mirrors sanitizeSubscription() but for invoice objects returned by retrieveNextInvoices.
* @param {Object} invoice - Stripe invoice or upcoming-invoice object
* @returns {Object} Sanitized invoice without discount/coupon fields
*/
function sanitizeInvoice(invoice) {
if (!invoice) return invoice;
const cleaned = { ...invoice };
// Remove invoice-level discount (same pattern as sanitizeSubscription)
// discount.coupon contains the full coupon object including coupon ID
delete cleaned.discount;
delete cleaned.discounts;
// Remove discount references from line items (discounts array may contain discount IDs)
// Note: discount_amounts only has numeric values so it's safe to keep
if (cleaned.lines && cleaned.lines.data) {
cleaned.lines = {
...cleaned.lines,
data: cleaned.lines.data.map(line => {
const cleanLine = { ...line };
delete cleanLine.discounts;
return cleanLine;
})
};
}
return cleaned;
}
/**
* Build pendingPromoDetails from subscription metadata.
* Gates on pending_coupon_id — written by updateAddonWithDeferredPromo.
* Returns the same shape as promoDetails so clients can display it identically.
* @param {Object} meta - subscription.metadata object
* @returns {Object|null} pendingPromoDetails or null if no deferred promo present
*/
function _buildPendingPromoDetailsFromMeta(meta) {
if (!meta || !meta.pending_coupon_id) return null;
const percentOff = meta.promo_percent_off ? Number(meta.promo_percent_off) : null;
const amountOff = meta.promo_amount_off ? Number(meta.promo_amount_off) : null;
const durationInMonths = meta.promo_duration_in_months ? Number(meta.promo_duration_in_months) : null;
return {
isPending: true,
appliesToNextPeriod: true,
name: meta.promo_name || 'Special Promotion',
discountDisplay: percentOff === 100 ? 'FREE'
: amountOff ? `$${(amountOff / 100).toFixed(2)} OFF`
: percentOff ? `${percentOff}% OFF`
: 'Special Discount',
percentOff,
amountOff,
currency: meta.promo_currency || null,
duration: meta.promo_duration || null,
durationInMonths,
expiresAt: null,
discountEndsAt: null,
daysRemaining: null,
daysUntilDiscountEnds: null,
isTimeLimited: false
};
}
/**
* Build pendingPromoDetails from a Stripe coupon object.
* Returns the same shape as promoDetails so clients can display it identically.
* @param {Object} couponObj - Stripe coupon object
* @returns {Object|null} pendingPromoDetails or null if couponObj falsy
*/
function _buildPendingPromoDetailsFromCoupon(couponObj) {
if (!couponObj) return null;
const percentOff = couponObj.percent_off ?? null;
const amountOff = couponObj.amount_off ?? null;
const durationInMonths = couponObj.duration_in_months ?? null;
return {
isPending: true,
appliesToNextPeriod: true,
name: couponObj.name || 'Special Promotion',
discountDisplay: percentOff === 100 ? 'FREE'
: amountOff ? `$${(amountOff / 100).toFixed(2)} OFF`
: percentOff ? `${percentOff}% OFF`
: 'Special Discount',
percentOff,
amountOff,
currency: couponObj.currency || null,
duration: couponObj.duration || null,
durationInMonths,
expiresAt: null,
discountEndsAt: null,
daysRemaining: null,
daysUntilDiscountEnds: null,
isTimeLimited: false
};
}
/**
* Add parsed promo details to subscription for client-side display
* Extracts coupon information from subscription.discount before it's sanitized
*
* Coupon retrieval logic:
* 1. Check subscription.discount.coupon (active coupons - forever, repeating, once before first billing)
* 2. Fallback to latest_invoice.discount.coupon (for "once" coupons already applied and removed)
*
* Note: Stripe automatically removes "once" coupons after first invoice is paid, but keeps them on invoice.
*
* expiresAt vs discountEndsAt clarification:
* - expiresAt: When promo closes to NEW subscribers (from promo.validUntil via schedule.discount.end)
* - discountEndsAt: When discount stops for THIS subscription
* - Repeating: subscription.start_date + durationInMonths
* - Once: 'applied' (special marker indicating one-time discount was used)
* - Schedule-managed: same as expiresAt
*
* Examples:
* 1. Forever coupon with validUntil (SubscriptionSchedule):
* - expiresAt: schedule.discount.end (matches promo.validUntil)
* - discountEndsAt: same as expiresAt
*
* 2. Repeating coupon (6 months) without validUntil:
* - expiresAt: null (always available)
* - discountEndsAt: subscription start + 6 months
*
* 3. Repeating coupon (6 months) with validUntil = Mar 31, subscribed Jan 1:
* - expiresAt: null (schedule.discount.end not set for repeating)
* - discountEndsAt: July 1 (Jan 1 + 6 months)
* - Note: validUntil only affects NEW subscribers, existing keep their discount
*
* 4. Once coupon (already applied):
* - expiresAt: null
* - discountEndsAt: 'applied'
* - Retrieved from latest_invoice.discount (not subscription.discount)
*
* @param {Object} subscription - Stripe subscription object (requires expansion: discount.coupon, latest_invoice.discount.coupon)
* @returns {Object} Sanitized subscription with promoDetails field
*/
function addPromoDetailsToSubscription(subscription) {
if (!subscription) return subscription;
const promoDetails = {
hasPromo: false,
name: null,
discountDisplay: null,
expiresAt: null, // When promo closes to NEW subscribers (schedule.discount.end)
discountEndsAt: null, // When discount stops for THIS subscription
daysRemaining: null, // Days until expiresAt
daysUntilDiscountEnds: null, // Days until discountEndsAt
isTimeLimited: false, // Has expiresAt or discountEndsAt date
durationInMonths: null, // For repeating coupons - number of months discount applies
duration: null, // Coupon duration type: 'forever', 'once', or 'repeating'
percentOff: null, // Percentage discount (e.g., 50 for 50% off)
amountOff: null, // Fixed amount discount in cents
currency: null // Currency for amount_off (e.g., 'usd')
};
let coupon = null;
let endTimestamp = null;
// Try to get coupon from subscription.discount first (active discounts)
if (subscription.discount && subscription.discount.coupon) {
coupon = subscription.discount.coupon;
endTimestamp = subscription.discount.end;
}
// Fallback: Check latest_invoice.discount for "once" coupons that were already applied
// Stripe removes "once" coupons from subscription after first invoice, but keeps them on invoice
else if (subscription.latest_invoice && typeof subscription.latest_invoice === 'object') {
if (subscription.latest_invoice.discount && subscription.latest_invoice.discount.coupon) {
coupon = subscription.latest_invoice.discount.coupon;
endTimestamp = subscription.latest_invoice.discount.end;
!env.PRODUCTION && debug(`Found coupon from latest_invoice.discount (likely "once" duration): ${coupon.id || coupon}`);
}
}
// Deferred promo: no active discount yet — starts next billing period.
// Stored as flat fields in subscription.metadata by updateAddonWithDeferredPromo.
// Not applicable for canceling subscriptions (cancel_at_period_end:true) — they won't reach next period.
const pendingPromoDetails = (!coupon && !subscription.cancel_at_period_end)
? _buildPendingPromoDetailsFromMeta(subscription.metadata) : null;
if (pendingPromoDetails) {
!env.PRODUCTION && debug(`Found pending deferred promo on sub metadata: ${pendingPromoDetails.discountDisplay}`);
}
if (coupon) {
promoDetails.hasPromo = true;
promoDetails.name = coupon.name || 'Special Promotion';
promoDetails.duration = coupon.duration; // 'forever', 'once', or 'repeating'
// Extract percentage or amount discount values
if (coupon.percent_off !== null && coupon.percent_off !== undefined) {
promoDetails.percentOff = coupon.percent_off;
}
if (coupon.amount_off !== null && coupon.amount_off !== undefined) {
promoDetails.amountOff = coupon.amount_off;
promoDetails.currency = coupon.currency;
}
// Generate display string
promoDetails.discountDisplay = coupon.percent_off === 100
? 'FREE'
: coupon.amount_off
? `$${(coupon.amount_off / 100).toFixed(2)} OFF`
: `${coupon.percent_off}% OFF`;
// Extract duration in months for repeating coupons
if (coupon.duration_in_months) {
promoDetails.durationInMonths = coupon.duration_in_months;
}
// Handle schedule-managed discount end (subscription.discount.end)
// This is set for forever coupons with validUntil managed by SubscriptionSchedules
// For repeating coupons, discount.end is NULL (expires naturally after N cycles)
if (endTimestamp) {
const expiresAt = new Date(endTimestamp * 1000);
const daysRemaining = Math.max(0, Math.ceil((endTimestamp * 1000 - Date.now()) / 86400000));
promoDetails.isTimeLimited = true;
promoDetails.expiresAt = expiresAt.toISOString();
promoDetails.daysRemaining = daysRemaining;
// For schedule-managed, discountEndsAt equals the billing boundary (exclusive start of normal billing).
// The frontend should display this as "through <day before>" rather than "on <this date>".
promoDetails.discountEndsAt = expiresAt.toISOString();
promoDetails.daysUntilDiscountEnds = daysRemaining;
}
// Fallback: Use coupon.redeem_by if no schedule management
// This shows when coupon closes to new subscribers (last date it can be redeemed)
else if (coupon.redeem_by) {
const redeemByTimestamp = coupon.redeem_by * 1000;
const redeemByDate = new Date(redeemByTimestamp);
const daysUntilRedeemBy = Math.max(0, Math.ceil((redeemByTimestamp - Date.now()) / 86400000));
promoDetails.expiresAt = redeemByDate.toISOString();
promoDetails.daysRemaining = daysUntilRedeemBy;
promoDetails.isTimeLimited = true;
// For forever coupons, discountEndsAt equals the billing boundary (redeem_by).
// The frontend should display this as "through <day before>" rather than "on <this date>".
if (coupon.duration === CouponDuration.FOREVER) {
promoDetails.discountEndsAt = redeemByDate.toISOString();
promoDetails.daysUntilDiscountEnds = daysUntilRedeemBy;
}
// For repeating coupons, discountEndsAt will be calculated separately below
}
// Calculate discountEndsAt for repeating coupons (not schedule-managed)
// Discount expires after durationInMonths billing cycles from subscription start
// Use start_date (original creation date), NOT current_period_start (advances each cycle)
if (coupon.duration === CouponDuration.REPEATING && coupon.duration_in_months && (subscription.start_date || subscription.current_period_start)) {
const subscriptionStartDate = moment.unix(subscription.start_date || subscription.current_period_start);
const discountEndDate = subscriptionStartDate.clone().add(coupon.duration_in_months, 'months');
const discountEndTimestamp = discountEndDate.unix();
const daysUntilDiscountEnds = Math.max(0, Math.ceil((discountEndTimestamp * 1000 - Date.now()) / 86400000));
// discountEndsAt is the billing boundary (exclusive). Frontend should display as "through <day before>".
promoDetails.discountEndsAt = discountEndDate.toISOString();
promoDetails.daysUntilDiscountEnds = daysUntilDiscountEnds;
// Mark as time-limited if not already set by schedule
if (!promoDetails.isTimeLimited) {
promoDetails.isTimeLimited = true;
}
}
// For "once" coupons, mark that discount was already applied
if (coupon.duration === CouponDuration.ONCE) {
promoDetails.discountEndsAt = 'applied'; // Special marker indicating it was a one-time discount
promoDetails.isTimeLimited = true;
}
}
// SECURITY: Sanitize subscription to remove discount/coupon data at all levels
const sanitizedSub = sanitizeSubscription(subscription);
return {
...sanitizedSub,
promoDetails,
...(pendingPromoDetails && { pendingPromoDetails })
};
}
/**
* Handle all stripe webhook events
*/
async function stripeWebhooks_post(req, res) {
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;
// Define valid subscription statuses for webhook processing
const validSubStatuses = [SubStatus.ACTIVE, SubStatus.TRIALING];
// 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) {
// Handle global events (not customer-specific) BEFORE customer resolution
if (event.type === Events.COUPON_DELETED) {
await handleCouponDeleted(eventData);
// Log the successfully handled event
await SubEvent.create({ eventId: event.id, type: event.type, createdAt: event.created, handledAt: moment.utc().unix() });
return; // Exit early - no customer context needed
}
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) {
const wasChanged = eventData.metadata?.upgrade_operation;
const wasMigrated = eventData.metadata?.migration_operation;
switch (event.type) {
case Events.CUST_SUB_TRIAL_WILL_END:
await handleTrialWillEnd(eventData, dbCustomer, req);
break;
case Events.CUST_SUB_DELETED:
// Decrement promo usageCount if subscription had a promo applied
if (eventData.metadata?.promoId) {
await decrementPromoUsageCount(eventData.metadata.promoId, 'subscription deleted');
}
dbCustomer = await updateCustSubStatus(eventData, dbCustomer);
// Skip email if this is part of an upgrade/downgrade or migration operation
if (dbCustomer && dbCustomer.membership && !wasChanged && !wasMigrated) {
await emailCurSubcriptions(dbCustomer, dbCustomer.membership?.subscriptions, req);
}
// Update subscription history cache
await updateSubscriptionHistoryOnDelete(eventData, dbCustomer);
break;
case Events.CUST_SUB_CREATED:
// Stamp metadata.type if missing (e.g. subscription created outside the AGM flow)
if (!eventData.metadata?.type) {
const inferredType = inferStripeSubType(eventData);
await stripe.subscriptions.update(eventData.id, { metadata: { type: inferredType } });
eventData.metadata = { ...eventData.metadata, type: inferredType };
debug(`Stamped missing metadata.type='${inferredType}' on subscription ${eventData.id}`);
}
dbCustomer = await updateCustSubStatus(eventData, dbCustomer);
if (dbCustomer) {
// Skip email if this is part of an upgrade/downgrade or migration operation
if (dbCustomer && dbCustomer.membership && validSubStatuses.includes(eventData.status) && !wasChanged && !wasMigrated) {
await emailCurSubcriptions(dbCustomer, dbCustomer.membership?.subscriptions, req);
}
await updateSubBillPeriod(dbCustomer, eventData);
// Update subscription history cache
await updateSubscriptionHistoryOnCreate(eventData, dbCustomer);
}
break;
case Events.CUST_SUB_UPDATED:
// Stamp metadata.type if missing (e.g. subscription updated from outside the AGM flow)
if (!eventData.metadata?.type) {
const inferredType = inferStripeSubType(eventData);
await stripe.subscriptions.update(eventData.id, { metadata: { type: inferredType } });
eventData.metadata = { ...eventData.metadata, type: inferredType };
debug(`Stamped missing metadata.type='${inferredType}' on subscription ${eventData.id}`);
}
dbCustomer = await updateCustSubStatus(eventData, dbCustomer);
if (dbCustomer) {
// For a just activated subscription?
const wasActivated = event.data.previous_attributes.status && validSubStatuses.includes(eventData.status);
const isTrialChanged = event.data.previous_attributes?.trial_end && (event.data.previous_attributes?.trial_end != event.data.object.trial_end);
const hasCurrentPeriod = event.data.previous_attributes.current_period_start && event.data.previous_attributes.current_period_end;
if (wasActivated || isTrialChanged || hasCurrentPeriod) {
await updateSubBillPeriod(dbCustomer, eventData);
await emailCurSubcriptions(dbCustomer, dbCustomer.membership?.subscriptions, req);
}
// Update subscription history cache
await updateSubscriptionHistoryOnUpdate(eventData, dbCustomer);
}
break;
// case Events.INV_PAID:
// const billingReason = event.data.object.billing_reason;
// if (billingReason === BillReasons.SUB_CREATE || billingReason === BillReasons.SUB_CYCLE) {
// }
// 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;
// Subscription Schedule events - for promo coupon expiry
case Events.SUB_SCHEDULE_COMPLETED:
// Schedule completed all phases - promo coupon has ended, normal billing begins
await handleSubscriptionScheduleCompleted(eventData, dbCustomer, req);
break;
case Events.SUB_SCHEDULE_RELEASED:
// Schedule released - subscription continues without schedule management
await handleSubscriptionScheduleReleased(eventData, dbCustomer, req);
break;
case Events.SUB_SCHEDULE_CANCELED:
// Schedule was canceled - log for audit purposes
debug(`Subscription schedule ${eventData.id} was canceled for customer ${eventData.customer}`);
break;
// Note: COUPON_DELETED is handled above, before customer resolution
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;
}
/**
* Handle the customer subscription trial will end event (customer.subscription.trial_will_end) from Stripe.
* 1. Check if the subscription is not set to cancel at period end (still active after trial)
* 2. Verify that the customer have a payment method so we can bill them. And
* 3. 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 userId = user._id;
const custId = sub.customer;
const subType = inferStripeSubType(sub);
const periodStart = sub.current_period_start;
const periodEnd = sub.current_period_end;
const lookupKey = sub.items.data[0].price.lookup_key;
if (userId && custId && subType && periodStart && periodEnd) {
// Use only userId, custId, and subType as filter to find existing billing period
// Update the periodStart, periodEnd, and lookupKey when package changes
const filter = { userId, custId, subType };
const update = {
$set: { periodStart, periodEnd, lookupKey },
$setOnInsert: { userId, custId, subType }
};
await BillPeriod.updateOne(filter, update, { 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(),
business_name: user.name.trim()
}),
...(user?.contact && { individual_name: user.contact.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)
}
));
}
// If user is authenticated and has custom limits, overwrite the matching price by lookup_key
if (req.userInfo && req.userInfo.membership && req.userInfo.membership.customLimits && prices.length > 0) {
const customLimits = req.userInfo.membership.customLimits;
const pkgSub = subUtil.getPkgSubfromUserInfo(req.userInfo, true);
// Get the lookup_key from the user's current package subscription
if (pkgSub && pkgSub.items && pkgSub.items[0] && pkgSub.items[0].price) {
const userPackageLookupKey = pkgSub.items[0].price;
// Find the price that matches the user's package lookup_key
const matchingPrice = prices.find(p => p.lookupKey === userPackageLookupKey);
if (matchingPrice) {
// Overwrite with custom limits if they exist
if (customLimits.maxVehicles !== null && customLimits.maxVehicles !== undefined) {
matchingPrice[SubFields.MAX_VEHICLES] = Number(customLimits.maxVehicles);
}
if (customLimits.maxAcres !== null && customLimits.maxAcres !== undefined) {
matchingPrice[SubFields.MAX_ACRES] = Number(customLimits.maxAcres);
}
}
}
}
res.json(prices);
}
/**
* Get a Stripe coupon by ID or promotion code
* Accepts both coupon IDs and promotion codes (client-facing codes)
* Validates restrictions against authenticated user and optional price keys
*
* @apiParam {String} coupon Coupon ID or promotion code
* @apiQuery {String[]} [priceKeys] Optional price lookup keys (e.g., ['ess_1', 'addon_1'])
*
* @return {object} the Stripe coupon object with detail info or Stripe exception for missing_resources
*/
async function getCoupon_get(req, res) {
const code = req.params.coupon;
// Get authenticated user's customer ID if available
let customerId = null;
if (req.userInfo?.membership?.custId) {
customerId = req.userInfo.membership.custId;
}
// Get optional price keys from query params
let priceKeys = [];
if (req.query.priceKeys) {
// Support both array and comma-separated string
priceKeys = Array.isArray(req.query.priceKeys)
? req.query.priceKeys
: req.query.priceKeys.split(',').map(k => k.trim());
}
debug(`getCoupon_get: code=${code}, customerId=${customerId || 'none'}, priceKeys=${priceKeys.join(',') || 'none'}`);
try {
// Use resolveCouponCode which already handles all validation logic
const resolvedCouponId = await resolveCouponCode(code, customerId, priceKeys);
// Retrieve the full coupon object to return
const coupon = await stripe.coupons.retrieve(resolvedCouponId, { expand: ['applies_to'] });
return res.json(coupon);
} catch (err) {
// resolveCouponCode already throws AppParamError for invalid/restricted coupons
throw err;
}
}
/**
* 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] };
// Sync customer name and contact when updating address
if (customer.name && customer.name.trim()) {
updateStripeOps.name = customer.name.trim();
updateStripeOps.business_name = customer.name.trim();
}
if (customer.contact && customer.contact.trim()) {
updateStripeOps.individual_name = customer.contact.trim();
}
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(), `Invalid package price key: ${input.package}`);
input.addons && !utils.isEmptyArray(input.addons) && (
input.addons.map(it => assert(env.PRICES[it.price], AppParamError.create(), `Invalid addon price key: ${it.price}`))
);
}
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);
}
/**
* Helper to collect price keys from subscription input
* @param {Object} input - Subscription input with package and addons
* @returns {String[]} Array of price keys (e.g., ['ess_1', 'addon_1'])
*/
function _collectPriceKeys(input) {
const priceKeys = [];
if (input.package) priceKeys.push(input.package);
if (input.addons && input.addons.length > 0) {
input.addons.forEach(addon => {
if (addon.price) priceKeys.push(addon.price);
});
}
return priceKeys;
}
/**
* Update addon subscription with deferred promo application
* Changes quantity immediately (no charge/refund) and applies promo from next billing period
* Uses Subscription Schedules with two phases:
* - Phase 1: Current period with new quantity, NO promo
* - Phase 2: Next period onwards with new quantity AND promo
*
* IMPORTANT: Only works for auto-renewing subscriptions (cancel_at_period_end: false)
*
* @param {Object} addonSub - Existing addon subscription (must have cancel_at_period_end: false)
* @param {Number} newQuantity - New addon quantity
* @param {String} couponId - Stripe coupon ID (already resolved from promo code)
* @param {String} custId - Stripe customer ID
* @returns {Object} Created subscription schedule
* @throws {AppParamError} If subscription is set to cancel at period end
*/
async function updateAddonWithDeferredPromo(addonSub, newQuantity, couponId, custId) {
if (!addonSub || !couponId) {
throw new AppParamError(Errors.INVALID_PARAM, 'Addon subscription and coupon required for deferred promo');
}
// Validate subscription is auto-renewing (not set to cancel)
if (addonSub.cancel_at_period_end) {
throw new AppParamError(Errors.INVALID_PARAM, 'Cannot apply deferred promo to subscription set to cancel at period end');
}
const currentPeriodEnd = addonSub.current_period_end;
const addonPriceId = addonSub.items.data[0].price.id;
debug(`Updating addon ${addonSub.id}: qty ${addonSub.items.data[0].quantity}${newQuantity}, promo ${couponId} deferred to next period`);
// Retrieve coupon details once so they can be stored on the subscription metadata
// This lets addPromoDetailsToSubscription build pendingPromoDetails without expanding the schedule
let couponObj = null;
try {
couponObj = await stripe.coupons.retrieve(couponId);
} catch (err) {
debug(`Failed to retrieve coupon ${couponId} for metadata enrichment: ${err.message}`);
}
// Flat string metadata for subscription (Stripe metadata values must be strings)
// pending_coupon_id is the canonical indicator — presence means deferred promo is active
const deferredPromoMeta = {
pending_coupon_id: couponId,
promo_name: couponObj?.name || '',
promo_percent_off: couponObj?.percent_off != null ? String(couponObj.percent_off) : '',
promo_amount_off: couponObj?.amount_off != null ? String(couponObj.amount_off) : '',
promo_currency: couponObj?.currency || '',
promo_duration: couponObj?.duration || '',
promo_duration_in_months: couponObj?.duration_in_months != null ? String(couponObj.duration_in_months) : ''
};
// Check if subscription already has a schedule attached
if (addonSub.schedule) {
// Extract schedule ID (could be string or expanded object)
const scheduleId = typeof addonSub.schedule === 'string'
? addonSub.schedule
: addonSub.schedule.id;
!env.PRODUCTION && debug(`Subscription already has schedule ${scheduleId}, updating existing schedule`);
// Retrieve the existing schedule if we don't have the full object
const existingSchedule = typeof addonSub.schedule === 'object'
? addonSub.schedule
: await stripe.subscriptionSchedules.retrieve(scheduleId);
const updatedSchedule = await stripe.subscriptionSchedules.update(scheduleId, {
proration_behavior: 'none', // CRITICAL: Prevent proration on schedule phase changes
phases: [
{
// Phase 1: Current period - new quantity, NO coupon (or keep existing if already started)
start_date: existingSchedule.current_phase.start_date,
items: [{
price: addonPriceId,
quantity: newQuantity
}],
end_date: currentPeriodEnd
},
{
// Phase 2: Next period onwards - new quantity WITH new coupon
items: [{
price: addonPriceId,
quantity: newQuantity
}],
coupon: couponId
}
],
metadata: {
deferred_promo: 'true',
promo_coupon: couponId,
original_quantity: addonSub.items.data[0].quantity,
new_quantity: newQuantity,
updated_at: Math.floor(Date.now() / 1000)
}
});
// Keep subscription metadata in sync so pendingPromoDetails reflects current coupon
await stripe.subscriptions.update(addonSub.id, {
metadata: { ...addonSub.metadata, ...deferredPromoMeta }
});
debug(`Updated existing schedule ${addonSub.schedule} with new quantity ${newQuantity} and coupon ${couponId}`);
return updatedSchedule;
}
// No existing schedule - create new one
// Step 1: Update quantity immediately with NO proration and NO coupon
const updatedSub = await stripe.subscriptions.update(addonSub.id, {
items: [{
id: addonSub.items.data[0].id,
quantity: newQuantity
}],
proration_behavior: 'none', // No immediate charge/refund
billing_cycle_anchor: 'unchanged', // Keep same billing cycle
metadata: {
...addonSub.metadata,
...deferredPromoMeta // Store promo display info for pendingPromoDetails
}
});
debug(`Updated addon quantity to ${newQuantity}, no proration applied`);
// Step 2: Create SubscriptionSchedule from existing subscription (no phases yet)
// Stripe auto-creates first phase from current subscription state
const initialSchedule = await stripe.subscriptionSchedules.create({
from_subscription: updatedSub.id
});
debug(`Created initial schedule ${initialSchedule.id}, now updating with deferred promo phases`);
// Step 3: Update schedule to add second phase with coupon
const schedule = await stripe.subscriptionSchedules.update(initialSchedule.id, {
proration_behavior: 'none', // CRITICAL: Prevent proration on schedule phase changes
phases: [
{
// Phase 1: Current period - new quantity, NO coupon
start_date: initialSchedule.current_phase.start_date,
items: [{
price: addonPriceId,
quantity: newQuantity
}],
end_date: currentPeriodEnd
},
{
// Phase 2: Next period onwards - new quantity WITH coupon
items: [{
price: addonPriceId,
quantity: newQuantity
}],
coupon: couponId // Promo applies from next period
}
],
metadata: {
deferred_promo: 'true',
promo_coupon: couponId,
original_quantity: addonSub.items.data[0].quantity,
new_quantity: newQuantity
}
});
debug(`Updated subscription schedule ${schedule.id} with deferred promo application`);
return schedule;
}
/**
* Resolve coupon code to coupon ID
* Accepts either a coupon ID or a promotion code (client-facing code)
* Filters out:
* - Expired coupons (past redeem_by date)
* - Coupons at max redemptions (times_redeemed >= max_redemptions)
* - Coupons with customer restrictions (if customer not matching)
* - Promotion codes with first_time_transaction restriction
* - Coupons with product restrictions (if products not matching)
* @param {String} code - Coupon ID or promotion code
* @param {String} [customerId] - Stripe customer ID (for checking customer restrictions)
* @param {String[]} [priceKeys] - Array of price lookup keys being subscribed (e.g., ['ess_1', 'addon_1'])
* @returns {String} Coupon ID to use in subscription
* @throws {AppParamError} If promotion code is invalid, not found, or has restrictions
*/
async function resolveCouponCode(code, customerId = null, priceKeys = []) {
if (!code) return null;
let coupon = null;
let isPromoCode = false;
let promoRestrictions = null;
let promoCode = null; // Store full promotion code object for customer validation
// STEP 1: Resolve what type of code this is (promotion code or coupon ID)
// Try as promotion code first
try {
const promoCodes = await stripe.promotionCodes.list({
code: code,
active: true,
limit: 1,
// NOTE: expand when version later 2025-01-27.acacia, use the new field promotion.coupon
expand: ['data.coupon', 'data.coupon.applies_to', 'data.promotion.coupon', 'data.promotion.coupon.applies_to']
});
if (promoCodes.data && promoCodes.data.length > 0) {
promoCode = promoCodes.data[0];
isPromoCode = true;
promoRestrictions = promoCode.restrictions;
// Get underlying coupon from promotion.coupon field
// The expansion already includes coupon and applies_to, so we can use it directly
// NOTE: when version later 2025-01-27.acacia, use the new field promotion.coupon
coupon = promoCode.coupon || promoCode.promotion?.coupon;
if (!coupon) {
throw new AppParamError(Errors.PROMO_INVALID_COUPON, `Promotion code "${code}" has no coupon`);
}
// If coupon is still a string (expansion didn't work), retrieve explicitly
if (typeof coupon === 'string') {
debug(`Promotion code coupon not expanded (ID: ${coupon}), retrieving explicitly`);
coupon = await stripe.coupons.retrieve(coupon, { expand: ['applies_to'] });
} else {
debug(`Using expanded coupon from promotion code response`);
}
debug(`Resolved "${code}" as promotion code with underlying coupon ${coupon.id}`);
}
} catch (err) {
debug(`Not a promotion code: ${err.message}`);
}
// If not a promotion code, try as direct coupon ID
if (!coupon) {
try {
// First, retrieve the coupon to verify it exists and is valid
debug(`Attempting to retrieve coupon ${code}`);
coupon = await stripe.coupons.retrieve(code, { expand: ['applies_to'] });
if (coupon && coupon.valid) {
debug(`Resolved "${code}" as direct coupon ID`);
// Now check if there's an active promotion code using this coupon
// This allows us to get promotion-level restrictions
try {
const promoCodes = await stripe.promotionCodes.list({
coupon: coupon.id,
active: true,
limit: 1,
expand: ['data.promotion.coupon', 'data.promotion.coupon.applies_to']
});
if (promoCodes.data && promoCodes.data.length > 0) {
promoCode = promoCodes.data[0];
debug(`Found active promotion code "${promoCode.code}" for coupon ${coupon.id}`);
// Use promotion code's expanded coupon data and restrictions
isPromoCode = true;
promoRestrictions = promoCode.restrictions;
// Use expanded coupon from promotion code if available
const promoCoupon = promoCode.promotion?.coupon;
if (promoCoupon && typeof promoCoupon === 'object' && promoCoupon.applies_to) {
coupon = promoCoupon;
debug(`Using expanded coupon from related promotion code`);
} else {
debug(`Using directly retrieved coupon (promo code coupon not fully expanded)`);
}
} else {
debug(`No active promotion code found for coupon ${coupon.id}, using coupon directly`);
}
} catch (err) {
debug(`Error checking for promotion codes: ${err.message}, using coupon directly`);
// Continue with the directly retrieved coupon
}
} else {
coupon = null;
}
} catch (err) {
debug(`Not a valid coupon ID: ${err.message}`);
}
}
// If we couldn't resolve it as either promotion code or coupon ID, throw error
if (!coupon) {
throw new AppParamError(Errors.PROMO_INVALID_COUPON, `Invalid coupon or promotion code: ${code}`);
}
// STEP 2: Validate coupon expiry and redemption limits
// Check if coupon has expired (redeem_by)
if (coupon.redeem_by) {
const redeemByTimestamp = coupon.redeem_by * 1000;
if (Date.now() > redeemByTimestamp) {
const expiredDate = new Date(redeemByTimestamp).toISOString();
!env.IS_PROD && debug(`Coupon expired on ${expiredDate}`);
throw new AppParamError(Errors.PROMO_INVALID_COUPON, `Coupon expired on ${expiredDate}`);
}
}
// Check if coupon has reached max redemptions
if (coupon.max_redemptions && coupon.times_redeemed >= coupon.max_redemptions) {
!env.IS_PROD && debug(`Coupon reached max redemptions: ${coupon.times_redeemed}/${coupon.max_redemptions}`);
throw new AppParamError(Errors.PROMO_INVALID_COUPON, `Coupon has reached maximum redemption limit`);
}
// STEP 3: Validate promotion code and coupon restrictions
// Check promotion code specific restrictions (first_time_transaction)
if (isPromoCode && promoRestrictions?.first_time_transaction === true) {
debug(`Rejected promotion code "${code}": has first_time_transaction restriction`);
throw new AppParamError(
Errors.PROMO_INVALID_COUPON,
`Promotion code "${code}" is restricted to first-time customers only`
);
}
// Check customer restriction (only available in promotion code object, not coupon)
if (isPromoCode && promoCode?.customer && promoCode.customer !== customerId) {
debug(`Rejected promotion code "${code}": restricted to customer ${promoCode.customer}, but got ${customerId}`);
throw new AppParamError(
Errors.PROMO_INVALID_COUPON,
`Promotion code "${code}" is not available for this customer`
);
}
// Check product restrictions
// For promotion codes: check both promo restrictions and underlying coupon
// For direct coupons: check coupon.applies_to only
let productRestrictions = null;
if (isPromoCode && promoRestrictions?.applies_to?.products) {
// Promotion code has its own product restrictions (2026-01-28.clover API)
productRestrictions = promoRestrictions.applies_to.products;
debug(`Found product restrictions on promotion code level: ${productRestrictions.join(', ')}`);
} else if (coupon.applies_to && coupon.applies_to.products) {
// Fallback to underlying coupon's product restrictions
productRestrictions = coupon.applies_to.products;
debug(`Found product restrictions on coupon level: ${productRestrictions.join(', ')}`);
}
if (productRestrictions && productRestrictions.length > 0) {
if (priceKeys && priceKeys.length > 0) {
// Fetch price objects to get their product IDs
const productIds = [];
for (const priceKey of priceKeys) {
const priceId = env.PRICES[priceKey];
if (priceId) {
try {
const price = await stripe.prices.retrieve(priceId);
if (price.product) {
productIds.push(price.product);
}
} catch (err) {
debug(`Failed to retrieve price ${priceKey}: ${err.message}`);
}
}
}
// Check if any of the subscription products are in the allowed products
const hasMatchingProduct = productIds.some(prodId =>
productRestrictions.includes(prodId)
);
if (!hasMatchingProduct) {
const type = isPromoCode ? 'promotion code' : 'coupon';
debug(`Rejected ${type} "${code}": products ${productIds.join(', ')} not in allowed list ${productRestrictions.join(', ')}`);
throw new AppParamError(
Errors.PROMO_INVALID_COUPON,
`${isPromoCode ? 'Promotion code' : 'Coupon'} "${code}" is not applicable to the selected products`
);
}
debug(`${isPromoCode ? 'Promotion code' : 'Coupon'} "${code}" product validation passed: products ${productIds.join(', ')} match allowed list`);
} else {
// No price keys provided - reject codes with product restrictions
const type = isPromoCode ? 'promotion code' : 'coupon';
debug(`Rejected ${type} "${code}": has product restrictions but no priceKeys to validate`);
throw new AppParamError(
Errors.PROMO_INVALID_COUPON,
`${isPromoCode ? 'Promotion code' : 'Coupon'} "${code}" is restricted to specific products only`
);
}
}
// All validations passed
const couponId = coupon.id;
debug(`Resolved ${isPromoCode ? 'promotion code' : 'coupon'} "${code}" to coupon ID: ${couponId} (all validations passed)`);
return couponId;
}
/**
* @api {post} /api/subscription/setupCard Setup Card Authentication
* @apiName SetupCardAuthentication
* @apiGroup Subscription
* @apiDescription Pre-authenticate a payment method using Stripe SetupIntent to verify 3D Secure
* before creating subscriptions. This prevents partial subscription creation when the same card
* is used for both package and addon subscriptions.
*
* **Recommended Flow**:
* 1. Call this endpoint to authenticate the card
* 2. If requiresAction=true, use Stripe.js confirmCardSetup() with clientSecret
* 3. After authentication completes, call /api/subscription/update to create subscriptions
*
* **Benefits**:
* - Single 3DS authentication for multiple subscriptions
* - Prevents partial subscription creation (package succeeds, addon fails)
* - Better user experience with clear authentication step
* - Atomic operations (all subscriptions succeed or none created)
*
* @apiParam {String} custId Stripe customer ID
* @apiParam {String} pmId Payment method ID to authenticate
*
* @apiSuccess {Boolean} requiresAction Whether 3DS authentication is required
* @apiSuccess {String} [clientSecret] SetupIntent client_secret (only if requiresAction=true)
* @apiSuccess {String} [setupIntentId] SetupIntent ID for status checking
* @apiSuccess {String} status SetupIntent status (requires_action, succeeded, etc.)
* @apiSuccess {String} message Success or instruction message
*
* @apiUse NotAuthorizedError
*
* @apiError (409) {Object} error Error object
* @apiError (409) {String} error..tag Error constant value (e.g., "invalid_param")
* @apiError (409) {String} [error.message] Error details (dev mode only)
*
* @apiExample {curl} Example Usage (No 3DS):
* curl -X POST https://localhost:4100/api/subscription/setupCard \
* -H "Authorization: Bearer <token>" \
* -H "Content-Type: application/json" \
* -d '{"custId":"cus_xxx","pmId":"pm_xxx"}'
*
* @apiSuccessExample {json} Success (No 3DS Required):
* HTTP/1.1 200 OK
* {
* "requiresAction": false,
* "status": "succeeded",
* "setupIntentId": "seti_xxx",
* "message": "Card authenticated successfully"
* }
*
* @apiSuccessExample {json} Success (3DS Required):
* HTTP/1.1 200 OK
* {
* "requiresAction": true,
* "clientSecret": "seti_xxx_secret_xxx",
* "setupIntentId": "seti_xxx",
* "status": "requires_action",
* "message": "Card authentication required"
* }
*
* @apiNote This endpoint should be called BEFORE creating subscriptions for cards that may require 3DS
* @apiNote Use Stripe.js confirmCardSetup() on frontend to complete 3DS if requiresAction is true
* @apiNote After successful authentication, proceed with subscription creation
*/
async function setupCardAuthentication_post(req, res) {
const { custId, pmId } = req.body;
// Validate required parameters
if (!custId || !pmId) {
throw new AppParamError(Errors.INVALID_PARAM, 'custId and pmId are required');
}
debug(`setupCardAuthentication: custId=${custId}, pmId=${pmId}`);
try {
// Verify payment method exists and is valid
let paymentMethod;
try {
paymentMethod = await stripe.paymentMethods.retrieve(pmId);
debug(`Retrieved payment method: ${pmId}, customer: ${paymentMethod.customer || 'none'}`);
} catch (err) {
debug(`Failed to retrieve payment method: ${err.message}`);
throw new AppParamError(Errors.INVALID_PAYMENT_METHOD, 'Payment method not found or invalid');
}
// Attach payment method to customer if not already attached
if (!paymentMethod.customer) {
debug(`Attaching payment method ${pmId} to customer ${custId}`);
paymentMethod = await stripe.paymentMethods.attach(pmId, { customer: custId });
} else if (paymentMethod.customer !== custId) {
debug(`Payment method belongs to different customer: ${paymentMethod.customer} vs ${custId}`);
throw new AppParamError(Errors.INVALID_PAYMENT_METHOD, 'Payment method belongs to a different customer');
}
// Create SetupIntent to pre-authenticate the card
// This triggers 3DS if required, without creating a subscription
debug(`Creating SetupIntent for customer ${custId} with payment method ${pmId}`);
const setupIntent = await stripe.setupIntents.create({
customer: custId,
payment_method: pmId,
usage: 'off_session', // Card will be used for future off-session charges
confirm: true, // Immediately attempt to confirm
return_url: `${env.SITE_URL || process.env.APP_URL || 'http://localhost:4100'}/subscription/setup-complete`
});
debug(`SetupIntent created: ${setupIntent.id}, status: ${setupIntent.status}`);
// Check if card requires 3DS authentication
if (setupIntent.status === IntentStatus.REQUIRES_ACTION || setupIntent.status === IntentStatus.REQUIRES_SOURCE_ACTION) {
debug(`Card requires 3DS authentication`);
return res.json({
requiresAction: true,
clientSecret: setupIntent.client_secret,
setupIntentId: setupIntent.id,
status: setupIntent.status,
message: 'Card authentication required'
});
}
// Card authenticated successfully (no 3DS needed)
if (setupIntent.status === 'succeeded') {
debug(`Card authenticated successfully (no 3DS required)`);
return res.json({
requiresAction: false,
status: setupIntent.status,
setupIntentId: setupIntent.id,
message: 'Card authenticated successfully'
});
}
// Handle failure status
if (setupIntent.status === IntentStatus.REQUIRES_PAYMENT_METHOD) {
debug(`Card authentication failed: ${IntentStatus.REQUIRES_PAYMENT_METHOD}`);
throw new AppMembershipError(
Errors.PAYMENT_FAILED,
'Card authentication failed. Please check your payment method and try again.'
);
}
// Other intermediate states (processing, etc.)
debug(`SetupIntent in intermediate state: ${setupIntent.status}`);
return res.json({
requiresAction: false,
status: setupIntent.status,
setupIntentId: setupIntent.id,
message: `Card authentication is ${setupIntent.status}`
});
} catch (error) {
debug(`SetupIntent error: ${error.message}`);
// Handle Stripe API errors
if (error.type === StripeErrorTypes.CARD_ERROR) {
throw new AppMembershipError(Errors.PAYMENT_FAILED, `Card authentication failed: ${error.message}`);
}
if (error.type === StripeErrorTypes.INVALID_REQUEST) {
throw new AppParamError(Errors.INVALID_PARAM, `Invalid request: ${error.message}`);
}
// Re-throw if already an AppError
if (error instanceof AppError || error instanceof AppMembershipError || error instanceof AppParamError) {
throw error;
}
// Generic error
throw new AppError(Errors.UNKNOWN_APP_ERROR, `Failed to setup card authentication: ${error.message}`);
}
}
/**
* @api {post} /api/subscription/update Update Customer Subscriptions
* @apiName UpdateSubscriptions
* @apiGroup Subscription
* @apiDescription Update customer's subscription based on the current selection.
* Creates, modifies, or cancels subscriptions for packages and addons.
*
* **Deferred Promo Application (AUTOMATIC)**:
* For addon quantity changes with 100% FREE promos, the system automatically:
* - Auto-matches eligible promos from settings.subscriptionPromos (based on type, priceKey, eligibility, priority)
* - Detects 100% off coupons (percent_off: 100)
* - Updates quantity immediately with NO charge/refund (proration_behavior: 'none')
* - Applies promo discount starting from NEXT billing period
* - Uses Subscription Schedules with phase management
* - Only for active, auto-renewing subscriptions
*
* Client does NOT need to pass any promo code - backend handles everything automatically.
*
* @apiParam {String} [pmId] Payment method ID (optional when canceling all subscriptions)
* @apiParam {Boolean} [defaultPM] Set as default payment method
* @apiParam {String} [package] Package subscription lookup key (e.g., 'ess_1')
* @apiParam {Object[]} [addons] Array of addon subscriptions
* @apiParam {String} addons.price Addon price lookup key (e.g., 'addon_1')
* @apiParam {Number} [addons.quantity=1] Addon quantity
* @apiParam {Number} [prorateTS] Proration moment timestamp
* @apiParam {String} [coupon] Discount coupon ID or promotion code (client-facing code)
* @apiNote Backend automatically detects 100% off promos and applies them from next period (deferred) for active subscriptions
* @apiParam {Number} [trial] Trial mode flag (1 for trial)
*
* @apiSuccess {Object[]} subscriptions List of Stripe subscription objects
*
* @apiUse NotAuthorizedError
* @apiUse PaymentFailedError
*
* @apiError (409) subscription_not_found No active subscription found
* @apiError (409) invalid_payment_method Payment method is invalid
* @apiError (409) trials_not_enabled Trials are not enabled for this account
* @apiError (409) trials_expired Trial period has expired
*
* @apiExample {json} Deferred Promo Example (Automatic):
* POST /api/subscription/update
* {
* "addons": [{"price": "addon_1", "quantity": 5}]
* }
* // Backend automatically:
* // 1. Auto-matches 100% FREE promo from settings.subscriptionPromos
* // 2. Updates quantity immediately with NO charge (proration_behavior: 'none')
* // 3. Applies promo from NEXT billing period using Subscription Schedules
* // Result: Qty=5 now (no charge/refund), 100% off starts next period
*
* @apiNote May 30th, 2023: Allow optional pmId when unsubscribing all
* @apiNote CRITICAL: SubscriptionSchedules create draft invoices - system automatically finalizes and verifies payment
* @apiNote Feb 17th, 2026: Added deferred promo application for addon quantity changes (100% automatic)
* @apiNote Feb 18th, 2026: Fixed proration issues - added proration_behavior: 'none' to schedule updates
*/
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;
debug(`updateSubscriptions_post called with package: ${input.package}, addons: ${JSON.stringify(input.addons)}`);
_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();
// Wrap in try-catch to handle 3DS authentication requirements
try {
// 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', 'data.discount.coupon', 'data.latest_invoice.discount.coupon', 'data.schedule']
});
if (!utils.isEmptyArray(subscriptions.data) && subscriptions.data.some(sub => [SubStatus.INCOMPLETE, SubStatus.PAST_DUE, SubStatus.UNPAID].includes(sub.status))) {
// SECURITY: Remove discount/coupon data before returning to client and add promoDetails
const sanitizedSubs = subscriptions.data.map(addPromoDetailsToSubscription);
return res.json(sanitizedSubs); // 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);
// Mark ALL subscription changes with upgrade_operation flag to prevent individual webhook emails
// Only the consolidated email at the end will be sent
const subOps = {
automatic_tax: { enabled: isTaxableCountry(customer.country) },
metadata: { upgrade_operation: 'true' }
};
// Resolve coupon/promotion code to coupon ID
let resolvedCouponId = null;
if (input.coupon) {
const priceKeys = _collectPriceKeys(input);
resolvedCouponId = await resolveCouponCode(input.coupon, membership.custId, priceKeys);
if (resolvedCouponId) {
subOps.coupon = resolvedCouponId;
debug(`Using resolved coupon ID: ${resolvedCouponId}`);
}
}
const trials = membership.trials;
if (input.trial && trials) {
// First, check if there are existing trials to preserve during upgrades/downgrades
// Each subscription type (PACKAGE/ADDON) preserves its own trial_end independently
if (!utils.isEmptyArray(subscriptions.data)) {
const trialConfByType = {};
// Check package subscription for existing trial
const pkgTrialSubs = subscriptions.data.filter(it =>
it.metadata && it.metadata[SubFields.TYPE] === SubType.PACKAGE && it.status === SubStatus.TRIALING && it.trial_end
);
if (pkgTrialSubs.length > 0) {
trialConfByType[SubType.PACKAGE] = { trial_end: pkgTrialSubs[0].trial_end, cancel_at_period_end: pkgTrialSubs[0].cancel_at_period_end };
}
// Check addon subscription for existing trial
const addonTrialSubs = subscriptions.data.filter(it =>
it.metadata && it.metadata[SubFields.TYPE] === SubType.ADDON && it.status === SubStatus.TRIALING && it.trial_end
);
if (addonTrialSubs.length > 0) {
trialConfByType[SubType.ADDON] = { trial_end: addonTrialSubs[0].trial_end, cancel_at_period_end: addonTrialSubs[0].cancel_at_period_end };
}
// Inherit trial config from the existing subscription type to the one being created
if (trialConfByType[SubType.PACKAGE] && !trialConfByType[SubType.ADDON]) {
// Package has trial but addon doesn't exist yet - addon should inherit package trial
trialConfByType[SubType.ADDON] = { ...trialConfByType[SubType.PACKAGE] };
} else if (trialConfByType[SubType.ADDON] && !trialConfByType[SubType.PACKAGE]) {
// Addon has trial but package doesn't exist yet - package should inherit addon trial
trialConfByType[SubType.PACKAGE] = { ...trialConfByType[SubType.ADDON] };
}
// Store in subOps for later retrieval in updateSubscription calls
if (Object.keys(trialConfByType).length > 0) {
subOps.trialConfByType = trialConfByType;
}
} else {
// No existing subscriptions - set trial_end based on membership trials config
// Store in trialConfByType so BOTH package and addon subscriptions get the same trial
const newTrialConf = { cancel_at_period_end: true };
if (TrialTypes.BY_DATE === trials.type) {
trials.byDate && (newTrialConf.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 && (newTrialConf.trial_end = endTrialDate.unix());
}
// Apply to both PACKAGE and ADDON subscription types
subOps.trialConfByType = {
[SubType.PACKAGE]: newTrialConf,
[SubType.ADDON]: newTrialConf
};
}
// Set trial_settings to handle missing payment method behavior, this allows trial subscriptions without payment methods
subOps.trial_settings = { end_behavior: { missing_payment_method: 'cancel' } };
}
// !(hasAnySubs && empty input of subscriptions from req) => Requiring pmId
if (!(!utils.isEmptyArray(subscriptions.data) && _isEmptySubsInput(input))) {
// Require pmId only if not in trial mode AND customer doesn't already have a payment method
!(input.trial) && !hasPM && (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);
// Filter out final status subscriptions (canceled, incomplete_expired) - they cannot be updated
const packageSubs = subscriptions.data && subscriptions.data.filter(it =>
it.metadata && it.metadata[SubFields.TYPE] === SubType.PACKAGE && !isFinalSubStatus(it.status)
);
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.
// Filter out final status subscriptions (canceled, incomplete_expired) - they cannot be updated
const addonsSubs = subscriptions.data && subscriptions.data.filter(it =>
it.metadata && it.metadata[SubFields.TYPE] === SubType.ADDON && !isFinalSubStatus(it.status)
);
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 hasChanges = false;
let itemsToUpdate = []; // Items to update/add
let itemsToDelete = []; // Items to remove
// Check existing subscription items
for (let i = 0; i < addonSub.items.data.length; i++) {
const subItem = addonSub.items.data[i];
const inputItem = addonsSet[subItem.price.id];
if (!inputItem || inputItem.quantity === 0) {
// Item not in input or quantity 0 = delete it
itemsToDelete.push({ id: subItem.id, deleted: true });
hasChanges = true;
} else if (inputItem.quantity !== subItem.quantity) {
// Quantity changed = update it
itemsToUpdate.push({
id: subItem.id,
price: subItem.price.id,
quantity: inputItem.quantity
});
hasChanges = true;
}
// Remove from set after processing
if (inputItem) {
delete addonsSet[subItem.price.id];
}
}
// Add any new addon items not previously in subscription
const newItems = Object.values(addonsSet).filter(it => it.quantity > 0);
if (newItems.length > 0) {
itemsToUpdate.push(...newItems);
hasChanges = true;
}
if (hasChanges) {
debug(`Updating addon subscription: ${itemsToUpdate.length} items to update/add, ${itemsToDelete.length} items to delete`);
// Auto-match promo for addon update (if no coupon manually provided)
let autoMatchedCouponId = resolvedCouponId; // Use manually provided coupon if exists
let shouldUseDeferredPromo = false;
if (!resolvedCouponId && env.PROMO_MODE !== PromoModes.DISABLED && itemsToUpdate.length > 0) {
// No manual coupon provided - try to auto-match from subscriptionPromos
const firstItem = itemsToUpdate[0];
const priceId = firstItem.price;
const priceKey = getPriceKeyFromId(priceId);
debug(`Attempting auto-match promo for addon update: type=${SubType.ADDON}, priceKey=${priceKey}`);
const autoPromo = await findMatchingPromo(SubType.ADDON, priceKey, membership.custId);
if (autoPromo && autoPromo.couponId) {
try {
const stripeCoupon = await stripe.coupons.retrieve(autoPromo.couponId);
if (stripeCoupon && !stripeCoupon.deleted) {
autoMatchedCouponId = autoPromo.couponId;
debug(`Auto-matched promo "${autoPromo.name}" with coupon ${autoMatchedCouponId}`);
}
} catch (err) {
debug(`Failed to retrieve auto-matched coupon ${autoPromo.couponId}: ${err.message}`);
}
}
}
// Detect if deferred promo should be used (100% off + active subscription + simple quantity update)
const isSimpleQuantityUpdate = itemsToUpdate.length === 1 &&
itemsToUpdate[0].id &&
itemsToDelete.length === 0;
if (autoMatchedCouponId &&
addonSub.status === SubStatus.ACTIVE &&
!addonSub.cancel_at_period_end &&
isSimpleQuantityUpdate) {
// Retrieve coupon to check if it's 100% off
try {
const coupon = await stripe.coupons.retrieve(autoMatchedCouponId);
if (coupon.percent_off === 100) {
shouldUseDeferredPromo = true;
debug(`Auto-detected 100% off coupon for simple qty update - using deferred promo`);
}
} catch (err) {
debug(`Failed to retrieve coupon ${autoMatchedCouponId}: ${err.message}`);
}
}
if (shouldUseDeferredPromo) {
// Simple quantity update with 100% off promo = deferred pattern
const newQuantity = itemsToUpdate[0].quantity;
debug(`Applying deferred promo: quantity=${newQuantity}, coupon=${autoMatchedCouponId}`);
await updateAddonWithDeferredPromo(addonSub, newQuantity, autoMatchedCouponId, membership.custId);
} else {
// Standard update: Update existing subscription, NO cancel/recreate, NO proration
// CRITICAL: Check if subscription is managed by a schedule
// If yes, release it first (can't update subscription directly when schedule attached)
if (addonSub.schedule) {
// Extract schedule ID (could be string or expanded object)
const scheduleId = typeof addonSub.schedule === 'string'
? addonSub.schedule
: addonSub.schedule.id;
debug(`Subscription ${addonSub.id} has schedule ${scheduleId}, releasing...`);
try {
await stripe.subscriptionSchedules.release(scheduleId);
debug(`Released subscription from schedule ${scheduleId}`);
} catch (releaseErr) {
debug(`Failed to release schedule: ${releaseErr.message}`);
// Try to cancel the schedule instead
try {
await stripe.subscriptionSchedules.cancel(scheduleId);
debug(`Cancelled schedule ${scheduleId} instead`);
} catch (cancelErr) {
debug(`Failed to cancel schedule: ${cancelErr.message}`);
}
}
}
const allItems = [...itemsToUpdate, ...itemsToDelete];
const updateOps = {
items: allItems,
proration_behavior: 'none', // NO proration = NO customer balance credits
billing_cycle_anchor: 'unchanged',
metadata: {
...addonSub.metadata,
...subOps.metadata,
// Clear deferred promo fields (null removes them in Stripe)
pending_coupon_id: null,
promo_name: null,
promo_percent_off: null,
promo_amount_off: null,
promo_currency: null,
promo_duration: null,
promo_duration_in_months: null
}
};
// Apply coupon if auto-matched or manually provided
if (autoMatchedCouponId) {
updateOps.coupon = autoMatchedCouponId;
debug(`Applying coupon ${autoMatchedCouponId} immediately (not 100% off or complex update)`);
}
// Preserve trial configuration if exists
if (subOps.trialConfByType && subOps.trialConfByType[SubType.ADDON]) {
const trialConf = subOps.trialConfByType[SubType.ADDON];
if (trialConf.trial_end) updateOps.trial_end = trialConf.trial_end;
if (trialConf.cancel_at_period_end !== undefined) {
updateOps.cancel_at_period_end = trialConf.cancel_at_period_end;
}
}
debug(`Updating addon ${addonSub.id} directly, no proration, no cancel/recreate`);
await stripe.subscriptions.update(addonSub.id, updateOps);
}
} else {
debug(`No changes detected in addon subscription`);
}
}
} else if (!utils.isEmptyArray(addonsSubs)) {
// None addons selected, go ahead to unsubcribe all addons within the addon subscription
debug(`Cancelling all addon subscriptions`);
await cancelSubscription(addonsSubs[0].id, prorateTS);
}
debug(`Fetching updated subscriptions for customer ${membership.custId}`);
const updatedSubs = await listCustomerSubscriptions(membership.custId, {
expand: ['data.latest_invoice.payment_intent', 'data.discount.coupon', 'data.latest_invoice.discount.coupon', 'data.schedule']
});
debug(`Got updatedSubs: ${!!updatedSubs}, has data: ${!!updatedSubs?.data}, data length: ${updatedSubs?.data?.length}`);
// Send a single consolidated email with the final subscription state after all changes
// Use Stripe data directly since DB may not be updated yet by webhooks
// Convert Stripe format to DB format before passing to email function
const updatedCustomer = await Customer.findOne({ _id: ObjectId(userInfo.puid) });
debug(`Got updatedCustomer: ${!!updatedCustomer}, username: ${updatedCustomer?.username}`);
if (updatedCustomer && updatedSubs && updatedSubs.data && updatedSubs.data.length > 0) {
debug(`Found ${updatedSubs.data.length} total subscriptions from Stripe`);
const convertedSubs = updatedSubs.data
.filter(sub => {
const isFinal = isFinalSubStatus(sub.status);
debug(`Subscription ${sub.id} status: ${sub.status}, isFinal: ${isFinal}`);
return !isFinal;
})
.map(sub => _toMembershipSubscription(sub))
.filter(sub => sub); // Filter out any null results
debug(`After conversion and filtering: ${convertedSubs.length} subscriptions to email`);
if (convertedSubs.length > 0) {
await emailCurSubcriptions(updatedCustomer, convertedSubs, req);
debug(`Email sent successfully to ${updatedCustomer.username}`);
} else {
debug(`No active subscriptions to email about`);
}
}
// SECURITY: Remove discount/coupon data before returning to client
// Also add promoDetails for client-side display
const sanitizedUpdatedSubs = updatedSubs.data.map(addPromoDetailsToSubscription);
res.json(sanitizedUpdatedSubs);
} // Close else block from line 812
} catch (error) {
// Check if this is a 3DS authentication required error
if (error.requires3DS && error.subscriptionData) {
debug(`Caught 3DS authentication requirement, returning client_secret to frontend`);
// SECURITY: Remove discount from 3DS error response and add promoDetails
const sanitizedSubData = addPromoDetailsToSubscription(error.subscriptionData);
return res.json([sanitizedSubData]);
}
// Re-throw any other errors
throw error;
}
}
async function listCustomerSubscriptions(custId, ops) {
let _ops = { customer: custId };
!utils.isEmptyObj(ops) && (_ops = Object.assign({}, _ops, ops));
return await stripe.subscriptions.list(_ops);
}
// ========== Subscription Promo Helpers ==========
/**
* Throw a 3DS authentication required error with subscription data
* This error will be caught by the route handler and returned as JSON response
* @param {Object} subscription - Stripe subscription object
* @param {Object} pi - Stripe payment intent object
* @throws {Error} Custom error with requires3DS flag and subscriptionData
*/
function throw3DSError(subscription, pi) {
// Guard: Validate required parameters
if (utils.isEmptyObj(subscription)) {
throw new AppInputError('throw3DSError: subscription parameter is required');
}
if (utils.isEmptyObj(pi)) {
throw new AppInputError('throw3DSError: payment intent parameter is required');
}
if (!pi.client_secret) {
throw new AppInputError('throw3DSError: payment intent missing client_secret');
}
const error = new Error('3DS_AUTHENTICATION_REQUIRED');
error.requires3DS = true;
error.subscriptionData = {
id: subscription?.id,
customer: subscription?.customer,
status: subscription?.status,
items: subscription?.items,
current_period_start: subscription?.current_period_start,
current_period_end: subscription?.current_period_end,
requires_action: true,
client_secret: pi?.client_secret,
payment_intent_id: pi?.id
};
throw error;
}
/**
* Get price lookup key from Stripe price ID
* @param {string} priceId - Stripe price ID
* @returns {string|null} Price lookup key (e.g., 'addon_1', 'ess_1')
*/
function getPriceKeyFromId(priceId) {
if (!priceId) return null;
const priceMap = {};
// Build reverse map from env.PRICES
if (env.PRICES) {
for (const [key, id] of Object.entries(env.PRICES)) {
if (id) priceMap[id] = key;
}
}
return priceMap[priceId] || null;
}
/**
* Find matching promo for subscription type and price
* Priority: exact match (type + priceKey) > type-only match > catch-all
* Within each match level, sort by: eligibility-qualified > priority (high>low) > createdAt (old>new)
* @param {string} type - Subscription type ('package' or 'addon')
* @param {string} priceKey - Price lookup key (e.g., 'addon_1', 'ess_1')
* @param {string} custId - Optional Stripe customer ID for eligibility filtering
* @returns {object|null} Matching promo or null
*/
async function findMatchingPromo(type, priceKey, custId = null) {
try {
const globalSettings = await Settings.findOne({ userId: null }).lean();
const promos = globalSettings?.subscriptionPromos || [];
if (promos.length === 0) return null;
const now = moment.utc();
// Filter to enabled promos: either future validUntil OR has durationInMonths (repeating coupons)
const activePromos = promos.filter(p => {
if (!p.enabled) return false;
// If validUntil is set, it takes precedence as the expiry gate:
// - Past validUntil → promo is EXPIRED, exclude it even if durationInMonths > 0
// - Future validUntil → include
if (p.validUntil) {
return moment.utc(p.validUntil).isAfter(now);
}
// No validUntil: include only if it has durationInMonths (repeating coupon with no hard expiry)
if (p.durationInMonths && p.durationInMonths > 0) {
return true;
}
return false;
});
if (activePromos.length === 0) return null;
// Debug: Log all active promos with their type/priceKey values
!env.PRODUCTION && debug(`Active promos for matching (type: ${type}, priceKey: ${priceKey}):`);
!env.PRODUCTION && activePromos.forEach(p => {
debug(` - "${p.name}": type=${p.type}, priceKey=${p.priceKey}, priority=${p.priority || 0}, eligibility=${p.eligibility || 'all'}`);
});
// Collect all matches with their match level
const matches = [];
// Priority 1: Exact match (type + priceKey)
activePromos.forEach(p => {
if (p.type === type && p.priceKey === priceKey) {
!env.PRODUCTION && debug(` ✓ Exact match: "${p.name}"`);
matches.push({ ...p, matchLevel: 1 });
}
});
// Priority 2: Type-only match (type specified, no priceKey)
activePromos.forEach(p => {
if (p.type === type && !p.priceKey) {
!env.PRODUCTION && debug(` ✓ Type-only match: "${p.name}"`);
matches.push({ ...p, matchLevel: 2 });
}
});
// Priority 3: Catch-all (no type, no priceKey)
activePromos.forEach(p => {
if (!p.type && !p.priceKey) {
!env.PRODUCTION && debug(` ✓ Catch-all match: "${p.name}"`);
matches.push({ ...p, matchLevel: 3 });
}
});
if (matches.length === 0) {
!env.PRODUCTION && debug(`No matching promos found for ${type}/${priceKey}`);
return null;
}
// Filter by eligibility if custId provided
let eligibleMatches = matches;
if (custId) {
eligibleMatches = [];
for (const match of matches) {
const isEligible = await checkPromoEligibility(match, custId, type);
if (isEligible) {
eligibleMatches.push(match);
}
}
if (eligibleMatches.length === 0) {
!env.PRODUCTION && debug(`No eligible promos found for ${type}/${priceKey} (customer: ${custId})`);
return null;
}
}
// Sort by: 1) matchLevel (exact>type>catchall), 2) priority (high>low), 3) createdAt (old>new)
eligibleMatches.sort((a, b) => {
// First: Match specificity (lower number = more specific)
if (a.matchLevel !== b.matchLevel) {
return a.matchLevel - b.matchLevel;
}
// Second: Priority (higher number = higher priority)
const priorityA = a.priority || 0;
const priorityB = b.priority || 0;
if (priorityA !== priorityB) {
return priorityB - priorityA; // Descending
}
// Third: Creation date (earlier = higher priority for stability)
const dateA = a.createdAt ? new Date(a.createdAt).getTime() : 0;
const dateB = b.createdAt ? new Date(b.createdAt).getTime() : 0;
return dateA - dateB; // Ascending
});
const selectedPromo = eligibleMatches[0];
!env.PRODUCTION && debug(`Found promo match: ${selectedPromo.name} for ${type}/${priceKey} (matchLevel: ${selectedPromo.matchLevel}, priority: ${selectedPromo.priority || 0}, eligibility: ${selectedPromo.eligibility || 'all'}, chainable: ${selectedPromo.chainable || false})`);
return selectedPromo;
} catch (err) {
debug('Error finding matching promo:', err);
return null;
}
}
/**
* Look up a promo by its ID from subscription metadata.
* Handles case where promo may have been deleted after subscription was created.
*
* @param {string} promoId - The promo _id from subscription metadata
* @returns {Object|null} The promo object or null if not found/deleted
* - If found: returns the promo with all current fields
* - If deleted: returns { _id: promoId, deleted: true, name: 'Deleted Promotion' }
*/
async function findPromoById(promoId) {
if (!promoId) return null;
try {
const globalSettings = await Settings.findOne({ userId: null }).lean();
const promos = globalSettings?.subscriptionPromos || [];
// MongoDB ObjectId comparison - convert to string for safety
const promo = promos.find(p => p._id?.toString() === promoId);
if (promo) {
return promo;
}
// Promo was deleted - return a placeholder object
debug(`Promo ${promoId} not found - may have been deleted`);
return {
_id: promoId,
deleted: true,
name: 'Deleted Promotion',
nameKey: 'PROMO_DELETED',
descriptionKey: 'PROMO_DELETED_DESC'
};
} catch (err) {
debug('Error finding promo by ID:', err);
return null;
}
}
/**
* Decrement the usageCount for a promo when a subscription using it is deleted
* or when the promo period expires.
*
* @param {String} promoId - The MongoDB _id of the promo
* @param {String} reason - Reason for decrement (for logging)
*/
async function decrementPromoUsageCount(promoId, reason) {
if (!promoId) return;
try {
const result = await Settings.updateOne(
{
userId: null,
'subscriptionPromos._id': promoId,
'subscriptionPromos.usageCount': { $gt: 0 } // Only decrement if > 0
},
{ $inc: { 'subscriptionPromos.$.usageCount': -1 } }
);
if (result.modifiedCount > 0) {
!env.PRODUCTION && debug(`Decremented usageCount for promo ${promoId} (${reason})`);
} else {
!env.PRODUCTION && debug(`Did not decrement usageCount for promo ${promoId} - either not found or already at 0 (${reason})`);
}
} catch (err) {
// Non-critical - log but don't fail the operation
debug(`Failed to decrement promo usageCount for ${promoId}:`, err.message);
}
}
/**
* Check if customer has subscription history (uses local DB cache)
* Fast lookup via SubscriptionHistory collection for eligibility checks
*
* @param {String} custId - Stripe customer ID
* @param {String} type - Subscription type ('package' or 'addon')
* @param {String} priceKey - Price key (e.g., 'ess_1') - optional for type-level check
* @returns {Boolean} true if customer has history for this type/price
*/
async function hasSubscriptionHistory(custId, type, priceKey = null) {
try {
const SubscriptionHistory = require('../model/subscription_history');
const query = { custId, type };
if (priceKey) query.priceKey = priceKey;
const history = await SubscriptionHistory.findOne(query).lean();
if (history) {
!env.PRODUCTION && debug(`Found subscription history in cache: ${type}/${priceKey || 'any'}, first: ${history.firstSubscribedAt}, total: ${history.totalSubscriptions}`);
return true;
}
!env.PRODUCTION && debug(`No subscription history in cache for ${type}/${priceKey || 'any'}`);
return false;
} catch (err) {
debug(`Error checking subscription history: ${err.message}`);
// Fail open - allow promo on error to avoid blocking customers
return false;
}
}
/**
* Check if customer is eligible for promo based on eligibility setting
*
* @param {Object} promo - Promo object with eligibility field
* @param {String} custId - Stripe customer ID
* @param {String} type - Subscription type ('package' or 'addon')
* @returns {Boolean} true if customer is eligible for the promo
*/
async function checkPromoEligibility(promo, custId, type) {
const eligibility = promo.eligibility || PromoEligibility.ALL;
if (eligibility === PromoEligibility.ALL) {
return true;
}
// Check history cache
const hasHistory = await hasSubscriptionHistory(custId, type, promo.priceKey);
if (eligibility === PromoEligibility.NEW_ONLY) {
const eligible = !hasHistory;
!env.PRODUCTION && debug(`Promo "${promo.name}" eligibility check (new_only): ${eligible ? 'ELIGIBLE' : 'NOT ELIGIBLE'} (hasHistory: ${hasHistory})`);
return eligible;
}
if (eligibility === PromoEligibility.RENEW_ONLY) {
const eligible = hasHistory;
!env.PRODUCTION && debug(`Promo "${promo.name}" eligibility check (renew_only): ${eligible ? 'ELIGIBLE' : 'NOT ELIGIBLE'} (hasHistory: ${hasHistory})`);
return eligible;
}
return true;
}
/**
* Update subscription history cache when subscription is created
* Called from webhook: customer.subscription.created
*
* @param {Object} subscription - Stripe subscription object
* @param {Object} dbCustomer - Customer document from MongoDB
*/
async function updateSubscriptionHistoryOnCreate(subscription, dbCustomer) {
try {
if (!subscription || !dbCustomer) return;
const stripeCustId = dbCustomer.membership?.custId;
if (!stripeCustId) return;
const priceKey = getPriceKeyFromSubscription(subscription);
const type = subscription.metadata?.type ||
(priceKey ? (priceKey.startsWith('addon_') ? SubType.ADDON : SubType.PACKAGE) : null);
if (!type || !priceKey) {
debug(`Cannot update subscription history: missing type or priceKey`);
return;
}
const now = new Date();
await SubscriptionHistory.findOneAndUpdate(
{ custId: stripeCustId, type, priceKey },
{
$setOnInsert: { firstSubscribedAt: now },
$set: {
lastSubscribedAt: now,
currentSubscriptionId: subscription.id,
lastSubscriptionStatus: subscription.status,
lastSyncedAt: now
},
$inc: { totalSubscriptions: 1 }
},
{ upsert: true, new: true }
);
!env.PRODUCTION && debug(`Updated subscription history on create: ${type}/${priceKey} for customer ${stripeCustId}`);
} catch (err) {
debug(`Error updating subscription history on create: ${err.message}`);
// Non-critical - don't throw, just log
}
}
/**
* Update subscription history cache when subscription is updated
* Called from webhook: customer.subscription.updated
*
* @param {Object} subscription - Stripe subscription object
* @param {Object} dbCustomer - Customer document from MongoDB
*/
async function updateSubscriptionHistoryOnUpdate(subscription, dbCustomer) {
try {
if (!subscription || !dbCustomer) return;
const stripeCustId = dbCustomer.membership?.custId;
if (!stripeCustId) return;
const priceKey = getPriceKeyFromSubscription(subscription);
const type = subscription.metadata?.type ||
(priceKey ? (priceKey.startsWith('addon_') ? SubType.ADDON : SubType.PACKAGE) : null);
if (!type || !priceKey) return;
const now = new Date();
// Only update if status changed to something final (e.g., canceled, incomplete_expired)
// Keep currentSubscriptionId if subscription still active
const updateFields = {
lastSyncedAt: now,
lastSubscriptionStatus: subscription.status
};
if (subscription.status === SubStatus.CANCELED || subscription.status === SubStatus.INCOMPLETE_EXPIRED) {
// Clear current subscription if it matches
const history = await SubscriptionHistory.findOne({
custId: stripeCustId,
type,
priceKey,
currentSubscriptionId: subscription.id
});
if (history) {
updateFields.currentSubscriptionId = null;
debug(`Cleared currentSubscriptionId for ${type}/${priceKey} due to status: ${subscription.status}`);
}
}
await SubscriptionHistory.findOneAndUpdate(
{ custId: stripeCustId, type, priceKey },
{ $set: updateFields },
{ new: true }
);
!env.PRODUCTION && debug(`Updated subscription history on update: ${type}/${priceKey}`);
} catch (err) {
debug(`Error updating subscription history on update: ${err.message}`);
}
}
/**
* Update subscription history cache when subscription is deleted
* Called from webhook: customer.subscription.deleted
*
* @param {Object} subscription - Stripe subscription object
* @param {Object} dbCustomer - Customer document from MongoDB
*/
async function updateSubscriptionHistoryOnDelete(subscription, dbCustomer) {
try {
if (!subscription || !dbCustomer) return;
const stripeCustId = dbCustomer.membership?.custId;
if (!stripeCustId) return;
const priceKey = getPriceKeyFromSubscription(subscription);
const type = subscription.metadata?.type ||
(priceKey ? (priceKey.startsWith('addon_') ? SubType.ADDON : SubType.PACKAGE) : null);
if (!type || !priceKey) return;
const now = new Date();
// Clear current subscription if it matches the deleted one
await SubscriptionHistory.findOneAndUpdate(
{
custId: stripeCustId,
type,
priceKey,
currentSubscriptionId: subscription.id
},
{
$set: {
currentSubscriptionId: null,
lastSyncedAt: now
}
}
);
!env.PRODUCTION && debug(`Updated subscription history on delete: ${type}/${priceKey}`);
} catch (err) {
debug(`Error updating subscription history on delete: ${err.message}`);
}
}
/**
* Infer the subscription type ('package' or 'addon') from a Stripe subscription object.
* Prefers the explicit value stored in metadata.type; falls back to the price lookup_key
* prefix so that subscriptions created outside the normal AGM sign-up flow (e.g. directly
* from the Stripe dashboard or a migration script) are still typed correctly.
*
* @param {Object} sub - Stripe subscription object
* @returns {String} SubType value
*/
function inferStripeSubType(sub) {
if (sub.metadata?.type) return sub.metadata.type;
const lookupKey = sub.items?.data?.[0]?.price?.lookup_key ?? '';
return lookupKey.startsWith('addon_') ? SubType.ADDON : SubType.PACKAGE;
}
/**
* Helper: Extract priceKey from subscription
* @param {Object} subscription - Stripe subscription object
* @returns {String|null} Price key or null
*/
function getPriceKeyFromSubscription(subscription) {
try {
if (!subscription.items || !subscription.items.data || !subscription.items.data[0]) {
return null;
}
const price = subscription.items.data[0].price;
// Use Stripe lookup_key directly when available (canonical, no env mapping needed)
if (price.lookup_key) return price.lookup_key;
// Fallback: reverse-map price ID via centralised PRICE_MAP
return env.PRICE_MAP[price.id] || null;
} catch (err) {
debug(`Error extracting priceKey from subscription: ${err.message}`);
return null;
}
}
/**
* Update all subscriptions using a promo to set new validUntil date via SubscriptionSchedule.
* For subscriptions with existing schedules: update phase end_date
* For subscriptions without schedules: skip (user has released schedule)
*
* Only processes ACTIVE subscriptions - trialing subscriptions are excluded because:
* - They haven't reached their first billing date yet
* - The schedule will be evaluated when trial ends and first billing occurs
* - Updating during trial is unnecessary and adds complexity
*
* Uses Stripe search API with metadata query for efficient retrieval.
*
* @param {string} promoId - The promo _id to find subscriptions
* @param {Date} newValidUntil - New expiry date for the promo discount
* @returns {Object} Result with counts of updated subscriptions
*/
async function updatePromoSubscriptionSchedules(promoId, newDiscountEndsAt) {
if (!promoId || !newDiscountEndsAt) return { updated: 0, skipped: 0, errors: [] };
// Ensure promoId is a string for comparison
const promoIdStr = promoId.toString();
const validUntilTs = moment.utc(newDiscountEndsAt).unix();
const results = { updated: 0, skipped: 0, errors: [] };
debug(`updatePromoSubscriptionSchedules called with promoId: ${promoIdStr}, discountEndsAt: ${newDiscountEndsAt}`);
try {
// Use Stripe search API for efficient querying by metadata
// Search for subscriptions with matching promoId in metadata
// Only search active subscriptions - trialing subscriptions are skipped because:
// 1. They haven't hit their first billing date yet, so validUntil hasn't been evaluated
// 2. The schedule will be properly created/updated when trial ends and first billing occurs
// 3. Updating schedules during trial adds unnecessary complexity
const query = `metadata['promoId']:'${promoIdStr}' AND status:'active'`;
debug(`Searching subscriptions with query: ${query}`);
const subscriptions = [];
for await (const sub of stripe.subscriptions.search({ query })) {
subscriptions.push(sub);
debug(`Found subscription ${sub.id} with promoId: ${sub.metadata?.promoId}`);
}
debug(`Found ${subscriptions.length} active subscriptions using promo ${promoIdStr}`);
for (const sub of subscriptions) {
try {
// Use scheduleId from metadata if available (optimization)
const scheduleId = sub.metadata?.scheduleId || sub.schedule;
if (scheduleId) {
// Subscription has a schedule - update the coupon phase end date
// NOTE: This updates when the COUPON DISCOUNT ends, NOT when the subscription ends
// The subscription end is controlled by user's cancel_at_period_end preference
// If schedule was released (user set cancel_at_period_end=true), skip it
const schedule = await stripe.subscriptionSchedules.retrieve(scheduleId);
if (schedule.status !== 'active') {
// Schedule was released or completed - subscription is no longer managed by schedule
// User controls it directly, don't update
debug(`Schedule ${schedule.id} status is ${schedule.status}, skipping (user may have set cancel_at_period_end)`);
results.skipped++;
continue;
}
if (schedule.phases?.length > 0) {
debug(`Schedule ${schedule.id} has end_behavior: ${schedule.end_behavior}, phases: ${schedule.phases.length}`);
// We always use 2-phase schedules now, update the coupon phase end_date
const currentPhase = schedule.phases.find(p => p.start_date <= Math.floor(Date.now() / 1000) && p.end_date > Math.floor(Date.now() / 1000));
if (currentPhase) {
const updatedPhases = schedule.phases.map((phase, idx) => {
const items = phase.items.map(item => ({
price: item.price,
quantity: item.quantity
}));
if (idx === 0) {
// First phase (with coupon) - update end_date when coupon expires
return {
items,
coupon: phase.coupon,
start_date: phase.start_date, // Required to anchor end_date
end_date: validUntilTs,
proration_behavior: 'none',
metadata: phase.metadata
};
} else {
// Second phase (no coupon) - continues with normal billing
// start_date auto-calculated from previous phase end_date
return {
items,
proration_behavior: 'none',
metadata: phase.metadata
};
}
});
await stripe.subscriptionSchedules.update(schedule.id, {
phases: updatedPhases
});
// Sync subscription metadata: new discountEndsAt + clear stale promoReminderSentAt.
// Setting promoReminderSentAt to '' removes it from Stripe metadata, so the cron
// sends a fresh reminder as the new deadline approaches.
const subIdToUpdate = typeof schedule.subscription === 'string'
? schedule.subscription
: schedule.subscription?.id || sub.id;
await stripe.subscriptions.update(subIdToUpdate, {
metadata: { discountEndsAt: String(validUntilTs), promoReminderSentAt: '' }
});
debug(`Updated schedule ${schedule.id} for subscription ${sub.id}, coupon phase end_date: ${validUntilTs}`);
results.updated++;
} else {
debug(`No current phase found for schedule ${schedule.id}, may be in second phase already`);
results.skipped++;
}
} else {
debug(`Schedule ${schedule.id} has no phases, skipping`);
results.skipped++;
}
} else {
// No schedule means user released it (set cancel_at_period_end=true)
// Don't create a new schedule - respect user's choice
debug(`Subscription ${sub.id} has no active schedule, skipping (user may have set cancel_at_period_end)`);
results.skipped++;
}
} catch (subErr) {
debug(`Error updating subscription ${sub.id}:`, subErr.message);
results.errors.push({ subscriptionId: sub.id, error: subErr.message });
}
}
} catch (err) {
debug('Error in updatePromoSubscriptionSchedules:', err);
results.errors.push({ error: err.message });
}
debug(`updatePromoSubscriptionSchedules results:`, results);
return results;
}
/**
* Handle payment verification for subscription creation
* Checks payment intent status and handles 3DS authentication requirements
*
* @param {Object} subscription - Stripe subscription object with expanded latest_invoice.payment_intent
* @throws {Error} Custom 3DS error with requires3DS flag if authentication needed
* @throws {AppMembershipError} If payment failed
*/
async function handleSubscriptionPayment(subscription) {
if (!subscription.latest_invoice) {
debug(`Subscription ${subscription.id} has no latest_invoice, skipping payment verification`);
return;
}
const invoice = typeof subscription.latest_invoice === 'string'
? await stripe.invoices.retrieve(subscription.latest_invoice, { expand: ['payment_intent'] })
: subscription.latest_invoice;
debug(`Invoice ${invoice.id} status: ${invoice.status}, amount_due: ${invoice.amount_due}`);
// Skip payment verification for $0 invoices (100% discount, free trials)
if (invoice.amount_due === 0) {
debug(`Invoice ${invoice.id} has amount_due = 0, skipping payment verification`);
return;
}
// If invoice is open with amount due, check payment intent status
if ((invoice.status === 'open' || invoice.status === 'draft') && invoice.amount_due > 0) {
let pi = invoice.payment_intent
? (typeof invoice.payment_intent === 'string'
? await stripe.paymentIntents.retrieve(invoice.payment_intent)
: invoice.payment_intent)
: null;
if (!pi) {
debug(`No payment intent found for invoice ${invoice.id}`);
return;
}
let piStatus = pi.status;
debug(`Payment intent ${pi.id} initial status: ${piStatus}`);
// Check if payment requires customer action (3DS)
if (piStatus === IntentStatus.REQUIRES_ACTION) {
debug(`Payment requires customer action (3DS) for subscription ${subscription.id}`);
// Note: This is expected even after SetupIntent - the FIRST payment still requires 3DS
// SetupIntent authenticates for FUTURE off-session payments, not the initial on-session payment
throw3DSError(subscription, pi);
}
// If payment intent requires confirmation, confirm it
// Note: For 3DS cards, confirmation will result in requires_action status
if (piStatus === IntentStatus.REQUIRES_CONFIRMATION) {
debug(`Confirming payment intent ${pi.id}...`);
try {
pi = await stripe.paymentIntents.confirm(pi.id, {
return_url: `${process.env.APP_URL}/payment-complete`
});
piStatus = pi.status;
debug(`Payment intent confirmed, new status: ${piStatus}`);
// Check again if confirmation resulted in requires_action (3DS)
if (piStatus === IntentStatus.REQUIRES_ACTION) {
debug(`Payment confirmation requires customer action (3DS) for subscription ${subscription.id}`);
throw3DSError(subscription, pi);
}
} catch (confirmErr) {
debug(`Payment intent confirmation error: ${confirmErr.message}`);
// If this is our custom 3DS error, re-throw it
if (confirmErr.requires3DS) {
throw confirmErr;
}
// Re-fetch payment intent to check actual status
try {
pi = await stripe.paymentIntents.retrieve(pi.id);
piStatus = pi.status;
debug(`Re-fetched payment intent status: ${piStatus}`);
if (piStatus === IntentStatus.REQUIRES_ACTION) {
debug(`Payment intent requires customer action (3DS) for subscription ${subscription.id}`);
throw3DSError(subscription, pi);
}
} catch (retrieveErr) {
if (retrieveErr.requires3DS) {
throw retrieveErr;
}
piStatus = IntentStatus.REQUIRES_PAYMENT_METHOD;
}
}
}
// Fail only on actual payment failures
if (piStatus === IntentStatus.REQUIRES_PAYMENT_METHOD) {
debug(`Payment failed for subscription ${subscription.id} (status: ${piStatus}), canceling`);
// Void the invoice if still open
try {
if (invoice.status === 'open') {
await stripe.invoices.voidInvoice(invoice.id);
debug(`Voided invoice ${invoice.id}`);
}
} catch (voidErr) {
debug(`Failed to void invoice ${invoice.id}: ${voidErr.message}`);
}
// Cancel the subscription
try {
await stripe.subscriptions.del(subscription.id);
debug(`Deleted subscription ${subscription.id}`);
} catch (delErr) {
if (delErr.code !== 'resource_missing') {
debug(`Failed to delete subscription ${subscription.id}: ${delErr.message}`);
}
}
throw new AppMembershipError(Errors.PAYMENT_FAILED,
'Payment failed. Please update your payment method and try again.');
}
debug(`Payment intent status ${piStatus} - allowing subscription to proceed`);
}
}
async function createSubscription(custId, items, type, subOps) {
if (utils.isEmptyArray(items) || !custId) return null;
let params = {
metadata: { type: type },
cancel_at_period_end: true, // Always set to true by default, can be overridden by subOps
expand: ['latest_invoice.payment_intent'],
// CRITICAL: Ensure failed payments result in incomplete subscriptions, not active ones
// This prevents subscriptions from appearing "active" when payment actually failed
// Particularly important with partial discount coupons where invoice > $0
payment_behavior: 'error_if_incomplete'
};
if (!utils.isEmptyObj(subOps)) {
// Exclude trialConfByType from being sent to Stripe (it's only for internal tracking)
const { trialConfByType, ...stripeSubOps } = subOps;
params = Object.assign({}, params, stripeSubOps);
// Extract trial configuration for this specific subscription type if preserved from existing subscription
if (trialConfByType && trialConfByType[type]) {
const trialConf = trialConfByType[type];
if (trialConf.trial_end) params.trial_end = trialConf.trial_end;
if (trialConf.cancel_at_period_end !== undefined) params.cancel_at_period_end = trialConf.cancel_at_period_end;
}
// Ensure type is preserved in metadata by merging metadata objects
if (subOps.metadata) {
params.metadata = { ...params.metadata, ...subOps.metadata, type: type };
}
}
// Check for matching promo and apply using SubscriptionSchedule if found (only if no coupon already set and PROMO_MODE allows)
let appliedPromo = null;
const promoMode = env.PROMO_MODE;
if (!params.coupon && promoMode !== PromoModes.DISABLED) {
const priceId = items[0]?.price;
const priceKey = getPriceKeyFromId(priceId);
// Pass custId for eligibility filtering
const promo = await findMatchingPromo(type, priceKey, custId);
if (promo && promo.couponId) {
// CRITICAL: Validate coupon before applying
try {
const stripeCoupon = await stripe.coupons.retrieve(promo.couponId);
if (!stripeCoupon || stripeCoupon.deleted) {
debug(`ERROR: Promo "${promo.name}" references deleted coupon ${promo.couponId} - skipping`);
// Don't throw error, just skip this promo
} else if (!Object.values(CouponDuration).includes(stripeCoupon.duration) || stripeCoupon.duration === CouponDuration.ONCE) {
debug(`ERROR: Promo "${promo.name}" uses coupon ${promo.couponId} with unsupported duration '${stripeCoupon.duration}' - skipping`);
// Don't throw error, just skip this promo
} else {
// Promo already filtered by eligibility in findMatchingPromo
appliedPromo = promo;
// NEW: Flag if this is a repeating coupon (no schedule needed)
appliedPromo.isRepeating = stripeCoupon.duration === CouponDuration.REPEATING;
// Store promoId in metadata for tracking
params.metadata.promoId = promo._id.toString();
debug(`Will apply promo "${promo.name}" (id: ${promo._id}) with ${stripeCoupon.duration} coupon ${promo.couponId} (pre-filtered by eligibility)`);
}
} catch (stripeErr) {
debug(`ERROR: Failed to validate coupon ${promo.couponId} for promo "${promo.name}": ${stripeErr.message} - skipping`);
// Don't throw error, just skip this promo
}
}
}
params.customer = custId;
params.items = items;
let subscription;
// Check if trial_end extends beyond promo validUntil - if so, skip SubscriptionSchedule
// because the customer should continue their trial, not start billing at validUntil
// Priority: check package subscription's trial_end first, then addon's trial_end
let effectiveTrialEndTs = null;
if (subOps?.trialConfByType) {
// Check package trial first (takes precedence)
const pkgTrialEnd = subOps.trialConfByType[SubType.PACKAGE]?.trial_end;
const addonTrialEnd = subOps.trialConfByType[SubType.ADDON]?.trial_end;
if (pkgTrialEnd) {
effectiveTrialEndTs = typeof pkgTrialEnd === 'number' ? pkgTrialEnd : moment.utc(pkgTrialEnd).unix();
debug(`Using package trial_end (${effectiveTrialEndTs}) for schedule decision`);
} else if (addonTrialEnd) {
effectiveTrialEndTs = typeof addonTrialEnd === 'number' ? addonTrialEnd : moment.utc(addonTrialEnd).unix();
debug(`Using addon trial_end (${effectiveTrialEndTs}) for schedule decision`);
}
}
// Fallback to params.trial_end if trialConfByType not available
if (!effectiveTrialEndTs && params.trial_end) {
effectiveTrialEndTs = typeof params.trial_end === 'number' ? params.trial_end : moment.utc(params.trial_end).unix();
}
// discountEndsAt controls when the applied coupon expires (schedule phase end_date).
// validUntil controls the eligibility window (last date a customer can apply this promo).
// When discountEndsAt is not set explicitly, fall back to validUntil for backward compatibility.
const discountEndDate = appliedPromo?.discountEndsAt || appliedPromo?.validUntil;
const validUntilTs = discountEndDate ? moment.utc(discountEndDate).unix() : null;
const skipScheduleDueToTrial = effectiveTrialEndTs && validUntilTs && effectiveTrialEndTs > validUntilTs;
if (skipScheduleDueToTrial) {
debug(`Effective trial end (${effectiveTrialEndTs}) exceeds promo discount end (${validUntilTs}), skipping SubscriptionSchedule - trial takes precedence`);
}
// If promo with a discount end date, use SubscriptionSchedule for automatic coupon removal.
// discountEndsAt (or validUntil fallback) is the phase end date; validUntil is the eligibility cutoff.
// Repeating coupons are excluded: Stripe auto-removes the discount after duration_in_months billing cycles;
// no schedule is needed, and end_date is computed per-subscriber as start_date + durationInMonths.
const nowTs = Math.floor(Date.now() / 1000);
if (appliedPromo && !appliedPromo.isRepeating && discountEndDate && validUntilTs && validUntilTs > nowTs && !skipScheduleDueToTrial) {
debug(`Creating subscription with SubscriptionSchedule for promo discount end at ${discountEndDate}`);
// Build phase items from subscription items
const phaseItems = items.map(item => ({
price: item.price,
quantity: item.quantity || 1
}));
// ALWAYS use 2-phase schedule with end_behavior: 'release'
// Phase 1: With coupon (promo period)
// Phase 2: Without coupon (normal billing after promo ends)
//
// The schedule controls COUPON DURATION, not subscription lifecycle.
// User can toggle cancel_at_period_end independently via setSubsSettings,
// which will release the schedule if they want to cancel.
const scheduleParams = {
customer: custId,
start_date: 'now',
end_behavior: 'release', // Always release - let user control cancel via setSubsSettings
metadata: params.metadata,
phases: [
{
items: phaseItems,
coupon: appliedPromo.couponId,
end_date: validUntilTs,
proration_behavior: 'none',
metadata: params.metadata
},
{
items: phaseItems,
// No coupon - normal billing resumes after promo
proration_behavior: 'none',
metadata: params.metadata
}
]
};
// Add trial if configured - at this point we know trial_end <= validUntil (or no trial)
if (params.trial_end && effectiveTrialEndTs) {
scheduleParams.phases[0].trial_end = effectiveTrialEndTs;
}
// Add automatic_tax if configured (apply to all phases, only pass enabled field)
if (params.automatic_tax?.enabled) {
scheduleParams.phases.forEach(phase => {
phase.automatic_tax = { enabled: true };
});
}
// Add default_payment_method if configured
if (params.default_payment_method) {
scheduleParams.default_settings = {
default_payment_method: params.default_payment_method
};
}
const schedule = await stripe.subscriptionSchedules.create(scheduleParams);
// Retrieve the subscription created by the schedule and store scheduleId in metadata
subscription = await stripe.subscriptions.retrieve(schedule.subscription, {
expand: ['latest_invoice.payment_intent']
});
// CRITICAL: SubscriptionSchedules create invoices in DRAFT status without payment collection
// We must manually finalize and pay the invoice to enforce payment requirements
if (subscription.latest_invoice) {
const invoice = typeof subscription.latest_invoice === 'string'
? await stripe.invoices.retrieve(subscription.latest_invoice, { expand: ['payment_intent'] })
: subscription.latest_invoice;
debug(`Invoice ${invoice.id} status: ${invoice.status}, amount_due: ${invoice.amount_due}`);
// CRITICAL: Skip payment verification for $0 invoices (100% discount, free promos)
// These invoices are automatically paid and have no payment intent
if (invoice.amount_due === 0) {
debug(`Invoice ${invoice.id} has amount_due = 0 (free/100% discount), skipping payment verification`);
// Continue with normal flow - no payment verification needed
}
// If invoice is draft AND has amount due, finalize it to trigger payment collection
else if (invoice.status === 'draft' && invoice.amount_due > 0) {
debug(`Finalizing draft invoice ${invoice.id} for subscription ${subscription.id}`);
let finalizedInvoice;
try {
finalizedInvoice = await stripe.invoices.finalizeInvoice(invoice.id);
debug(`Invoice finalized: ${finalizedInvoice.id}, status: ${finalizedInvoice.status}`);
} catch (finalizeErr) {
// If finalization fails (e.g., no payment method), cancel and throw
debug(`Invoice finalization failed: ${finalizeErr.message}`);
// Try to delete subscription - ignore if already deleted by Stripe
try {
await stripe.subscriptions.del(subscription.id);
debug(`Deleted subscription ${subscription.id} after finalization failure`);
} catch (delErr) {
// Ignore "resource_missing" errors - subscription already gone (goal achieved)
if (delErr.code !== 'resource_missing') {
debug(`Failed to delete subscription ${subscription.id}: ${delErr.message}`);
}
}
throw new AppMembershipError(Errors.PAYMENT_FAILED,
finalizeErr.message || 'Payment failed. Please add a valid payment method.');
}
// After finalization, check if payment succeeded or failed
if (finalizedInvoice.status === 'open' || finalizedInvoice.payment_intent) {
let pi = finalizedInvoice.payment_intent
? (typeof finalizedInvoice.payment_intent === 'string'
? await stripe.paymentIntents.retrieve(finalizedInvoice.payment_intent)
: finalizedInvoice.payment_intent)
: null;
let piStatus = pi?.status;
debug(`Payment intent initial status: ${piStatus}`);
// CRITICAL: Check if payment requires customer action (3D Secure)
// If so, return client_secret to frontend for client-side authentication
if (piStatus === IntentStatus.REQUIRES_ACTION && pi) {
debug(`Payment requires customer action (3DS) for subscription ${subscription.id}`);
throw3DSError(subscription, pi);
}
// CRITICAL FIX: If payment intent requires_confirmation, we must confirm it
// to trigger the actual payment attempt. Without confirmation, a failed card
// will stay in requires_confirmation state forever.
//
// IMPORTANT: For 3DS cards, Stripe will throw an error during server-side confirmation
// because it requires customer interaction. We need to handle this gracefully.
if (piStatus === IntentStatus.REQUIRES_CONFIRMATION && pi) {
debug(`Confirming payment intent ${pi.id} to trigger payment attempt...`);
try {
pi = await stripe.paymentIntents.confirm(pi.id, {
// Allow Stripe to return requires_action instead of throwing error
return_url: `${process.env.APP_URL}/payment-complete`
});
piStatus = pi.status;
debug(`Payment intent confirmed, new status: ${piStatus}`);
// CRITICAL: Check again if confirmation resulted in requires_action (3DS)
if (piStatus === IntentStatus.REQUIRES_ACTION) {
debug(`Payment confirmation requires customer action (3DS) for subscription ${subscription.id}`);
throw3DSError(subscription, pi);
}
} catch (confirmErr) {
debug(`Payment intent confirmation error: ${confirmErr.message}`);
// CRITICAL: If this is our custom 3DS error, re-throw it immediately
if (confirmErr.requires3DS) {
throw confirmErr;
}
// CRITICAL: After ANY confirmation error, check the actual payment intent status
// For 3DS cards, Stripe throws "Your card was declined" but the intent status is requires_action
debug(`Re-fetching payment intent to check actual status after confirmation error...`);
try {
pi = await stripe.paymentIntents.retrieve(pi.id);
piStatus = pi.status;
debug(`Re-fetched payment intent status: ${piStatus}`);
// If the payment intent requires customer action (3DS), return client_secret
if (piStatus === IntentStatus.REQUIRES_ACTION) {
debug(`Payment intent requires customer action (3DS) for subscription ${subscription.id}`);
throw3DSError(subscription, pi);
}
// If status is requires_payment_method or other failure status, continue to error handling below
debug(`Payment intent status after error: ${piStatus} - treating as payment failure`);
} catch (retrieveErr) {
debug(`Failed to retrieve payment intent after error: ${retrieveErr.message}`);
// CRITICAL: If this is our custom 3DS error, re-throw it
if (retrieveErr.requires3DS) {
throw retrieveErr;
}
// If we can't retrieve the intent, treat as payment failure
piStatus = IntentStatus.REQUIRES_PAYMENT_METHOD;
}
}
}
// CRITICAL: Only fail on actual payment failures
// - requires_payment_method: Payment failed, needs valid payment method
// - requires_action: Requires customer authentication (3D Secure) - OK, not a failure
// - succeeded: Payment succeeded
// - processing: Payment is being processed
if (piStatus === IntentStatus.REQUIRES_PAYMENT_METHOD) {
debug(`Payment failed for subscription ${subscription.id} (pi status: ${piStatus}), canceling`);
// CRITICAL: Void the failed invoice before deleting subscription
// This prevents incomplete invoices from accumulating in Stripe
try {
if (finalizedInvoice.status === 'open') {
const voidedInvoice = await stripe.invoices.voidInvoice(finalizedInvoice.id);
debug(`Voided invoice ${finalizedInvoice.id} after payment failure`);
}
} catch (voidErr) {
debug(`Failed to void invoice ${finalizedInvoice.id}: ${voidErr.message}`);
// Continue with subscription deletion even if void fails
}
// Cancel the subscription
try {
await stripe.subscriptions.del(subscription.id);
debug(`Deleted subscription ${subscription.id} after payment failure`);
} catch (delErr) {
if (delErr.code !== 'resource_missing') {
debug(`Failed to delete subscription ${subscription.id}: ${delErr.message}`);
}
}
// Throw error to frontend/client
throw new AppMembershipError(Errors.PAYMENT_FAILED,
'Payment failed. Please update your payment method and try again.');
}
}
} else if (invoice.status === 'open' && invoice.amount_due > 0) {
// Invoice is open with amount due - check payment intent status
// Don't automatically assume failure - check actual payment intent status
let pi = invoice.payment_intent
? (typeof invoice.payment_intent === 'string'
? await stripe.paymentIntents.retrieve(invoice.payment_intent)
: invoice.payment_intent)
: null;
let piStatus = pi?.status;
debug(`Invoice ${invoice.id} is open, payment intent initial status: ${piStatus}`);
// CRITICAL: Check if payment requires customer action (3D Secure)
if (piStatus === IntentStatus.REQUIRES_ACTION && pi) {
debug(`Payment requires customer action (3DS) for open invoice ${invoice.id}`);
throw3DSError(subscription, pi);
}
// CRITICAL FIX: If payment intent requires_confirmation, confirm it to trigger payment attempt
if (piStatus === IntentStatus.REQUIRES_CONFIRMATION && pi) {
debug(`Confirming payment intent ${pi.id} for open invoice...`);
try {
pi = await stripe.paymentIntents.confirm(pi.id, {
// Allow Stripe to return requires_action instead of throwing error
return_url: `${process.env.APP_URL}/payment-complete`
});
piStatus = pi.status;
debug(`Payment intent confirmed, new status: ${piStatus}`);
// CRITICAL: Check again if confirmation resulted in requires_action (3DS)
if (piStatus === IntentStatus.REQUIRES_ACTION) {
debug(`Payment confirmation requires customer action (3DS) for open invoice ${invoice.id}`);
throw3DSError(subscription, pi);
}
} catch (confirmErr) {
debug(`Payment intent confirmation error: ${confirmErr.message}`);
// CRITICAL: If this is our custom 3DS error, re-throw it immediately
if (confirmErr.requires3DS) {
throw confirmErr;
}
// CRITICAL: After ANY confirmation error, check the actual payment intent status
// For 3DS cards, Stripe throws "Your card was declined" but the intent status is requires_action
debug(`Re-fetching payment intent to check actual status after confirmation error...`);
try {
pi = await stripe.paymentIntents.retrieve(pi.id);
piStatus = pi.status;
debug(`Re-fetched payment intent status: ${piStatus}`);
// If the payment intent requires customer action (3DS), return client_secret
if (piStatus === IntentStatus.REQUIRES_ACTION) {
debug(`Payment intent requires customer action (3DS) for open invoice ${invoice.id}`);
throw3DSError(subscription, pi);
}
// If status is requires_payment_method or other failure status, continue to error handling below
debug(`Payment intent status after error: ${piStatus} - treating as payment failure`);
} catch (retrieveErr) {
debug(`Failed to retrieve payment intent after error: ${retrieveErr.message}`);
// CRITICAL: If this is our custom 3DS error, re-throw it
if (retrieveErr.requires3DS) {
throw retrieveErr;
}
// If we can't retrieve the intent, treat as payment failure
piStatus = IntentStatus.REQUIRES_PAYMENT_METHOD;
}
}
}
// Only fail if payment intent explicitly indicates failure
if (piStatus === IntentStatus.REQUIRES_PAYMENT_METHOD) {
debug(`Payment failed for open invoice ${invoice.id}, canceling subscription`);
// Try to delete subscription - ignore if already deleted
try {
await stripe.subscriptions.del(subscription.id);
debug(`Deleted subscription ${subscription.id} for failed payment`);
} catch (delErr) {
if (delErr.code !== 'resource_missing') {
debug(`Failed to delete subscription ${subscription.id}: ${delErr.message}`);
}
}
throw new AppMembershipError(Errors.PAYMENT_FAILED,
'Payment failed. Please update your payment method and try again.');
}
// For other statuses (processing, requires_action, succeeded),
// let the subscription proceed - payment may succeed or user can complete action
debug(`Invoice ${invoice.id} is open but payment status is ${piStatus} - allowing subscription to proceed`);
}
}
// Check if default is cancel_at_period_end: true
// If so, release the schedule immediately so user can cancel at their billing period
// The coupon is already applied to the subscription from the schedule creation
const shouldCancelAtEnd = params.cancel_at_period_end;
if (shouldCancelAtEnd) {
// Release the schedule - subscription keeps the coupon but is no longer schedule-managed
// This allows cancel_at_period_end to work normally
debug(`Releasing schedule ${schedule.id} for cancel_at_period_end=true default`);
await stripe.subscriptionSchedules.release(schedule.id);
// Set cancel_at_period_end on the subscription
await stripe.subscriptions.update(subscription.id, {
cancel_at_period_end: true,
// scheduleId cleared since schedule was released; discountEndsAt captures when the discount expires
// (independent of later changes to the promo definition's discountEndsAt/validUntil)
metadata: { ...subscription.metadata, promoId: params.metadata.promoId, scheduleId: '', discountEndsAt: String(validUntilTs) }
});
subscription.cancel_at_period_end = true;
debug(`Released schedule ${schedule.id}, subscription ${subscription.id} has cancel_at_period_end=true with coupon applied`);
} else {
// Keep schedule active - store scheduleId and discountEndsAt in subscription metadata.
// discountEndsAt records when THIS subscription's discount expires (snapshot of the phase
// end_date at creation time), decoupled from future edits to the promo definition.
await stripe.subscriptions.update(subscription.id, {
metadata: { ...subscription.metadata, scheduleId: schedule.id, discountEndsAt: String(validUntilTs) }
});
subscription.metadata.scheduleId = schedule.id;
debug(`Created SubscriptionSchedule ${schedule.id} with subscription ${subscription.id}, end_behavior: release`);
}
} else if (appliedPromo && appliedPromo.isRepeating && !skipScheduleDueToTrial) {
// NEW: Repeating coupon - apply directly, NO SCHEDULE NEEDED
// Stripe automatically removes the discount after duration_in_months
params.coupon = appliedPromo.couponId;
debug(`Creating subscription with repeating coupon (${appliedPromo.durationInMonths} months):`, JSON.stringify(params, null, 2));
subscription = await stripe.subscriptions.create({
...params,
expand: ['latest_invoice.payment_intent'],
payment_behavior: 'default_incomplete'
});
// Handle 3DS for repeating coupon subscriptions
await handleSubscriptionPayment(subscription);
// Store discountEndsAt snapshot in metadata: subscription.start_date + durationInMonths.
// This is per-subscriber and must not change when admin edits the promo's validUntil.
if (appliedPromo.durationInMonths && (subscription.start_date || subscription.current_period_start)) {
const startTs = subscription.start_date || subscription.current_period_start;
const repeatingDiscountEndsAtTs = moment.unix(startTs).utc().add(appliedPromo.durationInMonths, 'months').unix();
await stripe.subscriptions.update(subscription.id, {
metadata: { ...subscription.metadata, discountEndsAt: String(repeatingDiscountEndsAtTs) }
});
subscription.metadata.discountEndsAt = String(repeatingDiscountEndsAtTs);
debug(`Repeating coupon: stored discountEndsAt=${repeatingDiscountEndsAtTs} (start=${startTs} + ${appliedPromo.durationInMonths} months)`);
}
} else if (appliedPromo && !skipScheduleDueToTrial) {
// Promo without validUntil - use direct coupon (forever duration)
params.coupon = appliedPromo.couponId;
debug(`Creating subscription with forever coupon (no validUntil):`, JSON.stringify(params, null, 2));
subscription = await stripe.subscriptions.create({
...params,
expand: ['latest_invoice.payment_intent'],
payment_behavior: 'default_incomplete'
});
// Handle 3DS for direct coupon subscriptions
await handleSubscriptionPayment(subscription);
} else {
// No promo OR trial extends beyond promo validUntil - create regular subscription without coupon
if (skipScheduleDueToTrial) {
debug(`Skipping promo coupon - trial extends beyond validUntil, creating regular subscription`);
// Remove promoId from metadata since promo is not applied
delete params.metadata.promoId;
appliedPromo = null; // Clear so usageCount is not incremented
}
debug(`Creating subscription with params:`, JSON.stringify(params, null, 2));
subscription = await stripe.subscriptions.create({
...params,
expand: ['latest_invoice.payment_intent'],
payment_behavior: 'default_incomplete'
});
// Handle 3DS for regular subscriptions
await handleSubscriptionPayment(subscription);
}
// Increment promo usage count after successful subscription creation
if (appliedPromo) {
try {
await Settings.updateOne(
{ userId: null, 'subscriptionPromos._id': appliedPromo._id },
{ $inc: { 'subscriptionPromos.$.usageCount': 1 } }
);
debug(`Incremented usageCount for promo ${appliedPromo._id}`);
} catch (err) {
// Non-critical - log but don't fail subscription creation
debug('Failed to increment promo usageCount:', err.message);
}
}
return subscription;
}
async function updateSubscription(subscription, updatedItems, subOps, type, prorateTS) {
// Ensure metadata includes both type and any existing flags (like upgrade_operation)
const upgradeSubOps = {
...subOps,
metadata: { ...subOps?.metadata, type: type }
};
// Preserve trial configuration for this specific subscription type during upgrade/downgrade
// Check if we have stored trial config by type (from existing subscriptions)
if (subOps.trialConfByType && subOps.trialConfByType[type]) {
const trialConf = subOps.trialConfByType[type];
if (trialConf.trial_end) upgradeSubOps.trial_end = trialConf.trial_end;
if (trialConf.cancel_at_period_end !== undefined) upgradeSubOps.cancel_at_period_end = trialConf.cancel_at_period_end;
} else if (subscription.status === SubStatus.TRIALING && subscription.trial_end) {
// Fallback: If trialConfByType wasn't set but the subscription being updated is in trial,
// preserve its trial_end and cancel_at_period_end directly
upgradeSubOps.trial_end = subscription.trial_end;
if (subscription.cancel_at_period_end !== undefined) {
upgradeSubOps.cancel_at_period_end = subscription.cancel_at_period_end;
}
}
await cancelSubscription(subscription.id, prorateTS);
if (!utils.isEmptyArray(updatedItems)) {
const newSub = await createSubscription(subscription.customer, updatedItems, type, upgradeSubOps);
return newSub;
}
}
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 || utils.isEmptyArray(subs)) 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
// Expects subs in DB format (converted from Stripe format using _toMembershipSubscription)
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
*
* Response includes enhanced promo details for easier client-side display:
* - promoDetails.hasPromo: boolean indicating if promotion is active
* - promoDetails.name: promotion display name
* - promoDetails.discountDisplay: formatted discount string (e.g., "FREE" or "50% OFF")
* - promoDetails.expiresAt: ISO 8601 date string when promo expires (time-limited only)
* - promoDetails.daysRemaining: days until promo expires (time-limited only)
* - promoDetails.durationInMonths: number of months for repeating coupons (null if forever)
* - promoDetails.duration: coupon duration type ('forever', 'once', or 'repeating')
* - promoDetails.percentOff: percentage discount value (e.g., 50 for 50% off)
* - promoDetails.amountOff: fixed amount discount in cents
* - promoDetails.currency: currency for amount_off (e.g., 'usd')
*
* For deferred promo (100% off applied from next period via Subscription Schedule):
* - pendingPromoDetails (present only when deferred promo is active, absent otherwise).
* Has the same shape as promoDetails so clients use identical rendering logic:
* - isPending: true
* - appliesToNextPeriod: true
* - name, discountDisplay, percentOff, amountOff, currency
* - duration, durationInMonths
* - expiresAt, discountEndsAt, daysRemaining, daysUntilDiscountEnds, isTimeLimited
* Detection: presence of pending_coupon_id in subscription.metadata (written by updateAddonWithDeferredPromo).
* Note: promoDetails.hasPromo will be false while pendingPromoDetails is present
* (discount hasn't started yet). When next period begins the schedule activates
* the coupon, promoDetails.hasPromo flips to true and pendingPromoDetails disappears.
*/
async function getCustSubscriptions_get(req, res) {
const input = req.query;
assert(input && input.custId, AppParamError.create());
const ops = input.status ? { status: input.status } : {};
// Build expansion list
const expansions = ['data.discount.coupon', 'data.latest_invoice.discount.coupon'];
if (input.billInfo) {
expansions.push('data.latest_invoice.payment_intent');
}
ops.expand = expansions;
const subscriptions = await listCustomerSubscriptions(input.custId, ops);
// If customer has custom limits, overwrite the package subscription's price metadata
const dbCustomer = await findApplicatorByCustId(input.custId);
if (dbCustomer && dbCustomer.membership && dbCustomer.membership.customLimits && subscriptions.data.length > 0) {
// Find the package subscription (type = 'package')
const packageSub = subscriptions.data.find(sub =>
sub.metadata && sub.metadata[SubFields.TYPE] === SubType.PACKAGE
);
if (packageSub && packageSub.items && packageSub.items.data && packageSub.items.data.length > 0) {
const priceItem = packageSub.items.data[0];
if (priceItem && priceItem.price) {
if (!priceItem.price.metadata) {
priceItem.price.metadata = {};
}
const customLimits = dbCustomer.membership.customLimits;
if (customLimits.maxVehicles !== null && customLimits.maxVehicles !== undefined) {
priceItem.price.metadata[SubFields.MAX_VEHICLES] = String(customLimits.maxVehicles);
}
if (customLimits.maxAcres !== null && customLimits.maxAcres !== undefined) {
priceItem.price.metadata[SubFields.MAX_ACRES] = String(customLimits.maxAcres);
}
}
}
}
// Add parsed promo details for each subscription for easier client-side display
// SECURITY: Remove raw discount/coupon data to avoid leaking sensitive coupon IDs
const enhancedSubscriptions = subscriptions.data.map(addPromoDetailsToSubscription);
res.json(enhancedSubscriptions);
}
/**
* 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 ID or promotion code>
*
* **Deferred Promo Preview (AUTOMATIC)**:
* When backend auto-detects 100% off promo for active subscription, returns TWO invoice objects:
* - Invoice 1 (period_type: 'current'): Immediate change with proration_behavior:'none' ($0 charge/refund)
* - Invoice 2 (period_type: 'next'): Next billing period with 100% FREE promo applied
*
* Both invoices include:
* - next_billing_date: Unix timestamp — present on ALL invoice objects returned
* - period_type:'current' → addonSub.current_period_end
* - period_type:'next' → nextPeriodInvoice.period_start
* - standard addon → invoice.period_end ?? addonSub.current_period_end
* - package invoice → invoice.period_end ?? packageSub.current_period_end
* - pendingPromoDetails: full promoDetails-shaped object (same fields, isPending:true)
* Injected on all invoices for the subscription when a deferred promo is active,
* including calls where no quantity change is requested (detected via pending_coupon_id
* stored in subscription.metadata by updateAddonWithDeferredPromo).
*
* discount/coupon fields are always stripped server-side (sanitizeInvoice).
*
* 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);
// Resolve coupon/promotion code to coupon ID if provided
// OR auto-match from subscriptionPromos if not provided
let resolvedCouponId = null;
if (input.coupon) {
const priceKeys = _collectPriceKeys(input);
resolvedCouponId = await resolveCouponCode(input.coupon, input.custId, priceKeys);
debug(`Resolved coupon for invoice preview: ${resolvedCouponId}`);
} else if (env.PROMO_MODE !== PromoModes.DISABLED) {
// Auto-match promo for invoice preview (same as subscription update)
// Try addon first (most common case for invoice preview)
if (!utils.isEmptyArray(input.addons) && input.addons[0] && input.addons[0].price) {
const addonPriceKey = input.addons[0].price;
debug(`Attempting auto-match promo for invoice preview: type=${SubType.ADDON}, priceKey=${addonPriceKey}`);
const autoPromo = await findMatchingPromo(SubType.ADDON, addonPriceKey, input.custId);
if (autoPromo && autoPromo.couponId) {
try {
const stripeCoupon = await stripe.coupons.retrieve(autoPromo.couponId);
if (stripeCoupon && !stripeCoupon.deleted) {
resolvedCouponId = autoPromo.couponId;
debug(`Auto-matched promo "${autoPromo.name}" for invoice preview with coupon ${resolvedCouponId}`);
}
} catch (err) {
debug(`Failed to retrieve auto-matched coupon ${autoPromo.couponId}: ${err.message}`);
}
}
}
}
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] });
}
}
// Filter out final status subscriptions (canceled, incomplete_expired) for invoice preview
const addonsSubs = subscriptions.data && subscriptions.data.filter(it =>
it.metadata && it.metadata[SubFields.TYPE] === SubType.ADDON && !isFinalSubStatus(it.status)
);
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) {
if (item.price === subItem.price.id) {
// Same price, quantity change only - just update the quantity in-place
// Using delete+add with 'deleted: false' is invalid; Stripe ignores it → returns old quantity
adoSubItems.push({ id: subItem.id, quantity: item.quantity });
} else {
// Price change - delete old item, add new one (no 'deleted: false' on new item)
adoSubItems.push(
{ id: subItem.id, deleted: true },
{ price: item.price, quantity: item.quantity }
);
}
} 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 with promotion application
if (packageSub || !utils.isEmptyArray(pkgSubItems)) {
const pkgInvoices = await retreiveNextInvoices(input.custId, pkgSubItems, packageSub, input.prorateTS, resolvedCouponId, SubType.PACKAGE);
if (pkgInvoices[0]) {
pkgInvoices[0].next_billing_date = pkgInvoices[0].period_end ?? packageSub?.current_period_end ?? null;
}
invoices = invoices.concat(pkgInvoices);
}
// Automatically detect if deferred promo should be used for invoice preview
// Deferred promo is used when:
// 1. Coupon is provided
// 2. Subscription exists, is active, and auto-renewing
// 3. Coupon is 100% off (to avoid immediate refunds)
let isDeferredAddonPromo = false;
let deferredCoupon = null;
if (resolvedCouponId &&
addonSub &&
!utils.isEmptyArray(adoSubItems) &&
addonSub.status === SubStatus.ACTIVE &&
!addonSub.cancel_at_period_end) {
// Retrieve coupon to check if it's 100% off
try {
const coupon = await stripe.coupons.retrieve(resolvedCouponId);
if (coupon.percent_off === 100) {
isDeferredAddonPromo = true;
deferredCoupon = coupon; // Kept for building pendingPromoDetails in the response
debug(`Auto-detected 100% off coupon for invoice preview - showing deferred promo structure`);
}
} catch (err) {
debug(`Failed to retrieve coupon ${resolvedCouponId}: ${err.message}`);
}
}
if (addonSub || !utils.isEmptyArray(adoSubItems)) {
if (isDeferredAddonPromo) {
// For deferred promo: show two invoices (immediate and next period)
// Preview WITHOUT coupon for immediate changes (current period)
const immediateInvoice = await retreiveNextInvoices(
input.custId,
adoSubItems,
addonSub,
input.prorateTS,
null, // No coupon applied immediately
SubType.ADDON
);
// Preview WITH coupon for next billing period
// Get the price and quantity from adoSubItems
const addonItem = adoSubItems[0];
const addonQuantity = addonItem.quantity || 1;
// addonItem may only have { id, quantity } for quantity-only changes — fall back to existing price
const addonPriceId = addonItem.price || addonSub.items.data[0].price.id;
const nextPeriodInvoice = await stripe.invoices.retrieveUpcoming({
customer: input.custId,
subscription: addonSub.id,
subscription_items: [{
id: addonSub.items.data[0].id,
price: addonPriceId,
quantity: addonQuantity
}],
coupon: resolvedCouponId,
expand: ['lines.data.price', 'discount.coupon']
});
// Add markers to distinguish invoice types
if (immediateInvoice[0]) {
immediateInvoice[0].period_type = 'current';
immediateInvoice[0].has_promo = false;
// next_billing_date = when the promo period begins (and next payment is charged)
immediateInvoice[0].next_billing_date = addonSub.current_period_end;
}
// Build pendingPromoDetails from the coupon we already retrieved (reuse shared helper shape)
const pendingPromoDetails = deferredCoupon ? _buildPendingPromoDetailsFromCoupon(deferredCoupon) : null;
// Note: promo_coupon (raw coupon ID) intentionally omitted - coupon info is sanitized out
const nextInvoiceObj = {
...nextPeriodInvoice,
period_type: 'next',
has_promo: true,
// next_billing_date = period_start of next invoice = when this promo charge is collected
next_billing_date: nextPeriodInvoice.period_start,
...(pendingPromoDetails && { pendingPromoDetails })
};
invoices = invoices.concat(immediateInvoice);
invoices.push(nextInvoiceObj);
} else {
// Standard invoice preview with immediate promo application
const standardInvoices = await retreiveNextInvoices(input.custId, adoSubItems, addonSub, input.prorateTS, resolvedCouponId, SubType.ADDON);
// Add next_billing_date convenience field: period_end = when next billing cycle starts
if (standardInvoices[0]) {
standardInvoices[0].next_billing_date = standardInvoices[0].period_end ?? addonSub?.current_period_end ?? null;
}
invoices = invoices.concat(standardInvoices);
}
}
// If addonSub already has a deferred promo set up (from a previous update call),
// inject pendingPromoDetails into any addon invoice that doesn't already have it.
// Detection: read pending_coupon_id stored in subscription metadata by updateAddonWithDeferredPromo.
// Skip for canceling subscriptions — they won't reach the next period where promo applies.
let existingDeferredPromo = null;
if (addonSub && !addonSub.cancel_at_period_end && addonSub.metadata && addonSub.metadata.pending_coupon_id) {
try {
const pendingCoupon = await stripe.coupons.retrieve(addonSub.metadata.pending_coupon_id);
existingDeferredPromo = _buildPendingPromoDetailsFromCoupon(pendingCoupon);
debug(`Built pendingPromoDetails from pending_coupon_id ${addonSub.metadata.pending_coupon_id}`);
} catch (err) {
debug(`Failed to retrieve pending coupon ${addonSub.metadata.pending_coupon_id}: ${err.message}`);
}
}
if (existingDeferredPromo) {
invoices = invoices.map(inv => {
if (!inv || typeof inv !== 'object') return inv;
// Only annotate invoices for this addon subscription that don't already have pendingPromoDetails
if (inv.subscription === addonSub.id && !inv.pendingPromoDetails) {
return { ...inv, pendingPromoDetails: existingDeferredPromo };
}
return inv;
});
}
// Sanitize all invoice objects to remove sensitive coupon/discount data before sending to client
res.json(invoices.map(inv => (inv && typeof inv === 'object') ? sanitizeInvoice(inv) : inv));
}
/**
* 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.
*
* Promotion Application Logic (controlled by PROMO_MODE env var):
* - If explicit coupon provided: use it (no automatic promo)
* - If PROMO_MODE='disabled': never apply automatic promos (kill switch OFF)
* - If PROMO_MODE='enabled' (default): apply promos to eligible customers
* - Eligibility controlled by PromoEligibility constant ('all', 'new_only', 'renew_only')
* - Checked via checkPromoEligibility() function based on subscription history
*
* @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 ID (already resolved from promotion code if needed)
* @param {*} subType the subscription type ('package' or 'addon') for promo matching
* @returns List of upcoming (not yet created) invoices
*/
async function retreiveNextInvoices(custId, subItems, subscription, prorateTS, coupon, subType) {
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) {
// Determine whether to apply automatic promotion based on PROMO_MODE
const promoMode = env.PROMO_MODE;
const shouldApplyPromo = !coupon && promoMode === PromoModes.ENABLED;
if (shouldApplyPromo) {
env && !env.PRODUCTION && debug(`PROMO_MODE='enabled': will attempt to apply promotion for ${subType}`);
}
// Apply coupon (explicit or automatic promo)
if (coupon) {
// Explicit coupon always takes precedence
pkgOps.coupon = coupon;
debug(`Using explicit coupon: ${coupon}`);
} else if (shouldApplyPromo && subType && !utils.isEmptyArray(subItems)) {
// Try to find and apply matching promotion
const priceId = subItems.find(item => item.price && !item.deleted)?.price;
if (priceId) {
const priceKey = getPriceKeyFromId(priceId);
// Pass custId for eligibility filtering
const promo = await findMatchingPromo(subType, priceKey, pkgOps.customer);
if (promo && promo.couponId) {
// Validate coupon before applying
try {
const stripeCoupon = await stripe.coupons.retrieve(promo.couponId);
// Accept both forever and repeating coupons (consistent with createSubscription)
if (stripeCoupon && !stripeCoupon.deleted &&
(stripeCoupon.duration === CouponDuration.FOREVER || stripeCoupon.duration === CouponDuration.REPEATING)) {
pkgOps.coupon = promo.couponId;
debug(`Applied automatic promo "${promo.name}" (coupon: ${promo.couponId}, duration: ${stripeCoupon.duration}) to ${subType} invoice preview`);
} else {
debug(`Skipping invalid promo "${promo.name}" for ${subType} (deleted: ${stripeCoupon?.deleted}, duration: ${stripeCoupon?.duration})`);
}
} catch (stripeErr) {
debug(`Failed to validate coupon ${promo.couponId} for promo "${promo.name}": ${stripeErr.message}`);
}
} else {
env && !env.PRODUCTION && debug(`No matching promo found for ${subType}/${priceKey}`);
}
}
}
invoices = [await stripe.invoices.retrieveUpcoming(pkgOps)];
}
return invoices;
}
/**
* Update Ag-Nav db on corresponding Stripe customer's subscriptions when there has been subscription changes.
* Synchronizes MongoDB with Stripe subscription data using two different strategies based on the replaceAllExisting flag.
*
* **Full Replace Mode (replaceAllExisting=true):**
* - Used during migrations or when Stripe is the source of truth
* - Completely replaces the DB subscription array with Stripe data
* - Includes deduplication: ensures only ONE subscription per type (PACKAGE/ADDON)
* - When duplicates exist, keeps the most recent subscription (by periodStart)
* - Filters out final status subscriptions (CANCELED, INCOMPLETE_EXPIRED)
*
* **Incremental Update Mode (replaceAllExisting=false):**
* - Used by webhooks for individual subscription updates
* - Matches subscriptions by ID for targeted updates
* - Removes canceled/expired subscriptions from DB
* - Adds new subscriptions not yet in DB
* - Preserves subscriptions not in the update payload
*
* Both modes:
* - Only store active/relevant subscriptions (not CANCELED or INCOMPLETE_EXPIRED)
* - Update trial tracking dates (lastStartDate, lastEndDate)
* - Convert Stripe format to DB membership format via _toMembershipSubscription()
*
* @param {string} appId - The applicator user MongoDB ObjectId
* @param {Array} subscriptions - Array of Stripe subscription objects to sync
* @param {boolean} replaceAllExisting - Sync strategy flag (default: false)
* - true: Full replace with deduplication (used in migrations)
* - false: Incremental update by subscription ID (used in webhooks)
* @returns {Object} Updated customer object with synchronized subscriptions
*/
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 = [];
const subsByType = {}; // Track subscriptions by type to prevent duplicates
if (!utils.isEmptyArray(subscriptions)) {
for (let i = 0; i < subscriptions.length; i++) {
if (!isFinalSubStatus(subscriptions[i].status)) {
const mSub = _toMembershipSubscription(subscriptions[i]);
if (mSub && mSub.type) {
// Only keep the most recent subscription for each type
if (!subsByType[mSub.type]) {
subsByType[mSub.type] = mSub;
subs.push(mSub);
} else {
// If duplicate type found, keep the one with the later periodStart
if (mSub.periodStart > subsByType[mSub.type].periodStart) {
// Replace the older one
const oldIndex = subs.findIndex(s => s.id === subsByType[mSub.type].id);
if (oldIndex >= 0) {
subs[oldIndex] = mSub;
subsByType[mSub.type] = 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 config dates
if (newTrialSub) {
const trials = membership.trials || {};
const trialEndDate = new Date(newTrialSub.trial_end * 1e3);
// Update lastEndDate if this subscription's trial_end date is later
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: inferStripeSubType(sub),
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,
cancelAt: sub.cancel_at, // Timestamp when subscription will be canceled (set by schedule with end_behavior: 'cancel')
trialEnd: sub.trial_end,
// Promo tracking - which promo is applied to this subscription
promoId: sub.metadata?.promoId || null,
// Schedule info for promo-managed subscriptions
scheduleId: sub.metadata?.scheduleId || (typeof sub.schedule === 'string' ? sub.schedule : sub.schedule?.id) || null
};
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);
// SECURITY: Remove discount/coupon data before returning to client and add promoDetails
const sanitizedSubs = updatedSubs.map(addPromoDetailsToSubscription);
res.json(sanitizedSubs);
}
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);
}
/**
* Handle subscription_schedule.completed webhook event.
* This occurs when all phases of a schedule complete successfully.
* For promo schedules: Phase 1 (coupon) ended → Phase 2 (normal billing) will continue.
*
* @param {Object} schedule - The Stripe subscription_schedule object from webhook
* @param {Object} applicator - The customer/applicator from database
* @param {Object} req - The request object for baseUrl extraction
*/
async function handleSubscriptionScheduleCompleted(schedule, applicator, req) {
if (!schedule || !applicator) return;
debug(`Subscription schedule completed: ${schedule.id} for customer ${applicator.name || applicator.username}`);
try {
// Get promo info from schedule metadata
const promoId = schedule.metadata?.promoId;
if (!promoId) {
debug(`Schedule ${schedule.id} has no promoId metadata - skipping promo notification`);
return;
}
// Decrement promo usageCount when promo period expires
await decrementPromoUsageCount(promoId, 'schedule completed - promo period expired');
// Retrieve the subscription to get billing details
let subscription = null;
if (schedule.subscription) {
subscription = await stripe.subscriptions.retrieve(schedule.subscription, {
expand: ['items.data.price.product']
});
}
// Get promo details from settings
const settings = await Settings.findOne({});
const promo = settings?.subscriptionPromos?.find(p => p._id?.toString() === promoId);
// Prepare email locals
// Subscription name = human-readable product name(s) from Stripe
const subName = subscription?.items?.data?.length
? subscription.items.data.map(it => it.price?.product?.name).filter(Boolean).join(', ')
: 'Subscription';
// Subscription kind: use promo.type if set, otherwise infer from price keys
let rawSubKind = promo?.type || SubType.PACKAGE;
if (!promo?.type && subscription?.items?.data?.length) {
const allAddon = subscription.items.data.every(it => {
const pk = getPriceKeyFromId(it.price?.id);
return pk && pk.startsWith('addon_');
});
rawSubKind = allAddon ? SubType.ADDON : SubType.PACKAGE;
}
// All timestamps are UTC - format with explicit (UTC) suffix
const nextBillingDate = subscription?.current_period_end
? moment.unix(subscription.current_period_end).utc().format('MMMM D, YYYY [UTC]')
: null;
// Sum all items (unit_amount × quantity) for the total before-tax amount
const isTaxable = subscription?.automatic_tax?.enabled === true;
const chargeAmount = subscription?.items?.data?.length
? `$${(subscription.items.data.reduce((sum, it) => sum + (it.price.unit_amount * (it.quantity || 1)), 0) / 100).toFixed(2)}`
: null;
// Promo discount description (e.g. "20% off" or "$10.00 off")
const promoDiscount = utils.formatPromoDiscount(promo);
// Promo period from schedule phases (the phase containing the coupon)
const promoPhase = schedule.phases?.find(ph => ph.coupon || ph.discounts?.length);
const promoStartDate = promoPhase?.start_date
? moment.unix(promoPhase.start_date).utc().format('MMMM D, YYYY [UTC]')
: null;
const promoEndDate = promoPhase?.end_date
? moment.unix(promoPhase.end_date).utc().format('MMMM D, YYYY [UTC]')
: null;
const emailLocals = {
name: applicator.name || applicator.contact || applicator.username,
promoName: promo?.name || 'Promotional Discount',
subName,
subKind: rawSubKind, // 'package' or 'addon' — template translates via $t
promoDiscount,
promoStartDate,
promoEndDate,
newBillingDate: nextBillingDate,
chargeAmount,
isTaxable,
userId: applicator._id?.toString(),
lang: applicator.lang || DEFAULT_LANG
};
// Ensure req object is valid for mailer functions
req = utils.ensureValidReqObject(req);
// Set locals on req object (standard pattern)
req.locals = emailLocals;
// Send promo expired notification email
await mailer.sendPromoExpiredEmail(req, applicator.username);
debug(`Promo expired email sent to ${applicator.username} for schedule ${schedule.id}`);
} catch (err) {
debug(`Error handling schedule completed event: ${err.message}`);
}
}
/**
* Handle coupon deleted event
* Automatically disable any active promos using the deleted coupon
* This prevents new subscriptions from attempting to use a deleted coupon
*/
async function handleCouponDeleted(coupon) {
if (!coupon || !coupon.id) {
debug('handleCouponDeleted: Invalid coupon data');
return;
}
debug(`Coupon deleted: ${coupon.id} - checking for affected promos`);
try {
const settings = await Settings.findOne({ userId: null });
if (!settings?.subscriptionPromos) {
debug('No promos configured');
return;
}
// Find all promos using this coupon
const affectedPromos = settings.subscriptionPromos.filter(p =>
p.couponId === coupon.id && p.enabled
);
if (affectedPromos.length === 0) {
debug(`No active promos found using coupon ${coupon.id}`);
return;
}
debug(`Found ${affectedPromos.length} active promo(s) using deleted coupon ${coupon.id}`);
// Disable all affected promos
let disabledCount = 0;
for (const promo of affectedPromos) {
promo.enabled = false;
// Set validUntil to now to prevent any new usage
if (!promo.validUntil || moment.utc(promo.validUntil).isAfter(moment.utc())) {
promo.validUntil = new Date();
}
disabledCount++;
debug(`Disabled promo "${promo.name}" (id: ${promo._id}) due to coupon deletion`);
}
await settings.save();
debug(`Successfully disabled ${disabledCount} promo(s) affected by coupon ${coupon.id} deletion`);
// TODO: Consider sending email notification to admins about disabled promos
} catch (err) {
debug(`ERROR: Failed to handle coupon deletion for ${coupon.id}:`, err.message);
// Don't throw - webhook should succeed even if promo update fails
}
}
/**
* Handle subscription_schedule.released webhook event.
* This occurs when end_behavior is 'release' and all phases complete.
* The subscription continues without schedule management.
*
* @param {Object} schedule - The Stripe subscription_schedule object from webhook
* @param {Object} applicator - The customer/applicator from database
* @param {Object} req - The request object for baseUrl extraction
*/
async function handleSubscriptionScheduleReleased(schedule, applicator, req) {
if (!schedule || !applicator) return;
debug(`Subscription schedule released: ${schedule.id} for customer ${applicator.name || applicator.username}`);
try {
const promoId = schedule.metadata?.promoId;
if (!promoId) {
debug(`Schedule ${schedule.id} has no promoId metadata - skipping`);
return;
}
// Check if this was an immediate release (schedule released shortly after creation)
// This happens when we create a schedule to apply a coupon, then immediately release it
// for subscriptions with cancel_at_period_end: true (default)
// In this case, we should NOT send a promo expired email
const createdAt = schedule.created;
const releasedAt = schedule.released_at;
const IMMEDIATE_RELEASE_THRESHOLD_SECONDS = 60; // If released within 60 seconds of creation, it's an immediate release
if (createdAt && releasedAt && (releasedAt - createdAt) < IMMEDIATE_RELEASE_THRESHOLD_SECONDS) {
debug(`Schedule ${schedule.id} was released ${releasedAt - createdAt} seconds after creation - immediate release, skipping promo expired email`);
return;
}
// Decrement promo usageCount when promo period expires (non-immediate release)
await decrementPromoUsageCount(promoId, 'schedule released - promo period expired');
// Clear scheduleId from local subscription metadata if we stored it
// The subscription now operates independently without schedule management
if (schedule.subscription || schedule.released_subscription) {
const subId = schedule.subscription || schedule.released_subscription;
const subscription = await stripe.subscriptions.retrieve(subId);
if (subscription?.metadata?.scheduleId) {
await stripe.subscriptions.update(subId, {
metadata: { ...subscription.metadata, scheduleId: '' } // Clear the scheduleId
});
debug(`Cleared scheduleId from subscription ${subId} metadata`);
}
}
// For 'release' end_behavior, the promo period ended and subscription continues
// Send the same promo expired notification
await handleSubscriptionScheduleCompleted(schedule, applicator, req);
} catch (err) {
debug(`Error handling schedule released event: ${err.message}`);
}
}
/**
* 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;
}
/**
* Update subscription schedule's end_behavior and phases based on cancel_at_period_end setting.
* When subscription is managed by a schedule, we update the schedule's end_behavior:
* - cancel_at_period_end: true → Release the schedule and set cancel_at_period_end on subscription
* - cancel_at_period_end: false → end_behavior: 'release' (subscription continues after promo ends)
*
* Key insight: The schedule controls COUPON DURATION, not subscription end.
* User's cancel_at_period_end preference controls subscription lifecycle independently.
*
* @param {string} scheduleId - The Stripe SubscriptionSchedule ID
* @param {boolean} cancelAtPeriodEnd - Whether to cancel at period end (true) or auto-renew (false)
* @returns {Object} Result with schedule/subscription and update status
*/
async function updateScheduleEndBehavior(scheduleId, cancelAtPeriodEnd, subscriptionId) {
debug(`updateScheduleEndBehavior called: scheduleId=${scheduleId}, cancelAtPeriodEnd=${cancelAtPeriodEnd}, subscriptionId=${subscriptionId}`);
let schedule = null;
let scheduleStatus = null;
if (scheduleId) {
try {
schedule = await stripe.subscriptionSchedules.retrieve(scheduleId);
scheduleStatus = schedule.status;
} catch (err) {
// Schedule may have been deleted or doesn't exist
debug(`Could not retrieve schedule ${scheduleId}: ${err.message}`);
}
}
// If schedule doesn't exist or is not active, we need different handling
if (!schedule || scheduleStatus !== 'active') {
debug(`Schedule ${scheduleId} is not active (status: ${scheduleStatus || 'null'}), checking subscription for promo`);
// If user wants to cancel (cancel_at_period_end: true), just update the subscription directly
if (cancelAtPeriodEnd) {
if (subscriptionId) {
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const updatedSub = await stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: true,
metadata: { ...subscription.metadata, scheduleId: '' } // Clear scheduleId since schedule is not active
});
debug(`Set cancel_at_period_end=true on subscription ${subscriptionId} and cleared scheduleId`);
return { subscription: updatedSub, updated: true };
}
return { updated: false, reason: 'no_subscription_id' };
}
// User wants auto-renew (cancel_at_period_end: false)
// If subscription has a promoId, we need to recreate the schedule to enforce validUntil
if (subscriptionId) {
const subscription = await stripe.subscriptions.retrieve(subscriptionId, {
expand: ['items.data.price']
});
// IMPORTANT: Do NOT create schedules for TRIALING subscriptions
// Creating a schedule on a trial subscription immediately activates it and charges the customer
// Trial subscriptions should remain trials until trial_end, then transition naturally
if (subscription.status === SubStatus.TRIALING) {
debug(`Subscription ${subscriptionId} is in TRIALING status, only updating cancel_at_period_end`);
const updatedSub = await stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: false
});
debug(`Set cancel_at_period_end=false on trial subscription ${subscriptionId}`);
return { subscription: updatedSub, updated: true };
}
const promoId = subscription.metadata?.promoId;
if (promoId) {
debug(`Subscription ${subscriptionId} has promoId ${promoId}, checking if schedule needs to be created`);
// Look up the promo for couponId and deleted check
const promo = await findPromoById(promoId);
// Use the per-subscription discountEndsAt snapshot stored at creation time.
// This ensures the subscriber's original discount end date is honoured even if the
// admin later changes validUntil or discountEndsAt on the promo definition.
// Fall back chain: sub metadata snapshot → live promo.discountEndsAt → live promo.validUntil (legacy)
const metaDiscountEndsAt = subscription.metadata?.discountEndsAt
? new Date(Number(subscription.metadata.discountEndsAt) * 1000)
: null;
const promoDiscountEndDate = metaDiscountEndsAt || promo?.discountEndsAt || promo?.validUntil;
if (promo && promoDiscountEndDate && !promo.deleted) {
const validUntilTs = moment.utc(promoDiscountEndDate).unix();
const now = Math.floor(Date.now() / 1000);
// Only create schedule if the discount end is in the future
if (validUntilTs > now) {
debug(`Creating new schedule for subscription ${subscriptionId} to enforce promo discount end at ${promoDiscountEndDate}`);
// Build phase items from subscription items
const phaseItems = subscription.items.data.map(item => ({
price: item.price.id,
quantity: item.quantity || 1
}));
// Get coupon from current subscription discount if present
const couponId = subscription.discount?.coupon?.id || promo.couponId;
// Step 1: Create schedule from subscription (cannot set phases with from_subscription)
const newSchedule = await stripe.subscriptionSchedules.create({
from_subscription: subscriptionId
});
debug(`Created schedule ${newSchedule.id} from subscription, now updating phases`);
// Step 2: Update the schedule with our 2-phase configuration
const updateParams = {
end_behavior: 'release',
phases: [
{
items: phaseItems,
coupon: couponId,
start_date: newSchedule.phases[0]?.start_date || now,
end_date: validUntilTs,
proration_behavior: 'none'
},
{
items: phaseItems,
// No coupon - normal billing resumes after promo
proration_behavior: 'none'
}
]
};
// Add automatic_tax if subscription has it
if (subscription.automatic_tax?.enabled) {
updateParams.phases[0].automatic_tax = { enabled: true };
updateParams.phases[1].automatic_tax = { enabled: true };
}
// Add default_payment_method if subscription has one
if (subscription.default_payment_method) {
updateParams.default_settings = {
default_payment_method: typeof subscription.default_payment_method === 'string'
? subscription.default_payment_method
: subscription.default_payment_method.id
};
}
const updatedSchedule = await stripe.subscriptionSchedules.update(newSchedule.id, updateParams);
// Update subscription metadata: new scheduleId, discountEndsAt (snapshot of phase end_date),
// and clear any stale promoReminderSentAt so a fresh reminder fires near the new deadline.
await stripe.subscriptions.update(subscriptionId, {
metadata: { ...subscription.metadata, scheduleId: updatedSchedule.id, discountEndsAt: String(validUntilTs), promoReminderSentAt: '' }
});
debug(`Updated schedule ${updatedSchedule.id} for subscription ${subscriptionId}, coupon will expire at ${promoDiscountEndDate}`);
return { schedule: updatedSchedule, updated: true, scheduleCreated: true };
} else {
debug(`Promo validUntil ${promo.validUntil} is in the past, not creating schedule`);
}
} else {
debug(`Promo ${promoId} not found or has no validUntil, no schedule needed`);
}
}
// No promo or can't create schedule - just update cancel_at_period_end
const updatedSub = await stripe.subscriptions.update(subscriptionId, {
cancel_at_period_end: false
});
debug(`Set cancel_at_period_end=false on subscription ${subscriptionId} (no promo schedule needed)`);
return { subscription: updatedSub, updated: true };
}
return { updated: false, reason: 'schedule_not_active_and_no_subscription' };
}
const subIdFromSchedule = schedule.subscription;
// IMPORTANT: If subscription is in TRIALING status, do NOT manipulate schedules
// Trial subscriptions should remain trials until trial_end naturally expires
// Check subscription status before any schedule operations
let subscription = null;
if (subIdFromSchedule) {
subscription = await stripe.subscriptions.retrieve(subIdFromSchedule);
if (subscription.status === SubStatus.TRIALING) {
debug(`Subscription ${subIdFromSchedule} is in TRIALING status, releasing schedule and updating subscription directly`);
// Release the schedule - it shouldn't control a trial subscription
try {
await stripe.subscriptionSchedules.release(scheduleId);
debug(`Released schedule ${scheduleId} from trial subscription ${subIdFromSchedule}`);
} catch (err) {
debug(`Error releasing schedule from trial subscription: ${err.message}`);
}
// Update subscription directly
const updatedSub = await stripe.subscriptions.update(subIdFromSchedule, {
cancel_at_period_end: cancelAtPeriodEnd
});
debug(`Set cancel_at_period_end=${cancelAtPeriodEnd} on trial subscription ${subIdFromSchedule}`);
return { subscription: updatedSub, updated: true, scheduleReleased: true };
}
}
if (cancelAtPeriodEnd) {
// User wants to cancel at period end
// Release the schedule so we can control subscription directly
debug(`Releasing schedule ${scheduleId} to enable cancel_at_period_end on subscription ${subIdFromSchedule}`);
try {
// Release the schedule - subscription continues but without schedule management
await stripe.subscriptionSchedules.release(scheduleId);
// Now set cancel_at_period_end on the subscription directly
const updatedSub = await stripe.subscriptions.update(subIdFromSchedule, {
cancel_at_period_end: true
});
debug(`Released schedule ${scheduleId}, set cancel_at_period_end=true on subscription ${subIdFromSchedule}`);
return { subscription: updatedSub, updated: true, scheduleReleased: true };
} catch (err) {
debug(`Error releasing schedule: ${err.message}`);
throw err;
}
} else {
// User wants auto-renew (not cancel at period end)
// Ensure schedule has end_behavior: 'release' so subscription continues after promo
const currentEndBehavior = schedule.end_behavior;
if (currentEndBehavior === 'release') {
debug(`Schedule ${scheduleId} already has end_behavior: release, no update needed`);
return { schedule, updated: false, reason: 'already_correct' };
}
debug(`Updating schedule ${scheduleId} end_behavior from ${currentEndBehavior} to release`);
// Get current phase info
const now = Math.floor(Date.now() / 1000);
const currentPhaseIndex = schedule.phases.findIndex(p => p.start_date <= now && p.end_date > now);
const currentPhase = schedule.phases[currentPhaseIndex];
if (!currentPhase) {
debug(`No active phase found in schedule ${scheduleId}`);
return { schedule, updated: false, reason: 'no_active_phase' };
}
// Build phase items from current phase
const phaseItems = currentPhase.items.map(item => ({
price: item.price,
quantity: item.quantity || 1
}));
// Convert to 2-phase schedule with 'release' end_behavior
// Note: When updating an active schedule, use 'now' for start_date to handle test clock time advancement
const updateParams = {
end_behavior: 'release',
phases: [
{
items: phaseItems,
coupon: currentPhase.coupon,
start_date: 'now', // Use 'now' instead of currentPhase.start_date to handle test clocks
end_date: currentPhase.end_date,
proration_behavior: 'none',
metadata: currentPhase.metadata
},
{
items: phaseItems,
iterations: 1, // Run for 1 billing cycle then release
// No coupon - normal billing resumes after promo
proration_behavior: 'none',
metadata: currentPhase.metadata
}
]
};
// Add automatic_tax if present (only pass enabled field, not read-only fields like disabled_reason)
if (currentPhase.automatic_tax?.enabled) {
updateParams.phases[0].automatic_tax = { enabled: true };
updateParams.phases[1].automatic_tax = { enabled: true };
}
const updatedSchedule = await stripe.subscriptionSchedules.update(scheduleId, updateParams);
debug(`Updated schedule ${scheduleId} to end_behavior: release (2 phases)`);
return { schedule: updatedSchedule, updated: true };
}
}
/**
* Set Subscription settings
* Handles both regular subscriptions and schedule-managed subscriptions (from promos).
* For schedule-managed subscriptions, updates the schedule's end_behavior instead of
* the subscription's cancel_at_period_end directly.
*
* @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 stripeSubSettings = _toStripeSubSettings(input.subsSettings);
for (let i = 0; i < stripeSubSettings.length; i++) {
const { subId, settings } = stripeSubSettings[i];
// Check if subscription is managed by a schedule or has a promo
const subscription = await stripe.subscriptions.retrieve(subId);
const scheduleId = subscription.metadata?.scheduleId || subscription.schedule;
const promoId = subscription.metadata?.promoId;
// If subscription has a schedule OR has a promo (schedule may have been released), handle specially
if ((scheduleId || promoId) && settings.cancel_at_period_end !== undefined) {
// Subscription may be managed by a schedule or had one that was released
debug(`Subscription ${subId} has scheduleId=${scheduleId}, promoId=${promoId}`);
debug(`Request: cancel_at_period_end=${settings.cancel_at_period_end}`);
try {
const result = await updateScheduleEndBehavior(scheduleId, settings.cancel_at_period_end, subId);
debug(`updateScheduleEndBehavior result: updated=${result.updated}, reason=${result.reason || 'success'}, scheduleReleased=${result.scheduleReleased || false}, scheduleCreated=${result.scheduleCreated || false}`);
// Retrieve updated subscription with expanded items
const updatedSub = await stripe.subscriptions.retrieve(subId, { expand: ['items.data.price'] });
// If schedule was released, clear scheduleId from metadata
if (result.scheduleReleased) {
// Remove scheduleId from subscription metadata since schedule was released
const currentMetadata = updatedSub.metadata || {};
delete currentMetadata.scheduleId;
await stripe.subscriptions.update(subId, { metadata: currentMetadata });
updatedSub.metadata = currentMetadata;
debug(`Cleared scheduleId from subscription ${subId} metadata after schedule release`);
}
// If a new schedule was created, the metadata is already updated in updateScheduleEndBehavior
// Just refresh to get the latest
if (result.scheduleCreated) {
const refreshedSub = await stripe.subscriptions.retrieve(subId, { expand: ['items.data.price'] });
updatedSubs.push(_toMembershipSubscription(refreshedSub));
} else {
updatedSubs.push(_toMembershipSubscription(updatedSub));
}
} catch (err) {
debug(`Error updating schedule ${scheduleId}: ${err.message}`);
// If schedule update fails (e.g., schedule already completed/released), try direct update
if (err.code === 'resource_missing' || err.message.includes('already completed') || err.message.includes('released')) {
await stripe.subscriptions.update(subId, settings);
const updatedSub = await stripe.subscriptions.retrieve(subId, { expand: ['items.data.price'] });
updatedSubs.push(_toMembershipSubscription(updatedSub));
} else {
throw err;
}
}
} else {
// Regular subscription - update directly
await stripe.subscriptions.update(subId, settings);
const updatedSub = await stripe.subscriptions.retrieve(subId, { expand: ['items.data.price'] });
updatedSubs.push(_toMembershipSubscription(updatedSub));
}
}
// SECURITY: Remove discount/coupon data before returning to client and add promoDetails
const sanitizedSubs = updatedSubs.map(addPromoDetailsToSubscription);
res.json(sanitizedSubs);
}
/**
* @api {get} /api/subscription/status/:subscriptionId Check Subscription Status
* @apiName CheckSubscriptionStatus
* @apiGroup Subscription
* @apiDescription Check current subscription status (for polling after 3DS authentication)
*
* @apiParam {String} subscriptionId Stripe subscription ID
*
* @apiSuccess {String} id Subscription ID
* @apiSuccess {String} status Subscription status (active, incomplete, past_due, etc.)
* @apiSuccess {Number} current_period_start Current period start timestamp
* @apiSuccess {Number} current_period_end Current period end timestamp
*
* @apiError (409) {Object} error Error object
* @apiError (409) {String} error..tag Error constant value
* @apiError (409) {String} [error.message] Error details
*/
async function checkSubscriptionStatus(req, res) {
const { subscriptionId } = req.params;
if (!subscriptionId) AppParamError.throw(Errors.NOT_FOUND, 'Subscription Id is required');
debug(`Checking subscription status for ${subscriptionId}`);
const subscription = await stripe.subscriptions.retrieve(subscriptionId, { expand: ['latest_invoice'] });
debug(`Subscription ${subscriptionId} status: ${subscription.status}`);
// SECURITY: Sanitize latest_invoice to remove discount data
const sanitizedInvoice = subscription.latest_invoice && typeof subscription.latest_invoice === 'object'
? (() => {
const cleaned = { ...subscription.latest_invoice };
delete cleaned.discount;
delete cleaned.discounts;
return cleaned;
})()
: subscription.latest_invoice;
res.json({
id: subscription.id,
status: subscription.status,
current_period_start: subscription.current_period_start,
current_period_end: subscription.current_period_end,
latest_invoice: sanitizedInvoice
});
}
module.exports = {
stripeWebhooks_post,
apiConfig_get,
createPaymentUser,
resolvePaymentUser,
paymentMethods_get,
getCustDefaultPaymentMethod_get,
getPrices_get,
getCoupon_get,
getBillAddress_get,
updateBillAddress_put,
setupCardAuthentication_post,
updateSubscriptions_post,
getCustSubscriptions_get,
updateCustSubscriptions,
retrieveNextInvoices_post,
resolveUnpaidSubcriptions_post,
payInvoice_post,
finalizeCustDraftInvoices_post,
setSubsPaymentMethod_post,
hasApplVendor,
updateCustPaymentMethod_put,
addCustPaymentMethod_post,
deleteCustPaymentMethod,
customerInvoices_post,
getCustomerCharges_post,
getCustomerUsages_post,
getSubBillPeriods_post,
setSubsSettings_post,
findPromoById,
findMatchingPromo,
updatePromoSubscriptionSchedules,
decrementPromoUsageCount,
checkSubscriptionStatus,
// Eligibility checking functions (exported for reuse in other controllers)
checkPromoEligibility,
hasSubscriptionHistory,
// Email helpers (exported for use in migration scripts)
emailCurSubcriptions,
// Script helpers (exported for use in utility/migration scripts)
findApplicatorByCustId,
updateCustSubStatus,
updateSubBillPeriod
};