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 { 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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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: '' },
|
||||
|
|
|
|||
Loading…
Reference in a new issue