diff --git a/client/src/pages/Analytics.tsx b/client/src/pages/Analytics.tsx
index d4bca88..3ec148c 100644
--- a/client/src/pages/Analytics.tsx
+++ b/client/src/pages/Analytics.tsx
@@ -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 = () => {
+ {/* Advertising performance */}
+ {adSpend > 0 && (
+
+
Advertising Performance
+
+
+
Ad Spend
+
${adSpend.toFixed(2)}
+
Etsy Ads (onsite + offsite)
+
+
+
% of Revenue
+
25 ? 'text-red-600' : 'text-gray-900'}`}>
+ {adPctOfRevenue.toFixed(1)}%
+
+
of ${totalRevenue.toFixed(0)} revenue
+
+
+
ROAS
+
+ {roas.toFixed(1)}×
+
+
revenue per $1 of ad spend
+
+
+
+ )}
+
{/* Charts Row */}
{/* Revenue Chart */}
diff --git a/client/src/pages/Products.tsx b/client/src/pages/Products.tsx
index 2cb4490..570b41e 100644
--- a/client/src/pages/Products.tsx
+++ b/client/src/pages/Products.tsx
@@ -30,6 +30,7 @@ const Products = () => {
const [showDeleteConfirm, setShowDeleteConfirm] = useState
(null);
const [searchTerm, setSearchTerm] = useState('');
const [selectedCategory, setSelectedCategory] = useState('all');
+ const [showMissingCostsOnly, setShowMissingCostsOnly] = useState(false);
const fileInputRef = useRef(null);
const [formData, setFormData] = useState({
@@ -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 = () => {
{cat}
))}
+ {missingCostsCount > 0 && (
+ 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})
+
+ )}
{/* Add Product Form Modal */}
@@ -522,6 +543,11 @@ const Products = () => {
{product.category}
+ {needsCosts(product) && (
+
+ ⚠ No cost set
+
+ )}
{
@@ -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(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 = {};
+ 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 = () => {
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
+ 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
+
{/* Overview Metrics Cards */}
@@ -596,6 +638,91 @@ const ProfitAnalysis = () => {
>
)}
+
+ {/* P&L / GST */}
+ {selectedView === 'pl' && (
+
+ {/* Profit & Loss statement */}
+
+
Profit & Loss
+
+ {ProfitAnalysisService.getDateRangeLabel(dateRange, orders || [])}
+
+
+
+
+ Revenue (net of refunds)
+ {formatCurrency(plData.revenue)}
+
+
+
+
+ Cost of goods sold
+ ({formatCurrency(plData.cogs)})
+
+
+ Printing ({formatCurrency(plData.printing)})
+
+
+ Materials ({formatCurrency(plData.materials)})
+
+
+
+
+ Gross profit
+ {formatCurrency(plData.grossProfit)}
+
+
+
+
+ Operating expenses
+ ({formatCurrency(plData.operatingExpenses)})
+
+ {Object.entries(plData.byCategory)
+ .sort((a, b) => b[1] - a[1])
+ .map(([cat, amt]) => (
+
+ {cat} ({formatCurrency(amt)})
+
+ ))}
+
+
+
+ Net profit
+ = 0 ? 'text-green-600' : 'text-red-600'}`}>
+ {formatCurrency(plData.netProfit)}
+
+
+
+
+
+ {/* GST summary */}
+
+
GST Summary
+
Indicative — confirm against your BAS
+
+
+
+ GST on sales (collected by Etsy)
+ {formatCurrency(plData.gstOnSales)}
+
+
+ GST on Etsy fees (paid)
+ {formatCurrency(plData.gstPaidOnFees)}
+
+
+ Net GST position
+ {formatCurrency(plData.netGst)}
+
+
+
+
+ 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.
+
+
+
+ )}
);
};
diff --git a/client/src/pages/Settings.tsx b/client/src/pages/Settings.tsx
index d1b49a7..207d07a 100644
--- a/client/src/pages/Settings.tsx
+++ b/client/src/pages/Settings.tsx
@@ -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');
diff --git a/client/src/utils/profitAnalysisService.ts b/client/src/utils/profitAnalysisService.ts
index 8be732e..1a372bb 100644
--- a/client/src/utils/profitAnalysisService.ts
+++ b/client/src/utils/profitAnalysisService.ts
@@ -390,9 +390,26 @@ export class ProfitAnalysisService {
type: 'year' as const
}));
+ // Australian financial years (Jul–Jun), expressed as custom date ranges so
+ // the existing range filter handles them
+ const fyStarts = new Set();
+ 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
diff --git a/server/src/models/Customer.ts b/server/src/models/Customer.ts
index 27e25d2..460c91f 100644
--- a/server/src/models/Customer.ts
+++ b/server/src/models/Customer.ts
@@ -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 },
diff --git a/server/src/services/etsyApi.ts b/server/src/services/etsyApi.ts
index 68ed449..b2bab33 100644
--- a/server/src/services/etsyApi.ts
+++ b/server/src/services/etsyApi.ts
@@ -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, 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): Promise => {
+ 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 => {
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();
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;