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 = () => { ))} + {missingCostsCount > 0 && ( + + )}
{/* Add Product Form Modal */} @@ -522,6 +543,11 @@ const Products = () => { {product.category} + {needsCosts(product) && ( + + ⚠ No cost set + + )}
+
{/* 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;