Files
overleaf-cep/services/web/app/src/Features/Subscription/SubscriptionHelper.js
roo hutton 58b8e36739 Merge pull request #27215 from overleaf/rh-stripe-pause-status
Update features and subscription state when Stripe pause starts and ends

GitOrigin-RevId: 368f5d9b046cfe26e996be336189081b96926713
2025-08-06 08:04:57 +00:00

211 lines
6.0 KiB
JavaScript

const { formatCurrency } = require('../../util/currency')
const GroupPlansData = require('./GroupPlansData')
const { isStandaloneAiAddOnPlanCode } = require('./AiHelper')
const { Subscription } = require('../../models/Subscription')
const MILLISECONDS = 1_000
/**
* Recompute the subscription state for Stripe subscriptions based on pause periods.
* This function checks if a subscription should transition between 'active' and 'paused'
* states based on the current time and pause period metadata.
*
* @param {Object} subscription - The MongoDB subscription document
* @returns {Promise<Object>} - The updated subscription document with recomputed state
*/
async function recomputeSubscriptionState(subscription) {
if (
!subscription?.paymentProvider?.subscriptionId ||
!subscription.paymentProvider.pausePeriodStart ||
!subscription.paymentProvider.pausePeriodEnd ||
!subscription?.paymentProvider.service.includes('stripe')
) {
return subscription
}
const now = Date.now() / MILLISECONDS
const pauseStartTime =
new Date(subscription.paymentProvider.pausePeriodStart).getTime() /
MILLISECONDS
const currentState = subscription.paymentProvider.state
const pauseEndTime =
new Date(subscription.paymentProvider.pausePeriodEnd).getTime() /
MILLISECONDS
const shouldBePaused =
pauseEndTime && now >= pauseStartTime && now < pauseEndTime
let newState
if (shouldBePaused && currentState !== 'paused') {
newState = 'paused'
} else if (
!shouldBePaused &&
currentState === 'paused' &&
pauseEndTime &&
now >= pauseEndTime
) {
newState = 'active'
}
if (newState) {
await Subscription.updateOne(
{ _id: subscription._id },
{ 'paymentProvider.state': newState }
).exec()
subscription.paymentProvider.state = newState
}
return subscription
}
/**
* If the user changes to a less expensive plan, we shouldn't apply the change immediately.
* This is to avoid unintended/artifical credits on users Recurly accounts.
*/
function shouldPlanChangeAtTermEnd(oldPlan, newPlan, isInTrial) {
if (isInTrial) {
// we should always upgrade or downgrade immediately if actively in trial
return false
}
if (
oldPlan.annual === newPlan.annual &&
isStandaloneAiAddOnPlanCode(oldPlan.planCode) &&
!isStandaloneAiAddOnPlanCode(newPlan.planCode)
) {
// changing from an standalone AI add-on plan to a non-AI plan should not be considered a downgrade
return false
}
return oldPlan.price_in_cents > newPlan.price_in_cents
}
/**
* @import { CurrencyCode } from '../../../../types/subscription/currency'
*/
/**
* @typedef {Object} PlanToPrice
* @property {string} collaborator
* @property {string} professional
*/
/**
* @typedef {Object} LocalizedGroupPrice
* @property {PlanToPrice} price
* @property {PlanToPrice} pricePerUser
*/
/**
* @param {CurrencyCode} recommendedCurrency
* @param {string} locale
* @returns {LocalizedGroupPrice}
*/
function generateInitialLocalizedGroupPrice(recommendedCurrency, locale) {
const INITIAL_LICENSE_SIZE = 2
// the price is in cents, so divide by 100 to get the value
const collaboratorPrice =
GroupPlansData.enterprise.collaborator[recommendedCurrency][
INITIAL_LICENSE_SIZE
].price_in_cents / 100
const collaboratorPricePerUser = collaboratorPrice / INITIAL_LICENSE_SIZE
const professionalPrice =
GroupPlansData.enterprise.professional[recommendedCurrency][
INITIAL_LICENSE_SIZE
].price_in_cents / 100
const professionalPricePerUser = professionalPrice / INITIAL_LICENSE_SIZE
/**
* @param {number} price
* @returns {string}
*/
const formatPrice = price =>
formatCurrency(price, recommendedCurrency, locale, true)
return {
price: {
collaborator: formatPrice(collaboratorPrice),
professional: formatPrice(professionalPrice),
},
pricePerUser: {
collaborator: formatPrice(collaboratorPricePerUser),
professional: formatPrice(professionalPricePerUser),
},
}
}
function isPaidSubscription(subscription) {
const hasRecurlySubscription =
subscription?.recurlySubscription_id &&
subscription?.recurlySubscription_id !== ''
const hasStripeSubscription =
subscription?.paymentProvider?.subscriptionId &&
subscription?.paymentProvider?.subscriptionId !== ''
return !!(subscription && (hasRecurlySubscription || hasStripeSubscription))
}
function isIndividualActivePaidSubscription(subscription) {
return (
isPaidSubscription(subscription) &&
subscription?.groupPlan === false &&
subscription?.recurlyStatus?.state !== 'canceled' &&
subscription?.paymentProvider?.state !== 'canceled'
)
}
function getPaymentProviderSubscriptionId(subscription) {
if (subscription?.recurlySubscription_id) {
return subscription.recurlySubscription_id
}
if (subscription?.paymentProvider?.subscriptionId) {
return subscription.paymentProvider.subscriptionId
}
return null
}
function getPaidSubscriptionState(subscription) {
if (subscription?.recurlyStatus?.state) {
return subscription.recurlyStatus.state
}
if (subscription?.paymentProvider?.state) {
return subscription.paymentProvider.state
}
return null
}
function getSubscriptionTrialStartedAt(subscription) {
if (subscription?.recurlyStatus?.trialStartedAt) {
return subscription.recurlyStatus?.trialStartedAt
}
return subscription?.paymentProvider?.trialStartedAt
}
function getSubscriptionTrialEndsAt(subscription) {
if (subscription?.recurlyStatus?.trialEndsAt) {
return subscription.recurlyStatus?.trialEndsAt
}
return subscription?.paymentProvider?.trialEndsAt
}
function isInTrial(trialEndsAt) {
if (!trialEndsAt) {
return false
}
return trialEndsAt.getTime() > Date.now()
}
module.exports = {
shouldPlanChangeAtTermEnd,
generateInitialLocalizedGroupPrice,
isPaidSubscription,
isIndividualActivePaidSubscription,
getPaymentProviderSubscriptionId,
getPaidSubscriptionState,
getSubscriptionTrialStartedAt,
getSubscriptionTrialEndsAt,
isInTrial,
recomputeSubscriptionState,
}