Discount simulator fixes/adjustments

This commit is contained in:
2026-04-28 14:28:45 -04:00
parent 8721ba67df
commit edfa86608c
7 changed files with 411 additions and 341 deletions

View File

@@ -4,57 +4,54 @@ const { getDbConnection } = require('../db/connection');
const router = express.Router(); 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 = [ const RANGE_BOUNDS = [
10, 20, 30, 40, 50, 60, 70, 80, 90, 10, 20, 30, 40, 50, 60, 70, 80, 90,
100, 110, 120, 130, 140, 150, 160, 170, 180, 190, 200, 100, 110, 120, 130, 140, 150, 160, 170, 180, 190, 200,
300, 400, 500, 1000, 1500, 2000 300, 400, 500, 1000, 1500
]; ];
const FINAL_BUCKET_KEY = 'PLUS'; const FINAL_BUCKET_KEY = '99999';
function buildRangeDefinitions() { function buildRangeDefinitions() {
const ranges = []; const ranges = [];
let previous = 0; let previous = 0;
for (const bound of RANGE_BOUNDS) { for (const bound of RANGE_BOUNDS) {
const label = `$${previous.toLocaleString()} - $${bound.toLocaleString()}`;
const key = bound.toString().padStart(5, '0'); const key = bound.toString().padStart(5, '0');
ranges.push({ ranges.push({
min: previous, min: previous,
max: bound, max: bound,
label, label: `$${previous.toLocaleString()} - $${bound.toLocaleString()}`,
key, key,
sort: bound
}); });
previous = bound; previous = bound;
} }
// Remove the 2000+ category - all orders >2000 will go into the 2000 bucket const lastBound = RANGE_BOUNDS[RANGE_BOUNDS.length - 1];
ranges.push({
min: lastBound,
max: null,
label: `$${lastBound.toLocaleString()}+`,
key: FINAL_BUCKET_KEY,
});
return ranges; return ranges;
} }
const RANGE_DEFINITIONS = buildRangeDefinitions(); const RANGE_DEFINITIONS = buildRangeDefinitions();
const BUCKET_CASE = (() => { function bucketKeyFor(subtotal) {
const parts = []; for (const range of RANGE_DEFINITIONS) {
for (let i = 0; i < RANGE_BOUNDS.length; i++) { if (range.max == null) return range.key;
const bound = RANGE_BOUNDS[i]; if (subtotal <= range.max) return range.key;
const key = bound.toString().padStart(5, '0');
if (i === RANGE_BOUNDS.length - 1) {
// For the last bucket (2000), include all orders >= 1500 (previous bound)
parts.push(`ELSE '${key}'`);
} else {
parts.push(`WHEN o.summary_subtotal <= ${bound} THEN '${key}'`);
}
} }
return `CASE\n ${parts.join('\n ')}\n END`; return FINAL_BUCKET_KEY;
})(); }
const DEFAULT_POINT_DOLLAR_VALUE = 0.005; // 1000 points = $5, so 200 points = $1 const DEFAULT_POINT_DOLLAR_VALUE = 0.005;
const DEFAULTS = { const DEFAULTS = {
merchantFeePercent: 2.9, merchantFeePercent: 2.9,
fixedCostPerOrder: 1.5, fixedCostPerOrder: 1.25,
pointsPerDollar: 0,
pointsRedemptionRate: 0, // Will be calculated from actual data
pointDollarValue: DEFAULT_POINT_DOLLAR_VALUE, pointDollarValue: DEFAULT_POINT_DOLLAR_VALUE,
}; };
@@ -73,13 +70,6 @@ function formatDateForSql(dt) {
return dt.toFormat('yyyy-LL-dd HH:mm:ss'); return dt.toFormat('yyyy-LL-dd HH:mm:ss');
} }
function getMidpoint(range) {
if (range.max == null) {
return range.min + 200; // Rough estimate for 2000+
}
return (range.min + range.max) / 2;
}
router.get('/promos', async (req, res) => { router.get('/promos', async (req, res) => {
let connection; let connection;
try { try {
@@ -156,6 +146,188 @@ router.get('/promos', async (req, res) => {
} }
}); });
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) => { router.post('/simulate', async (req, res) => {
const { const {
dateRange = {}, dateRange = {},
@@ -167,6 +339,7 @@ router.post('/simulate', async (req, res) => {
merchantFeePercent, merchantFeePercent,
fixedCostPerOrder, fixedCostPerOrder,
cogsCalculationMode = 'actual', cogsCalculationMode = 'actual',
applyHistoricalProductPromo = false,
pointsConfig = {} pointsConfig = {}
} = req.body || {}; } = req.body || {};
@@ -176,20 +349,15 @@ router.post('/simulate', async (req, res) => {
const endDt = parseDate(dateRange.end, endDefault).endOf('day'); const endDt = parseDate(dateRange.end, endDefault).endOf('day');
const shipCountry = filters.shipCountry || 'US'; const shipCountry = filters.shipCountry || 'US';
const rawPromoFilters = [ const promoIds = Array.from(
...(Array.isArray(filters.promoIds) ? filters.promoIds : []),
...(Array.isArray(filters.promoCodes) ? filters.promoCodes : []),
];
const promoCodes = Array.from(
new Set( new Set(
rawPromoFilters [
...(Array.isArray(filters.promoIds) ? filters.promoIds : []),
...(Array.isArray(filters.promoCodes) ? filters.promoCodes : []),
]
.map((value) => { .map((value) => {
if (typeof value === 'string') { if (typeof value === 'string') return value.trim();
return value.trim(); if (typeof value === 'number') return String(value);
}
if (typeof value === 'number') {
return String(value);
}
return ''; return '';
}) })
.filter((value) => value.length > 0) .filter((value) => value.length > 0)
@@ -199,6 +367,8 @@ router.post('/simulate', async (req, res) => {
const config = { const config = {
merchantFeePercent: typeof merchantFeePercent === 'number' ? merchantFeePercent : DEFAULTS.merchantFeePercent, merchantFeePercent: typeof merchantFeePercent === 'number' ? merchantFeePercent : DEFAULTS.merchantFeePercent,
fixedCostPerOrder: typeof fixedCostPerOrder === 'number' ? fixedCostPerOrder : DEFAULTS.fixedCostPerOrder, fixedCostPerOrder: typeof fixedCostPerOrder === 'number' ? fixedCostPerOrder : DEFAULTS.fixedCostPerOrder,
cogsCalculationMode,
applyHistoricalProductPromo: applyHistoricalProductPromo === true,
productPromo: { productPromo: {
type: productPromo.type || 'none', type: productPromo.type || 'none',
value: Number(productPromo.value || 0), value: Number(productPromo.value || 0),
@@ -248,300 +418,131 @@ router.post('/simulate', async (req, res) => {
connection = dbConn.connection; connection = dbConn.connection;
release = dbConn.release; release = dbConn.release;
const filteredOrdersParams = [ const params = [shipCountry, formatDateForSql(startDt), formatDateForSql(endDt)];
shipCountry, let promoExistsClause = '';
formatDateForSql(startDt), if (promoIds.length > 0) {
formatDateForSql(endDt) const placeholders = promoIds.map(() => '?').join(',');
]; promoExistsClause = `
const promoJoin = promoCodes.length > 0 AND EXISTS (
? 'JOIN order_discounts od ON od.order_id = o.order_id AND od.discount_active = 1 AND od.discount_type = 10' SELECT 1 FROM order_discounts od
: ''; WHERE od.order_id = o.order_id
AND od.discount_active = 1
let promoFilterClause = ''; AND od.discount_type = 10
if (promoCodes.length > 0) { AND od.discount_code IN (${placeholders})
const placeholders = promoCodes.map(() => '?').join(','); )
promoFilterClause = `AND od.discount_code IN (${placeholders})`; `;
filteredOrdersParams.push(...promoCodes); params.push(...promoIds);
} }
const filteredOrdersQuery = ` const ordersQuery = `
SELECT SELECT
o.order_id, o.order_id,
o.order_cid,
o.summary_subtotal, o.summary_subtotal,
o.summary_discount_subtotal, COALESCE(o.summary_subtotal_retail, o.summary_subtotal) AS summary_subtotal_retail,
o.summary_shipping, COALESCE(o.summary_discount_subtotal, 0) AS summary_discount_subtotal,
o.ship_method_rate, COALESCE(o.summary_shipping, 0) AS summary_shipping,
o.ship_method_cost, COALESCE(o.summary_shipping_rush, 0) AS summary_shipping_rush,
o.summary_points, COALESCE(o.ship_method_cost, 0) AS ship_method_cost,
${BUCKET_CASE} AS bucket_key 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 FROM _order o
${promoJoin}
WHERE o.summary_shipping > 0
AND o.summary_total > 0
AND o.order_status NOT IN (15)
AND o.ship_method_selected <> 'holdit'
AND o.ship_country = ?
AND o.date_placed BETWEEN ? AND ?
${promoFilterClause}
`;
const bucketParams = [
...filteredOrdersParams,
formatDateForSql(startDt),
formatDateForSql(endDt)
];
const bucketQuery = `
SELECT
f.bucket_key,
COUNT(*) AS order_count,
SUM(f.summary_subtotal) AS subtotal_sum,
SUM(f.summary_discount_subtotal) AS product_discount_sum,
SUM(f.summary_subtotal + f.summary_discount_subtotal) AS regular_subtotal_sum,
SUM(f.ship_method_rate) AS ship_rate_sum,
SUM(f.ship_method_cost) AS ship_cost_sum,
SUM(f.summary_points) AS points_awarded_sum,
SUM(COALESCE(p.points_redeemed, 0)) AS points_redeemed_sum,
SUM(COALESCE(c.total_cogs, 0)) AS cogs_sum,
AVG(f.summary_subtotal) AS avg_subtotal,
AVG(f.summary_discount_subtotal) AS avg_product_discount,
AVG(f.ship_method_rate) AS avg_ship_rate,
AVG(f.ship_method_cost) AS avg_ship_cost,
AVG(COALESCE(c.total_cogs, 0)) AS avg_cogs
FROM (
${filteredOrdersQuery}
) AS f
LEFT JOIN ( LEFT JOIN (
SELECT order_id, SUM(cogs_amount) AS total_cogs SELECT order_id, SUM(cogs_amount) AS total_cogs
FROM report_sales_data FROM report_sales_data
WHERE action IN (1,2,3) WHERE action IN (1,2,3)
AND date_change BETWEEN ? AND ?
GROUP BY order_id GROUP BY order_id
) AS c ON c.order_id = f.order_id ) c ON c.order_id = o.order_id
LEFT JOIN ( LEFT JOIN (
SELECT order_id, SUM(discount_amount) AS points_redeemed SELECT order_id, SUM(discount_amount_subtotal) AS points_redeemed
FROM order_discounts FROM order_discounts
WHERE discount_type = 20 AND discount_active = 1 WHERE discount_type = 20 AND discount_active = 1
GROUP BY order_id GROUP BY order_id
) AS p ON p.order_id = f.order_id ) p ON p.order_id = o.order_id
GROUP BY f.bucket_key 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 [rows] = await connection.execute(bucketQuery, bucketParams); const [orders] = await connection.execute(ordersQuery, params);
const totals = {
orders: 0,
subtotal: 0,
productDiscount: 0,
regularSubtotal: 0,
shipRate: 0,
shipCost: 0,
cogs: 0,
pointsAwarded: 0,
pointsRedeemed: 0
};
const rowMap = new Map();
for (const row of rows) {
const key = row.bucket_key || FINAL_BUCKET_KEY;
const parsed = {
orderCount: Number(row.order_count || 0),
subtotalSum: Number(row.subtotal_sum || 0),
productDiscountSum: Number(row.product_discount_sum || 0),
regularSubtotalSum: Number(row.regular_subtotal_sum || 0),
shipRateSum: Number(row.ship_rate_sum || 0),
shipCostSum: Number(row.ship_cost_sum || 0),
pointsAwardedSum: Number(row.points_awarded_sum || 0),
pointsRedeemedSum: Number(row.points_redeemed_sum || 0),
cogsSum: Number(row.cogs_sum || 0),
avgSubtotal: Number(row.avg_subtotal || 0),
avgProductDiscount: Number(row.avg_product_discount || 0),
avgShipRate: Number(row.avg_ship_rate || 0),
avgShipCost: Number(row.avg_ship_cost || 0),
avgCogs: Number(row.avg_cogs || 0)
};
rowMap.set(key, parsed);
totals.orders += parsed.orderCount;
totals.subtotal += parsed.subtotalSum;
totals.productDiscount += parsed.productDiscountSum;
totals.regularSubtotal += parsed.regularSubtotalSum;
totals.shipRate += parsed.shipRateSum;
totals.shipCost += parsed.shipCostSum;
totals.cogs += parsed.cogsSum;
totals.pointsAwarded += parsed.pointsAwardedSum;
totals.pointsRedeemed += parsed.pointsRedeemedSum;
}
const productDiscountRate = totals.regularSubtotal > 0
? totals.productDiscount / totals.regularSubtotal
: 0;
const pointsPerDollar = config.points.pointsPerDollar != null
? config.points.pointsPerDollar
: totals.subtotal > 0
? totals.pointsAwarded / totals.subtotal
: 0;
const pointDollarValue = config.points.pointDollarValue || DEFAULT_POINT_DOLLAR_VALUE;
// Calculate redemption rate using dollars redeemed from the matched order set
let calculatedRedemptionRate = 0;
if (config.points.redemptionRate != null) {
calculatedRedemptionRate = config.points.redemptionRate;
} else if (totals.pointsAwarded > 0 && pointDollarValue > 0) {
const totalRedeemedPoints = totals.pointsRedeemed / pointDollarValue;
if (totalRedeemedPoints > 0) {
calculatedRedemptionRate = Math.min(1, totalRedeemedPoints / totals.pointsAwarded);
}
}
const redemptionRate = calculatedRedemptionRate;
// Calculate overall average COGS percentage for 'average' mode
let overallCogsPercentage = 0;
if (cogsCalculationMode === 'average' && totals.subtotal > 0) {
overallCogsPercentage = totals.cogs / totals.subtotal;
}
const bucketResults = [];
let weightedProfitAmount = 0;
let weightedProfitPercent = 0;
for (const range of RANGE_DEFINITIONS) {
const data = rowMap.get(range.key) || {
orderCount: 0,
avgSubtotal: 0,
avgShipRate: 0,
avgShipCost: 0,
avgCogs: 0
};
const orderValue = data.avgSubtotal > 0 ? data.avgSubtotal : getMidpoint(range);
const shippingChargeBase = data.avgShipCost > 0 ? data.avgShipCost : 0;
const actualShippingCost = data.avgShipCost > 0 ? data.avgShipCost : 0;
// Calculate COGS based on the selected mode
let productCogs;
if (cogsCalculationMode === 'average') {
// Use overall average COGS percentage applied to this bucket's order value
productCogs = orderValue * overallCogsPercentage;
} else {
// Use actual COGS data from this bucket (existing behavior)
productCogs = data.avgCogs > 0 ? data.avgCogs : 0;
}
const productDiscountAmount = orderValue * productDiscountRate;
const effectiveRegularPrice = productDiscountRate < 0.99
? orderValue / (1 - productDiscountRate)
: orderValue;
let promoProductDiscount = 0;
if (config.productPromo.type === 'percentage_subtotal' && orderValue >= config.productPromo.minSubtotal) {
promoProductDiscount = Math.min(orderValue, (config.productPromo.value / 100) * orderValue);
} else if (config.productPromo.type === 'percentage_regular' && orderValue >= config.productPromo.minSubtotal) {
const targetRate = config.productPromo.value / 100;
const additionalRate = Math.max(0, targetRate - productDiscountRate);
promoProductDiscount = Math.min(orderValue, additionalRate * effectiveRegularPrice);
} else if (config.productPromo.type === 'fixed_amount' && orderValue >= config.productPromo.minSubtotal) {
promoProductDiscount = Math.min(orderValue, config.productPromo.value);
}
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);
}
// Calculate surcharges
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 merchantFees = totalRevenue * (config.merchantFeePercent / 100);
const pointsCost = customerItemCost * pointsPerDollar * redemptionRate * pointDollarValue;
const fixedCosts = config.fixedCostPerOrder;
const totalCosts = productCogs + actualShippingCost + merchantFees + pointsCost + fixedCosts;
const profit = totalRevenue - totalCosts;
const profitPercent = totalRevenue > 0 ? (profit / totalRevenue) : 0;
const weight = totals.orders > 0 ? (data.orderCount || 0) / totals.orders : 0;
weightedProfitAmount += profit * weight;
weightedProfitPercent += profitPercent * weight;
bucketResults.push({
key: range.key,
label: range.label,
min: range.min,
max: range.max,
orderCount: data.orderCount || 0,
weight,
orderValue,
productDiscountAmount,
promoProductDiscount,
customerItemCost,
shippingChargeBase,
shippingAfterAuto,
shipPromoDiscount,
shippingSurcharge,
orderSurcharge,
customerShipCost,
actualShippingCost,
totalRevenue,
productCogs,
merchantFees,
pointsCost,
fixedCosts,
totalCosts,
profit,
profitPercent
});
}
if (release) { if (release) {
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({ res.json({
dateRange: { dateRange: {
start: startDt.toISO(), start: startDt.toISO(),
end: endDt.toISO() end: endDt.toISO()
}, },
totals: { totals: {
orders: totals.orders, orders: totalOrders,
subtotal: totals.subtotal, subtotal: totalSubtotal,
productDiscountRate, productDiscountRate,
pointsPerDollar, pointsPerDollar,
redemptionRate, redemptionRate,

View File

@@ -8,6 +8,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { Switch } from "@/components/ui/switch";
import { DiscountPromoOption, DiscountPromoType, ShippingPromoType, ShippingTierConfig, SurchargeConfig, DiscountSimulationResponse, CogsCalculationMode } from "@/types/discount-simulator"; import { DiscountPromoOption, DiscountPromoType, ShippingPromoType, ShippingTierConfig, SurchargeConfig, DiscountSimulationResponse, CogsCalculationMode } from "@/types/discount-simulator";
import { formatNumber } from "@/utils/productUtils"; import { formatNumber } from "@/utils/productUtils";
import { PlusIcon, X } from "lucide-react"; import { PlusIcon, X } from "lucide-react";
@@ -43,6 +44,8 @@ interface ConfigPanelProps {
onFixedCostChange: (value: number) => void; onFixedCostChange: (value: number) => void;
cogsCalculationMode: CogsCalculationMode; cogsCalculationMode: CogsCalculationMode;
onCogsCalculationModeChange: (mode: CogsCalculationMode) => void; onCogsCalculationModeChange: (mode: CogsCalculationMode) => void;
applyHistoricalProductPromo: boolean;
onApplyHistoricalProductPromoChange: (value: boolean) => void;
pointsPerDollar: number; pointsPerDollar: number;
redemptionRate: number; redemptionRate: number;
onRedemptionRateChange: (value: number) => void; onRedemptionRateChange: (value: number) => void;
@@ -58,6 +61,9 @@ interface ConfigPanelProps {
} }
function parseNumber(value: string, fallback = 0) { function parseNumber(value: string, fallback = 0) {
if (value.trim() === '') {
return fallback;
}
const parsed = Number(value); const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback; return Number.isFinite(parsed) ? parsed : fallback;
} }
@@ -113,6 +119,8 @@ export function ConfigPanel({
onFixedCostChange, onFixedCostChange,
cogsCalculationMode, cogsCalculationMode,
onCogsCalculationModeChange, onCogsCalculationModeChange,
applyHistoricalProductPromo,
onApplyHistoricalProductPromoChange,
pointsPerDollar, pointsPerDollar,
redemptionRate, redemptionRate,
onRedemptionRateChange, onRedemptionRateChange,
@@ -480,6 +488,23 @@ export function ConfigPanel({
</div> </div>
</div> </div>
)} )}
<div className="flex items-start justify-between gap-2 pt-1">
<div className="flex flex-col">
<Label className={labelClass}>Replay historical discount</Label>
<span className="text-[0.65rem] text-muted-foreground leading-snug">
Only when promo type is "No additional promo"
</span>
</div>
<Switch
checked={applyHistoricalProductPromo}
onCheckedChange={(checked) => {
onConfigInputChange();
onApplyHistoricalProductPromoChange(checked);
setTimeout(() => onRunSimulation(), 0);
}}
disabled={productPromo.type !== "none"}
/>
</div>
</div> </div>
</section> </section>
@@ -826,11 +851,11 @@ export function ConfigPanel({
<Input <Input
className={compactNumberClass} className={compactNumberClass}
type="number" type="number"
step="1" step="0.01"
value={Math.round(redemptionRate * 100)} value={Number((redemptionRate * 100).toFixed(2))}
onChange={(event) => { onChange={(event) => {
onConfigInputChange(); onConfigInputChange();
onRedemptionRateChange(parseNumber(event.target.value, 90) / 100); onRedemptionRateChange(parseNumber(event.target.value, redemptionRate * 100) / 100);
}} }}
onBlur={handleFieldBlur} onBlur={handleFieldBlur}
/> />

View File

@@ -67,12 +67,11 @@ export function ResultsChart({ buckets, isLoading }: ResultsChartProps) {
return null; return null;
} }
// Use the same format as the table - show top range value
const labels = buckets.map((bucket) => { const labels = buckets.map((bucket) => {
if (bucket.max == null) { if (bucket.max == null) {
return `$${bucket.min.toLocaleString()}+`; return `$${bucket.min.toLocaleString()}+`;
} }
return `$${bucket.max.toLocaleString()}`; return `$${bucket.min.toLocaleString()}$${bucket.max.toLocaleString()}`;
}); });
const profitPercentages = buckets.map((bucket) => Number((bucket.profitPercent * 100).toFixed(2))); const profitPercentages = buckets.map((bucket) => Number((bucket.profitPercent * 100).toFixed(2)));
@@ -109,13 +108,28 @@ export function ResultsChart({ buckets, isLoading }: ResultsChartProps) {
pointBorderColor: pointColors, pointBorderColor: pointColors,
pointRadius: 6, pointRadius: 6,
pointHoverRadius: 8, pointHoverRadius: 8,
tension: 0.3, tension: 0,
fill: false, fill: false,
}, },
], ],
}; };
}, [buckets]); }, [buckets]);
const yAxisBounds = useMemo(() => {
const populated = buckets.filter((bucket) => bucket.orderCount > 0);
if (populated.length === 0) {
return { min: 0, max: 50 };
}
const values = populated.map((bucket) => bucket.profitPercent * 100);
const rawMin = Math.min(...values, 0);
const rawMax = Math.max(...values, 0);
const span = Math.max(rawMax - rawMin, 5);
const pad = Math.max(span * 0.1, 1);
const min = Math.floor((rawMin - pad) / 5) * 5;
const max = Math.ceil((rawMax + pad) / 5) * 5;
return { min, max: Math.max(max, min + 5) };
}, [buckets]);
const options = useMemo(() => ({ const options = useMemo(() => ({
responsive: true, responsive: true,
maintainAspectRatio: false, maintainAspectRatio: false,
@@ -157,8 +171,8 @@ export function ResultsChart({ buckets, isLoading }: ResultsChartProps) {
type: 'linear' as const, type: 'linear' as const,
display: true, display: true,
position: 'left' as const, position: 'left' as const,
min: 0, min: yAxisBounds.min,
max: 50, max: yAxisBounds.max,
ticks: { ticks: {
stepSize: 5, stepSize: 5,
callback: (value: number | string) => `${Number(value).toFixed(0)}`, callback: (value: number | string) => `${Number(value).toFixed(0)}`,
@@ -177,7 +191,7 @@ export function ResultsChart({ buckets, isLoading }: ResultsChartProps) {
}, },
}, },
}, },
}), [buckets]); }), [buckets, yAxisBounds]);
if (isLoading && !chartData) { if (isLoading && !chartData) {
return ( return (

View File

@@ -145,10 +145,10 @@ const rowLabels: RowConfig[] = [
const formatRangeUpperBound = (bucket: DiscountSimulationBucket) => { const formatRangeUpperBound = (bucket: DiscountSimulationBucket) => {
if (bucket.max == null) { if (bucket.max == null) {
return `${formatCurrency(bucket.min)}+`; return `${formatCurrency(bucket.min, 0)}+`;
} }
return formatCurrency(bucket.max); return `${formatCurrency(bucket.min, 0)}${formatCurrency(bucket.max, 0)}`;
}; };
export function ResultsTable({ buckets, isLoading }: ResultsTableProps) { export function ResultsTable({ buckets, isLoading }: ResultsTableProps) {
@@ -173,7 +173,7 @@ export function ResultsTable({ buckets, isLoading }: ResultsTableProps) {
<colgroup> <colgroup>
<col style={{ width: '156px' }} /> <col style={{ width: '156px' }} />
{buckets.map((bucket) => ( {buckets.map((bucket) => (
<col key={bucket.key} style={{ minWidth: '70px', width: '75px' }} /> <col key={bucket.key} style={{ minWidth: '95px', width: '110px' }} />
))} ))}
</colgroup> </colgroup>
<TableHeader> <TableHeader>

View File

@@ -57,6 +57,10 @@ interface SummaryCardProps {
onClearBaseline?: () => void; onClearBaseline?: () => void;
} }
function rangesMatch(a: DiscountSimulationResponse, b: DiscountSimulationResponse): boolean {
return a.dateRange.start === b.dateRange.start && a.dateRange.end === b.dateRange.end;
}
function calculateAnnualizedProfitDiff( function calculateAnnualizedProfitDiff(
current: DiscountSimulationResponse, current: DiscountSimulationResponse,
baseline: DiscountSimulationResponse baseline: DiscountSimulationResponse
@@ -65,6 +69,9 @@ function calculateAnnualizedProfitDiff(
const baselineTotals = baseline.totals; const baselineTotals = baseline.totals;
if (!currentTotals || !baselineTotals) return null; if (!currentTotals || !baselineTotals) return null;
if (!Number.isFinite(currentTotals.weightedProfitAmount) || !Number.isFinite(baselineTotals.weightedProfitAmount)) {
return null;
}
// Calculate days in the current simulation period // Calculate days in the current simulation period
const startDate = new Date(current.dateRange.start); const startDate = new Date(current.dateRange.start);
@@ -118,7 +125,9 @@ export function SummaryCard({
} }
const totals = result?.totals; const totals = result?.totals;
const weightedProfitAmount = totals ? formatCurrency(totals.weightedProfitAmount) : '—'; const weightedProfitAmount = totals && Number.isFinite(totals.weightedProfitAmount)
? formatCurrency(totals.weightedProfitAmount)
: '—';
const weightedProfitPercent = totals ? formatPercent(totals.weightedProfitPercent) : '—'; const weightedProfitPercent = totals ? formatPercent(totals.weightedProfitPercent) : '—';
// Get color for profit percentage // Get color for profit percentage
@@ -130,8 +139,9 @@ export function SummaryCard({
: "destructive" : "destructive"
: "secondary"; : "secondary";
// Calculate annualized profit difference if baseline exists const baselineScopeMatches = !!(result && baselineResult && rangesMatch(result, baselineResult));
const annualizedDiff = result && baselineResult // Calculate annualized profit difference if baseline exists and scope matches
const annualizedDiff = result && baselineResult && baselineScopeMatches
? calculateAnnualizedProfitDiff(result, baselineResult) ? calculateAnnualizedProfitDiff(result, baselineResult)
: null; : null;
const hasBaseline = !!baselineResult; const hasBaseline = !!baselineResult;
@@ -194,6 +204,27 @@ export function SummaryCard({
</> </>
)} )}
{hasBaseline && !baselineScopeMatches && (
<>
<Separator orientation="vertical" className="h-12" />
<div className="text-center">
<p className="text-sm text-muted-foreground mb-2">Baseline</p>
<span className="inline-block px-3 py-1.5 rounded bg-secondary text-amber-700 text-sm">
Date range changed clear or restore to compare
</span>
</div>
<Button
variant="ghost"
size="sm"
className="text-xs text-muted-foreground hover:text-foreground"
onClick={onClearBaseline}
>
<X className="h-3 w-3 mr-1" />
Clear
</Button>
</>
)}
{/* Save as baseline button */} {/* Save as baseline button */}
{result && !hasBaseline && ( {result && !hasBaseline && (
<> <>

View File

@@ -62,6 +62,7 @@ export function DiscountSimulator() {
const [merchantFeePercent, setMerchantFeePercent] = useState(DEFAULT_MERCHANT_FEE); const [merchantFeePercent, setMerchantFeePercent] = useState(DEFAULT_MERCHANT_FEE);
const [fixedCostPerOrder, setFixedCostPerOrder] = useState(DEFAULT_FIXED_COST); const [fixedCostPerOrder, setFixedCostPerOrder] = useState(DEFAULT_FIXED_COST);
const [cogsCalculationMode, setCogsCalculationMode] = useState<CogsCalculationMode>('actual'); const [cogsCalculationMode, setCogsCalculationMode] = useState<CogsCalculationMode>('actual');
const [applyHistoricalProductPromo, setApplyHistoricalProductPromo] = useState(false);
const [pointDollarValue, setPointDollarValue] = useState(DEFAULT_POINT_VALUE); const [pointDollarValue, setPointDollarValue] = useState(DEFAULT_POINT_VALUE);
const [pointDollarTouched, setPointDollarTouched] = useState(false); const [pointDollarTouched, setPointDollarTouched] = useState(false);
const [redemptionRate, setRedemptionRate] = useState(DEFAULT_REDEMPTION_RATE); const [redemptionRate, setRedemptionRate] = useState(DEFAULT_REDEMPTION_RATE);
@@ -70,7 +71,6 @@ export function DiscountSimulator() {
const [isSimulating, setIsSimulating] = useState(false); const [isSimulating, setIsSimulating] = useState(false);
const [hasLoadedConfig, setHasLoadedConfig] = useState(false); const [hasLoadedConfig, setHasLoadedConfig] = useState(false);
const [loadedFromStorage, setLoadedFromStorage] = useState(false); const [loadedFromStorage, setLoadedFromStorage] = useState(false);
const initialRunRef = useRef(false);
const skipAutoRunRef = useRef(false); const skipAutoRunRef = useRef(false);
const latestPayloadKeyRef = useRef(''); const latestPayloadKeyRef = useRef('');
const pendingCountRef = useRef(0); const pendingCountRef = useRef(0);
@@ -127,13 +127,6 @@ export function DiscountSimulator() {
const promosLoading = !promosQuery.data && promosQuery.isLoading; const promosLoading = !promosQuery.data && promosQuery.isLoading;
const selectedPromoCode = useMemo(() => {
if (selectedPromoId == null) {
return undefined;
}
return promosQuery.data?.find((promo) => promo.id === selectedPromoId)?.code || undefined;
}, [promosQuery.data, selectedPromoId]);
const createPayload = useCallback((): DiscountSimulationRequest => { const createPayload = useCallback((): DiscountSimulationRequest => {
const { from, to } = ensureDateRange(dateRange); const { from, to } = ensureDateRange(dateRange);
@@ -151,7 +144,6 @@ export function DiscountSimulator() {
filters: { filters: {
shipCountry: 'US', shipCountry: 'US',
promoIds: selectedPromoId ? [selectedPromoId] : undefined, promoIds: selectedPromoId ? [selectedPromoId] : undefined,
promoCodes: selectedPromoCode ? [selectedPromoCode] : undefined,
}, },
productPromo, productPromo,
shippingPromo, shippingPromo,
@@ -168,12 +160,12 @@ export function DiscountSimulator() {
merchantFeePercent, merchantFeePercent,
fixedCostPerOrder, fixedCostPerOrder,
cogsCalculationMode, cogsCalculationMode,
applyHistoricalProductPromo,
pointsConfig: payloadPointsConfig, pointsConfig: payloadPointsConfig,
}; };
}, [ }, [
dateRange, dateRange,
selectedPromoId, selectedPromoId,
selectedPromoCode,
productPromo, productPromo,
shippingPromo, shippingPromo,
shippingTiers, shippingTiers,
@@ -181,6 +173,7 @@ export function DiscountSimulator() {
merchantFeePercent, merchantFeePercent,
fixedCostPerOrder, fixedCostPerOrder,
cogsCalculationMode, cogsCalculationMode,
applyHistoricalProductPromo,
pointDollarValue, pointDollarValue,
redemptionRate, redemptionRate,
]); ]);
@@ -264,6 +257,7 @@ export function DiscountSimulator() {
merchantFeePercent?: number; merchantFeePercent?: number;
fixedCostPerOrder?: number; fixedCostPerOrder?: number;
cogsCalculationMode?: CogsCalculationMode; cogsCalculationMode?: CogsCalculationMode;
applyHistoricalProductPromo?: boolean;
pointsConfig?: { pointsConfig?: {
pointsPerDollar?: number | null; pointsPerDollar?: number | null;
redemptionRate?: number | null; redemptionRate?: number | null;
@@ -319,6 +313,10 @@ export function DiscountSimulator() {
setCogsCalculationMode(parsed.cogsCalculationMode); setCogsCalculationMode(parsed.cogsCalculationMode);
} }
if (typeof parsed.applyHistoricalProductPromo === 'boolean') {
setApplyHistoricalProductPromo(parsed.applyHistoricalProductPromo);
}
if (parsed.pointsConfig && typeof parsed.pointsConfig.pointDollarValue === 'number') { if (parsed.pointsConfig && typeof parsed.pointsConfig.pointDollarValue === 'number') {
setPointDollarValue(parsed.pointsConfig.pointDollarValue); setPointDollarValue(parsed.pointsConfig.pointDollarValue);
setPointDollarTouched(true); setPointDollarTouched(true);
@@ -361,10 +359,11 @@ export function DiscountSimulator() {
merchantFeePercent, merchantFeePercent,
fixedCostPerOrder, fixedCostPerOrder,
cogsCalculationMode, cogsCalculationMode,
applyHistoricalProductPromo,
pointDollarValue, pointDollarValue,
redemptionRate, redemptionRate,
}); });
}, [dateRange, selectedPromoId, productPromo, shippingPromo, shippingTiers, surcharges, merchantFeePercent, fixedCostPerOrder, cogsCalculationMode, pointDollarValue, redemptionRate]); }, [dateRange, selectedPromoId, productPromo, shippingPromo, shippingTiers, surcharges, merchantFeePercent, fixedCostPerOrder, cogsCalculationMode, applyHistoricalProductPromo, pointDollarValue, redemptionRate]);
useEffect(() => { useEffect(() => {
if (!hasLoadedConfig) { if (!hasLoadedConfig) {
@@ -379,10 +378,6 @@ export function DiscountSimulator() {
}, [serializedConfig, hasLoadedConfig]); }, [serializedConfig, hasLoadedConfig]);
useEffect(() => { useEffect(() => {
if (!initialRunRef.current) {
initialRunRef.current = true;
}
if (skipAutoRunRef.current) { if (skipAutoRunRef.current) {
skipAutoRunRef.current = false; skipAutoRunRef.current = false;
return; return;
@@ -448,6 +443,7 @@ export function DiscountSimulator() {
setMerchantFeePercent(DEFAULT_MERCHANT_FEE); setMerchantFeePercent(DEFAULT_MERCHANT_FEE);
setFixedCostPerOrder(DEFAULT_FIXED_COST); setFixedCostPerOrder(DEFAULT_FIXED_COST);
setCogsCalculationMode('actual'); setCogsCalculationMode('actual');
setApplyHistoricalProductPromo(false);
setPointDollarValue(DEFAULT_POINT_VALUE); setPointDollarValue(DEFAULT_POINT_VALUE);
setPointDollarTouched(false); setPointDollarTouched(false);
setRedemptionRate(DEFAULT_REDEMPTION_RATE); setRedemptionRate(DEFAULT_REDEMPTION_RATE);
@@ -499,6 +495,8 @@ export function DiscountSimulator() {
onFixedCostChange={setFixedCostPerOrder} onFixedCostChange={setFixedCostPerOrder}
cogsCalculationMode={cogsCalculationMode} cogsCalculationMode={cogsCalculationMode}
onCogsCalculationModeChange={setCogsCalculationMode} onCogsCalculationModeChange={setCogsCalculationMode}
applyHistoricalProductPromo={applyHistoricalProductPromo}
onApplyHistoricalProductPromoChange={setApplyHistoricalProductPromo}
pointsPerDollar={currentPointsPerDollar} pointsPerDollar={currentPointsPerDollar}
redemptionRate={redemptionRate} redemptionRate={redemptionRate}
onRedemptionRateChange={setRedemptionRate} onRedemptionRateChange={setRedemptionRate}

View File

@@ -106,6 +106,7 @@ export interface DiscountSimulationRequest {
merchantFeePercent: number; merchantFeePercent: number;
fixedCostPerOrder: number; fixedCostPerOrder: number;
cogsCalculationMode: CogsCalculationMode; cogsCalculationMode: CogsCalculationMode;
applyHistoricalProductPromo: boolean;
pointsConfig: { pointsConfig: {
pointsPerDollar: number | null; pointsPerDollar: number | null;
redemptionRate: number | null; redemptionRate: number | null;