diff --git a/client/src/pages/Analytics.tsx b/client/src/pages/Analytics.tsx index ec65cfd..42a2a99 100644 --- a/client/src/pages/Analytics.tsx +++ b/client/src/pages/Analytics.tsx @@ -1,7 +1,7 @@ import { useState, useMemo } from 'react'; import { useSelector } from 'react-redux'; import { RootState } from '../store'; -import { calculateOrderPrintingCost, enrichOrderItemsWithCosts } from '../utils/orderCalculations'; +import { calculateOrderPrintingCost, enrichOrderItemsWithCosts, orderNetRevenue } from '../utils/orderCalculations'; import { formatAustralianDate } from '../utils/dateFormatter'; import { TrendingUp, DollarSign, Package, Users, ShoppingCart, Download, Printer } from 'lucide-react'; @@ -253,7 +253,9 @@ const Analytics = () => { }, [expenses, dateRange]); // Calculate metrics with filtered data - const totalRevenue = filteredOrders.reduce((sum, order) => sum + (order?.total || 0), 0); + // Revenue is net of buyer refunds + const totalRevenue = filteredOrders.reduce((sum, order) => sum + orderNetRevenue(order), 0); + const totalRefunds = filteredOrders.reduce((sum, order) => sum + (order?.refundTotal || 0), 0); const totalPrintingCosts = filteredOrders.reduce((sum, order) => { return sum + getUpdatedPrintingCost(order); }, 0); @@ -313,7 +315,7 @@ const Analytics = () => { const monthKey = `${months[monthIndex]} ${year}`; const current = monthlyMap.get(monthKey); if (current) { - current.revenue += order.total || 0; + current.revenue += orderNetRevenue(order); } } }); @@ -557,15 +559,18 @@ const Analytics = () => {

Total Revenue

${totalRevenue.toFixed(2)}

+

net of refunds

- - 12.5% - vs last period + {totalRefunds > 0 ? ( + −${totalRefunds.toFixed(2)} refunded + ) : ( + No refunds + )}
diff --git a/client/src/store/slices/orderSlice.ts b/client/src/store/slices/orderSlice.ts index b583f1c..67dbb2f 100644 --- a/client/src/store/slices/orderSlice.ts +++ b/client/src/store/slices/orderSlice.ts @@ -4,7 +4,8 @@ export interface Order { _id: string; orderNumber: string; total: number; - status: 'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled'; + refundTotal?: number; + status: 'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled' | 'refunded'; dateOrdered: string; customer?: { name: string; diff --git a/client/src/utils/orderCalculations.ts b/client/src/utils/orderCalculations.ts index 16fe1a5..b23ce85 100644 --- a/client/src/utils/orderCalculations.ts +++ b/client/src/utils/orderCalculations.ts @@ -17,6 +17,10 @@ export interface Product { sku?: string; } +// Net revenue kept on an order after buyer refunds (gross total - refunds) +export const orderNetRevenue = (order: { total?: number; refundTotal?: number }): number => + Math.max(0, (order.total || 0) - (order.refundTotal || 0)); + // Calculate total printing cost for an order export const calculateOrderPrintingCost = (items: OrderItem[]): number => { return items.reduce((total, item) => { diff --git a/client/src/utils/profitAnalysisService.ts b/client/src/utils/profitAnalysisService.ts index e641619..d36bd98 100644 --- a/client/src/utils/profitAnalysisService.ts +++ b/client/src/utils/profitAnalysisService.ts @@ -1,10 +1,11 @@ import { Order } from '../store/slices/orderSlice'; import { Product } from '../store/slices/productSlice'; import { Expense } from '../store/slices/expenseSlice'; -import { calculateOrderPrintingCost, calculateOrderProfit, enrichOrderItemsWithCosts } from './orderCalculations'; +import { calculateOrderPrintingCost, calculateOrderProfit, enrichOrderItemsWithCosts, orderNetRevenue } from './orderCalculations'; export interface ProfitMetrics { - totalRevenue: number; + totalRevenue: number; // Net of buyer refunds + totalRefunds: number; totalPrintingCosts: number; totalExpenses: number; // Added: All expenses excluding transaction fees totalProfit: number; @@ -75,6 +76,7 @@ export class ProfitAnalysisService { if (!orders || orders.length === 0) { return { totalRevenue: 0, + totalRefunds: 0, totalPrintingCosts: 0, totalExpenses: 0, totalProfit: 0, @@ -87,18 +89,20 @@ export class ProfitAnalysisService { } let totalRevenue = 0; + let totalRefunds = 0; let totalPrintingCosts = 0; let profitableOrderCount = 0; - // Calculate revenue and printing costs from orders + // Calculate revenue (net of refunds) and printing costs from orders orders.forEach(order => { - const revenue = order.total || 0; + const revenue = orderNetRevenue(order); const enrichedItems = enrichOrderItemsWithCosts(order.items || [], products || []); const printingCost = calculateOrderPrintingCost(enrichedItems); - + totalRevenue += revenue; + totalRefunds += order.refundTotal || 0; totalPrintingCosts += printingCost; - + // Count profitable orders based on printing costs only (before expenses) const orderProfit = calculateOrderProfit(enrichedItems, revenue); if (orderProfit > 0) profitableOrderCount++; @@ -118,6 +122,7 @@ export class ProfitAnalysisService { return { totalRevenue, + totalRefunds, totalPrintingCosts, totalExpenses, totalProfit, @@ -193,7 +198,7 @@ export class ProfitAnalysisService { 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 revenue = orderNetRevenue(order); const enrichedItems = enrichOrderItemsWithCosts(order.items || [], products || []); const costs = calculateOrderPrintingCost(enrichedItems); @@ -441,7 +446,7 @@ export class ProfitAnalysisService { return orders.map(order => { const enrichedItems = enrichOrderItemsWithCosts(order.items || [], products); - const revenue = order.total || 0; + const revenue = orderNetRevenue(order); const printingCosts = calculateOrderPrintingCost(enrichedItems); const profit = calculateOrderProfit(enrichedItems, revenue); const margin = revenue > 0 ? (profit / revenue) * 100 : 0; diff --git a/server/src/models/Order.ts b/server/src/models/Order.ts index d766d67..8e2fdad 100644 --- a/server/src/models/Order.ts +++ b/server/src/models/Order.ts @@ -20,6 +20,7 @@ export interface IOrder extends Document { shipping: number; tax: number; total: number; + refundTotal: number; status: 'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled' | 'refunded'; paymentStatus: 'pending' | 'paid' | 'refunded' | 'failed'; shippingAddress?: { @@ -69,6 +70,8 @@ const OrderSchema: Schema = new Schema({ shipping: { type: Number, default: 0 }, tax: { type: Number, default: 0 }, total: { type: Number, required: true, min: 0 }, + // Amount refunded to the buyer; net revenue for the order is total - refundTotal + refundTotal: { type: Number, default: 0, min: 0 }, status: { type: String, enum: ['pending', 'processing', 'shipped', 'delivered', 'cancelled', 'refunded'], diff --git a/server/src/services/etsyApi.ts b/server/src/services/etsyApi.ts index 73a6018..006fdf7 100644 --- a/server/src/services/etsyApi.ts +++ b/server/src/services/etsyApi.ts @@ -195,9 +195,15 @@ const matchProduct = ( return undefined; }; -const mapStatus = (receipt: any): string => { +// Sum all refunds on a receipt (each refund.amount is a Money object) +const refundTotalOf = (receipt: any): number => + (receipt.refunds || []).reduce((sum: number, r: any) => sum + money(r.amount), 0); + +const mapStatus = (receipt: any, refundTotal: number, total: number): string => { const status = String(receipt.status || '').toLowerCase(); if (status === 'canceled') return 'cancelled'; + // A refund covering (nearly) the whole order marks it refunded + if (refundTotal > 0 && refundTotal >= total - 0.01) return 'refunded'; if (status === 'completed') return 'delivered'; if (receipt.is_shipped) return 'shipped'; return 'processing'; @@ -280,15 +286,18 @@ const upsertOrderFromReceipt = async ( }); const shipment = (receipt.shipments || [])[0]; + const total = money(receipt.grandtotal); + const refundTotal = refundTotalOf(receipt); const orderData: any = { orderNumber, etsyOrderId: orderNumber, - total: money(receipt.grandtotal), + total, + refundTotal, subtotal: money(receipt.subtotal), shipping: money(receipt.total_shipping_cost), tax: money(receipt.total_tax_cost), - status: mapStatus(receipt), + status: mapStatus(receipt, refundTotal, total), paymentStatus: receipt.is_paid ? 'paid' : 'pending', dateOrdered: new Date((receipt.created_timestamp || receipt.create_timestamp) * 1000), customer: { name: receipt.name || 'Etsy Customer', email: '' },