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:
parent
9dee4b32a1
commit
a36582b843
4 changed files with 147 additions and 65 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
);
|
||||
// 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: `Etsy Fees - Order #${order.orderNumber}`,
|
||||
amount: order.etsyFees,
|
||||
category: 'Transaction Fees',
|
||||
date: order.date,
|
||||
taxDeductible: true,
|
||||
vendor: 'Etsy',
|
||||
reference: order.orderNumber
|
||||
});
|
||||
}
|
||||
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 {
|
||||
|
|
@ -230,6 +233,9 @@ export default function DataImport() {
|
|||
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) {
|
||||
const shippingContent = await readFileAsText(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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue