etsy-finance-tracker/client/src/utils/profitAnalysisService.ts
dlawler489 9e1a098a70 Initial commit: Complete Etsy Business Tracker with Profit Analysis Dashboard
Features:
- React + TypeScript frontend with Tailwind CSS
- Node.js + Express backend with TypeScript
- Comprehensive order tracking and management
- Product catalog with inventory tracking
- Customer data management
- Expense tracking and categorization
- Advanced Profit Analysis Dashboard with:
  - Real-time profit metrics and KPI visualization
  - Detailed order-level profit breakdown
  - Product performance analysis
  - Enhanced time range filtering (monthly, quarterly, yearly)
  - Interactive expandable order analysis
  - Performance categorization and color coding
- CSV import functionality for Etsy statements
- PDF parsing capabilities
- Redux state management with persistence
- Responsive design with mobile support
- Australian date formatting and currency display
2026-04-20 09:44:54 +10:00

473 lines
No EOL
15 KiB
TypeScript

import { Order } from '../store/slices/orderSlice';
import { Product } from '../store/slices/productSlice';
import { calculateOrderPrintingCost, calculateOrderProfit, enrichOrderItemsWithCosts } from './orderCalculations';
export interface ProfitMetrics {
totalRevenue: number;
totalPrintingCosts: number;
totalProfit: number;
averageMargin: number;
orderCount: number;
profitableOrders: number;
averageOrderValue: number;
averageCost: number;
}
export interface ProductProfitability {
name: string;
revenue: number;
cost: number;
profit: number;
margin: number;
orders: number;
quantity: number;
averageOrderValue: number;
profitPerUnit: number;
}
export interface MonthlyProfit {
month: string;
revenue: number;
costs: number;
profit: number;
margin: number;
orderCount: number;
}
export interface OrderProfitAnalysis {
orderId: string;
orderNumber: string;
date: string;
customerName: string;
revenue: number;
printingCosts: number;
profit: number;
margin: number;
items: OrderItemAnalysis[];
profitCategory: 'excellent' | 'good' | 'average' | 'poor' | 'loss';
}
export interface OrderItemAnalysis {
title: string;
quantity: number;
price: number;
printingCost: number;
itemRevenue: number;
itemCost: number;
itemProfit: number;
itemMargin: number;
}
export interface DateRangeOption {
value: string;
label: string;
type: 'preset' | 'month' | 'quarter' | 'year' | 'custom';
}
export class ProfitAnalysisService {
/**
* Calculate comprehensive profit metrics from orders
*/
static calculateProfitMetrics(orders: Order[], products: Product[]): ProfitMetrics {
if (!orders || orders.length === 0) {
return {
totalRevenue: 0,
totalPrintingCosts: 0,
totalProfit: 0,
averageMargin: 0,
orderCount: 0,
profitableOrders: 0,
averageOrderValue: 0,
averageCost: 0
};
}
let totalRevenue = 0;
let totalPrintingCosts = 0;
let totalProfit = 0;
let profitableOrderCount = 0;
orders.forEach(order => {
const revenue = order.total || 0;
const enrichedItems = enrichOrderItemsWithCosts(order.items || [], products || []);
const printingCost = calculateOrderPrintingCost(enrichedItems);
const profit = calculateOrderProfit(enrichedItems, revenue);
totalRevenue += revenue;
totalPrintingCosts += printingCost;
totalProfit += profit;
if (profit > 0) profitableOrderCount++;
});
const averageMargin = totalRevenue > 0 ? (totalProfit / totalRevenue) * 100 : 0;
const averageOrderValue = orders.length > 0 ? totalRevenue / orders.length : 0;
const averageCost = orders.length > 0 ? totalPrintingCosts / orders.length : 0;
return {
totalRevenue,
totalPrintingCosts,
totalProfit,
averageMargin,
orderCount: orders.length,
profitableOrders: profitableOrderCount,
averageOrderValue,
averageCost
};
}
/**
* Analyze profitability by product
*/
static analyzeProductProfitability(orders: Order[], products: Product[]): ProductProfitability[] {
if (!orders || !products) return [];
const productStats = new Map<string, ProductProfitability>();
orders.forEach(order => {
const enrichedItems = enrichOrderItemsWithCosts(order.items || [], products);
enrichedItems.forEach(item => {
const revenue = (item.price || 0) * (item.quantity || 1);
const cost = (item.printingCost || 0) * (item.quantity || 1);
const profit = revenue - cost;
if (!productStats.has(item.title)) {
productStats.set(item.title, {
name: item.title,
revenue: 0,
cost: 0,
profit: 0,
margin: 0,
orders: 0,
quantity: 0,
averageOrderValue: 0,
profitPerUnit: 0
});
}
const stats = productStats.get(item.title)!;
stats.revenue += revenue;
stats.cost += cost;
stats.profit += profit;
stats.orders += 1;
stats.quantity += item.quantity || 1;
});
});
// Calculate derived metrics
productStats.forEach(stats => {
stats.margin = stats.revenue > 0 ? (stats.profit / stats.revenue) * 100 : 0;
stats.averageOrderValue = stats.orders > 0 ? stats.revenue / stats.orders : 0;
stats.profitPerUnit = stats.quantity > 0 ? stats.profit / stats.quantity : 0;
});
return Array.from(productStats.values())
.sort((a, b) => b.profit - a.profit);
}
/**
* Calculate monthly profit trends
*/
static calculateMonthlyTrends(orders: Order[], products: Product[]): MonthlyProfit[] {
if (!orders || orders.length === 0) return [];
const monthlyData = new Map<string, MonthlyProfit>();
orders.forEach(order => {
const orderDate = new Date(order.dateOrdered);
const monthKey = `${orderDate.getFullYear()}-${String(orderDate.getMonth() + 1).padStart(2, '0')}`;
const monthName = orderDate.toLocaleDateString('en-AU', { year: 'numeric', month: 'long' });
const revenue = order.total || 0;
const enrichedItems = enrichOrderItemsWithCosts(order.items || [], products || []);
const costs = calculateOrderPrintingCost(enrichedItems);
const profit = revenue - costs;
if (!monthlyData.has(monthKey)) {
monthlyData.set(monthKey, {
month: monthName,
revenue: 0,
costs: 0,
profit: 0,
margin: 0,
orderCount: 0
});
}
const monthStats = monthlyData.get(monthKey)!;
monthStats.revenue += revenue;
monthStats.costs += costs;
monthStats.profit += profit;
monthStats.orderCount += 1;
});
// Calculate margins and sort by date
const results = Array.from(monthlyData.entries())
.map(([key, data]) => {
data.margin = data.revenue > 0 ? (data.profit / data.revenue) * 100 : 0;
return { key, ...data };
})
.sort((a, b) => a.key.localeCompare(b.key))
.map(({ key, ...data }) => data);
return results;
}
/**
* Filter orders by date range
*/
static filterOrdersByDateRange(orders: Order[], dateRange: string): Order[] {
if (dateRange === 'all' || !orders) return orders;
const now = new Date();
const filteredOrders = orders.filter(order => {
const orderDate = new Date(order.dateOrdered);
// Handle specific month format (e.g., "2025-07" for July 2025)
if (dateRange.match(/^\d{4}-\d{2}$/)) {
const [year, month] = dateRange.split('-');
return orderDate.getFullYear() === parseInt(year) &&
orderDate.getMonth() === parseInt(month) - 1;
}
// Handle quarter format (e.g., "2025-Q1")
if (dateRange.match(/^\d{4}-Q[1-4]$/)) {
const [year, quarter] = dateRange.split('-Q');
const targetYear = parseInt(year);
const targetQuarter = parseInt(quarter);
const orderYear = orderDate.getFullYear();
const orderQuarter = Math.floor(orderDate.getMonth() / 3) + 1;
return orderYear === targetYear && orderQuarter === targetQuarter;
}
// Handle year format (e.g., "2025")
if (dateRange.match(/^\d{4}$/)) {
return orderDate.getFullYear() === parseInt(dateRange);
}
// Handle custom date range format (e.g., "2025-01-01_2025-12-31")
if (dateRange.includes('_')) {
const [startDate, endDate] = dateRange.split('_');
const start = new Date(startDate);
const end = new Date(endDate);
return orderDate >= start && orderDate <= end;
}
// Handle legacy preset ranges
const timeDiff = now.getTime() - orderDate.getTime();
const daysDiff = timeDiff / (24 * 60 * 60 * 1000);
switch (dateRange) {
case 'week':
return daysDiff >= 0 && daysDiff <= 7;
case 'month':
return daysDiff >= 0 && daysDiff <= 30;
case 'quarter':
return daysDiff >= 0 && daysDiff <= 90;
case 'year':
return daysDiff >= 0 && daysDiff <= 365;
default:
return true;
}
});
return filteredOrders;
}
/**
* Generate available date range options based on order data
*/
static generateDateRangeOptions(orders: Order[]): DateRangeOption[] {
if (!orders || orders.length === 0) {
return [{ value: 'all', label: 'All Time', type: 'preset' }];
}
const options: DateRangeOption[] = [
{ value: 'all', label: 'All Time', type: 'preset' },
{ value: 'week', label: 'Last 7 Days', type: 'preset' },
{ value: 'month', label: 'Last 30 Days', type: 'preset' },
{ value: 'quarter', label: 'Last 90 Days', type: 'preset' },
{ value: 'year', label: 'Last 365 Days', type: 'preset' }
];
// Get unique months from orders
const monthsSet = new Set<string>();
const quartersSet = new Set<string>();
const yearsSet = new Set<string>();
orders.forEach(order => {
const date = new Date(order.dateOrdered);
const year = date.getFullYear();
const month = date.getMonth();
const quarter = Math.floor(month / 3) + 1;
// Add specific months
const monthKey = `${year}-${String(month + 1).padStart(2, '0')}`;
monthsSet.add(monthKey);
// Add quarters
const quarterKey = `${year}-Q${quarter}`;
quartersSet.add(quarterKey);
// Add years
yearsSet.add(year.toString());
});
// Convert to options and sort
const monthOptions: DateRangeOption[] = Array.from(monthsSet)
.sort((a, b) => b.localeCompare(a)) // Newest first
.slice(0, 24) // Limit to last 24 months
.map(monthKey => {
const [year, month] = monthKey.split('-');
const date = new Date(parseInt(year), parseInt(month) - 1);
const label = date.toLocaleDateString('en-AU', { year: 'numeric', month: 'long' });
return { value: monthKey, label, type: 'month' as const };
});
const quarterOptions: DateRangeOption[] = Array.from(quartersSet)
.sort((a, b) => b.localeCompare(a)) // Newest first
.slice(0, 8) // Limit to last 8 quarters
.map(quarterKey => ({
value: quarterKey,
label: quarterKey.replace('-', ' '),
type: 'quarter' as const
}));
const yearOptions: DateRangeOption[] = Array.from(yearsSet)
.sort((a, b) => parseInt(b) - parseInt(a)) // Newest first
.slice(0, 5) // Limit to last 5 years
.map(year => ({
value: year,
label: year,
type: 'year' as const
}));
// Combine all options with separators
return [
...options,
...monthOptions,
...quarterOptions,
...yearOptions
];
}
/**
* Get date range label for display
*/
static getDateRangeLabel(dateRange: string, orders: Order[]): string {
const options = this.generateDateRangeOptions(orders);
const option = options.find(opt => opt.value === dateRange);
return option ? option.label : dateRange;
}
/**
* Get top/bottom performing products
*/
static getTopPerformingProducts(orders: Order[], products: Product[], count: number = 10): ProductProfitability[] {
return this.analyzeProductProfitability(orders, products).slice(0, count);
}
static getWorstPerformingProducts(orders: Order[], products: Product[], count: number = 10): ProductProfitability[] {
return this.analyzeProductProfitability(orders, products)
.sort((a, b) => a.profit - b.profit)
.slice(0, count);
}
/**
* Calculate profit margin categories
*/
static categorizeByMargin(products: ProductProfitability[]): {
excellent: ProductProfitability[]; // >50%
good: ProductProfitability[]; // 30-50%
average: ProductProfitability[]; // 15-30%
poor: ProductProfitability[]; // 0-15%
loss: ProductProfitability[]; // <0%
} {
return {
excellent: products.filter(p => p.margin > 50),
good: products.filter(p => p.margin > 30 && p.margin <= 50),
average: products.filter(p => p.margin > 15 && p.margin <= 30),
poor: products.filter(p => p.margin > 0 && p.margin <= 15),
loss: products.filter(p => p.margin <= 0)
};
}
/**
* Analyze individual order profitability with item breakdown
*/
static analyzeOrderProfitability(orders: Order[], products: Product[]): OrderProfitAnalysis[] {
if (!orders || !products) return [];
return orders.map(order => {
const enrichedItems = enrichOrderItemsWithCosts(order.items || [], products);
const revenue = order.total || 0;
const printingCosts = calculateOrderPrintingCost(enrichedItems);
const profit = calculateOrderProfit(enrichedItems, revenue);
const margin = revenue > 0 ? (profit / revenue) * 100 : 0;
// Analyze each item
const itemAnalyses: OrderItemAnalysis[] = enrichedItems.map(item => {
const itemRevenue = (item.price || 0) * (item.quantity || 1);
const itemCost = (item.printingCost || 0) * (item.quantity || 1);
const itemProfit = itemRevenue - itemCost;
const itemMargin = itemRevenue > 0 ? (itemProfit / itemRevenue) * 100 : 0;
return {
title: item.title,
quantity: item.quantity || 1,
price: item.price || 0,
printingCost: item.printingCost || 0,
itemRevenue,
itemCost,
itemProfit,
itemMargin
};
});
// Categorize profit performance
let profitCategory: OrderProfitAnalysis['profitCategory'];
if (margin > 50) profitCategory = 'excellent';
else if (margin > 30) profitCategory = 'good';
else if (margin > 15) profitCategory = 'average';
else if (margin > 0) profitCategory = 'poor';
else profitCategory = 'loss';
return {
orderId: order._id,
orderNumber: order.orderNumber,
date: order.dateOrdered,
customerName: order.customer?.name || 'Unknown Customer',
revenue,
printingCosts,
profit,
margin,
items: itemAnalyses,
profitCategory
};
}).sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
}
/**
* Get orders by profit category
*/
static getOrdersByProfitCategory(orders: Order[], products: Product[]): {
excellent: OrderProfitAnalysis[];
good: OrderProfitAnalysis[];
average: OrderProfitAnalysis[];
poor: OrderProfitAnalysis[];
loss: OrderProfitAnalysis[];
} {
const analyses = this.analyzeOrderProfitability(orders, products);
return {
excellent: analyses.filter(a => a.profitCategory === 'excellent'),
good: analyses.filter(a => a.profitCategory === 'good'),
average: analyses.filter(a => a.profitCategory === 'average'),
poor: analyses.filter(a => a.profitCategory === 'poor'),
loss: analyses.filter(a => a.profitCategory === 'loss')
};
}
}
export default ProfitAnalysisService;