Fix profit analysis calculations to include shipping expenses and other costs
- Updated ProfitAnalysisService to accept and integrate expense data - Modified calculateProfitMetrics to include expenses in profit calculations - Added totalExpenses field to ProfitMetrics interface - Updated ProfitAnalysis component to pass filtered expenses to calculations - Enhanced UI to show breakdown of expenses vs printing costs - Fixed inconsistency between Analytics and ProfitAnalysis profit calculations - Now shipping expenses and Etsy fees are properly deducted from profits - Excluded transaction fees to avoid double-counting
This commit is contained in:
parent
22799cb732
commit
761fce047a
3 changed files with 159 additions and 17 deletions
|
|
@ -7,6 +7,7 @@ import { TrendingUp, DollarSign, Package, Target, BarChart3, ChevronDown, Chevro
|
||||||
const ProfitAnalysis = () => {
|
const ProfitAnalysis = () => {
|
||||||
const { orders } = useSelector((state: RootState) => state.orders);
|
const { orders } = useSelector((state: RootState) => state.orders);
|
||||||
const { products } = useSelector((state: RootState) => state.products);
|
const { products } = useSelector((state: RootState) => state.products);
|
||||||
|
const { expenses } = useSelector((state: RootState) => state.expenses);
|
||||||
const [dateRange, setDateRange] = useState('all');
|
const [dateRange, setDateRange] = useState('all');
|
||||||
const [selectedView, setSelectedView] = useState<'overview' | 'trends' | 'products' | 'orders'>('overview');
|
const [selectedView, setSelectedView] = useState<'overview' | 'trends' | 'products' | 'orders'>('overview');
|
||||||
const [expandedOrder, setExpandedOrder] = useState<string | null>(null);
|
const [expandedOrder, setExpandedOrder] = useState<string | null>(null);
|
||||||
|
|
@ -23,10 +24,41 @@ const ProfitAnalysis = () => {
|
||||||
return ProfitAnalysisService.filterOrdersByDateRange(orders || [], dateRange);
|
return ProfitAnalysisService.filterOrdersByDateRange(orders || [], dateRange);
|
||||||
}, [orders, dateRange]);
|
}, [orders, dateRange]);
|
||||||
|
|
||||||
// Calculate profit metrics using the service
|
// Filter expenses by the same date range
|
||||||
|
const filteredExpenses = useMemo(() => {
|
||||||
|
if (!expenses || dateRange === 'all') return expenses || [];
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
return expenses.filter(expense => {
|
||||||
|
const expenseDate = new Date(expense.date);
|
||||||
|
|
||||||
|
// Handle specific month format (e.g., "2025-07" for July 2025)
|
||||||
|
if (dateRange.match(/^\d{4}-\d{2}$/)) {
|
||||||
|
const [year, month] = dateRange.split('-');
|
||||||
|
return expenseDate.getFullYear() === parseInt(year) &&
|
||||||
|
expenseDate.getMonth() === parseInt(month) - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle preset ranges
|
||||||
|
switch (dateRange) {
|
||||||
|
case 'week':
|
||||||
|
return expenseDate >= new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||||
|
case 'month':
|
||||||
|
return expenseDate >= new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||||
|
case 'quarter':
|
||||||
|
return expenseDate >= new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
|
||||||
|
case 'year':
|
||||||
|
return expenseDate >= new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000);
|
||||||
|
default:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [expenses, dateRange]);
|
||||||
|
|
||||||
|
// Calculate profit metrics using the service with expenses
|
||||||
const profitMetrics = useMemo(() => {
|
const profitMetrics = useMemo(() => {
|
||||||
return ProfitAnalysisService.calculateProfitMetrics(filteredOrders, products || []);
|
return ProfitAnalysisService.calculateProfitMetrics(filteredOrders, products || [], filteredExpenses);
|
||||||
}, [filteredOrders, products]);
|
}, [filteredOrders, products, filteredExpenses]);
|
||||||
|
|
||||||
// Top performing products analysis
|
// Top performing products analysis
|
||||||
const productPerformance = useMemo(() => {
|
const productPerformance = useMemo(() => {
|
||||||
|
|
@ -215,6 +247,48 @@ const ProfitAnalysis = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Second row for additional metrics */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
||||||
|
{/* Total Expenses */}
|
||||||
|
<div className="bg-white p-6 rounded-lg shadow">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="p-3 rounded-full bg-red-100">
|
||||||
|
<BarChart3 className="w-6 h-6 text-red-600" />
|
||||||
|
</div>
|
||||||
|
<div className="ml-4">
|
||||||
|
<p className="text-sm font-medium text-gray-600">Other Expenses</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">${profitMetrics.totalExpenses.toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Total Costs */}
|
||||||
|
<div className="bg-white p-6 rounded-lg shadow">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="p-3 rounded-full bg-yellow-100">
|
||||||
|
<Target className="w-6 h-6 text-yellow-600" />
|
||||||
|
</div>
|
||||||
|
<div className="ml-4">
|
||||||
|
<p className="text-sm font-medium text-gray-600">Total Costs</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">${(profitMetrics.totalPrintingCosts + profitMetrics.totalExpenses).toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Average Order Value */}
|
||||||
|
<div className="bg-white p-6 rounded-lg shadow">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="p-3 rounded-full bg-indigo-100">
|
||||||
|
<DollarSign className="w-6 h-6 text-indigo-600" />
|
||||||
|
</div>
|
||||||
|
<div className="ml-4">
|
||||||
|
<p className="text-sm font-medium text-gray-600">Avg Order Value</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">${profitMetrics.averageOrderValue.toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Profit Summary */}
|
{/* Profit Summary */}
|
||||||
<div className="bg-white p-6 rounded-lg shadow mb-8">
|
<div className="bg-white p-6 rounded-lg shadow mb-8">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Profit Summary</h3>
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Profit Summary</h3>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
import { Order } from '../store/slices/orderSlice';
|
import { Order } from '../store/slices/orderSlice';
|
||||||
import { Product } from '../store/slices/productSlice';
|
import { Product } from '../store/slices/productSlice';
|
||||||
|
import { Expense } from '../store/slices/expenseSlice';
|
||||||
import { calculateOrderPrintingCost, calculateOrderProfit, enrichOrderItemsWithCosts } from './orderCalculations';
|
import { calculateOrderPrintingCost, calculateOrderProfit, enrichOrderItemsWithCosts } from './orderCalculations';
|
||||||
|
|
||||||
export interface ProfitMetrics {
|
export interface ProfitMetrics {
|
||||||
totalRevenue: number;
|
totalRevenue: number;
|
||||||
totalPrintingCosts: number;
|
totalPrintingCosts: number;
|
||||||
|
totalExpenses: number; // Added: All expenses excluding transaction fees
|
||||||
totalProfit: number;
|
totalProfit: number;
|
||||||
averageMargin: number;
|
averageMargin: number;
|
||||||
orderCount: number;
|
orderCount: number;
|
||||||
|
|
@ -29,6 +31,7 @@ export interface MonthlyProfit {
|
||||||
month: string;
|
month: string;
|
||||||
revenue: number;
|
revenue: number;
|
||||||
costs: number;
|
costs: number;
|
||||||
|
expenses: number; // Added: Total expenses for the month
|
||||||
profit: number;
|
profit: number;
|
||||||
margin: number;
|
margin: number;
|
||||||
orderCount: number;
|
orderCount: number;
|
||||||
|
|
@ -66,13 +69,14 @@ export interface DateRangeOption {
|
||||||
|
|
||||||
export class ProfitAnalysisService {
|
export class ProfitAnalysisService {
|
||||||
/**
|
/**
|
||||||
* Calculate comprehensive profit metrics from orders
|
* Calculate comprehensive profit metrics from orders and expenses
|
||||||
*/
|
*/
|
||||||
static calculateProfitMetrics(orders: Order[], products: Product[]): ProfitMetrics {
|
static calculateProfitMetrics(orders: Order[], products: Product[], expenses?: Expense[]): ProfitMetrics {
|
||||||
if (!orders || orders.length === 0) {
|
if (!orders || orders.length === 0) {
|
||||||
return {
|
return {
|
||||||
totalRevenue: 0,
|
totalRevenue: 0,
|
||||||
totalPrintingCosts: 0,
|
totalPrintingCosts: 0,
|
||||||
|
totalExpenses: 0,
|
||||||
totalProfit: 0,
|
totalProfit: 0,
|
||||||
averageMargin: 0,
|
averageMargin: 0,
|
||||||
orderCount: 0,
|
orderCount: 0,
|
||||||
|
|
@ -84,29 +88,49 @@ export class ProfitAnalysisService {
|
||||||
|
|
||||||
let totalRevenue = 0;
|
let totalRevenue = 0;
|
||||||
let totalPrintingCosts = 0;
|
let totalPrintingCosts = 0;
|
||||||
let totalProfit = 0;
|
|
||||||
let profitableOrderCount = 0;
|
let profitableOrderCount = 0;
|
||||||
|
|
||||||
|
// Calculate revenue and printing costs from orders
|
||||||
orders.forEach(order => {
|
orders.forEach(order => {
|
||||||
const revenue = order.total || 0;
|
const revenue = order.total || 0;
|
||||||
const enrichedItems = enrichOrderItemsWithCosts(order.items || [], products || []);
|
const enrichedItems = enrichOrderItemsWithCosts(order.items || [], products || []);
|
||||||
const printingCost = calculateOrderPrintingCost(enrichedItems);
|
const printingCost = calculateOrderPrintingCost(enrichedItems);
|
||||||
const profit = calculateOrderProfit(enrichedItems, revenue);
|
|
||||||
|
|
||||||
totalRevenue += revenue;
|
totalRevenue += revenue;
|
||||||
totalPrintingCosts += printingCost;
|
totalPrintingCosts += printingCost;
|
||||||
totalProfit += profit;
|
|
||||||
|
|
||||||
if (profit > 0) profitableOrderCount++;
|
// Count profitable orders based on printing costs only (before expenses)
|
||||||
|
const orderProfit = calculateOrderProfit(enrichedItems, revenue);
|
||||||
|
if (orderProfit > 0) profitableOrderCount++;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Calculate total expenses (excluding transaction fees to avoid double-counting)
|
||||||
|
const totalExpenses = expenses ? expenses.reduce((sum, expense) => {
|
||||||
|
// More comprehensive detection of Etsy transaction fees
|
||||||
|
const isEtsyTransactionFee = (
|
||||||
|
expense.category?.toLowerCase() === 'transaction fees' ||
|
||||||
|
(expense.vendor?.toLowerCase().includes('etsy') &&
|
||||||
|
(expense.category?.toLowerCase().includes('transaction fee') ||
|
||||||
|
expense.description?.toLowerCase().includes('transaction fee') ||
|
||||||
|
expense.description?.toLowerCase().includes('etsy transaction fee')))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isEtsyTransactionFee) {
|
||||||
|
return sum; // Skip transaction fees as they're already deducted from order totals
|
||||||
|
}
|
||||||
|
return sum + (expense?.amount || 0);
|
||||||
|
}, 0) : 0;
|
||||||
|
|
||||||
|
// Calculate final profit including all expenses
|
||||||
|
const totalProfit = totalRevenue - totalPrintingCosts - totalExpenses;
|
||||||
const averageMargin = totalRevenue > 0 ? (totalProfit / totalRevenue) * 100 : 0;
|
const averageMargin = totalRevenue > 0 ? (totalProfit / totalRevenue) * 100 : 0;
|
||||||
const averageOrderValue = orders.length > 0 ? totalRevenue / orders.length : 0;
|
const averageOrderValue = orders.length > 0 ? totalRevenue / orders.length : 0;
|
||||||
const averageCost = orders.length > 0 ? totalPrintingCosts / orders.length : 0;
|
const averageCost = orders.length > 0 ? (totalPrintingCosts + totalExpenses) / orders.length : 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalRevenue,
|
totalRevenue,
|
||||||
totalPrintingCosts,
|
totalPrintingCosts,
|
||||||
|
totalExpenses,
|
||||||
totalProfit,
|
totalProfit,
|
||||||
averageMargin,
|
averageMargin,
|
||||||
orderCount: orders.length,
|
orderCount: orders.length,
|
||||||
|
|
@ -167,13 +191,14 @@ export class ProfitAnalysisService {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculate monthly profit trends
|
* Calculate monthly profit trends including expenses
|
||||||
*/
|
*/
|
||||||
static calculateMonthlyTrends(orders: Order[], products: Product[]): MonthlyProfit[] {
|
static calculateMonthlyTrends(orders: Order[], products: Product[], expenses?: Expense[]): MonthlyProfit[] {
|
||||||
if (!orders || orders.length === 0) return [];
|
if (!orders || orders.length === 0) return [];
|
||||||
|
|
||||||
const monthlyData = new Map<string, MonthlyProfit>();
|
const monthlyData = new Map<string, MonthlyProfit>();
|
||||||
|
|
||||||
|
// Process orders by month
|
||||||
orders.forEach(order => {
|
orders.forEach(order => {
|
||||||
const orderDate = new Date(order.dateOrdered);
|
const orderDate = new Date(order.dateOrdered);
|
||||||
const monthKey = `${orderDate.getFullYear()}-${String(orderDate.getMonth() + 1).padStart(2, '0')}`;
|
const monthKey = `${orderDate.getFullYear()}-${String(orderDate.getMonth() + 1).padStart(2, '0')}`;
|
||||||
|
|
@ -182,13 +207,13 @@ export class ProfitAnalysisService {
|
||||||
const revenue = order.total || 0;
|
const revenue = order.total || 0;
|
||||||
const enrichedItems = enrichOrderItemsWithCosts(order.items || [], products || []);
|
const enrichedItems = enrichOrderItemsWithCosts(order.items || [], products || []);
|
||||||
const costs = calculateOrderPrintingCost(enrichedItems);
|
const costs = calculateOrderPrintingCost(enrichedItems);
|
||||||
const profit = revenue - costs;
|
|
||||||
|
|
||||||
if (!monthlyData.has(monthKey)) {
|
if (!monthlyData.has(monthKey)) {
|
||||||
monthlyData.set(monthKey, {
|
monthlyData.set(monthKey, {
|
||||||
month: monthName,
|
month: monthName,
|
||||||
revenue: 0,
|
revenue: 0,
|
||||||
costs: 0,
|
costs: 0,
|
||||||
|
expenses: 0,
|
||||||
profit: 0,
|
profit: 0,
|
||||||
margin: 0,
|
margin: 0,
|
||||||
orderCount: 0
|
orderCount: 0
|
||||||
|
|
@ -198,13 +223,49 @@ export class ProfitAnalysisService {
|
||||||
const monthStats = monthlyData.get(monthKey)!;
|
const monthStats = monthlyData.get(monthKey)!;
|
||||||
monthStats.revenue += revenue;
|
monthStats.revenue += revenue;
|
||||||
monthStats.costs += costs;
|
monthStats.costs += costs;
|
||||||
monthStats.profit += profit;
|
|
||||||
monthStats.orderCount += 1;
|
monthStats.orderCount += 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Calculate margins and sort by date
|
// Process expenses by month (if provided)
|
||||||
|
if (expenses) {
|
||||||
|
expenses.forEach(expense => {
|
||||||
|
const expenseDate = new Date(expense.date);
|
||||||
|
const monthKey = `${expenseDate.getFullYear()}-${String(expenseDate.getMonth() + 1).padStart(2, '0')}`;
|
||||||
|
const monthName = expenseDate.toLocaleDateString('en-AU', { year: 'numeric', month: 'long' });
|
||||||
|
|
||||||
|
// Skip transaction fees to avoid double-counting
|
||||||
|
const isEtsyTransactionFee = (
|
||||||
|
expense.category?.toLowerCase() === 'transaction fees' ||
|
||||||
|
(expense.vendor?.toLowerCase().includes('etsy') &&
|
||||||
|
(expense.category?.toLowerCase().includes('transaction fee') ||
|
||||||
|
expense.description?.toLowerCase().includes('transaction fee') ||
|
||||||
|
expense.description?.toLowerCase().includes('etsy transaction fee')))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isEtsyTransactionFee) return;
|
||||||
|
|
||||||
|
// Create month entry if it doesn't exist (for expense-only months)
|
||||||
|
if (!monthlyData.has(monthKey)) {
|
||||||
|
monthlyData.set(monthKey, {
|
||||||
|
month: monthName,
|
||||||
|
revenue: 0,
|
||||||
|
costs: 0,
|
||||||
|
expenses: 0,
|
||||||
|
profit: 0,
|
||||||
|
margin: 0,
|
||||||
|
orderCount: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const monthStats = monthlyData.get(monthKey)!;
|
||||||
|
monthStats.expenses += expense.amount || 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate final profits and margins
|
||||||
const results = Array.from(monthlyData.entries())
|
const results = Array.from(monthlyData.entries())
|
||||||
.map(([key, data]) => {
|
.map(([key, data]) => {
|
||||||
|
data.profit = data.revenue - data.costs - data.expenses;
|
||||||
data.margin = data.revenue > 0 ? (data.profit / data.revenue) * 100 : 0;
|
data.margin = data.revenue > 0 ? (data.profit / data.revenue) * 100 : 0;
|
||||||
return { key, ...data };
|
return { key, ...data };
|
||||||
})
|
})
|
||||||
|
|
|
||||||
11
package.json
11
package.json
|
|
@ -18,10 +18,17 @@
|
||||||
"client:test": "cd client && npm test",
|
"client:test": "cd client && npm test",
|
||||||
"server:test": "cd server && npm test"
|
"server:test": "cd server && npm test"
|
||||||
},
|
},
|
||||||
"keywords": ["etsy", "business", "tracking", "analytics", "inventory", "orders"],
|
"keywords": [
|
||||||
|
"etsy",
|
||||||
|
"business",
|
||||||
|
"tracking",
|
||||||
|
"analytics",
|
||||||
|
"inventory",
|
||||||
|
"orders"
|
||||||
|
],
|
||||||
"author": "Your Name",
|
"author": "Your Name",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"concurrently": "^8.2.2"
|
"concurrently": "^8.2.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue