Handle refunds: net revenue = order total - refunds
- Order model gains refundTotal; sync sums receipt.refunds (Money objects) onto each order and marks fully-refunded orders 'refunded' - orderNetRevenue() helper (total - refundTotal); profit metrics, monthly trends, and per-order analysis now use net revenue so refunded sales no longer overstate profit - ProfitMetrics adds totalRefunds; Analytics revenue card shows net revenue with the refunded amount, and monthly revenue is net Note: product-level profitability stays gross since receipt refunds are order-level amounts with no line-item breakdown. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
d5742940ec
commit
8e6680f2de
6 changed files with 45 additions and 18 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import { RootState } from '../store';
|
import { RootState } from '../store';
|
||||||
import { calculateOrderPrintingCost, enrichOrderItemsWithCosts } from '../utils/orderCalculations';
|
import { calculateOrderPrintingCost, enrichOrderItemsWithCosts, orderNetRevenue } from '../utils/orderCalculations';
|
||||||
import { formatAustralianDate } from '../utils/dateFormatter';
|
import { formatAustralianDate } from '../utils/dateFormatter';
|
||||||
import { TrendingUp, DollarSign, Package, Users, ShoppingCart, Download, Printer } from 'lucide-react';
|
import { TrendingUp, DollarSign, Package, Users, ShoppingCart, Download, Printer } from 'lucide-react';
|
||||||
|
|
||||||
|
|
@ -253,7 +253,9 @@ const Analytics = () => {
|
||||||
}, [expenses, dateRange]);
|
}, [expenses, dateRange]);
|
||||||
|
|
||||||
// Calculate metrics with filtered data
|
// 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) => {
|
const totalPrintingCosts = filteredOrders.reduce((sum, order) => {
|
||||||
return sum + getUpdatedPrintingCost(order);
|
return sum + getUpdatedPrintingCost(order);
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
@ -313,7 +315,7 @@ const Analytics = () => {
|
||||||
const monthKey = `${months[monthIndex]} ${year}`;
|
const monthKey = `${months[monthIndex]} ${year}`;
|
||||||
const current = monthlyMap.get(monthKey);
|
const current = monthlyMap.get(monthKey);
|
||||||
if (current) {
|
if (current) {
|
||||||
current.revenue += order.total || 0;
|
current.revenue += orderNetRevenue(order);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -557,15 +559,18 @@ const Analytics = () => {
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-gray-500">Total Revenue</p>
|
<p className="text-sm font-medium text-gray-500">Total Revenue</p>
|
||||||
<p className="text-2xl font-semibold text-gray-900">${totalRevenue.toFixed(2)}</p>
|
<p className="text-2xl font-semibold text-gray-900">${totalRevenue.toFixed(2)}</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-0.5">net of refunds</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<DollarSign className="h-8 w-8 text-green-500" />
|
<DollarSign className="h-8 w-8 text-green-500" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex items-center text-sm">
|
<div className="mt-2 flex items-center text-sm">
|
||||||
<TrendingUp className="w-4 h-4 text-green-500 mr-1" />
|
{totalRefunds > 0 ? (
|
||||||
<span className="text-green-600 font-medium">12.5%</span>
|
<span className="text-red-600">−${totalRefunds.toFixed(2)} refunded</span>
|
||||||
<span className="text-gray-600 ml-1">vs last period</span>
|
) : (
|
||||||
|
<span className="text-gray-500">No refunds</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@ export interface Order {
|
||||||
_id: string;
|
_id: string;
|
||||||
orderNumber: string;
|
orderNumber: string;
|
||||||
total: number;
|
total: number;
|
||||||
status: 'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled';
|
refundTotal?: number;
|
||||||
|
status: 'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled' | 'refunded';
|
||||||
dateOrdered: string;
|
dateOrdered: string;
|
||||||
customer?: {
|
customer?: {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,10 @@ export interface Product {
|
||||||
sku?: string;
|
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
|
// Calculate total printing cost for an order
|
||||||
export const calculateOrderPrintingCost = (items: OrderItem[]): number => {
|
export const calculateOrderPrintingCost = (items: OrderItem[]): number => {
|
||||||
return items.reduce((total, item) => {
|
return items.reduce((total, item) => {
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
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 { Expense } from '../store/slices/expenseSlice';
|
||||||
import { calculateOrderPrintingCost, calculateOrderProfit, enrichOrderItemsWithCosts } from './orderCalculations';
|
import { calculateOrderPrintingCost, calculateOrderProfit, enrichOrderItemsWithCosts, orderNetRevenue } from './orderCalculations';
|
||||||
|
|
||||||
export interface ProfitMetrics {
|
export interface ProfitMetrics {
|
||||||
totalRevenue: number;
|
totalRevenue: number; // Net of buyer refunds
|
||||||
|
totalRefunds: number;
|
||||||
totalPrintingCosts: number;
|
totalPrintingCosts: number;
|
||||||
totalExpenses: number; // Added: All expenses excluding transaction fees
|
totalExpenses: number; // Added: All expenses excluding transaction fees
|
||||||
totalProfit: number;
|
totalProfit: number;
|
||||||
|
|
@ -75,6 +76,7 @@ export class ProfitAnalysisService {
|
||||||
if (!orders || orders.length === 0) {
|
if (!orders || orders.length === 0) {
|
||||||
return {
|
return {
|
||||||
totalRevenue: 0,
|
totalRevenue: 0,
|
||||||
|
totalRefunds: 0,
|
||||||
totalPrintingCosts: 0,
|
totalPrintingCosts: 0,
|
||||||
totalExpenses: 0,
|
totalExpenses: 0,
|
||||||
totalProfit: 0,
|
totalProfit: 0,
|
||||||
|
|
@ -87,18 +89,20 @@ export class ProfitAnalysisService {
|
||||||
}
|
}
|
||||||
|
|
||||||
let totalRevenue = 0;
|
let totalRevenue = 0;
|
||||||
|
let totalRefunds = 0;
|
||||||
let totalPrintingCosts = 0;
|
let totalPrintingCosts = 0;
|
||||||
let profitableOrderCount = 0;
|
let profitableOrderCount = 0;
|
||||||
|
|
||||||
// Calculate revenue and printing costs from orders
|
// Calculate revenue (net of refunds) and printing costs from orders
|
||||||
orders.forEach(order => {
|
orders.forEach(order => {
|
||||||
const revenue = order.total || 0;
|
const revenue = orderNetRevenue(order);
|
||||||
const enrichedItems = enrichOrderItemsWithCosts(order.items || [], products || []);
|
const enrichedItems = enrichOrderItemsWithCosts(order.items || [], products || []);
|
||||||
const printingCost = calculateOrderPrintingCost(enrichedItems);
|
const printingCost = calculateOrderPrintingCost(enrichedItems);
|
||||||
|
|
||||||
totalRevenue += revenue;
|
totalRevenue += revenue;
|
||||||
|
totalRefunds += order.refundTotal || 0;
|
||||||
totalPrintingCosts += printingCost;
|
totalPrintingCosts += printingCost;
|
||||||
|
|
||||||
// Count profitable orders based on printing costs only (before expenses)
|
// Count profitable orders based on printing costs only (before expenses)
|
||||||
const orderProfit = calculateOrderProfit(enrichedItems, revenue);
|
const orderProfit = calculateOrderProfit(enrichedItems, revenue);
|
||||||
if (orderProfit > 0) profitableOrderCount++;
|
if (orderProfit > 0) profitableOrderCount++;
|
||||||
|
|
@ -118,6 +122,7 @@ export class ProfitAnalysisService {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalRevenue,
|
totalRevenue,
|
||||||
|
totalRefunds,
|
||||||
totalPrintingCosts,
|
totalPrintingCosts,
|
||||||
totalExpenses,
|
totalExpenses,
|
||||||
totalProfit,
|
totalProfit,
|
||||||
|
|
@ -193,7 +198,7 @@ export class ProfitAnalysisService {
|
||||||
const monthKey = `${orderDate.getFullYear()}-${String(orderDate.getMonth() + 1).padStart(2, '0')}`;
|
const monthKey = `${orderDate.getFullYear()}-${String(orderDate.getMonth() + 1).padStart(2, '0')}`;
|
||||||
const monthName = orderDate.toLocaleDateString('en-AU', { year: 'numeric', month: 'long' });
|
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 enrichedItems = enrichOrderItemsWithCosts(order.items || [], products || []);
|
||||||
const costs = calculateOrderPrintingCost(enrichedItems);
|
const costs = calculateOrderPrintingCost(enrichedItems);
|
||||||
|
|
||||||
|
|
@ -441,7 +446,7 @@ export class ProfitAnalysisService {
|
||||||
|
|
||||||
return orders.map(order => {
|
return orders.map(order => {
|
||||||
const enrichedItems = enrichOrderItemsWithCosts(order.items || [], products);
|
const enrichedItems = enrichOrderItemsWithCosts(order.items || [], products);
|
||||||
const revenue = order.total || 0;
|
const revenue = orderNetRevenue(order);
|
||||||
const printingCosts = calculateOrderPrintingCost(enrichedItems);
|
const printingCosts = calculateOrderPrintingCost(enrichedItems);
|
||||||
const profit = calculateOrderProfit(enrichedItems, revenue);
|
const profit = calculateOrderProfit(enrichedItems, revenue);
|
||||||
const margin = revenue > 0 ? (profit / revenue) * 100 : 0;
|
const margin = revenue > 0 ? (profit / revenue) * 100 : 0;
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ export interface IOrder extends Document {
|
||||||
shipping: number;
|
shipping: number;
|
||||||
tax: number;
|
tax: number;
|
||||||
total: number;
|
total: number;
|
||||||
|
refundTotal: number;
|
||||||
status: 'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled' | 'refunded';
|
status: 'pending' | 'processing' | 'shipped' | 'delivered' | 'cancelled' | 'refunded';
|
||||||
paymentStatus: 'pending' | 'paid' | 'refunded' | 'failed';
|
paymentStatus: 'pending' | 'paid' | 'refunded' | 'failed';
|
||||||
shippingAddress?: {
|
shippingAddress?: {
|
||||||
|
|
@ -69,6 +70,8 @@ const OrderSchema: Schema = new Schema({
|
||||||
shipping: { type: Number, default: 0 },
|
shipping: { type: Number, default: 0 },
|
||||||
tax: { type: Number, default: 0 },
|
tax: { type: Number, default: 0 },
|
||||||
total: { type: Number, required: true, min: 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: {
|
status: {
|
||||||
type: String,
|
type: String,
|
||||||
enum: ['pending', 'processing', 'shipped', 'delivered', 'cancelled', 'refunded'],
|
enum: ['pending', 'processing', 'shipped', 'delivered', 'cancelled', 'refunded'],
|
||||||
|
|
|
||||||
|
|
@ -195,9 +195,15 @@ const matchProduct = (
|
||||||
return undefined;
|
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();
|
const status = String(receipt.status || '').toLowerCase();
|
||||||
if (status === 'canceled') return 'cancelled';
|
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 (status === 'completed') return 'delivered';
|
||||||
if (receipt.is_shipped) return 'shipped';
|
if (receipt.is_shipped) return 'shipped';
|
||||||
return 'processing';
|
return 'processing';
|
||||||
|
|
@ -280,15 +286,18 @@ const upsertOrderFromReceipt = async (
|
||||||
});
|
});
|
||||||
|
|
||||||
const shipment = (receipt.shipments || [])[0];
|
const shipment = (receipt.shipments || [])[0];
|
||||||
|
const total = money(receipt.grandtotal);
|
||||||
|
const refundTotal = refundTotalOf(receipt);
|
||||||
|
|
||||||
const orderData: any = {
|
const orderData: any = {
|
||||||
orderNumber,
|
orderNumber,
|
||||||
etsyOrderId: orderNumber,
|
etsyOrderId: orderNumber,
|
||||||
total: money(receipt.grandtotal),
|
total,
|
||||||
|
refundTotal,
|
||||||
subtotal: money(receipt.subtotal),
|
subtotal: money(receipt.subtotal),
|
||||||
shipping: money(receipt.total_shipping_cost),
|
shipping: money(receipt.total_shipping_cost),
|
||||||
tax: money(receipt.total_tax_cost),
|
tax: money(receipt.total_tax_cost),
|
||||||
status: mapStatus(receipt),
|
status: mapStatus(receipt, refundTotal, total),
|
||||||
paymentStatus: receipt.is_paid ? 'paid' : 'pending',
|
paymentStatus: receipt.is_paid ? 'paid' : 'pending',
|
||||||
dateOrdered: new Date((receipt.created_timestamp || receipt.create_timestamp) * 1000),
|
dateOrdered: new Date((receipt.created_timestamp || receipt.create_timestamp) * 1000),
|
||||||
customer: { name: receipt.name || 'Etsy Customer', email: '' },
|
customer: { name: receipt.name || 'Etsy Customer', email: '' },
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue