'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 }