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:
parent
2b15fd505e
commit
c5a6cba041
7 changed files with 294 additions and 9 deletions
|
|
@ -267,6 +267,14 @@ const Analytics = () => {
|
||||||
const netProfit = totalRevenue - totalExpenses - totalPrintingCosts;
|
const netProfit = totalRevenue - totalExpenses - totalPrintingCosts;
|
||||||
const profitMargin = totalRevenue > 0 ? (netProfit / totalRevenue) * 100 : 0;
|
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 averageOrderValue = filteredOrders.length > 0 ? totalRevenue / filteredOrders.length : 0;
|
||||||
const totalCustomers = customers?.length || 0;
|
const totalCustomers = customers?.length || 0;
|
||||||
|
|
||||||
|
|
@ -645,6 +653,34 @@ const Analytics = () => {
|
||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Charts Row */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||||
{/* Revenue Chart */}
|
{/* Revenue Chart */}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ const Products = () => {
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState<string | null>(null);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState<string | null>(null);
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [selectedCategory, setSelectedCategory] = useState('all');
|
const [selectedCategory, setSelectedCategory] = useState('all');
|
||||||
|
const [showMissingCostsOnly, setShowMissingCostsOnly] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const [formData, setFormData] = useState<ProductFormData>({
|
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 filteredProducts = products.filter(product => {
|
||||||
const matchesSearch = product.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
const matchesSearch = product.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
product.sku.toLowerCase().includes(searchTerm.toLowerCase());
|
product.sku.toLowerCase().includes(searchTerm.toLowerCase());
|
||||||
const matchesCategory = selectedCategory === 'all' || product.category === selectedCategory;
|
const matchesCategory = selectedCategory === 'all' || product.category === selectedCategory;
|
||||||
return matchesSearch && matchesCategory;
|
const matchesMissing = !showMissingCostsOnly || needsCosts(product);
|
||||||
|
return matchesSearch && matchesCategory && matchesMissing;
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -350,6 +358,19 @@ const Products = () => {
|
||||||
<option key={cat} value={cat}>{cat}</option>
|
<option key={cat} value={cat}>{cat}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Add Product Form Modal */}
|
{/* 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">
|
<span className="inline-block bg-blue-100 text-blue-800 text-xs px-2 py-1 rounded-full mt-1">
|
||||||
{product.category}
|
{product.category}
|
||||||
</span>
|
</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>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { useState, useMemo } from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import { RootState } from '../store';
|
import { RootState } from '../store';
|
||||||
import ProfitAnalysisService, { DateRangeOption } from '../utils/profitAnalysisService';
|
import ProfitAnalysisService, { DateRangeOption } from '../utils/profitAnalysisService';
|
||||||
|
import { enrichOrderItemsWithCosts, orderNetRevenue } from '../utils/orderCalculations';
|
||||||
import { TrendingUp, DollarSign, Package, Target, BarChart3, ChevronDown, ChevronRight } from 'lucide-react';
|
import { TrendingUp, DollarSign, Package, Target, BarChart3, ChevronDown, ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
const ProfitAnalysis = () => {
|
const ProfitAnalysis = () => {
|
||||||
|
|
@ -14,7 +15,7 @@ const ProfitAnalysis = () => {
|
||||||
});
|
});
|
||||||
const [customStartDate, setCustomStartDate] = useState('');
|
const [customStartDate, setCustomStartDate] = useState('');
|
||||||
const [customEndDate, setCustomEndDate] = 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 [expandedOrder, setExpandedOrder] = useState<string | null>(null);
|
||||||
const [orderSortBy, setOrderSortBy] = useState<'date' | 'profit' | 'margin' | 'revenue'>('date');
|
const [orderSortBy, setOrderSortBy] = useState<'date' | 'profit' | 'margin' | 'revenue'>('date');
|
||||||
const [orderFilterCategory, setOrderFilterCategory] = useState<'all' | 'excellent' | 'good' | 'average' | 'poor' | 'loss'>('all');
|
const [orderFilterCategory, setOrderFilterCategory] = useState<'all' | 'excellent' | 'good' | 'average' | 'poor' | 'loss'>('all');
|
||||||
|
|
@ -39,6 +40,37 @@ const ProfitAnalysis = () => {
|
||||||
return ProfitAnalysisService.calculateProfitMetrics(filteredOrders, products || [], filteredExpenses);
|
return ProfitAnalysisService.calculateProfitMetrics(filteredOrders, products || [], filteredExpenses);
|
||||||
}, [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
|
// Top performing products analysis
|
||||||
const productPerformance = useMemo(() => {
|
const productPerformance = useMemo(() => {
|
||||||
return ProfitAnalysisService.getTopPerformingProducts(filteredOrders, products || [], 10);
|
return ProfitAnalysisService.getTopPerformingProducts(filteredOrders, products || [], 10);
|
||||||
|
|
@ -205,13 +237,23 @@ const ProfitAnalysis = () => {
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectedView('orders')}
|
onClick={() => setSelectedView('orders')}
|
||||||
className={`px-4 py-2 rounded-lg font-medium ${
|
className={`px-4 py-2 rounded-lg font-medium ${
|
||||||
selectedView === 'orders'
|
selectedView === 'orders'
|
||||||
? 'bg-blue-600 text-white'
|
? 'bg-blue-600 text-white'
|
||||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
Orders
|
Orders
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Overview Metrics Cards */}
|
{/* Overview Metrics Cards */}
|
||||||
|
|
@ -596,6 +638,91 @@ const ProfitAnalysis = () => {
|
||||||
</div>
|
</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 & 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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { useDispatch } from 'react-redux';
|
||||||
import { DataManager } from '../utils/dataManager';
|
import { DataManager } from '../utils/dataManager';
|
||||||
import { setOrders } from '../store/slices/orderSlice';
|
import { setOrders } from '../store/slices/orderSlice';
|
||||||
import { setProducts } from '../store/slices/productSlice';
|
import { setProducts } from '../store/slices/productSlice';
|
||||||
|
import { setCustomers } from '../store/slices/customerSlice';
|
||||||
import { MissingProductsModal } from '../components/MissingProductsModal';
|
import { MissingProductsModal } from '../components/MissingProductsModal';
|
||||||
import { Trash2, Download, RefreshCw, AlertTriangle, Database, Store, Link2, Unlink, Wrench, Package } from 'lucide-react';
|
import { Trash2, Download, RefreshCw, AlertTriangle, Database, Store, Link2, Unlink, Wrench, Package } from 'lucide-react';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
@ -129,8 +130,11 @@ const Settings = () => {
|
||||||
setUnknownDebits([]);
|
setUnknownDebits([]);
|
||||||
try {
|
try {
|
||||||
const res = await api.post('/etsy/sync');
|
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`);
|
toast.success(`Synced ${receiptsSeen} Etsy orders: ${created} new, ${updated} updated`);
|
||||||
|
if (customersUpserted > 0) {
|
||||||
|
toast.success(`Updated ${customersUpserted} customer record(s)`);
|
||||||
|
}
|
||||||
if (dedupedFees > 0) {
|
if (dedupedFees > 0) {
|
||||||
toast.success(`Cleaned up ${dedupedFees} duplicate fee expense(s) from earlier syncs`);
|
toast.success(`Cleaned up ${dedupedFees} duplicate fee expense(s) from earlier syncs`);
|
||||||
}
|
}
|
||||||
|
|
@ -142,9 +146,13 @@ const Settings = () => {
|
||||||
if (unmatched?.length > 0) {
|
if (unmatched?.length > 0) {
|
||||||
setUnmatchedItems(unmatched);
|
setUnmatchedItems(unmatched);
|
||||||
}
|
}
|
||||||
// Refresh orders in the app
|
// Refresh orders and customers in the app
|
||||||
const ordersRes = await api.get('/orders?limit=1000');
|
const [ordersRes, customersRes] = await Promise.all([
|
||||||
|
api.get('/orders?limit=1000'),
|
||||||
|
api.get('/customers?limit=1000'),
|
||||||
|
]);
|
||||||
dispatch(setOrders(ordersRes.data.orders));
|
dispatch(setOrders(ordersRes.data.orders));
|
||||||
|
dispatch(setCustomers(customersRes.data.customers));
|
||||||
loadEtsyStatus();
|
loadEtsyStatus();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error(error.response?.data?.message || 'Etsy sync failed');
|
toast.error(error.response?.data?.message || 'Etsy sync failed');
|
||||||
|
|
|
||||||
|
|
@ -390,9 +390,26 @@ export class ProfitAnalysisService {
|
||||||
type: 'year' as const
|
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<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
|
// Combine all options with separators
|
||||||
return [
|
return [
|
||||||
...options,
|
...options,
|
||||||
|
...fyOptions,
|
||||||
...monthOptions,
|
...monthOptions,
|
||||||
...quarterOptions,
|
...quarterOptions,
|
||||||
...yearOptions
|
...yearOptions
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,8 @@ export interface ICustomer extends Document {
|
||||||
|
|
||||||
const CustomerSchema: Schema = new Schema({
|
const CustomerSchema: Schema = new Schema({
|
||||||
name: { type: String, required: true, trim: true },
|
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 },
|
etsyUserId: { type: String },
|
||||||
address: {
|
address: {
|
||||||
street1: { type: String },
|
street1: { type: String },
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { IEtsyConnection } from '../models/EtsyConnection';
|
||||||
import Order from '../models/Order';
|
import Order from '../models/Order';
|
||||||
import Product, { IProduct } from '../models/Product';
|
import Product, { IProduct } from '../models/Product';
|
||||||
import Expense from '../models/Expense';
|
import Expense from '../models/Expense';
|
||||||
|
import Customer from '../models/Customer';
|
||||||
|
|
||||||
const ETSY_API_BASE = 'https://api.etsy.com/v3';
|
const ETSY_API_BASE = 'https://api.etsy.com/v3';
|
||||||
const ETSY_TOKEN_URL = 'https://api.etsy.com/v3/public/oauth/token';
|
const ETSY_TOKEN_URL = 'https://api.etsy.com/v3/public/oauth/token';
|
||||||
|
|
@ -214,11 +215,77 @@ export interface SyncResult {
|
||||||
updated: number;
|
updated: number;
|
||||||
unmatchedItems: string[];
|
unmatchedItems: string[];
|
||||||
receiptsSeen: number;
|
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> => {
|
export const syncReceipts = async (creds: EtsyCredentials, connection: IEtsyConnection): Promise<SyncResult> => {
|
||||||
const products = await Product.find({ userId: connection.userId });
|
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;
|
const limit = 100;
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
|
|
@ -235,12 +302,15 @@ export const syncReceipts = async (creds: EtsyCredentials, connection: IEtsyConn
|
||||||
for (const receipt of receipts) {
|
for (const receipt of receipts) {
|
||||||
result.receiptsSeen++;
|
result.receiptsSeen++;
|
||||||
await upsertOrderFromReceipt(connection, receipt, products, result);
|
await upsertOrderFromReceipt(connection, receipt, products, result);
|
||||||
|
accumulateBuyer(buyers, receipt);
|
||||||
}
|
}
|
||||||
|
|
||||||
offset += receipts.length;
|
offset += receipts.length;
|
||||||
if (offset >= (page.count || 0)) break;
|
if (offset >= (page.count || 0)) break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
result.customersUpserted = await upsertCustomers(connection, buyers);
|
||||||
|
|
||||||
connection.lastSyncedAt = new Date();
|
connection.lastSyncedAt = new Date();
|
||||||
await connection.save();
|
await connection.save();
|
||||||
return result;
|
return result;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue