Add customers-from-sync, ad ROI, FY P&L + GST, missing-cost flags

- Customers: receipt sync accumulates each buyer (name/email/address) and
  upserts Customer records with net totals (idempotent, keyed by Etsy
  buyer id); lights up the Customers page and repeat-rate metric. Email
  no longer required (Etsy may omit it).
- Ad ROI: Analytics shows Advertising Performance (spend, % of revenue,
  ROAS) from Marketing & Advertising expenses.
- FY P&L + GST: financial-year (Jul-Jun) date options; new P&L/GST tab in
  Profit Analysis with Revenue -> COGS (printing+materials) -> Gross
  profit -> operating expenses by category -> Net profit, plus an
  indicative GST summary.
- Products: 'Missing costs' filter + count and a per-card warning for
  products with no printing/material cost set.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
dlawler489 2026-06-14 11:54:51 +10:00
parent 2b15fd505e
commit c5a6cba041
7 changed files with 294 additions and 9 deletions

View file

@ -267,6 +267,14 @@ const Analytics = () => {
const netProfit = totalRevenue - totalExpenses - totalPrintingCosts;
const profitMargin = totalRevenue > 0 ? (netProfit / totalRevenue) * 100 : 0;
// Advertising performance — ads are typically the largest expense
const adSpend = filteredExpenses.reduce(
(sum, e) => sum + (e.category === 'Marketing & Advertising' ? (e.amount || 0) : 0),
0
);
const adPctOfRevenue = totalRevenue > 0 ? (adSpend / totalRevenue) * 100 : 0;
const roas = adSpend > 0 ? totalRevenue / adSpend : 0;
const averageOrderValue = filteredOrders.length > 0 ? totalRevenue / filteredOrders.length : 0;
const totalCustomers = customers?.length || 0;
@ -645,6 +653,34 @@ const Analytics = () => {
</div>
</div>
{/* Advertising performance */}
{adSpend > 0 && (
<div className="bg-white rounded-lg shadow p-6 mb-8">
<h3 className="text-lg font-medium text-gray-900 mb-4">Advertising Performance</h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6">
<div>
<p className="text-sm font-medium text-gray-500">Ad Spend</p>
<p className="text-2xl font-semibold text-gray-900">${adSpend.toFixed(2)}</p>
<p className="text-xs text-gray-400 mt-0.5">Etsy Ads (onsite + offsite)</p>
</div>
<div>
<p className="text-sm font-medium text-gray-500">% of Revenue</p>
<p className={`text-2xl font-semibold ${adPctOfRevenue > 25 ? 'text-red-600' : 'text-gray-900'}`}>
{adPctOfRevenue.toFixed(1)}%
</p>
<p className="text-xs text-gray-400 mt-0.5">of ${totalRevenue.toFixed(0)} revenue</p>
</div>
<div>
<p className="text-sm font-medium text-gray-500">ROAS</p>
<p className={`text-2xl font-semibold ${roas < 4 ? 'text-amber-600' : 'text-green-600'}`}>
{roas.toFixed(1)}×
</p>
<p className="text-xs text-gray-400 mt-0.5">revenue per $1 of ad spend</p>
</div>
</div>
</div>
)}
{/* Charts Row */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
{/* Revenue Chart */}

View file

@ -30,6 +30,7 @@ const Products = () => {
const [showDeleteConfirm, setShowDeleteConfirm] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [selectedCategory, setSelectedCategory] = useState('all');
const [showMissingCostsOnly, setShowMissingCostsOnly] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [formData, setFormData] = useState<ProductFormData>({
@ -264,11 +265,18 @@ const Products = () => {
});
};
// A product "needs costs" when neither printing nor material cost is set —
// common for items just pulled in from the Etsy catalog sync
const needsCosts = (p: typeof products[number]) =>
!((p.printingCost || 0) > 0) && !((p.costOfGoods || 0) > 0);
const missingCostsCount = products.filter(needsCosts).length;
const filteredProducts = products.filter(product => {
const matchesSearch = product.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
product.sku.toLowerCase().includes(searchTerm.toLowerCase());
const matchesCategory = selectedCategory === 'all' || product.category === selectedCategory;
return matchesSearch && matchesCategory;
const matchesMissing = !showMissingCostsOnly || needsCosts(product);
return matchesSearch && matchesCategory && matchesMissing;
});
return (
@ -350,6 +358,19 @@ const Products = () => {
<option key={cat} value={cat}>{cat}</option>
))}
</select>
{missingCostsCount > 0 && (
<button
onClick={() => setShowMissingCostsOnly(v => !v)}
className={`flex items-center gap-2 px-4 py-2 rounded-lg border text-sm whitespace-nowrap ${
showMissingCostsOnly
? 'bg-amber-600 text-white border-amber-600'
: 'bg-amber-50 text-amber-700 border-amber-200 hover:bg-amber-100'
}`}
title="Products with no printing or material cost set"
>
Missing costs ({missingCostsCount})
</button>
)}
</div>
{/* Add Product Form Modal */}
@ -522,6 +543,11 @@ const Products = () => {
<span className="inline-block bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full mt-1">
{product.category}
</span>
{needsCosts(product) && (
<span className="inline-block bg-amber-100 text-amber-800 text-xs px-2 py-1 rounded-full mt-1 ml-1">
No cost set
</span>
)}
</div>
<div className="flex gap-2">
<button

View file

@ -2,6 +2,7 @@ import { useState, useMemo } from 'react';
import { useSelector } from 'react-redux';
import { RootState } from '../store';
import ProfitAnalysisService, { DateRangeOption } from '../utils/profitAnalysisService';
import { enrichOrderItemsWithCosts, orderNetRevenue } from '../utils/orderCalculations';
import { TrendingUp, DollarSign, Package, Target, BarChart3, ChevronDown, ChevronRight } from 'lucide-react';
const ProfitAnalysis = () => {
@ -14,7 +15,7 @@ const ProfitAnalysis = () => {
});
const [customStartDate, setCustomStartDate] = useState('');
const [customEndDate, setCustomEndDate] = useState('');
const [selectedView, setSelectedView] = useState<'overview' | 'trends' | 'products' | 'orders'>('overview');
const [selectedView, setSelectedView] = useState<'overview' | 'trends' | 'products' | 'orders' | 'pl'>('overview');
const [expandedOrder, setExpandedOrder] = useState<string | null>(null);
const [orderSortBy, setOrderSortBy] = useState<'date' | 'profit' | 'margin' | 'revenue'>('date');
const [orderFilterCategory, setOrderFilterCategory] = useState<'all' | 'excellent' | 'good' | 'average' | 'poor' | 'loss'>('all');
@ -39,6 +40,37 @@ const ProfitAnalysis = () => {
return ProfitAnalysisService.calculateProfitMetrics(filteredOrders, products || [], filteredExpenses);
}, [filteredOrders, products, filteredExpenses]);
// Profit & loss statement + indicative GST summary for the selected period
const plData = useMemo(() => {
const revenue = filteredOrders.reduce((s, o) => s + orderNetRevenue(o), 0);
let printing = 0;
let materials = 0;
let gstOnSales = 0;
filteredOrders.forEach(o => {
const items = enrichOrderItemsWithCosts(o.items || [], products || []);
printing += items.reduce((s, it) => s + (it.printingCost || 0) * (it.quantity || 1), 0);
materials += items.reduce((s, it) => s + (it.costOfGoods || 0) * (it.quantity || 1), 0);
gstOnSales += (o as any).tax || 0;
});
const cogs = printing + materials;
const grossProfit = revenue - cogs;
const byCategory: Record<string, number> = {};
filteredExpenses.forEach(e => {
const cat = e.category || 'Other';
byCategory[cat] = (byCategory[cat] || 0) + (e.amount || 0);
});
const operatingExpenses = Object.values(byCategory).reduce((s, v) => s + v, 0);
const netProfit = grossProfit - operatingExpenses;
const gstPaidOnFees = byCategory['Taxes & GST'] || 0;
return {
revenue, printing, materials, cogs, grossProfit,
byCategory, operatingExpenses, netProfit,
gstOnSales, gstPaidOnFees, netGst: gstOnSales - gstPaidOnFees,
};
}, [filteredOrders, filteredExpenses, products]);
// Top performing products analysis
const productPerformance = useMemo(() => {
return ProfitAnalysisService.getTopPerformingProducts(filteredOrders, products || [], 10);
@ -205,13 +237,23 @@ const ProfitAnalysis = () => {
<button
onClick={() => setSelectedView('orders')}
className={`px-4 py-2 rounded-lg font-medium ${
selectedView === 'orders'
? 'bg-blue-600 text-white'
selectedView === 'orders'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
Orders
</button>
<button
onClick={() => setSelectedView('pl')}
className={`px-4 py-2 rounded-lg font-medium ${
selectedView === 'pl'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
P&L / GST
</button>
</div>
{/* Overview Metrics Cards */}
@ -596,6 +638,91 @@ const ProfitAnalysis = () => {
</div>
</>
)}
{/* P&L / GST */}
{selectedView === 'pl' && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Profit & Loss statement */}
<div className="lg:col-span-2 bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-1">Profit &amp; Loss</h3>
<p className="text-sm text-gray-500 mb-4">
{ProfitAnalysisService.getDateRangeLabel(dateRange, orders || [])}
</p>
<div className="divide-y divide-gray-100 text-sm">
<div className="flex justify-between py-2">
<span className="font-medium text-gray-900">Revenue (net of refunds)</span>
<span className="font-medium text-gray-900">{formatCurrency(plData.revenue)}</span>
</div>
<div className="py-2">
<div className="flex justify-between text-gray-500">
<span>Cost of goods sold</span>
<span>({formatCurrency(plData.cogs)})</span>
</div>
<div className="flex justify-between text-gray-500 pl-4 text-xs mt-1">
<span>Printing</span><span>({formatCurrency(plData.printing)})</span>
</div>
<div className="flex justify-between text-gray-500 pl-4 text-xs">
<span>Materials</span><span>({formatCurrency(plData.materials)})</span>
</div>
</div>
<div className="flex justify-between py-2">
<span className="font-medium text-gray-900">Gross profit</span>
<span className="font-medium text-gray-900">{formatCurrency(plData.grossProfit)}</span>
</div>
<div className="py-2">
<div className="flex justify-between text-gray-700 font-medium mb-1">
<span>Operating expenses</span>
<span>({formatCurrency(plData.operatingExpenses)})</span>
</div>
{Object.entries(plData.byCategory)
.sort((a, b) => b[1] - a[1])
.map(([cat, amt]) => (
<div key={cat} className="flex justify-between text-gray-500 pl-4 text-xs">
<span>{cat}</span><span>({formatCurrency(amt)})</span>
</div>
))}
</div>
<div className="flex justify-between py-3">
<span className="text-base font-bold text-gray-900">Net profit</span>
<span className={`text-base font-bold ${plData.netProfit >= 0 ? 'text-green-600' : 'text-red-600'}`}>
{formatCurrency(plData.netProfit)}
</span>
</div>
</div>
</div>
{/* GST summary */}
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-1">GST Summary</h3>
<p className="text-sm text-gray-500 mb-4">Indicative confirm against your BAS</p>
<div className="space-y-3 text-sm">
<div className="flex justify-between">
<span className="text-gray-600">GST on sales (collected by Etsy)</span>
<span className="font-medium text-gray-900">{formatCurrency(plData.gstOnSales)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">GST on Etsy fees (paid)</span>
<span className="font-medium text-gray-900">{formatCurrency(plData.gstPaidOnFees)}</span>
</div>
<div className="flex justify-between border-t border-gray-100 pt-3">
<span className="font-medium text-gray-900">Net GST position</span>
<span className="font-medium text-gray-900">{formatCurrency(plData.netGst)}</span>
</div>
</div>
<p className="text-xs text-gray-400 mt-4">
Etsy generally collects and remits GST on AU sales on your behalf. This summary is for
your records, not tax advice check the figures against your Etsy statements and BAS.
</p>
</div>
</div>
)}
</div>
);
};

View file

@ -3,6 +3,7 @@ import { useDispatch } from 'react-redux';
import { DataManager } from '../utils/dataManager';
import { setOrders } from '../store/slices/orderSlice';
import { setProducts } from '../store/slices/productSlice';
import { setCustomers } from '../store/slices/customerSlice';
import { MissingProductsModal } from '../components/MissingProductsModal';
import { Trash2, Download, RefreshCw, AlertTriangle, Database, Store, Link2, Unlink, Wrench, Package } from 'lucide-react';
import toast from 'react-hot-toast';
@ -129,8 +130,11 @@ const Settings = () => {
setUnknownDebits([]);
try {
const res = await api.post('/etsy/sync');
const { created, updated, unmatchedItems: unmatched, receiptsSeen, ledger, legacyEtsyExpenses: legacy, dedupedFees } = res.data;
const { created, updated, unmatchedItems: unmatched, receiptsSeen, customersUpserted, ledger, legacyEtsyExpenses: legacy, dedupedFees } = res.data;
toast.success(`Synced ${receiptsSeen} Etsy orders: ${created} new, ${updated} updated`);
if (customersUpserted > 0) {
toast.success(`Updated ${customersUpserted} customer record(s)`);
}
if (dedupedFees > 0) {
toast.success(`Cleaned up ${dedupedFees} duplicate fee expense(s) from earlier syncs`);
}
@ -142,9 +146,13 @@ const Settings = () => {
if (unmatched?.length > 0) {
setUnmatchedItems(unmatched);
}
// Refresh orders in the app
const ordersRes = await api.get('/orders?limit=1000');
// Refresh orders and customers in the app
const [ordersRes, customersRes] = await Promise.all([
api.get('/orders?limit=1000'),
api.get('/customers?limit=1000'),
]);
dispatch(setOrders(ordersRes.data.orders));
dispatch(setCustomers(customersRes.data.customers));
loadEtsyStatus();
} catch (error: any) {
toast.error(error.response?.data?.message || 'Etsy sync failed');

View file

@ -390,9 +390,26 @@ export class ProfitAnalysisService {
type: 'year' as const
}));
// Australian financial years (JulJun), expressed as custom date ranges so
// the existing range filter handles them
const fyStarts = new Set<number>();
orders.forEach(order => {
const date = new Date(order.dateOrdered);
fyStarts.add(date.getMonth() >= 6 ? date.getFullYear() : date.getFullYear() - 1);
});
const fyOptions: DateRangeOption[] = Array.from(fyStarts)
.sort((a, b) => b - a)
.slice(0, 5)
.map(start => ({
value: `${start}-07-01_${start + 1}-06-30`,
label: `FY ${start}${String(start + 1).slice(2)}`,
type: 'custom' as const,
}));
// Combine all options with separators
return [
...options,
...fyOptions,
...monthOptions,
...quarterOptions,
...yearOptions

View file

@ -21,7 +21,8 @@ export interface ICustomer extends Document {
const CustomerSchema: Schema = new Schema({
name: { type: String, required: true, trim: true },
email: { type: String, required: true, trim: true, lowercase: true },
// Not required: Etsy doesn't always expose a buyer email via the API
email: { type: String, trim: true, lowercase: true, default: '' },
etsyUserId: { type: String },
address: {
street1: { type: String },

View file

@ -3,6 +3,7 @@ import { IEtsyConnection } from '../models/EtsyConnection';
import Order from '../models/Order';
import Product, { IProduct } from '../models/Product';
import Expense from '../models/Expense';
import Customer from '../models/Customer';
const ETSY_API_BASE = 'https://api.etsy.com/v3';
const ETSY_TOKEN_URL = 'https://api.etsy.com/v3/public/oauth/token';
@ -214,11 +215,77 @@ export interface SyncResult {
updated: number;
unmatchedItems: string[];
receiptsSeen: number;
customersUpserted: number;
}
interface BuyerAgg {
etsyUserId?: string;
name: string;
email: string;
address: any;
totalOrders: number;
totalSpent: number;
}
// Accumulate one receipt into its buyer's running totals (net of refunds)
const accumulateBuyer = (buyers: Map<string, BuyerAgg>, receipt: any): void => {
const key = String(receipt.buyer_user_id || receipt.buyer_email || receipt.name || 'unknown');
const net = Math.max(0, money(receipt.grandtotal) - refundTotalOf(receipt));
const b: BuyerAgg = buyers.get(key) || {
etsyUserId: receipt.buyer_user_id ? String(receipt.buyer_user_id) : undefined,
name: 'Etsy Customer',
email: '',
address: {},
totalOrders: 0,
totalSpent: 0,
};
b.totalOrders += 1;
b.totalSpent += net;
if (receipt.name) b.name = receipt.name;
if (receipt.buyer_email) b.email = receipt.buyer_email;
b.address = {
street1: receipt.first_line,
street2: receipt.second_line,
city: receipt.city,
state: receipt.state,
zipCode: receipt.zip,
country: receipt.country_iso,
};
buyers.set(key, b);
};
// Idempotently upsert customers from accumulated buyer totals (set, not $inc)
const upsertCustomers = async (connection: IEtsyConnection, buyers: Map<string, BuyerAgg>): Promise<number> => {
let count = 0;
for (const b of buyers.values()) {
const filter = b.etsyUserId
? { userId: connection.userId, etsyUserId: b.etsyUserId }
: { userId: connection.userId, name: b.name, email: b.email };
await Customer.findOneAndUpdate(
filter,
{
$set: {
userId: connection.userId,
name: b.name,
email: b.email,
etsyUserId: b.etsyUserId,
address: b.address,
totalOrders: b.totalOrders,
totalSpent: b.totalSpent,
},
},
{ upsert: true, new: true, setDefaultsOnInsert: true }
);
count++;
}
return count;
};
export const syncReceipts = async (creds: EtsyCredentials, connection: IEtsyConnection): Promise<SyncResult> => {
const products = await Product.find({ userId: connection.userId });
const result: SyncResult = { created: 0, updated: 0, unmatchedItems: [], receiptsSeen: 0 };
const result: SyncResult = { created: 0, updated: 0, unmatchedItems: [], receiptsSeen: 0, customersUpserted: 0 };
const buyers = new Map<string, BuyerAgg>();
const limit = 100;
let offset = 0;
@ -235,12 +302,15 @@ export const syncReceipts = async (creds: EtsyCredentials, connection: IEtsyConn
for (const receipt of receipts) {
result.receiptsSeen++;
await upsertOrderFromReceipt(connection, receipt, products, result);
accumulateBuyer(buyers, receipt);
}
offset += receipts.length;
if (offset >= (page.count || 0)) break;
}
result.customersUpserted = await upsertCustomers(connection, buyers);
connection.lastSyncedAt = new Date();
await connection.save();
return result;