diff --git a/server/src/services/etsyApi.ts b/server/src/services/etsyApi.ts index 4ddf1fb..73a6018 100644 --- a/server/src/services/etsyApi.ts +++ b/server/src/services/etsyApi.ts @@ -337,22 +337,25 @@ export interface LedgerSyncResult { // 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 +// Classify a ledger entry: +// - a category string → import as an expense +// - 'skip' → deliberately not an expense (credit, disbursement, refund) +// - null → an unrecognised debit, reported so rules can be refined +// Conservative: only genuine business costs become expenses. +export const classifyLedgerEntry = (entry: any): string | 'skip' | null => { + if ((entry.amount || 0) >= 0) return 'skip'; // 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; + // Transfers to bank and revenue reversals are intentionally not expenses + if (/disburse|deposit|withdraw|payout/.test(text)) return 'skip'; + if (/refund|reversal|return/.test(text)) return 'skip'; + // Etsy's onsite ads are logged as "prolist" (Promoted Listings) + if (/prolist|promoted|ads|advertis|marketing|offsite/.test(text)) return 'Marketing & Advertising'; 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'; @@ -400,8 +403,9 @@ export const syncLedgerEntries = async ( for (const entry of entries) { result.entriesSeen++; const category = classifyLedgerEntry(entry); - if (!category) { - // Collect unclassified debits for reporting (capped) + if (category === 'skip') continue; // deliberately not an expense + if (category === null) { + // Genuinely unrecognised debit — report (capped) so rules can be refined 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);