diff --git a/client/src/pages/Analytics.tsx b/client/src/pages/Analytics.tsx index 8c34844..3c7401d 100644 --- a/client/src/pages/Analytics.tsx +++ b/client/src/pages/Analytics.tsx @@ -133,22 +133,23 @@ const Analytics = () => { return sum + getUpdatedPrintingCost(order); }, 0); - // Calculate expenses excluding only Etsy transaction fees to avoid double-counting - // (Etsy transaction fees are already deducted from order totals in the CSV) - // But we DO want to include Etsy Ads, Listing Fees, subscriptions, etc. + // Calculate expenses excluding only sale transaction fees to avoid double-counting + // (Sale transaction fees are already deducted from order totals in the CSV) + // But we DO want to include Etsy Ads, Listing Fees, GST, subscriptions, etc. const totalExpenses = filteredExpenses.reduce((sum, expense) => { - // More comprehensive detection of Etsy transaction fees - const isEtsyTransactionFee = ( - expense.category?.toLowerCase() === 'transaction fees' || - (expense.vendor?.toLowerCase().includes('etsy') && - (expense.category?.toLowerCase().includes('transaction fee') || - expense.description?.toLowerCase().includes('transaction fee') || - expense.description?.toLowerCase().includes('etsy transaction fee'))) + // Only exclude transaction fees that are tied to specific orders (already deducted from order totals) + // Include: Listing Fees, Marketing/Ads, GST, and other business expenses + const isSaleTransactionFee = ( + expense.vendor?.toLowerCase().includes('etsy') && + expense.category?.toLowerCase() === 'transaction fees' && + expense.reference && // Has an order reference + (expense.description?.toLowerCase().includes('transaction fee') || + expense.description?.toLowerCase().includes('processing fee')) ); - if (isEtsyTransactionFee) { - console.log('Excluding transaction fee from profit calc:', expense.description, '$' + expense.amount, 'Category:', expense.category); - return sum; // Skip transaction fees as they're already deducted from order totals + if (isSaleTransactionFee) { + console.log('Excluding sale transaction fee from profit calc:', expense.description, '$' + expense.amount, 'Category:', expense.category); + return sum; // Skip sale transaction fees as they're already deducted from order totals } return sum + (expense?.amount || 0); }, 0); @@ -164,17 +165,18 @@ const Analytics = () => { const allExpensesTotal = filteredExpenses.reduce((sum, expense) => sum + (expense.amount || 0), 0); const transactionFeesTotal = filteredExpenses.reduce((sum, expense) => { - const isEtsyTransactionFee = ( - expense.category?.toLowerCase() === 'transaction fees' || - (expense.vendor?.toLowerCase().includes('etsy') && - (expense.category?.toLowerCase().includes('transaction fee') || - expense.description?.toLowerCase().includes('transaction fee') || - expense.description?.toLowerCase().includes('etsy transaction fee'))) + const isSaleTransactionFee = ( + expense.vendor?.toLowerCase().includes('etsy') && + expense.category?.toLowerCase() === 'transaction fees' && + expense.reference && // Has an order reference + (expense.description?.toLowerCase().includes('transaction fee') || + expense.description?.toLowerCase().includes('processing fee')) ); - if (isEtsyTransactionFee) { - console.log('Found transaction fee:', expense.description, '$' + expense.amount, 'Category:', expense.category); + + if (isSaleTransactionFee) { + console.log('Found sale transaction fee:', expense.description, '$' + expense.amount, 'Category:', expense.category); } - return isEtsyTransactionFee ? sum + (expense.amount || 0) : sum; + return isSaleTransactionFee ? sum + (expense.amount || 0) : sum; }, 0); console.log('All Expenses Total:', allExpensesTotal); diff --git a/client/src/pages/DataImport.tsx b/client/src/pages/DataImport.tsx index e9a2156..8486b36 100644 --- a/client/src/pages/DataImport.tsx +++ b/client/src/pages/DataImport.tsx @@ -1,5 +1,5 @@ import React, { useState, useRef } from 'react'; -import { csvImportService, ParsedEtsyOrder, ParsedShippingRecord } from '../utils/csvImportService'; +import { csvImportService, ParsedEtsyOrder, ParsedShippingRecord, EtsyFeeRecord } from '../utils/csvImportService'; import { pdfParser, ParsedPackingSlip } from '../utils/pdfParser'; import { matchOrderItemsToProducts } from '../utils/productMatcher'; import { MissingProductsModal, MissingProduct } from '../components/MissingProductsModal'; @@ -114,7 +114,8 @@ export default function DataImport() { // Create expenses from CSV import data const createExpensesFromCsvData = async ( orderCosts: ImportResults['orderCosts'], - shippingRecords: ParsedShippingRecord[] + shippingRecords: ParsedShippingRecord[], + etsyFees: EtsyFeeRecord[] ) => { try { // Get existing expenses to avoid duplicates @@ -123,26 +124,28 @@ export default function DataImport() { const expensesToCreate: any[] = []; - // Create Etsy fee expenses (check for duplicates by order number) - orderCosts.forEach(order => { - if (order.etsyFees > 0) { - const isDuplicate = existingExpenses.some((expense: any) => - expense.reference === order.orderNumber && - expense.vendor === 'Etsy' && - expense.category === 'Transaction Fees' - ); - - if (!isDuplicate) { - expensesToCreate.push({ - description: `Etsy Fees - Order #${order.orderNumber}`, - amount: order.etsyFees, - category: 'Transaction Fees', - date: order.date, - taxDeductible: true, - vendor: 'Etsy', - reference: order.orderNumber - }); - } + // Create granular Etsy fee expenses with proper categorization + etsyFees.forEach(fee => { + const isDuplicate = existingExpenses.some((expense: any) => + expense.vendor === fee.vendor && + expense.category === fee.category && + expense.description === fee.description && + Math.abs(expense.amount - fee.amount) < 0.01 && + new Date(expense.date).toDateString() === new Date(fee.date).toDateString() + ); + + if (!isDuplicate) { + expensesToCreate.push({ + description: fee.description, + amount: fee.amount, + category: fee.category, + date: fee.date, + taxDeductible: fee.taxDeductible, + vendor: fee.vendor, + reference: fee.reference + }); + } else { + console.log(`Skipping duplicate Etsy fee: ${fee.description} - $${fee.amount} on ${fee.date}`); } }); @@ -202,7 +205,7 @@ export default function DataImport() { if (created > 0 && skippedDuplicates > 0) { toast.success(`Created ${created} new expenses. Skipped ${skippedDuplicates} duplicates.`); } else if (created > 0) { - toast.success(`Created ${created} expenses from CSV data (Etsy fees + shipping costs)`); + toast.success(`Created ${created} expenses from CSV data (listing fees, ads, shipping, transaction fees)`); } else if (skippedDuplicates > 0) { toast.success(`All ${skippedDuplicates} expenses already exist - no duplicates created`); } else { @@ -229,6 +232,9 @@ export default function DataImport() { try { const etsyContent = await readFileAsText(etsyFile); const etsyOrders = csvImportService.parseEtsyStatement(etsyContent); + + // Parse individual Etsy fees for granular expense tracking + const etsyFees = csvImportService.parseEtsyFees(etsyContent); let shippingRecords: ParsedShippingRecord[] = []; if (shippingFile) { @@ -288,7 +294,7 @@ export default function DataImport() { dispatch(setOrders(ordersRes.data.orders)); // Create expenses from CSV data - await createExpensesFromCsvData(orderCosts, shippingRecords); + await createExpensesFromCsvData(orderCosts, shippingRecords, etsyFees); toast.success(`CSV imported! Created ${res.data.created} new orders and updated ${res.data.updated} existing orders.`); } catch { diff --git a/client/src/utils/csvImportService.ts b/client/src/utils/csvImportService.ts index 53a3117..bde7058 100644 --- a/client/src/utils/csvImportService.ts +++ b/client/src/utils/csvImportService.ts @@ -1,6 +1,6 @@ export interface EtsyStatementRecord { Date: string; - Type: 'Sale' | 'Fee' | 'GST' | 'Marketing'; + Type: 'Sale' | 'Fee' | 'GST' | 'Marketing' | 'Shipping' | 'Tax'; Title: string; Info: string; Currency: string; @@ -10,6 +10,16 @@ export interface EtsyStatementRecord { 'Tax Details': string; } +export interface EtsyFeeRecord { + date: string; + description: string; + amount: number; + category: string; + vendor: string; + reference?: string; + taxDeductible: boolean; +} + export interface ProductImportRecord { title: string; description?: string; @@ -209,6 +219,68 @@ export class CSVImportService { return Array.from(orderMap.values()).filter(order => order.saleAmount > 0); } + /** + * Parse individual Etsy fees from statement CSV for detailed expense tracking + */ + parseEtsyFees(csvText: string): EtsyFeeRecord[] { + const records = this.parseCSV(csvText) as unknown as EtsyStatementRecord[]; + const feeRecords: EtsyFeeRecord[] = []; + + records.forEach(record => { + // Skip sales as they're not expenses + if (record.Type === 'Sale') return; + + const amount = Math.abs(this.parseAmount(record.Net)); + if (amount <= 0) return; + + let category: string; + let reference: string | undefined; + const description = record.Title; + const lowerTitle = description.toLowerCase(); + + // Categorize different types of Etsy fees + if (record.Type === 'Marketing' || lowerTitle.includes('etsy ads')) { + category = 'Marketing & Advertising'; + } else if (lowerTitle.includes('listing fee')) { + category = 'Listing Fees'; + // Extract listing ID if available + const listingMatch = description.match(/listing.*?#?(\d+)/i); + if (listingMatch) reference = `listing-${listingMatch[1]}`; + } else if (lowerTitle.includes('transaction fee')) { + category = 'Transaction Fees'; + // Extract order number if available + const orderMatch = description.match(/order.*?#?(\d+)/i); + if (orderMatch) reference = orderMatch[1]; + } else if (lowerTitle.includes('processing fee')) { + category = 'Payment Processing Fees'; + // Extract order number if available + const orderMatch = description.match(/order.*?#?(\d+)/i); + if (orderMatch) reference = orderMatch[1]; + } else if (record.Type === 'GST' || lowerTitle.includes('gst')) { + category = 'Taxes & GST'; + } else if (lowerTitle.includes('shipping') || lowerTitle.includes('postage')) { + category = 'Shipping & Postage'; + // Extract label/tracking number if available + const labelMatch = description.match(/label.*?#?(\d+)/i); + if (labelMatch) reference = `label-${labelMatch[1]}`; + } else { + category = 'Other Etsy Fees'; + } + + feeRecords.push({ + date: this.parseDate(record.Date), + description: description, + amount: amount, + category: category, + vendor: 'Etsy', + reference: reference, + taxDeductible: true + }); + }); + + return feeRecords; + } + /** * Parse Australia Post shipping CSV */ diff --git a/client/src/utils/profitAnalysisService.ts b/client/src/utils/profitAnalysisService.ts index 2123219..ef55c54 100644 --- a/client/src/utils/profitAnalysisService.ts +++ b/client/src/utils/profitAnalysisService.ts @@ -104,19 +104,21 @@ export class ProfitAnalysisService { if (orderProfit > 0) profitableOrderCount++; }); - // Calculate total expenses (excluding transaction fees to avoid double-counting) + // Calculate total expenses (excluding only sale-related transaction fees to avoid double-counting) const totalExpenses = expenses ? expenses.reduce((sum, expense) => { - // More comprehensive detection of Etsy transaction fees - const isEtsyTransactionFee = ( - expense.category?.toLowerCase() === 'transaction fees' || - (expense.vendor?.toLowerCase().includes('etsy') && - (expense.category?.toLowerCase().includes('transaction fee') || - expense.description?.toLowerCase().includes('transaction fee') || - expense.description?.toLowerCase().includes('etsy transaction fee'))) + // Only exclude transaction fees that are tied to specific orders (already deducted from order totals) + // Include: Listing Fees, Marketing/Ads, GST, and other business expenses + const isSaleTransactionFee = ( + expense.vendor?.toLowerCase().includes('etsy') && + expense.category?.toLowerCase() === 'transaction fees' && + expense.reference && // Has an order reference + (expense.description?.toLowerCase().includes('transaction fee') || + expense.description?.toLowerCase().includes('processing fee')) ); - if (isEtsyTransactionFee) { - return sum; // Skip transaction fees as they're already deducted from order totals + if (isSaleTransactionFee) { + console.log('Excluding sale transaction fee (already in order totals):', expense.description, expense.reference); + return sum; // Skip sale transaction fees as they're already deducted from order totals } return sum + (expense?.amount || 0); }, 0) : 0; @@ -233,16 +235,16 @@ export class ProfitAnalysisService { const monthKey = `${expenseDate.getFullYear()}-${String(expenseDate.getMonth() + 1).padStart(2, '0')}`; const monthName = expenseDate.toLocaleDateString('en-AU', { year: 'numeric', month: 'long' }); - // Skip transaction fees to avoid double-counting - const isEtsyTransactionFee = ( - expense.category?.toLowerCase() === 'transaction fees' || - (expense.vendor?.toLowerCase().includes('etsy') && - (expense.category?.toLowerCase().includes('transaction fee') || - expense.description?.toLowerCase().includes('transaction fee') || - expense.description?.toLowerCase().includes('etsy transaction fee'))) + // Skip only sale transaction fees to avoid double-counting (keep listing fees, ads, etc.) + const isSaleTransactionFee = ( + expense.vendor?.toLowerCase().includes('etsy') && + expense.category?.toLowerCase() === 'transaction fees' && + expense.reference && // Has an order reference + (expense.description?.toLowerCase().includes('transaction fee') || + expense.description?.toLowerCase().includes('processing fee')) ); - if (isEtsyTransactionFee) return; + if (isSaleTransactionFee) return; // Create month entry if it doesn't exist (for expense-only months) if (!monthlyData.has(monthKey)) {