Redemption rate part 3 + update cogs options

This commit is contained in:
2025-09-26 00:11:09 -04:00
parent dc774862a7
commit 1696ecf591
4 changed files with 95 additions and 87 deletions

View File

@@ -165,6 +165,7 @@ router.post('/simulate', async (req, res) => {
shippingTiers = [], shippingTiers = [],
merchantFeePercent, merchantFeePercent,
fixedCostPerOrder, fixedCostPerOrder,
cogsCalculationMode = 'actual',
pointsConfig = {} pointsConfig = {}
} = req.body || {}; } = req.body || {};
@@ -373,74 +374,25 @@ router.post('/simulate', async (req, res) => {
const pointDollarValue = config.points.pointDollarValue || DEFAULT_POINT_DOLLAR_VALUE; const pointDollarValue = config.points.pointDollarValue || DEFAULT_POINT_DOLLAR_VALUE;
// Calculate redemption rate using aggregated award vs redemption pairing per customer // Calculate redemption rate using dollars redeemed from the matched order set
let calculatedRedemptionRate = 0; let calculatedRedemptionRate = 0;
if (config.points.redemptionRate != null) { if (config.points.redemptionRate != null) {
calculatedRedemptionRate = config.points.redemptionRate; calculatedRedemptionRate = config.points.redemptionRate;
} else if (totals.pointsAwarded > 0 && pointDollarValue > 0) { } else if (totals.pointsAwarded > 0 && pointDollarValue > 0) {
const extendedEndDt = DateTime.min( const totalRedeemedPoints = totals.pointsRedeemed / pointDollarValue;
endDt.plus({ months: 12 }), if (totalRedeemedPoints > 0) {
DateTime.now().endOf('day') calculatedRedemptionRate = Math.min(1, totalRedeemedPoints / totals.pointsAwarded);
);
const redemptionStatsQuery = `
SELECT
SUM(awards.points_awarded) AS total_awarded_points,
SUM(
LEAST(
awards.points_awarded,
COALESCE(redemptions.redemption_amount, 0) / ?
)
) AS matched_redeemed_points
FROM (
SELECT
o.order_cid,
SUM(o.summary_points) AS points_awarded
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}
GROUP BY o.order_cid
) AS awards
LEFT JOIN (
SELECT
o.order_cid,
SUM(od.discount_amount) AS redemption_amount
FROM order_discounts od
JOIN _order o ON od.order_id = o.order_id
WHERE od.discount_type = 20 AND od.discount_active = 1
AND o.order_status NOT IN (15)
AND o.ship_country = ?
AND o.date_placed BETWEEN ? AND ?
GROUP BY o.order_cid
) AS redemptions ON redemptions.order_cid = awards.order_cid
`;
const redemptionStatsParams = [
pointDollarValue,
...filteredOrdersParams,
shipCountry,
formatDateForSql(startDt),
formatDateForSql(extendedEndDt)
];
const [redemptionStatsRows] = await connection.execute(redemptionStatsQuery, redemptionStatsParams);
const redemptionStats = redemptionStatsRows[0] || {};
const totalAwardedPoints = Number(redemptionStats.total_awarded_points || 0);
const matchedRedeemedPoints = Number(redemptionStats.matched_redeemed_points || 0);
if (totalAwardedPoints > 0 && matchedRedeemedPoints > 0) {
calculatedRedemptionRate = Math.min(1, matchedRedeemedPoints / totalAwardedPoints);
} }
} }
const redemptionRate = calculatedRedemptionRate; 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 = []; const bucketResults = [];
let weightedProfitAmount = 0; let weightedProfitAmount = 0;
let weightedProfitPercent = 0; let weightedProfitPercent = 0;
@@ -457,7 +409,16 @@ router.post('/simulate', async (req, res) => {
const orderValue = data.avgSubtotal > 0 ? data.avgSubtotal : getMidpoint(range); const orderValue = data.avgSubtotal > 0 ? data.avgSubtotal : getMidpoint(range);
const shippingChargeBase = data.avgShipRate > 0 ? data.avgShipRate : 0; const shippingChargeBase = data.avgShipRate > 0 ? data.avgShipRate : 0;
const actualShippingCost = data.avgShipCost > 0 ? data.avgShipCost : 0; const actualShippingCost = data.avgShipCost > 0 ? data.avgShipCost : 0;
const productCogs = data.avgCogs > 0 ? data.avgCogs : 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 productDiscountAmount = orderValue * productDiscountRate;
const effectiveRegularPrice = productDiscountRate < 0.99 const effectiveRegularPrice = productDiscountRate < 0.99
? orderValue / (1 - productDiscountRate) ? orderValue / (1 - productDiscountRate)
@@ -557,7 +518,8 @@ router.post('/simulate', async (req, res) => {
redemptionRate, redemptionRate,
pointDollarValue, pointDollarValue,
weightedProfitAmount, weightedProfitAmount,
weightedProfitPercent weightedProfitPercent,
overallCogsPercentage: cogsCalculationMode === 'average' ? overallCogsPercentage : undefined
}, },
buckets: bucketResults buckets: bucketResults
}); });

View File

@@ -8,7 +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 { DiscountPromoOption, DiscountPromoType, ShippingPromoType, ShippingTierConfig, DiscountSimulationResponse } from "@/types/discount-simulator"; import { DiscountPromoOption, DiscountPromoType, ShippingPromoType, ShippingTierConfig, DiscountSimulationResponse, CogsCalculationMode } from "@/types/discount-simulator";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { formatNumber } from "@/utils/productUtils"; import { formatNumber } from "@/utils/productUtils";
@@ -41,6 +41,8 @@ interface ConfigPanelProps {
onMerchantFeeChange: (value: number) => void; onMerchantFeeChange: (value: number) => void;
fixedCostPerOrder: number; fixedCostPerOrder: number;
onFixedCostChange: (value: number) => void; onFixedCostChange: (value: number) => void;
cogsCalculationMode: CogsCalculationMode;
onCogsCalculationModeChange: (mode: CogsCalculationMode) => void;
pointsPerDollar: number; pointsPerDollar: number;
redemptionRate: number; redemptionRate: number;
pointDollarValue: number; pointDollarValue: number;
@@ -105,6 +107,8 @@ export function ConfigPanel({
onMerchantFeeChange, onMerchantFeeChange,
fixedCostPerOrder, fixedCostPerOrder,
onFixedCostChange, onFixedCostChange,
cogsCalculationMode,
onCogsCalculationModeChange,
pointsPerDollar, pointsPerDollar,
redemptionRate, redemptionRate,
pointDollarValue, pointDollarValue,
@@ -288,6 +292,11 @@ export function ConfigPanel({
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
{formatPercent(result.totals.productDiscountRate)} avg discount {formatPercent(result.totals.productDiscountRate)} avg discount
</Badge> </Badge>
{result.totals.overallCogsPercentage != null && (
<Badge variant="secondary" className="text-xs">
{formatPercent(result.totals.overallCogsPercentage)} avg COGS
</Badge>
)}
</div> </div>
)} )}
</section> </section>
@@ -515,34 +524,54 @@ export function ConfigPanel({
<section className={sectionClass}> <section className={sectionClass}>
<span className={sectionTitleClass}>Order costs</span> <span className={sectionTitleClass}>Order costs</span>
<div className={fieldRowHorizontalClass}> <div className={fieldRowClass}>
<div className={fieldClass}> <div className={fieldClass}>
<Label className={labelClass}>Merchant fee (%)</Label> <Label className={labelClass}>COGS calculation</Label>
<Input <Select
className={compactNumberClass} value={cogsCalculationMode}
type="number" onValueChange={(value) => {
step="0.01"
value={merchantFeePercent}
onChange={(event) => {
onConfigInputChange(); onConfigInputChange();
onMerchantFeeChange(parseNumber(event.target.value, merchantFeePercent)); onCogsCalculationModeChange(value as CogsCalculationMode);
}} }}
onBlur={handleFieldBlur} >
/> <SelectTrigger className={`${compactTriggerClass} w-full`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="actual">Actual COGS per bucket</SelectItem>
<SelectItem value="average">Average COGS percentage</SelectItem>
</SelectContent>
</Select>
</div> </div>
<div className={fieldClass}> <div className={fieldRowHorizontalClass}>
<Label className={labelClass}>Fixed cost/order ($)</Label> <div className={fieldClass}>
<Input <Label className={labelClass}>Merchant fee (%)</Label>
className={compactNumberClass} <Input
type="number" className={compactNumberClass}
step="0.01" type="number"
value={fixedCostPerOrder} step="0.01"
onChange={(event) => { value={merchantFeePercent}
onConfigInputChange(); onChange={(event) => {
onFixedCostChange(parseNumber(event.target.value, fixedCostPerOrder)); onConfigInputChange();
}} onMerchantFeeChange(parseNumber(event.target.value, merchantFeePercent));
onBlur={handleFieldBlur} }}
/> onBlur={handleFieldBlur}
/>
</div>
<div className={fieldClass}>
<Label className={labelClass}>Fixed cost/order ($)</Label>
<Input
className={compactNumberClass}
type="number"
step="0.01"
value={fixedCostPerOrder}
onChange={(event) => {
onConfigInputChange();
onFixedCostChange(parseNumber(event.target.value, fixedCostPerOrder));
}}
onBlur={handleFieldBlur}
/>
</div>
</div> </div>
</div> </div>
</section> </section>

View File

@@ -16,6 +16,7 @@ import {
DiscountPromoType, DiscountPromoType,
ShippingPromoType, ShippingPromoType,
ShippingTierConfig, ShippingTierConfig,
CogsCalculationMode,
} from "@/types/discount-simulator"; } from "@/types/discount-simulator";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
@@ -57,6 +58,7 @@ export function DiscountSimulator() {
const [shippingTiers, setShippingTiers] = useState<ShippingTierConfig[]>([]); const [shippingTiers, setShippingTiers] = useState<ShippingTierConfig[]>([]);
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 [pointDollarValue, setPointDollarValue] = useState(DEFAULT_POINT_VALUE); const [pointDollarValue, setPointDollarValue] = useState(DEFAULT_POINT_VALUE);
const [pointDollarTouched, setPointDollarTouched] = useState(false); const [pointDollarTouched, setPointDollarTouched] = useState(false);
const [simulationResult, setSimulationResult] = useState<DiscountSimulationResponse | undefined>(undefined); const [simulationResult, setSimulationResult] = useState<DiscountSimulationResponse | undefined>(undefined);
@@ -155,6 +157,7 @@ export function DiscountSimulator() {
}), }),
merchantFeePercent, merchantFeePercent,
fixedCostPerOrder, fixedCostPerOrder,
cogsCalculationMode,
pointsConfig: payloadPointsConfig, pointsConfig: payloadPointsConfig,
}; };
}, [ }, [
@@ -166,6 +169,7 @@ export function DiscountSimulator() {
shippingTiers, shippingTiers,
merchantFeePercent, merchantFeePercent,
fixedCostPerOrder, fixedCostPerOrder,
cogsCalculationMode,
pointDollarValue, pointDollarValue,
]); ]);
@@ -246,6 +250,7 @@ export function DiscountSimulator() {
shippingTiers?: ShippingTierConfig[]; shippingTiers?: ShippingTierConfig[];
merchantFeePercent?: number; merchantFeePercent?: number;
fixedCostPerOrder?: number; fixedCostPerOrder?: number;
cogsCalculationMode?: CogsCalculationMode;
pointsConfig?: { pointsConfig?: {
pointsPerDollar?: number | null; pointsPerDollar?: number | null;
redemptionRate?: number | null; redemptionRate?: number | null;
@@ -292,6 +297,10 @@ export function DiscountSimulator() {
setFixedCostPerOrder(parsed.fixedCostPerOrder); setFixedCostPerOrder(parsed.fixedCostPerOrder);
} }
if (parsed.cogsCalculationMode === 'actual' || parsed.cogsCalculationMode === 'average') {
setCogsCalculationMode(parsed.cogsCalculationMode);
}
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);
@@ -328,9 +337,10 @@ export function DiscountSimulator() {
shippingTiers, shippingTiers,
merchantFeePercent, merchantFeePercent,
fixedCostPerOrder, fixedCostPerOrder,
cogsCalculationMode,
pointDollarValue, pointDollarValue,
}); });
}, [dateRange, selectedPromoId, productPromo, shippingPromo, shippingTiers, merchantFeePercent, fixedCostPerOrder, pointDollarValue]); }, [dateRange, selectedPromoId, productPromo, shippingPromo, shippingTiers, merchantFeePercent, fixedCostPerOrder, cogsCalculationMode, pointDollarValue]);
useEffect(() => { useEffect(() => {
if (!hasLoadedConfig) { if (!hasLoadedConfig) {
@@ -403,6 +413,7 @@ export function DiscountSimulator() {
setShippingTiers([]); setShippingTiers([]);
setMerchantFeePercent(DEFAULT_MERCHANT_FEE); setMerchantFeePercent(DEFAULT_MERCHANT_FEE);
setFixedCostPerOrder(DEFAULT_FIXED_COST); setFixedCostPerOrder(DEFAULT_FIXED_COST);
setCogsCalculationMode('actual');
setPointDollarValue(DEFAULT_POINT_VALUE); setPointDollarValue(DEFAULT_POINT_VALUE);
setPointDollarTouched(false); setPointDollarTouched(false);
setSimulationResult(undefined); setSimulationResult(undefined);
@@ -449,6 +460,8 @@ export function DiscountSimulator() {
onMerchantFeeChange={setMerchantFeePercent} onMerchantFeeChange={setMerchantFeePercent}
fixedCostPerOrder={fixedCostPerOrder} fixedCostPerOrder={fixedCostPerOrder}
onFixedCostChange={setFixedCostPerOrder} onFixedCostChange={setFixedCostPerOrder}
cogsCalculationMode={cogsCalculationMode}
onCogsCalculationModeChange={setCogsCalculationMode}
pointsPerDollar={currentPointsPerDollar} pointsPerDollar={currentPointsPerDollar}
redemptionRate={currentRedemptionRate} redemptionRate={currentRedemptionRate}
pointDollarValue={pointDollarValue} pointDollarValue={pointDollarValue}

View File

@@ -54,6 +54,7 @@ export interface DiscountSimulationTotals {
pointDollarValue: number; pointDollarValue: number;
weightedProfitAmount: number; weightedProfitAmount: number;
weightedProfitPercent: number; weightedProfitPercent: number;
overallCogsPercentage?: number;
} }
export interface DiscountSimulationResponse { export interface DiscountSimulationResponse {
@@ -65,6 +66,8 @@ export interface DiscountSimulationResponse {
buckets: DiscountSimulationBucket[]; buckets: DiscountSimulationBucket[];
} }
export type CogsCalculationMode = 'actual' | 'average';
export interface DiscountSimulationRequest { export interface DiscountSimulationRequest {
dateRange: { dateRange: {
start: string; start: string;
@@ -89,6 +92,7 @@ export interface DiscountSimulationRequest {
shippingTiers: ShippingTierConfig[]; shippingTiers: ShippingTierConfig[];
merchantFeePercent: number; merchantFeePercent: number;
fixedCostPerOrder: number; fixedCostPerOrder: number;
cogsCalculationMode: CogsCalculationMode;
pointsConfig: { pointsConfig: {
pointsPerDollar: number | null; pointsPerDollar: number | null;
redemptionRate: number | null; redemptionRate: number | null;