agmission/Development/server/controllers/main.js

806 lines
30 KiB
JavaScript

'use strict';
const { SubType } = require("../model/subscription");
const https = require("https"),
ObjectId = require('mongodb').ObjectId,
os = require('os'), path = require('path'),
moment = require('moment'),
Settings = require('../model/setting'),
env = require('../helpers/env'),
{ UserTypes, DEFAULT_TRIAL_DAYS, Errors, PromoModes, APIActions, PromoEligibility, CouponDuration, StripeErrorTypes } = require('../helpers/constants'),
{ AppAuthError, AppParamError } = require("../helpers/app_error"),
{ version } = require('../package.json'),
WorkerPool = require('../workers/worker_pool'),
utils = require('../helpers/utils'),
{ updatePromoSubscriptionSchedules } = require('./subscription'),
{ enhancedRunInTransaction } = require('../helpers/mongo_enhanced'),
debug = require('debug')('agm:main');
let wkPool;
async function pingAPI_get(req, res) {
res.json(`AGM API Endpoint is working. Version - ${version}`);
}
function isSysAdmin(kind) {
return (UserTypes.ADMIN === kind);
}
async function getAppConfig_get(req, res) {
const userInfo = req.userInfo;
if (!userInfo) AppAuthError.throw();
const masterUserSettings = await Settings.findOne({ userId: isSysAdmin(userInfo.kind) ? null : ObjectId(userInfo.puid) }).lean();
const isNormalUser = (userInfo.kind > UserTypes.APP);
const userSettings = (isNormalUser ? await Settings.findOne({ userId: ObjectId(userInfo.id) }).lean() : masterUserSettings) || {};
// Apply global applicator's settings to non-applicator user's
if (isNormalUser && masterUserSettings) {
userSettings.jobsByPilot = !!(masterUserSettings.jobsByPilot);
}
delete userSettings.userId;
if (isSysAdmin(userInfo.kind)) {
if (utils.isEmptyArray(userSettings.trialDays)) userSettings.trialDays = DEFAULT_TRIAL_DAYS;
userSettings.promoMinExpiryDays = env.PROMO_MIN_EXPIRY_DAYS;
}
return res.send(userSettings);
}
async function setAppConfig_post(req, res) {
const userInfo = req.userInfo;
if (!userInfo) AppAuthError.throw();
const updatedUserSettings = await setConfig((UserTypes.ADMIN === userInfo.kind) ? null : ObjectId(req.uid), req.body?.settings || req.body);
res.json(updatedUserSettings);
}
async function setConfig(userId, newConfig) {
const newConf = newConfig || {};
if (utils.isObjectId(userId)) newConf.userId = ObjectId(userId);
return await Settings.findOneAndUpdate(
{ userId: newConf.userId }, // find a document with that filter
{ $set: newConf }, // document to insert when nothing was found
{ upsert: true, new: true, runValidators: true, lean: true });
}
function getSiteVer_post(req, res) {
const ops = req.body;
// Verify the reCaptcha token with GG at https://www.google.com/recaptcha/api/siteverify
// Ref: https://developers.google.com/recaptcha/docs/verify
const options = {
protoco: 'https',
hostname: 'www.google.com',
path: `/recaptcha/api/siteverify?secret=${env.CAPTCHA_SITESEC}&response=${ops.tk}`,
method: 'POST',
headers: {
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8"
}
};
let data = '';
const vReq = https.request(options, resp => {
resp.on('data', d => data += d);
resp.on('end', () => {
let vresult = JSON.parse(data);
res.send(vresult).end();
});
});
vReq.on('error', error => {
debug(error);
res.json(null).end();
});
vReq.end();
}
function doLongOp_post(req, res, next) {
const a = req.query.a;
const b = req.query.b;
console.log(req.query);
if (wkPool)
wkPool = new WorkerPool(os.cpus().length / 2, path.resolve(__dirname, '../workers/test_worker.js'));
console.log("Num of free worker:", wkPool.freeWorkers.length);
wkPool.runTask({ a, b }, (err, result) => {
if (err)
return next(err);
res.json({ result: result });
console.log("Num of free worker:", wkPool.freeWorkers.length);
});
/** Test with SYNC version
const multiply = (a, b) => {
let output = 0
for (let i = 0; i < b; i++)
output += a
return output
}
let result = multiply(parseInt(a), parseInt(b));
res.json({ result: result }).end();
*/
}
// ========== Subscription Promo Endpoints ==========
/**
* Helper function to get current PROMO_MODE information
* @returns {Object} Current mode details
*/
function getCurrentPromoModeInfo() {
const promoMode = env.PROMO_MODE || PromoModes.ENABLED;
const modeDescriptions = {
[PromoModes.ENABLED]: 'Promotions enabled (targeting controlled by PromoEligibility)',
[PromoModes.DISABLED]: 'Promotions DISABLED (kill switch OFF)'
};
return {
mode: promoMode,
description: modeDescriptions[promoMode] || 'Unknown mode',
isActive: promoMode !== PromoModes.DISABLED
};
}
/**
* @api {get} /api/activePromos Get Active Subscription Promos (Authenticated)
* @apiName GetActivePromos
* @apiGroup SubscriptionPromo
* @apiDescription Get active subscription promos eligible for the authenticated user's customer account.
* **Requires authentication** - automatically filters by customer's subscription history and eligibility.
* Returns only enabled promos that are either:
* - Have a validUntil date in the future, OR
* - Are repeating coupons with durationInMonths (self-expiring, no validUntil needed)
* Excludes sensitive couponId field.
* Includes i18n keys (SCREAMING_SNAKE) for Angular translation.
* Respects PROMO_MODE kill switch - returns empty array if mode is 'disabled'.
*
* @apiSuccess {Object[]} promos Array of active and eligible promotions
* @apiSuccess {String} [promos.type] Subscription type ('package' or 'addon')
* @apiSuccess {String} [promos.priceKey] Price key (e.g., 'ess_1', 'addon_1')
* @apiSuccess {Date} promos.validUntil Promo expiration date
* @apiSuccess {String} promos.name Fallback display name
* @apiSuccess {String} [promos.nameKey] i18n translation key for name
* @apiSuccess {String} [promos.descriptionKey] i18n translation key for description
* @apiSuccess {String} [promos.discountType] Discount type: 'free', 'percent', 'fixed'
* @apiSuccess {Number} [promos.discountValue] Discount value (e.g., 100, 50, 500)
* @apiSuccess {Number} promos.priority Priority for matching (higher = higher priority, default: 0)
* @apiSuccess {String} promos.eligibility Eligibility: 'all', 'new_only', 'renew_only' (default: 'all')
* @apiSuccess {Number} [promos.durationInMonths] Duration for repeating coupons (e.g., 3, 6, 12)
* @apiSuccess {Boolean} promos.chainable Whether promo applies to renewals (default: false)
* @apiSuccess {Object} currentMode Current promotion mode information
* @apiSuccess {String} currentMode.mode Current mode: 'enabled' or 'disabled'
* @apiSuccess {String} currentMode.description Human-readable description of current mode
* @apiSuccess {Boolean} currentMode.isActive Whether promotions are active (false when mode='disabled')
*
* @apiSuccessExample {json} Success-Response:
* HTTP/1.1 200 OK
* {
* "promos": [
* {
* "type": "addon",
* "priceKey": "addon_1",
* "validUntil": "2026-12-31T23:59:59.000Z",
* "name": "First Addon Free",
* "nameKey": "PROMO_FIRST_ADDON",
* "descriptionKey": "PROMO_FIRST_ADDON_DESC",
* "discountType": "free",
* "discountValue": 100,
* "priority": 10,
* "eligibility": "new_only",
* "durationInMonths": 3,
* "chainable": false
* }
* ],
* "currentMode": {
* "mode": "enabled",
* "description": "Promotions enabled",
* "isActive": true
* }
* }
*/
async function getActivePromos_get(req, res) {
// Check PROMO_MODE kill switch
if (env.PROMO_MODE === PromoModes.DISABLED) {
return res.json({
promos: [],
currentMode: getCurrentPromoModeInfo()
});
}
// Get customer ID from authenticated user
const userInfo = req.userInfo;
if (!userInfo) {
throw new AppAuthError(Errors.NOT_AUTHORIZED, 'Authentication required');
}
const custId = userInfo.puid; // For applicators: own ID, for sub-users: parent's ID
if (!custId) {
throw new AppParamError(Errors.INVALID_PARAM, 'Customer ID not found');
}
// Get customer's Stripe customer ID
const Customer = require('../model/customer');
const customer = await Customer.findOne({ _id: ObjectId(custId) }).lean();
const stripeCustId = customer?.membership?.custId;
if (!stripeCustId) {
!env.PRODUCTION && debug(`Customer ${custId} has no Stripe customer ID - returning empty promos`);
return res.json({
promos: [],
currentMode: getCurrentPromoModeInfo()
});
}
const settings = await Settings.findOne({ userId: null }).lean();
const now = moment.utc();
// Filter for active promos
const activePromos = (settings?.subscriptionPromos || [])
.filter(p => {
if (!p.enabled) return false;
const validDate = p.validUntil;
// Include if:
// 1. Has validUntil in the future, OR
// 2. Is a repeating coupon (has durationInMonths) without validUntil (self-expiring)
if (validDate) {
return moment.utc(validDate).isAfter(now);
} else {
// No validUntil - include only if it's a repeating coupon with duration
return p.durationInMonths && p.durationInMonths > 0;
}
});
// Check eligibility for each promo
const { checkPromoEligibility } = require('./subscription');
const eligiblePromos = [];
for (const promo of activePromos) {
const isEligible = await checkPromoEligibility(promo, stripeCustId, promo.type);
if (isEligible) {
eligiblePromos.push({
type: promo.type,
priceKey: promo.priceKey,
validUntil: promo.validUntil,
name: promo.name, // Fallback display name
nameKey: promo.nameKey, // i18n key e.g., 'PROMO_ADDON_FREE'
descriptionKey: promo.descriptionKey, // i18n key e.g., 'PROMO_ADDON_FREE_DESC'
discountType: promo.discountType, // 'free', 'percent', 'fixed'
discountValue: promo.discountValue, // e.g., 100, 50, 500
// V2 Enhancement fields
priority: promo.priority || 0, // Priority for matching (higher = higher priority)
eligibility: promo.eligibility || PromoEligibility.ALL, // 'all', 'new_only', 'renew_only'
durationInMonths: promo.durationInMonths, // Repeating coupon duration (if applicable)
chainable: promo.chainable || false // Whether promo applies to renewals
});
}
}
res.json({
promos: eligiblePromos,
currentMode: getCurrentPromoModeInfo()
});
}
/**
* Get all subscription promos (admin only)
* Returns full promo details including couponId and current PROMO_MODE status
*/
async function getSubscriptionPromos_get(req, res) {
const userInfo = req.userInfo;
if (!userInfo || !isSysAdmin(userInfo.kind)) AppAuthError.throw();
const settings = await Settings.findOne({ userId: null }).lean();
res.json({
promos: settings?.subscriptionPromos || [],
currentMode: getCurrentPromoModeInfo()
});
}
/**
* Create or update subscription promos (admin only)
* @param {Array} req.body.promos - Array of promo rules
*/
async function setSubscriptionPromos_post(req, res) {
const userInfo = req.userInfo;
if (!userInfo || !isSysAdmin(userInfo.kind)) AppAuthError.throw();
const { promos } = req.body;
if (!Array.isArray(promos)) AppParamError.throw();
// Validate promo rules
for (const promo of promos) {
if (promo.type && !Object.values(SubType).includes(promo.type)) AppParamError.throw();
const validDate = promo.validUntil;
if (validDate && !moment.utc(validDate).isValid()) AppParamError.throw();
if (promo.discountType && !['free', 'percent', 'fixed'].includes(promo.discountType)) AppParamError.throw();
}
// Add createdAt to new promos without it
const promosWithDates = promos.map(p => ({
...p,
createdAt: p.createdAt || new Date()
}));
const updated = await Settings.findOneAndUpdate(
{ userId: null },
{ $set: { subscriptionPromos: promosWithDates } },
{ upsert: true, new: true, lean: true }
);
debug('Updated subscription promos:', updated.subscriptionPromos?.length);
res.json({
promos: updated.subscriptionPromos || [],
currentMode: getCurrentPromoModeInfo()
});
}
/**
* Sanitize subscription promos before saving to avoid Mongoose validation errors
* Converts empty strings to undefined for optional enum fields (type, priceKey)
* @param {Object} settings - Settings document with subscriptionPromos array
*/
function sanitizePromos(settings) {
if (!settings?.subscriptionPromos) return;
settings.subscriptionPromos.forEach(p => {
if (p.type === '') p.type = undefined;
if (p.priceKey === '') p.priceKey = undefined;
});
}
/**
* @api {get} /api/admin/subscriptionPromos/coupons Get Valid Coupons
* @apiName GetValidCoupons
* @apiGroup SubscriptionPromo
* @apiDescription Get all valid Stripe coupons with duration='forever' or 'repeating' for promo selection. Excludes 'once' duration coupons as they are not supported in V2 enhancements.
*
* @apiPermission admin
*
* @apiSuccess {Object[]} coupons Array of valid coupons (forever or repeating duration)
* @apiSuccess {String} coupons.id Coupon ID
* @apiSuccess {String} coupons.name Coupon name
* @apiSuccess {Number} [coupons.percent_off] Percent discount (e.g., 50 for 50% off)
* @apiSuccess {Number} [coupons.amount_off] Fixed amount discount in cents
* @apiSuccess {String} [coupons.currency] Currency for amount_off (e.g., 'usd')
* @apiSuccess {String} coupons.duration 'forever' or 'repeating'
* @apiSuccess {Number} [coupons.duration_in_months] Number of months (only for repeating coupons)
* @apiSuccess {Boolean} coupons.valid Whether coupon is valid
* @apiSuccess {Number} coupons.created Unix timestamp of creation
*
* @apiSuccessExample {json} Success-Response:
* HTTP/1.1 200 OK
* [
* {
* "id": "PROMO50",
* "name": "50% Off Forever",
* "percent_off": 50,
* "duration": "forever",
* "valid": true,
* "created": 1640995200
* },
* {
* "id": "LOYALTY30",
* "name": "30% Off for 6 Months",
* "percent_off": 30,
* "duration": "repeating",
* "duration_in_months": 6,
* "valid": true,
* "created": 1640995300
* }
* ]
*
* @apiError (401) {Object} error Not authorized
* @apiError (401) {String} error..tag "not_authorized"
*/
async function getForeverCoupons_get(req, res) {
const userInfo = req.userInfo;
if (!userInfo || !isSysAdmin(userInfo.kind)) AppAuthError.throw();
const { stripe } = require('../helpers/subscription_util');
if (!stripe) {
return res.json([]);
}
try {
// Fetch all active coupons from Stripe (coupons are global discount rules)
// Note: This returns base coupons, not promotion codes (which can have customer restrictions)
const coupons = await stripe.coupons.list({ limit: 100 });
// Filter for forever and repeating duration (exclude 'once')
// Returns base coupons which can be applied to any customer
// Promotion codes (customer-specific) are handled separately in subscription endpoints
const validCoupons = coupons.data
.filter(c => !c.deleted && (c.duration === CouponDuration.FOREVER || c.duration === CouponDuration.REPEATING))
.map(c => ({
id: c.id,
name: c.name || c.id,
percent_off: c.percent_off,
amount_off: c.amount_off,
currency: c.currency,
duration: c.duration,
duration_in_months: c.duration_in_months,
valid: c.valid,
created: c.created,
// Note: Coupons themselves don't have customer restrictions
// Customer restrictions are enforced through promotion codes
applies_to: c.applies_to // Product restrictions if any
}));
// !env.PRODUCTION && debug(`Found ${validCoupons.length} forever/repeating duration coupons (base coupons, no customer restrictions)`);
res.json(validCoupons);
} catch (err) {
debug('Error fetching coupons:', err.message);
res.json([]);
}
}
/**
* Add a single subscription promo (admin only)
* Validates that coupon exists and has duration='forever'
*/
async function addSubscriptionPromo_post(req, res) {
const userInfo = req.userInfo;
if (!userInfo || !isSysAdmin(userInfo.kind)) AppAuthError.throw();
const promo = req.body;
if (!promo || typeof promo !== 'object') {
throw new AppParamError(Errors.INVALID_PARAM, 'Promo data is required and must be an object');
}
if (promo.type && !Object.values(SubType).includes(promo.type)) {
throw new AppParamError(Errors.INVALID_PARAM, `Invalid type: ${promo.type}. Must be 'package' or 'addon'`);
}
// NEW: Validate priority
if (promo.priority !== undefined && (typeof promo.priority !== 'number' || promo.priority < 0)) {
throw new AppParamError(Errors.INVALID_PARAM, 'Priority must be a non-negative number');
}
// NEW: Validate chainable
if (promo.chainable !== undefined && typeof promo.chainable !== 'boolean') {
throw new AppParamError(Errors.INVALID_PARAM, 'Chainable must be a boolean');
}
// NEW: Validate eligibility
if (promo.eligibility && !Object.values(PromoEligibility).includes(promo.eligibility)) {
throw new AppParamError(Errors.INVALID_PARAM, `Invalid eligibility: ${promo.eligibility}`);
}
// NEW: Validate durationInMonths
if (promo.durationInMonths !== undefined &&
(typeof promo.durationInMonths !== 'number' || promo.durationInMonths < 1)) {
throw new AppParamError(Errors.INVALID_PARAM, 'durationInMonths must be a positive number');
}
// Validate coupon if provided
if (promo.couponId) {
const { stripe } = require('../helpers/subscription_util');
if (!stripe) {
throw new AppParamError(Errors.SUBSCRIPTION_NOT_ENABLED, 'Stripe not configured');
}
try {
const stripeCoupon = await stripe.coupons.retrieve(promo.couponId);
// Validate coupon exists and is valid
if (!stripeCoupon || stripeCoupon.deleted) {
throw new AppParamError(Errors.PROMO_COUPON_NOT_FOUND, `Coupon ${promo.couponId} does not exist or has been deleted`);
}
// NEW: Accept both forever and repeating coupons
if (!Object.values(CouponDuration).includes(stripeCoupon.duration) || stripeCoupon.duration === CouponDuration.ONCE) {
throw new AppParamError(Errors.INVALID_PARAM,
`Only coupons with duration='${CouponDuration.FOREVER}' or '${CouponDuration.REPEATING}' are supported. Coupon ${promo.couponId} has duration='${stripeCoupon.duration}'`);
}
// NEW: Auto-populate durationInMonths for repeating coupons
if (stripeCoupon.duration === CouponDuration.REPEATING) {
promo.durationInMonths = stripeCoupon.duration_in_months;
!env.PRODUCTION && debug(`Repeating coupon detected: ${stripeCoupon.duration_in_months} months`);
}
!env.PRODUCTION && debug(`Validated coupon ${promo.couponId}: duration=${stripeCoupon.duration}, valid=true`);
} catch (stripeError) {
if (stripeError.type === StripeErrorTypes.INVALID_REQUEST) {
throw new AppParamError(Errors.PROMO_COUPON_NOT_FOUND, `Invalid coupon: ${stripeError.message}`);
}
throw stripeError;
}
}
// Sanitize promo fields - convert empty strings to null/undefined
const promoWithDate = {
...promo,
// Convert empty strings to undefined for optional enum fields
type: promo.type || undefined,
priceKey: promo.priceKey || undefined,
eligibility: promo.eligibility || PromoEligibility.ALL,
priority: promo.priority || 0,
chainable: promo.chainable || false,
createdAt: new Date()
};
// Check for duplicates before adding
const settings = await Settings.findOne({ userId: null }).lean();
const existingPromos = settings?.subscriptionPromos || [];
// Check 1: Duplicate type + priceKey + eligibility (if all specified)
if (promoWithDate.type && promoWithDate.priceKey && promoWithDate.eligibility) {
const duplicateTypePrice = existingPromos.find(p =>
p.type === promoWithDate.type &&
p.priceKey === promoWithDate.priceKey &&
p.eligibility === promoWithDate.eligibility &&
p.enabled !== false
);
if (duplicateTypePrice) {
throw new AppParamError(Errors.PROMO_DUPLICATE_TYPE_PRICEKEY,
`Active promo already exists for ${promoWithDate.type}/${promoWithDate.priceKey}/${promoWithDate.eligibility}: "${duplicateTypePrice.name}"`);
}
}
// Check 2: Duplicate couponId (if specified)
if (promoWithDate.couponId) {
const duplicateCoupon = existingPromos.find(p =>
p.couponId === promoWithDate.couponId &&
p.enabled !== false
);
if (duplicateCoupon) {
throw new AppParamError(Errors.PROMO_DUPLICATE_COUPON,
`Active promo already uses coupon ${promoWithDate.couponId}: "${duplicateCoupon.name}"`);
}
}
// Check 3: Overlapping validUntil dates for same type/priceKey/eligibility (if all specified)
if (promoWithDate.validUntil && promoWithDate.type && promoWithDate.priceKey && promoWithDate.eligibility) {
const newValidUntil = moment.utc(promoWithDate.validUntil);
const overlapping = existingPromos.find(p =>
p.type === promoWithDate.type &&
p.priceKey === promoWithDate.priceKey &&
p.eligibility === promoWithDate.eligibility &&
p.validUntil &&
p.enabled !== false &&
moment.utc(p.validUntil).isAfter(moment.utc()) && // Still active
newValidUntil.isAfter(moment.utc()) // New promo is also future-dated
);
if (overlapping) {
throw new AppParamError(Errors.PROMO_OVERLAPPING_DATES,
`Overlapping promo period for ${promoWithDate.type}/${promoWithDate.priceKey}/${promoWithDate.eligibility}: "${overlapping.name}" (valid until ${overlapping.validUntil})`);
}
}
const updated = await Settings.findOneAndUpdate(
{ userId: null },
{ $push: { subscriptionPromos: promoWithDate } },
{ upsert: true, new: true, lean: true }
);
debug('Added subscription promo:', promo.name, 'with coupon:', promo.couponId);
res.json({
promos: updated.subscriptionPromos || [],
currentMode: getCurrentPromoModeInfo()
});
}
/**
* Disable a subscription promo by _id (admin only)
* Promos that have been applied to subscriptions require a validUntil date to be set.
* Minimum grace period is controlled by PROMO_MIN_EXPIRY_DAYS env (default: 3 days).
*/
async function deleteSubscriptionPromo_delete(req, res) {
const userInfo = req.userInfo;
if (!userInfo || !isSysAdmin(userInfo.kind)) AppAuthError.throw();
const promoId = req.params.id;
if (!promoId) AppParamError.throw();
const settings = await Settings.findOne({ userId: null });
if (!settings?.subscriptionPromos) AppParamError.throw();
const promo = settings.subscriptionPromos.find(p => p._id?.toString() === promoId);
if (!promo) AppParamError.throw(Errors.NOT_FOUND);
// Check if promo has been used
if (promo.usageCount > 0) {
const minExpiryDays = parseInt(process.env.PROMO_MIN_EXPIRY_DAYS, 10) || 3;
const validUntil = req.body?.validUntil;
// Require validUntil to be provided for promos with usage
if (!validUntil) {
AppParamError.throw(Errors.PROMO_IN_USE_VALID_UNTIL_REQUIRED);
}
// Validate validUntil is at least minExpiryDays from now
const newValidUntil = moment.utc(validUntil);
const minValidUntil = moment.utc().add(minExpiryDays, 'days');
if (!newValidUntil.isValid()) {
AppParamError.throw(Errors.PROMO_INVALID_VALID_UNTIL);
}
if (newValidUntil.isBefore(minValidUntil)) {
AppParamError.throw(Errors.PROMO_VALID_UNTIL_TOO_SOON);
}
// Use transaction to ensure atomic update of promo
// NOTE: Promo validUntil only affects NEW subscriptions, not existing ones
// Existing subscriptions keep their current discount until the subscription ends
const result = await enhancedRunInTransaction(async (session) => {
// Update promo: disable and set new validUntil
promo.enabled = false;
promo.validUntil = newValidUntil.toDate();
sanitizePromos(settings);
await settings.save({ session });
debug('Disabled subscription promo (has usage):', promo.name, '(id:', promoId, '), usageCount:', promo.usageCount, ', validUntil:', promo.validUntil);
return {
action: APIActions.DISABLED,
promo: {
_id: promo._id,
name: promo.name,
usageCount: promo.usageCount,
validUntil: promo.validUntil,
couponId: promo.couponId,
enabled: false
}
};
});
return res.json(result);
}
// No usage - safe to delete (use transaction for consistency)
const result = await enhancedRunInTransaction(async (session) => {
const promoIndex = settings.subscriptionPromos.findIndex(p => p._id?.toString() === promoId);
const deletedPromo = { _id: promo._id, name: promo.name };
settings.subscriptionPromos.splice(promoIndex, 1);
sanitizePromos(settings);
await settings.save({ session });
debug('Deleted subscription promo (no usage):', promo.name, '(id:', promoId, ')');
return {
action: APIActions.DELETED,
promo: deletedPromo
};
});
res.json(result);
}
/**
* Update a subscription promo (System Admin only)
* Updatable fields: name, nameKey, descriptionKey, description, validUntil, enabled,
* discountType, discountValue, eligibility, priority, chainable, durationInMonths.
* Note: type, priceKey, and couponId are immutable after creation.
* When validUntil changes:
* - For subscriptions with active schedules (auto-renew ON), update the coupon phase end_date
* - Subscriptions that were released (user set cancel_at_period_end=true) are NOT affected
* Users control subscription lifecycle via cancelAtPeriodEnd, promo controls coupon duration.
*/
async function updateSubscriptionPromo_put(req, res) {
const userInfo = req.userInfo;
if (!userInfo || !isSysAdmin(userInfo.kind)) AppAuthError.throw();
const promoId = req.params.id;
const updates = req.body;
if (!promoId || !updates || typeof updates !== 'object') AppParamError.throw();
const settings = await Settings.findOne({ userId: null });
if (!settings?.subscriptionPromos) {
AppParamError.throw(Errors.PROMO_NOT_FOUND);
}
const promo = settings.subscriptionPromos.find(p => p._id?.toString() === promoId);
if (!promo) {
AppParamError.throw(Errors.PROMO_NOT_FOUND);
}
// Track if eligibility cutoff (validUntil) or discount end date (discountEndsAt) is changing.
// Only discountEndsAt changes propagate to existing schedule phase end_dates.
const oldValidUntil = promo.validUntil;
const newValidUntil = updates.validUntil ? moment.utc(updates.validUntil) : null;
const validUntilChanged = newValidUntil && newValidUntil.isValid() &&
(!oldValidUntil || !moment.utc(oldValidUntil).isSame(newValidUntil));
const oldDiscountEndsAt = promo.discountEndsAt;
const newDiscountEndsAt = updates.discountEndsAt ? moment.utc(updates.discountEndsAt) : null;
const discountEndsAtChanged = newDiscountEndsAt && newDiscountEndsAt.isValid() &&
(!oldDiscountEndsAt || !moment.utc(oldDiscountEndsAt).isSame(newDiscountEndsAt));
// Validate new validUntil if provided
if (newValidUntil && !newValidUntil.isValid()) {
AppParamError.throw(Errors.PROMO_INVALID_VALID_UNTIL);
}
// Validate new discountEndsAt if provided
if (newDiscountEndsAt && !newDiscountEndsAt.isValid()) {
throw new AppParamError(Errors.INVALID_PARAM, 'discountEndsAt must be a valid date');
}
// Validate eligibility if provided
if (updates.eligibility !== undefined && !Object.values(PromoEligibility).includes(updates.eligibility)) {
throw new AppParamError(Errors.INVALID_PARAM, `Invalid eligibility: ${updates.eligibility}. Must be one of: ${Object.values(PromoEligibility).join(', ')}`);
}
// Validate priority if provided
if (updates.priority !== undefined && (typeof updates.priority !== 'number' || updates.priority < 0)) {
throw new AppParamError(Errors.INVALID_PARAM, 'Priority must be a non-negative number');
}
// Validate chainable if provided
if (updates.chainable !== undefined && typeof updates.chainable !== 'boolean') {
throw new AppParamError(Errors.INVALID_PARAM, 'Chainable must be a boolean');
}
// Validate durationInMonths if provided
if (updates.durationInMonths !== undefined && updates.durationInMonths !== null &&
(typeof updates.durationInMonths !== 'number' || updates.durationInMonths < 1)) {
throw new AppParamError(Errors.INVALID_PARAM, 'durationInMonths must be a positive number');
}
// Use transaction for atomic update
const result = await enhancedRunInTransaction(async (session) => {
// Update allowed fields
const allowedFields = ['name', 'nameKey', 'descriptionKey', 'description', 'validUntil', 'discountEndsAt', 'enabled',
'discountType', 'discountValue', 'eligibility', 'priority', 'chainable', 'durationInMonths'];
for (const field of allowedFields) {
if (updates[field] !== undefined) {
promo[field] = updates[field];
}
}
sanitizePromos(settings);
await settings.save({ session });
let scheduleResults = null;
// If discountEndsAt changed, propagate the new discount end date to all active schedules.
// validUntil is the eligibility window only — changing it does NOT touch existing schedules.
// This only affects subscriptions with active schedules (end_behavior: 'release').
// Subscriptions where user set cancel_at_period_end (schedule released) are NOT affected.
const effectiveDiscountEnd = discountEndsAtChanged ? newDiscountEndsAt : null;
if (effectiveDiscountEnd && promo.usageCount > 0) {
scheduleResults = await updatePromoSubscriptionSchedules(promoId, effectiveDiscountEnd.toDate());
}
debug('Updated subscription promo:', promo.name, '(id:', promoId, ')');
return {
action: APIActions.UPDATED,
promo: {
_id: promo._id,
name: promo.name,
nameKey: promo.nameKey,
validUntil: promo.validUntil,
enabled: promo.enabled,
usageCount: promo.usageCount
},
...(scheduleResults && {
schedulesUpdated: scheduleResults.updated,
schedulesSkipped: scheduleResults.skipped,
scheduleErrors: scheduleResults.errors.length > 0 ? scheduleResults.errors : undefined
})
};
});
res.json(result);
}
module.exports = {
pingAPI_get, getAppConfig_get, setAppConfig_post, getSiteVer_post, doLongOp_post,
getActivePromos_get, getSubscriptionPromos_get, setSubscriptionPromos_post,
addSubscriptionPromo_post, deleteSubscriptionPromo_delete, updateSubscriptionPromo_put,
getForeverCoupons_get
}