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 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 */}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 & 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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue