806 lines
30 KiB
JavaScript
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
|
|
}
|