Files
inventory/inventory-server/dashboard/acot-server/routes/discounts.js

577 lines
20 KiB
JavaScript

const express = require('express');
const { DateTime } = require('luxon');
const { getDbConnection } = require('../db/connection');
const router = express.Router();
// Bucket boundaries by summary_subtotal (post-item-sale, pre-order-promo).
// The final entry is open-ended: all orders >= the last bound land there.
const RANGE_BOUNDS = [
10, 20, 30, 40, 50, 60, 70, 80, 90,
100, 110, 120, 130, 140, 150, 160, 170, 180, 190, 200,
300, 400, 500, 1000, 1500
];
const FINAL_BUCKET_KEY = '99999';
function buildRangeDefinitions() {
const ranges = [];
let previous = 0;
for (const bound of RANGE_BOUNDS) {
const key = bound.toString().padStart(5, '0');
ranges.push({
min: previous,
max: bound,
label: `$${previous.toLocaleString()} - $${bound.toLocaleString()}`,
key,
});
previous = bound;
}
const lastBound = RANGE_BOUNDS[RANGE_BOUNDS.length - 1];
ranges.push({
min: lastBound,
max: null,
label: `$${lastBound.toLocaleString()}+`,
key: FINAL_BUCKET_KEY,
});
return ranges;
}
const RANGE_DEFINITIONS = buildRangeDefinitions();
function bucketKeyFor(subtotal) {
for (const range of RANGE_DEFINITIONS) {
if (range.max == null) return range.key;
if (subtotal <= range.max) return range.key;
}
return FINAL_BUCKET_KEY;
}
const DEFAULT_POINT_DOLLAR_VALUE = 0.005;
const DEFAULTS = {
merchantFeePercent: 2.9,
fixedCostPerOrder: 1.25,
pointDollarValue: DEFAULT_POINT_DOLLAR_VALUE,
};
function parseDate(value, fallback) {
if (!value) {
return fallback;
}
const parsed = DateTime.fromISO(value);
if (!parsed.isValid) {
return fallback;
}
return parsed;
}
function formatDateForSql(dt) {
return dt.toFormat('yyyy-LL-dd HH:mm:ss');
}
router.get('/promos', async (req, res) => {
let connection;
try {
const { connection: conn, release } = await getDbConnection();
connection = conn;
const releaseConnection = release;
const { startDate, endDate } = req.query || {};
const now = DateTime.now().endOf('day');
const defaultStart = now.minus({ years: 3 }).startOf('day');
const parsedStart = startDate ? parseDate(startDate, defaultStart).startOf('day') : defaultStart;
const parsedEnd = endDate ? parseDate(endDate, now).endOf('day') : now;
const rangeStart = parsedStart <= parsedEnd ? parsedStart : parsedEnd;
const rangeEnd = parsedEnd >= parsedStart ? parsedEnd : parsedStart;
const rangeStartSql = formatDateForSql(rangeStart);
const rangeEndSql = formatDateForSql(rangeEnd);
const sql = `
SELECT
p.promo_id AS id,
p.promo_code AS code,
p.promo_description_online AS description_online,
p.promo_description_private AS description_private,
p.date_start,
p.date_end,
COALESCE(u.usage_count, 0) AS usage_count
FROM promos p
LEFT JOIN (
SELECT
discount_code,
COUNT(DISTINCT order_id) AS usage_count
FROM order_discounts
WHERE discount_type = 10 AND discount_active = 1
GROUP BY discount_code
) u ON u.discount_code = p.promo_id
WHERE p.date_start IS NOT NULL
AND p.date_end IS NOT NULL
AND NOT (p.date_end < ? OR p.date_start > ?)
AND p.store = 1
AND p.date_start >= '2010-01-01'
ORDER BY p.promo_id DESC
LIMIT 200
`;
const [rows] = await connection.execute(sql, [rangeStartSql, rangeEndSql]);
releaseConnection();
const promos = rows.map(row => ({
id: Number(row.id),
code: row.code,
description: row.description_online || row.description_private || '',
privateDescription: row.description_private || '',
promo_description_online: row.description_online || '',
promo_description_private: row.description_private || '',
dateStart: row.date_start,
dateEnd: row.date_end,
usageCount: Number(row.usage_count || 0)
}));
res.json({ promos });
} catch (error) {
if (connection) {
try {
connection.destroy();
} catch (destroyError) {
console.error('Failed to destroy connection after error:', destroyError);
}
}
console.error('Error fetching promos:', error);
res.status(500).json({ error: 'Failed to fetch promos' });
}
});
function emptyBucketAccumulator(range) {
return {
key: range.key,
label: range.label,
min: range.min,
max: range.max,
orderCount: 0,
sumOrderValue: 0,
sumProductDiscountAmount: 0,
sumPromoProductDiscount: 0,
sumCustomerItemCost: 0,
sumShippingChargeBase: 0,
sumShippingAfterAuto: 0,
sumShipPromoDiscount: 0,
sumShippingSurcharge: 0,
sumOrderSurcharge: 0,
sumCustomerShipCost: 0,
sumActualShippingCost: 0,
sumTotalRevenue: 0,
sumProductCogs: 0,
sumMerchantFees: 0,
sumPointsCost: 0,
sumFixedCosts: 0,
sumTotalCosts: 0,
sumProfit: 0,
};
}
function simulateOrder(order, config, derived) {
const orderValue = Number(order.summary_subtotal) || 0;
const retail = Number(order.summary_subtotal_retail) || orderValue;
const productDiscountAmount = Number(order.summary_discount_subtotal) || 0;
const pointsRedeemedDollars = Number(order.points_redeemed) || 0;
// summary_discount_subtotal is a kitchen-sink rollup that includes points
// redemptions (type 20). pointsCost already accrues for points awarded, so
// the points portion of historical discount must be excluded here to avoid
// double-counting it on orders that redeemed points.
const historicalProductDiscountExPoints = Math.max(0, productDiscountAmount - pointsRedeemedDollars);
const shippingChargeBase =
(Number(order.summary_shipping) || 0) + (Number(order.summary_shipping_rush) || 0);
const actualShippingCost = Number(order.ship_method_cost) || 0;
const cogs = Number(order.total_cogs) || 0;
let promoProductDiscount = 0;
if (config.productPromo.type === 'percentage_subtotal' && orderValue >= config.productPromo.minSubtotal) {
promoProductDiscount = orderValue * (config.productPromo.value / 100);
} else if (config.productPromo.type === 'percentage_regular' && orderValue >= config.productPromo.minSubtotal) {
const targetRate = config.productPromo.value / 100;
const targetCustomerPrice = retail * (1 - targetRate);
promoProductDiscount = Math.max(0, orderValue - targetCustomerPrice);
} else if (config.productPromo.type === 'fixed_amount' && orderValue >= config.productPromo.minSubtotal) {
promoProductDiscount = config.productPromo.value;
} else if (config.productPromo.type === 'none' && config.applyHistoricalProductPromo) {
promoProductDiscount = historicalProductDiscountExPoints;
}
promoProductDiscount = Math.max(0, Math.min(promoProductDiscount, orderValue));
let shippingAfterAuto = shippingChargeBase;
for (const tier of config.shippingTiers) {
if (orderValue >= tier.threshold) {
if (tier.mode === 'percentage') {
shippingAfterAuto = shippingChargeBase * Math.max(0, 1 - tier.value / 100);
} else if (tier.mode === 'flat') {
shippingAfterAuto = tier.value;
}
}
}
let shipPromoDiscount = 0;
if (config.shippingPromo.type !== 'none' && orderValue >= config.shippingPromo.minSubtotal) {
if (config.shippingPromo.type === 'percentage') {
shipPromoDiscount = shippingAfterAuto * (config.shippingPromo.value / 100);
} else if (config.shippingPromo.type === 'fixed') {
shipPromoDiscount = config.shippingPromo.value;
}
if (config.shippingPromo.maxDiscount > 0) {
shipPromoDiscount = Math.min(shipPromoDiscount, config.shippingPromo.maxDiscount);
}
shipPromoDiscount = Math.min(shipPromoDiscount, shippingAfterAuto);
}
let shippingSurcharge = 0;
let orderSurcharge = 0;
for (const surcharge of config.surcharges) {
const meetsMin = orderValue >= surcharge.threshold;
const meetsMax = surcharge.maxThreshold == null || orderValue < surcharge.maxThreshold;
if (meetsMin && meetsMax) {
if (surcharge.target === 'shipping') shippingSurcharge += surcharge.amount;
else if (surcharge.target === 'order') orderSurcharge += surcharge.amount;
}
}
const customerShipCost = Math.max(0, shippingAfterAuto - shipPromoDiscount + shippingSurcharge);
const customerItemCost = Math.max(0, orderValue - promoProductDiscount + orderSurcharge);
const totalRevenue = customerItemCost + customerShipCost;
const productCogs = config.cogsCalculationMode === 'average'
? orderValue * derived.overallCogsPercentage
: cogs;
const merchantFees = totalRevenue * (config.merchantFeePercent / 100);
const pointsCost = orderValue * derived.pointsPerDollar * derived.redemptionRate * derived.pointDollarValue;
const fixedCosts = config.fixedCostPerOrder;
const totalCosts = productCogs + actualShippingCost + merchantFees + pointsCost + fixedCosts;
const profit = totalRevenue - totalCosts;
return {
orderValue,
productDiscountAmount,
promoProductDiscount,
customerItemCost,
shippingChargeBase,
shippingAfterAuto,
shipPromoDiscount,
shippingSurcharge,
orderSurcharge,
customerShipCost,
actualShippingCost,
totalRevenue,
productCogs,
merchantFees,
pointsCost,
fixedCosts,
totalCosts,
profit,
};
}
function accumulate(bucket, sim) {
bucket.orderCount += 1;
bucket.sumOrderValue += sim.orderValue;
bucket.sumProductDiscountAmount += sim.productDiscountAmount;
bucket.sumPromoProductDiscount += sim.promoProductDiscount;
bucket.sumCustomerItemCost += sim.customerItemCost;
bucket.sumShippingChargeBase += sim.shippingChargeBase;
bucket.sumShippingAfterAuto += sim.shippingAfterAuto;
bucket.sumShipPromoDiscount += sim.shipPromoDiscount;
bucket.sumShippingSurcharge += sim.shippingSurcharge;
bucket.sumOrderSurcharge += sim.orderSurcharge;
bucket.sumCustomerShipCost += sim.customerShipCost;
bucket.sumActualShippingCost += sim.actualShippingCost;
bucket.sumTotalRevenue += sim.totalRevenue;
bucket.sumProductCogs += sim.productCogs;
bucket.sumMerchantFees += sim.merchantFees;
bucket.sumPointsCost += sim.pointsCost;
bucket.sumFixedCosts += sim.fixedCosts;
bucket.sumTotalCosts += sim.totalCosts;
bucket.sumProfit += sim.profit;
}
function finalizeBucket(b, totalOrders) {
const n = b.orderCount;
const avg = (sum) => (n > 0 ? sum / n : 0);
return {
key: b.key,
label: b.label,
min: b.min,
max: b.max,
orderCount: n,
weight: totalOrders > 0 ? n / totalOrders : 0,
orderValue: avg(b.sumOrderValue),
productDiscountAmount: avg(b.sumProductDiscountAmount),
promoProductDiscount: avg(b.sumPromoProductDiscount),
customerItemCost: avg(b.sumCustomerItemCost),
shippingChargeBase: avg(b.sumShippingChargeBase),
shippingAfterAuto: avg(b.sumShippingAfterAuto),
shipPromoDiscount: avg(b.sumShipPromoDiscount),
shippingSurcharge: avg(b.sumShippingSurcharge),
orderSurcharge: avg(b.sumOrderSurcharge),
customerShipCost: avg(b.sumCustomerShipCost),
actualShippingCost: avg(b.sumActualShippingCost),
totalRevenue: avg(b.sumTotalRevenue),
productCogs: avg(b.sumProductCogs),
merchantFees: avg(b.sumMerchantFees),
pointsCost: avg(b.sumPointsCost),
fixedCosts: avg(b.sumFixedCosts),
totalCosts: avg(b.sumTotalCosts),
profit: avg(b.sumProfit),
profitPercent: b.sumTotalRevenue > 0 ? b.sumProfit / b.sumTotalRevenue : 0,
};
}
router.post('/simulate', async (req, res) => {
const {
dateRange = {},
filters = {},
productPromo = {},
shippingPromo = {},
shippingTiers = [],
surcharges = [],
merchantFeePercent,
fixedCostPerOrder,
cogsCalculationMode = 'actual',
applyHistoricalProductPromo = false,
pointsConfig = {}
} = req.body || {};
const endDefault = DateTime.now();
const startDefault = endDefault.minus({ months: 6 });
const startDt = parseDate(dateRange.start, startDefault).startOf('day');
const endDt = parseDate(dateRange.end, endDefault).endOf('day');
const shipCountry = filters.shipCountry || 'US';
const promoIds = Array.from(
new Set(
[
...(Array.isArray(filters.promoIds) ? filters.promoIds : []),
...(Array.isArray(filters.promoCodes) ? filters.promoCodes : []),
]
.map((value) => {
if (typeof value === 'string') return value.trim();
if (typeof value === 'number') return String(value);
return '';
})
.filter((value) => value.length > 0)
)
);
const config = {
merchantFeePercent: typeof merchantFeePercent === 'number' ? merchantFeePercent : DEFAULTS.merchantFeePercent,
fixedCostPerOrder: typeof fixedCostPerOrder === 'number' ? fixedCostPerOrder : DEFAULTS.fixedCostPerOrder,
cogsCalculationMode,
applyHistoricalProductPromo: applyHistoricalProductPromo === true,
productPromo: {
type: productPromo.type || 'none',
value: Number(productPromo.value || 0),
minSubtotal: Number(productPromo.minSubtotal || 0)
},
shippingPromo: {
type: shippingPromo.type || 'none',
value: Number(shippingPromo.value || 0),
minSubtotal: Number(shippingPromo.minSubtotal || 0),
maxDiscount: Number(shippingPromo.maxDiscount || 0)
},
shippingTiers: Array.isArray(shippingTiers)
? shippingTiers
.map(tier => ({
threshold: Number(tier.threshold || 0),
mode: tier.mode === 'percentage' || tier.mode === 'flat' ? tier.mode : 'percentage',
value: Number(tier.value || 0)
}))
.filter(tier => tier.threshold >= 0 && tier.value >= 0)
.sort((a, b) => a.threshold - b.threshold)
: [],
surcharges: Array.isArray(surcharges)
? surcharges
.map(s => ({
threshold: Number(s.threshold || 0),
maxThreshold: typeof s.maxThreshold === 'number' && s.maxThreshold > 0 ? s.maxThreshold : null,
target: s.target === 'shipping' || s.target === 'order' ? s.target : 'shipping',
amount: Number(s.amount || 0)
}))
.filter(s => s.threshold >= 0 && s.amount >= 0)
.sort((a, b) => a.threshold - b.threshold)
: [],
points: {
pointsPerDollar: typeof pointsConfig.pointsPerDollar === 'number' ? pointsConfig.pointsPerDollar : null,
redemptionRate: typeof pointsConfig.redemptionRate === 'number' ? pointsConfig.redemptionRate : null,
pointDollarValue: typeof pointsConfig.pointDollarValue === 'number'
? pointsConfig.pointDollarValue
: DEFAULT_POINT_DOLLAR_VALUE
}
};
let connection;
let release;
try {
const dbConn = await getDbConnection();
connection = dbConn.connection;
release = dbConn.release;
const params = [shipCountry, formatDateForSql(startDt), formatDateForSql(endDt)];
let promoExistsClause = '';
if (promoIds.length > 0) {
const placeholders = promoIds.map(() => '?').join(',');
promoExistsClause = `
AND EXISTS (
SELECT 1 FROM order_discounts od
WHERE od.order_id = o.order_id
AND od.discount_active = 1
AND od.discount_type = 10
AND od.discount_code IN (${placeholders})
)
`;
params.push(...promoIds);
}
const ordersQuery = `
SELECT
o.order_id,
o.summary_subtotal,
COALESCE(o.summary_subtotal_retail, o.summary_subtotal) AS summary_subtotal_retail,
COALESCE(o.summary_discount_subtotal, 0) AS summary_discount_subtotal,
COALESCE(o.summary_shipping, 0) AS summary_shipping,
COALESCE(o.summary_shipping_rush, 0) AS summary_shipping_rush,
COALESCE(o.ship_method_cost, 0) AS ship_method_cost,
COALESCE(o.summary_points, 0) AS summary_points,
COALESCE(c.total_cogs, 0) AS total_cogs,
COALESCE(p.points_redeemed, 0) AS points_redeemed
FROM _order o
LEFT JOIN (
SELECT order_id, SUM(cogs_amount) AS total_cogs
FROM report_sales_data
WHERE action IN (1,2,3)
GROUP BY order_id
) c ON c.order_id = o.order_id
LEFT JOIN (
SELECT order_id, SUM(discount_amount_subtotal) AS points_redeemed
FROM order_discounts
WHERE discount_type = 20 AND discount_active = 1
GROUP BY order_id
) p ON p.order_id = o.order_id
WHERE o.summary_total > 0
AND o.order_status >= 20
AND o.ship_method_selected <> 'holdit'
AND o.ship_country = ?
AND o.date_placed BETWEEN ? AND ?
${promoExistsClause}
`;
const [orders] = await connection.execute(ordersQuery, params);
if (release) {
release();
release = null;
}
let totalSubtotal = 0;
let totalProductDiscount = 0;
let totalCogs = 0;
let totalPointsAwarded = 0;
let totalPointsRedeemedDollars = 0;
for (const o of orders) {
totalSubtotal += Number(o.summary_subtotal) || 0;
totalProductDiscount += Number(o.summary_discount_subtotal) || 0;
totalCogs += Number(o.total_cogs) || 0;
totalPointsAwarded += Number(o.summary_points) || 0;
totalPointsRedeemedDollars += Number(o.points_redeemed) || 0;
}
const productDiscountRate = totalSubtotal > 0 ? totalProductDiscount / totalSubtotal : 0;
const overallCogsPercentage = totalSubtotal > 0 ? totalCogs / totalSubtotal : 0;
const pointsPerDollar = config.points.pointsPerDollar != null
? config.points.pointsPerDollar
: (totalSubtotal > 0 ? totalPointsAwarded / totalSubtotal : 0);
const pointDollarValue = config.points.pointDollarValue || DEFAULT_POINT_DOLLAR_VALUE;
let redemptionRate;
if (config.points.redemptionRate != null) {
redemptionRate = config.points.redemptionRate;
} else if (totalPointsAwarded > 0 && pointDollarValue > 0) {
const totalRedeemedPoints = totalPointsRedeemedDollars / pointDollarValue;
redemptionRate = Math.min(1, totalRedeemedPoints / totalPointsAwarded);
} else {
redemptionRate = 0;
}
const derived = {
overallCogsPercentage,
pointsPerDollar,
redemptionRate,
pointDollarValue,
};
const buckets = new Map();
for (const range of RANGE_DEFINITIONS) {
buckets.set(range.key, emptyBucketAccumulator(range));
}
let grandTotalProfit = 0;
let grandTotalRevenue = 0;
for (const order of orders) {
const sim = simulateOrder(order, config, derived);
const bucketKey = bucketKeyFor(sim.orderValue);
const bucket = buckets.get(bucketKey);
accumulate(bucket, sim);
grandTotalProfit += sim.profit;
grandTotalRevenue += sim.totalRevenue;
}
const totalOrders = orders.length;
const bucketResults = RANGE_DEFINITIONS.map((range) =>
finalizeBucket(buckets.get(range.key), totalOrders)
);
const weightedProfitAmount = totalOrders > 0 ? grandTotalProfit / totalOrders : 0;
const weightedProfitPercent = grandTotalRevenue > 0 ? grandTotalProfit / grandTotalRevenue : 0;
res.json({
dateRange: {
start: startDt.toISO(),
end: endDt.toISO()
},
totals: {
orders: totalOrders,
subtotal: totalSubtotal,
productDiscountRate,
pointsPerDollar,
redemptionRate,
pointDollarValue,
weightedProfitAmount,
weightedProfitPercent,
overallCogsPercentage: cogsCalculationMode === 'average' ? overallCogsPercentage : undefined
},
buckets: bucketResults
});
} catch (error) {
if (release) {
try {
release();
} catch (releaseError) {
console.error('Failed to release connection after error:', releaseError);
}
} else if (connection) {
try {
connection.destroy();
} catch (destroyError) {
console.error('Failed to destroy connection after error:', destroyError);
}
}
console.error('Error running discount simulation:', error);
res.status(500).json({ error: 'Failed to run discount simulation' });
}
});
module.exports = router;