Fix Etsy fee categorization to include listing fees, ads, and other business expenses

Major improvements to expense categorization and profit calculations:

NEW: Granular Etsy Fee Parsing
- Parse individual Etsy fees with proper categorization:
  * Listing Fees (should be included in expenses)
  * Marketing & Advertising (Etsy Ads - should be included)
  * Transaction Fees (tied to orders - excluded to avoid double-counting)
  * Processing Fees (tied to orders - excluded to avoid double-counting)
  * Taxes & GST (should be included)
  * Shipping & Postage (should be included)

FIXED: Profit Calculation Logic
- Only exclude sale transaction fees that have order references
- Include all other Etsy business expenses (ads, listing fees, GST)
- More accurate profit margins that account for all business costs

ENHANCED: CSV Import
- Creates specific expense categories instead of lumping as 'Transaction Fees'
- Better duplicate detection based on description, amount, date, and category
- Improved user feedback showing specific fee types created

This fixes the issue where listing fees and advertising costs were incorrectly excluded from profit calculations, resulting in unrealistically high profit margins.
This commit is contained in:
dlawler489 2026-05-05 18:32:23 +10:00
parent 9dee4b32a1
commit a36582b843
4 changed files with 147 additions and 65 deletions

View file

@ -133,22 +133,23 @@ const Analytics = () => {
return sum + getUpdatedPrintingCost(order); return sum + getUpdatedPrintingCost(order);
}, 0); }, 0);
// Calculate expenses excluding only Etsy transaction fees to avoid double-counting // Calculate expenses excluding only sale transaction fees to avoid double-counting
// (Etsy transaction fees are already deducted from order totals in the CSV) // (Sale transaction fees are already deducted from order totals in the CSV)
// But we DO want to include Etsy Ads, Listing Fees, subscriptions, etc. // But we DO want to include Etsy Ads, Listing Fees, GST, subscriptions, etc.
const totalExpenses = filteredExpenses.reduce((sum, expense) => { const totalExpenses = filteredExpenses.reduce((sum, expense) => {
// More comprehensive detection of Etsy transaction fees // Only exclude transaction fees that are tied to specific orders (already deducted from order totals)
const isEtsyTransactionFee = ( // Include: Listing Fees, Marketing/Ads, GST, and other business expenses
expense.category?.toLowerCase() === 'transaction fees' || const isSaleTransactionFee = (
(expense.vendor?.toLowerCase().includes('etsy') && expense.vendor?.toLowerCase().includes('etsy') &&
(expense.category?.toLowerCase().includes('transaction fee') || expense.category?.toLowerCase() === 'transaction fees' &&
expense.description?.toLowerCase().includes('transaction fee') || expense.reference && // Has an order reference
expense.description?.toLowerCase().includes('etsy transaction fee'))) (expense.description?.toLowerCase().includes('transaction fee') ||
expense.description?.toLowerCase().includes('processing fee'))
); );
if (isEtsyTransactionFee) { if (isSaleTransactionFee) {
console.log('Excluding transaction fee from profit calc:', expense.description, '$' + expense.amount, 'Category:', expense.category); console.log('Excluding sale 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 return sum; // Skip sale transaction fees as they're already deducted from order totals
} }
return sum + (expense?.amount || 0); return sum + (expense?.amount || 0);
}, 0); }, 0);
@ -164,17 +165,18 @@ const Analytics = () => {
const allExpensesTotal = filteredExpenses.reduce((sum, expense) => sum + (expense.amount || 0), 0); const allExpensesTotal = filteredExpenses.reduce((sum, expense) => sum + (expense.amount || 0), 0);
const transactionFeesTotal = filteredExpenses.reduce((sum, expense) => { const transactionFeesTotal = filteredExpenses.reduce((sum, expense) => {
const isEtsyTransactionFee = ( const isSaleTransactionFee = (
expense.category?.toLowerCase() === 'transaction fees' || expense.vendor?.toLowerCase().includes('etsy') &&
(expense.vendor?.toLowerCase().includes('etsy') && expense.category?.toLowerCase() === 'transaction fees' &&
(expense.category?.toLowerCase().includes('transaction fee') || expense.reference && // Has an order reference
expense.description?.toLowerCase().includes('transaction fee') || (expense.description?.toLowerCase().includes('transaction fee') ||
expense.description?.toLowerCase().includes('etsy 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); }, 0);
console.log('All Expenses Total:', allExpensesTotal); console.log('All Expenses Total:', allExpensesTotal);

View file

@ -1,5 +1,5 @@
import React, { useState, useRef } from 'react'; 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 { pdfParser, ParsedPackingSlip } from '../utils/pdfParser';
import { matchOrderItemsToProducts } from '../utils/productMatcher'; import { matchOrderItemsToProducts } from '../utils/productMatcher';
import { MissingProductsModal, MissingProduct } from '../components/MissingProductsModal'; import { MissingProductsModal, MissingProduct } from '../components/MissingProductsModal';
@ -114,7 +114,8 @@ export default function DataImport() {
// Create expenses from CSV import data // Create expenses from CSV import data
const createExpensesFromCsvData = async ( const createExpensesFromCsvData = async (
orderCosts: ImportResults['orderCosts'], orderCosts: ImportResults['orderCosts'],
shippingRecords: ParsedShippingRecord[] shippingRecords: ParsedShippingRecord[],
etsyFees: EtsyFeeRecord[]
) => { ) => {
try { try {
// Get existing expenses to avoid duplicates // Get existing expenses to avoid duplicates
@ -123,26 +124,28 @@ export default function DataImport() {
const expensesToCreate: any[] = []; const expensesToCreate: any[] = [];
// Create Etsy fee expenses (check for duplicates by order number) // Create granular Etsy fee expenses with proper categorization
orderCosts.forEach(order => { etsyFees.forEach(fee => {
if (order.etsyFees > 0) { const isDuplicate = existingExpenses.some((expense: any) =>
const isDuplicate = existingExpenses.some((expense: any) => expense.vendor === fee.vendor &&
expense.reference === order.orderNumber && expense.category === fee.category &&
expense.vendor === 'Etsy' && expense.description === fee.description &&
expense.category === 'Transaction Fees' Math.abs(expense.amount - fee.amount) < 0.01 &&
); new Date(expense.date).toDateString() === new Date(fee.date).toDateString()
);
if (!isDuplicate) {
expensesToCreate.push({ if (!isDuplicate) {
description: `Etsy Fees - Order #${order.orderNumber}`, expensesToCreate.push({
amount: order.etsyFees, description: fee.description,
category: 'Transaction Fees', amount: fee.amount,
date: order.date, category: fee.category,
taxDeductible: true, date: fee.date,
vendor: 'Etsy', taxDeductible: fee.taxDeductible,
reference: order.orderNumber 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) { if (created > 0 && skippedDuplicates > 0) {
toast.success(`Created ${created} new expenses. Skipped ${skippedDuplicates} duplicates.`); toast.success(`Created ${created} new expenses. Skipped ${skippedDuplicates} duplicates.`);
} else if (created > 0) { } 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) { } else if (skippedDuplicates > 0) {
toast.success(`All ${skippedDuplicates} expenses already exist - no duplicates created`); toast.success(`All ${skippedDuplicates} expenses already exist - no duplicates created`);
} else { } else {
@ -229,6 +232,9 @@ export default function DataImport() {
try { try {
const etsyContent = await readFileAsText(etsyFile); const etsyContent = await readFileAsText(etsyFile);
const etsyOrders = csvImportService.parseEtsyStatement(etsyContent); const etsyOrders = csvImportService.parseEtsyStatement(etsyContent);
// Parse individual Etsy fees for granular expense tracking
const etsyFees = csvImportService.parseEtsyFees(etsyContent);
let shippingRecords: ParsedShippingRecord[] = []; let shippingRecords: ParsedShippingRecord[] = [];
if (shippingFile) { if (shippingFile) {
@ -288,7 +294,7 @@ export default function DataImport() {
dispatch(setOrders(ordersRes.data.orders)); dispatch(setOrders(ordersRes.data.orders));
// Create expenses from CSV data // 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.`); toast.success(`CSV imported! Created ${res.data.created} new orders and updated ${res.data.updated} existing orders.`);
} catch { } catch {

View file

@ -1,6 +1,6 @@
export interface EtsyStatementRecord { export interface EtsyStatementRecord {
Date: string; Date: string;
Type: 'Sale' | 'Fee' | 'GST' | 'Marketing'; Type: 'Sale' | 'Fee' | 'GST' | 'Marketing' | 'Shipping' | 'Tax';
Title: string; Title: string;
Info: string; Info: string;
Currency: string; Currency: string;
@ -10,6 +10,16 @@ export interface EtsyStatementRecord {
'Tax Details': string; 'Tax Details': string;
} }
export interface EtsyFeeRecord {
date: string;
description: string;
amount: number;
category: string;
vendor: string;
reference?: string;
taxDeductible: boolean;
}
export interface ProductImportRecord { export interface ProductImportRecord {
title: string; title: string;
description?: string; description?: string;
@ -209,6 +219,68 @@ export class CSVImportService {
return Array.from(orderMap.values()).filter(order => order.saleAmount > 0); 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 * Parse Australia Post shipping CSV
*/ */

View file

@ -104,19 +104,21 @@ export class ProfitAnalysisService {
if (orderProfit > 0) profitableOrderCount++; 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) => { const totalExpenses = expenses ? expenses.reduce((sum, expense) => {
// More comprehensive detection of Etsy transaction fees // Only exclude transaction fees that are tied to specific orders (already deducted from order totals)
const isEtsyTransactionFee = ( // Include: Listing Fees, Marketing/Ads, GST, and other business expenses
expense.category?.toLowerCase() === 'transaction fees' || const isSaleTransactionFee = (
(expense.vendor?.toLowerCase().includes('etsy') && expense.vendor?.toLowerCase().includes('etsy') &&
(expense.category?.toLowerCase().includes('transaction fee') || expense.category?.toLowerCase() === 'transaction fees' &&
expense.description?.toLowerCase().includes('transaction fee') || expense.reference && // Has an order reference
expense.description?.toLowerCase().includes('etsy transaction fee'))) (expense.description?.toLowerCase().includes('transaction fee') ||
expense.description?.toLowerCase().includes('processing fee'))
); );
if (isEtsyTransactionFee) { if (isSaleTransactionFee) {
return sum; // Skip transaction fees as they're already deducted from order totals 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); return sum + (expense?.amount || 0);
}, 0) : 0; }, 0) : 0;
@ -233,16 +235,16 @@ export class ProfitAnalysisService {
const monthKey = `${expenseDate.getFullYear()}-${String(expenseDate.getMonth() + 1).padStart(2, '0')}`; const monthKey = `${expenseDate.getFullYear()}-${String(expenseDate.getMonth() + 1).padStart(2, '0')}`;
const monthName = expenseDate.toLocaleDateString('en-AU', { year: 'numeric', month: 'long' }); const monthName = expenseDate.toLocaleDateString('en-AU', { year: 'numeric', month: 'long' });
// Skip transaction fees to avoid double-counting // Skip only sale transaction fees to avoid double-counting (keep listing fees, ads, etc.)
const isEtsyTransactionFee = ( const isSaleTransactionFee = (
expense.category?.toLowerCase() === 'transaction fees' || expense.vendor?.toLowerCase().includes('etsy') &&
(expense.vendor?.toLowerCase().includes('etsy') && expense.category?.toLowerCase() === 'transaction fees' &&
(expense.category?.toLowerCase().includes('transaction fee') || expense.reference && // Has an order reference
expense.description?.toLowerCase().includes('transaction fee') || (expense.description?.toLowerCase().includes('transaction fee') ||
expense.description?.toLowerCase().includes('etsy 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) // Create month entry if it doesn't exist (for expense-only months)
if (!monthlyData.has(monthKey)) { if (!monthlyData.has(monthKey)) {