diff --git a/client/src/pages/Settings.tsx b/client/src/pages/Settings.tsx index 8b9ce3a..dd79975 100644 --- a/client/src/pages/Settings.tsx +++ b/client/src/pages/Settings.tsx @@ -42,6 +42,8 @@ const Settings = () => { const [isSyncing, setIsSyncing] = useState(false); const [unmatchedItems, setUnmatchedItems] = useState([]); const [showResolveModal, setShowResolveModal] = useState(false); + const [unknownDebits, setUnknownDebits] = useState([]); + const [legacyEtsyExpenses, setLegacyEtsyExpenses] = useState(0); useEffect(() => { updateStorageSummary(); @@ -122,10 +124,16 @@ const Settings = () => { const handleEtsySync = async () => { setIsSyncing(true); setUnmatchedItems([]); + setUnknownDebits([]); try { const res = await api.post('/etsy/sync'); - const { created, updated, unmatchedItems: unmatched, receiptsSeen } = res.data; + const { created, updated, unmatchedItems: unmatched, receiptsSeen, ledger, legacyEtsyExpenses: legacy } = res.data; toast.success(`Synced ${receiptsSeen} Etsy orders: ${created} new, ${updated} updated`); + if (ledger) { + toast.success(`Fees from Etsy ledger: ${ledger.feesCreated} new expense(s) imported`); + setUnknownDebits(ledger.unknownDebits || []); + } + setLegacyEtsyExpenses(legacy || 0); if (unmatched?.length > 0) { setUnmatchedItems(unmatched); } @@ -140,6 +148,20 @@ const Settings = () => { } }; + const handleRemoveLegacyFees = async () => { + if (!window.confirm( + 'Remove Etsy fee expenses that were imported from the statement CSV? ' + + 'The Etsy ledger sync replaces them. This avoids double-counting fees.' + )) return; + try { + const res = await api.delete('/etsy/legacy-fees'); + toast.success(`Removed ${res.data.deleted} CSV-imported Etsy fee expense(s)`); + setLegacyEtsyExpenses(0); + } catch { + toast.error('Failed to remove legacy Etsy fees'); + } + }; + const handleEtsyDisconnect = async () => { if (!window.confirm('Disconnect your Etsy shop? Synced data stays; only the connection is removed.')) return; try { @@ -256,6 +278,45 @@ const Settings = () => {

)} + + {legacyEtsyExpenses > 0 && ( +
+
+
+

+ {legacyEtsyExpenses} Etsy fee expense(s) from the old statement-CSV import are still present. +

+

+ The ledger sync now pulls fees directly from Etsy. Remove the old CSV-imported + ones so fees aren't counted twice. (You can stop importing the Etsy statement CSV.) +

+
+ +
+
+ )} + + {unknownDebits.length > 0 && ( +
+

+ {unknownDebits.length} ledger charge type(s) weren't auto-categorised as fees and were skipped: +

+
    + {unknownDebits.map((d, idx) => ( +
  • {d}
  • + ))} +
+

+ If any of these are real business costs, let me know and I'll add them to the fee rules. +

+
+ )} ) : (
diff --git a/server/src/routes/etsy.ts b/server/src/routes/etsy.ts index 9a031d4..047fd7b 100644 --- a/server/src/routes/etsy.ts +++ b/server/src/routes/etsy.ts @@ -3,12 +3,14 @@ import crypto from 'crypto'; import { authenticate, AuthRequest } from '../middleware/authenticate'; import EtsyConnection from '../models/EtsyConnection'; import EtsySettings from '../models/EtsySettings'; +import Expense from '../models/Expense'; import { buildAuthUrl, exchangeCode, fetchIdentity, generatePkce, syncReceipts, + syncLedgerEntries, } from '../services/etsyApi'; const router = Router(); @@ -213,14 +215,41 @@ router.post('/sync', authenticate, async (req: AuthRequest, res: Response) => { const config = await loadConfig(req.userId!); if (!config) return res.status(400).json({ message: 'Etsy API key is not configured' }); - const result = await syncReceipts(toCredentials(config), connection); - res.json(result); + const creds = toCredentials(config); + const result = await syncReceipts(creds, connection); + + // Also pull the payment-account ledger into expenses (exact fees from Etsy) + const ledger = await syncLedgerEntries(creds, connection); + + // Warn if pre-ledger CSV-imported Etsy fees still exist (would double-count) + const legacyEtsyExpenses = await Expense.countDocuments({ + userId: req.userId, + vendor: 'Etsy', + reference: { $not: /^etsy-ledger-/ }, + }); + + res.json({ ...result, ledger, legacyEtsyExpenses }); } catch (err: any) { console.error('Etsy sync failed:', err); res.status(500).json({ message: err.message || 'Etsy sync failed' }); } }); +// Remove fee expenses imported from the statement CSV before ledger sync, +// so the ledger becomes the single source of Etsy fees (no double counting) +router.delete('/legacy-fees', authenticate, async (req: AuthRequest, res: Response) => { + try { + const result = await Expense.deleteMany({ + userId: req.userId, + vendor: 'Etsy', + reference: { $not: /^etsy-ledger-/ }, + }); + res.json({ deleted: result.deletedCount }); + } catch (err) { + res.status(500).json({ message: 'Failed to remove legacy Etsy fees', error: err }); + } +}); + router.delete('/connection', authenticate, async (req: AuthRequest, res: Response) => { try { await EtsyConnection.findOneAndDelete({ userId: req.userId }); diff --git a/server/src/services/etsyApi.ts b/server/src/services/etsyApi.ts index 76aa6d8..c6da893 100644 --- a/server/src/services/etsyApi.ts +++ b/server/src/services/etsyApi.ts @@ -2,6 +2,7 @@ import crypto from 'crypto'; import { IEtsyConnection } from '../models/EtsyConnection'; import Order from '../models/Order'; import Product, { IProduct } from '../models/Product'; +import Expense from '../models/Expense'; const ETSY_API_BASE = 'https://api.etsy.com/v3'; const ETSY_TOKEN_URL = 'https://api.etsy.com/v3/public/oauth/token'; @@ -319,3 +320,134 @@ const upsertOrderFromReceipt = async ( result.created++; } }; + +// ---------- Payment ledger → Expense sync ---------- + +export interface LedgerSyncResult { + feesCreated: number; + feesDuplicate: number; + feesByCategory: Record; + entriesSeen: number; + // Debit entries we couldn't confidently classify as a fee — surfaced so the + // category rules can be refined rather than silently miscategorising money + unknownDebits: string[]; +} + +// Etsy ledger amounts are integers in the entry currency's minor unit (cents); +// there is no per-entry divisor, so /100 (all Etsy currencies are 2-decimal) +const ledgerAmount = (entry: any): number => (entry.amount || 0) / 100; + +// Map a debit ledger entry to an expense category, or null to skip it. +// Conservative: only genuine business costs become expenses. Credits (sales), +// disbursements to bank, and refunds (revenue reversals) are never expenses. +export const classifyLedgerEntry = (entry: any): string | null => { + if ((entry.amount || 0) >= 0) return null; // credits: sale payments, misc credits + + const text = `${entry.description || ''} ${entry.ledger_type || ''} ${entry.reference_type || ''}`.toLowerCase(); + + // Transfers and revenue reversals are not expenses + if (/disburse|deposit|withdraw|payout/.test(text)) return null; + if (/refund|reversal|return/.test(text)) return null; + + if (/listing/.test(text)) return 'Listing Fees'; + if (/transaction/.test(text)) return 'Transaction Fees'; + if (/processing|payment fee/.test(text)) return 'Payment Processing Fees'; + if (/ads|advertis|marketing|offsite/.test(text)) return 'Marketing & Advertising'; + if (/shipping|postage|label/.test(text)) return 'Shipping & Postage'; + if (/vat|gst|\btax\b/.test(text)) return 'Taxes & GST'; + if (/subscription|etsy plus/.test(text)) return 'Subscriptions'; + if (/regulatory|operating fee|fee|bill/.test(text)) return 'Other Etsy Fees'; + + return null; // unknown debit — reported, not guessed +}; + +export const syncLedgerEntries = async ( + creds: EtsyCredentials, + connection: IEtsyConnection, + // Default lookback covers the shop's full selling history; chunked to avoid + // any range limits on the endpoint + startDate: Date = new Date(Date.now() - 5 * 365 * 24 * 60 * 60 * 1000) +): Promise => { + const result: LedgerSyncResult = { + feesCreated: 0, + feesDuplicate: 0, + feesByCategory: {}, + entriesSeen: 0, + unknownDebits: [], + }; + + const windowSeconds = 90 * 24 * 60 * 60; + const nowSec = Math.floor(Date.now() / 1000); + let windowStart = Math.floor(startDate.getTime() / 1000); + + const toCreate: any[] = []; + + while (windowStart < nowSec) { + const windowEnd = Math.min(windowStart + windowSeconds, nowSec); + let offset = 0; + + while (true) { + const page = await etsyGet( + creds, + connection, + `/application/shops/${connection.shopId}/payment-account/ledger-entries` + + `?min_created=${windowStart}&max_created=${windowEnd}&limit=100&offset=${offset}` + ); + const entries: any[] = page.results || []; + if (entries.length === 0) break; + + for (const entry of entries) { + result.entriesSeen++; + const category = classifyLedgerEntry(entry); + if (!category) { + // Collect unclassified debits for reporting (capped) + if ((entry.amount || 0) < 0 && result.unknownDebits.length < 25) { + const desc = String(entry.description || entry.ledger_type || 'unknown').trim(); + if (!result.unknownDebits.includes(desc)) result.unknownDebits.push(desc); + } + continue; + } + + const when = new Date((entry.created_timestamp || entry.create_date) * 1000); + toCreate.push({ + category, + description: String(entry.description || category), + amount: Math.abs(ledgerAmount(entry)), + date: when, + taxDeductible: true, + vendor: 'Etsy', + // Stable per-entry reference makes re-syncs idempotent via the unique + // {reference, vendor, amount, date} index + reference: `etsy-ledger-${entry.entry_id}`, + notes: entry.reference_id ? `Etsy ${entry.reference_type || 'ref'} ${entry.reference_id}` : undefined, + userId: connection.userId, + }); + result.feesByCategory[category] = (result.feesByCategory[category] || 0) + 1; + } + + offset += entries.length; + if (offset >= (page.count || 0)) break; + } + + windowStart = windowEnd; + } + + // Idempotent insert: the unique index rejects entries already imported + if (toCreate.length > 0) { + try { + const inserted = await Expense.insertMany(toCreate, { ordered: false, rawResult: true } as any); + result.feesCreated = (inserted as any).insertedCount ?? toCreate.length; + } catch (bulkError: any) { + if (bulkError.writeErrors) { + for (const e of bulkError.writeErrors) { + if (e.code === 11000) result.feesDuplicate++; + } + result.feesCreated = bulkError.result?.insertedCount ?? (toCreate.length - bulkError.writeErrors.length); + } else { + throw bulkError; + } + } + } + + return result; +};