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:
dlawler489 2026-06-13 17:07:48 +10:00
parent d5742940ec
commit 8e6680f2de
6 changed files with 45 additions and 18 deletions

View file

@ -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 = () => {
<div>
<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-xs text-gray-400 mt-0.5">net of refunds</p>
</div>
<div className="flex-shrink-0">
<DollarSign className="h-8 w-8 text-green-500" />
</div>
</div>
<div className="mt-2 flex items-center text-sm">
<TrendingUp className="w-4 h-4 text-green-500 mr-1" />
<span className="text-green-600 font-medium">12.5%</span>
<span className="text-gray-600 ml-1">vs last period</span>
{totalRefunds > 0 ? (
<span className="text-red-600">${totalRefunds.toFixed(2)} refunded</span>
) : (
<span className="text-gray-500">No refunds</span>
)}
</div>
</div>

View file

@ -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;

View file

@ -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) => {

View file

@ -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,16 +89,18 @@ 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)
@ -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;

View file

@ -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'],

View file

@ -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: '' },