Add alias matching, cost snapshots, reference-based fee allocation; remove debug tooling

- Products store packing-slip title aliases; matcher checks exact title/alias
  first so previously-matched items skip fuzzy matching entirely
- Packing-slip imports snapshot printingCost/costOfGoods/productId onto order
  items; profit analysis reads stored costs so catalog edits don't rewrite history
- Etsy statement fees allocate to orders via Order # references in Title/Info
  instead of date proximity; shop-level fees (listings, ads) no longer leak
  into order fees
- Remove broken transaction-fee exclusion guard (order totals are gross, so
  all expenses count once)
- Remove debug buttons, date-fix banner, test files, and console.log noise;
  Clear All Orders now uses a bulk DELETE /orders endpoint

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
dlawler489 2026-06-12 18:39:59 +10:00
parent d325d547be
commit b27ede4131
16 changed files with 216 additions and 945 deletions

View file

@ -1,113 +0,0 @@
// Test data script - run in browser console to add sample data with printing costs
console.log('Adding test data with printing costs...');
// Sample products with printing costs
const sampleProducts = [
{
_id: 'prod1',
title: 'Custom Business Cards',
description: 'Professional business cards with custom design',
price: 25.00,
costOfGoods: 5.00,
printingCost: 3.50,
sku: 'BC001',
stockLevel: 100,
category: 'Business Cards'
},
{
_id: 'prod2',
title: 'Wedding Invitations',
description: 'Elegant wedding invitations with RSVP cards',
price: 45.00,
costOfGoods: 8.00,
printingCost: 6.00,
sku: 'WI001',
stockLevel: 50,
category: 'Invitations'
},
{
_id: 'prod3',
title: 'Photo Prints 8x10',
description: 'High quality photo prints',
price: 15.00,
costOfGoods: 2.00,
printingCost: 4.50,
sku: 'PP001',
stockLevel: 200,
category: 'Photo Prints'
}
];
// Sample orders with printing costs
const sampleOrders = [
{
_id: 'order1',
orderNumber: '1001',
customer: {
name: 'John Smith',
email: 'john@example.com',
address: {
street1: '123 Main St',
city: 'New York',
state: 'NY',
postalCode: '10001',
country: 'US'
}
},
items: [
{
title: 'Custom Business Cards',
quantity: 2,
price: 25.00,
printingCost: 3.50,
costOfGoods: 5.00
}
],
total: 50.00,
status: 'completed',
dateOrdered: new Date().toISOString()
},
{
_id: 'order2',
orderNumber: '1002',
customer: {
name: 'Sarah Johnson',
email: 'sarah@example.com',
address: {
street1: '456 Oak Ave',
city: 'Los Angeles',
state: 'CA',
postalCode: '90210',
country: 'US'
}
},
items: [
{
title: 'Wedding Invitations',
quantity: 1,
price: 45.00,
printingCost: 6.00,
costOfGoods: 8.00
},
{
title: 'Photo Prints 8x10',
quantity: 3,
price: 15.00,
printingCost: 4.50,
costOfGoods: 2.00
}
],
total: 90.00,
status: 'processing',
dateOrdered: new Date(Date.now() - 24*60*60*1000).toISOString()
}
];
// Add to localStorage
localStorage.setItem('etsy-tracker-products', JSON.stringify(sampleProducts));
localStorage.setItem('etsy-tracker-orders', JSON.stringify(sampleOrders));
// Dispatch Redux actions to update state
window.location.reload();
console.log('Test data added! Page will reload to update the state.');

View file

@ -1,8 +1,8 @@
import React, { useState, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { RootState } from '../store';
import { addProduct } from '../store/slices/productSlice';
import { findPotentialMatches } from '../utils/productMatcher';
import { addProduct, updateProduct } from '../store/slices/productSlice';
import { findPotentialMatches, normalizeTitle } from '../utils/productMatcher';
import { X, Plus, AlertCircle, CheckCircle } from 'lucide-react';
import toast from 'react-hot-toast';
import api from '../utils/api';
@ -90,18 +90,32 @@ export const MissingProductsModal: React.FC<MissingProductsModalProps> = ({
for (const product of missingProducts) {
const data = productData[product.title] || {};
// If user selected to use existing product, add it to matches
// If user selected to use an existing product, save the packing-slip
// title as an alias on it so this and all future imports match it
// deterministically
if (data.useExisting) {
const existingProduct = products.find(p => p._id === data.useExisting);
if (existingProduct) {
existingMatches.push({
orderItem: {
title: product.title,
quantity: product.quantity,
price: product.price
},
matchedProduct: existingProduct
});
let updatedProduct: any = existingProduct;
const normalized = normalizeTitle(product.title);
const alreadyKnown =
normalizeTitle(existingProduct.title) === normalized ||
(existingProduct.aliases || []).some(alias => normalizeTitle(alias) === normalized);
if (!alreadyKnown) {
const newAliases = [...(existingProduct.aliases || []), product.title];
try {
const res = await api.put(`/products/${existingProduct._id}`, { aliases: newAliases });
updatedProduct = res.data;
} catch (error) {
console.error('Failed to save alias for product:', existingProduct.title, error);
// Keep the alias locally so the current import still matches
updatedProduct = { ...existingProduct, aliases: newAliases };
}
dispatch(updateProduct(updatedProduct));
}
existingMatches.push(updatedProduct);
continue;
}
}

View file

@ -1,344 +0,0 @@
import React, { useState, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { RootState } from '../store';
import { addProduct } from '../store/slices/productSlice';
import { findPotentialMatches } from '../utils/productMatcher';
import { X, Plus, AlertCircle, CheckCircle } from 'lucide-react';
import toast from 'react-hot-toast';
import api from '../utils/api';
export interface MissingProduct {
title: string;
quantity: number;
price?: number;
size?: string;
orderNumber?: string;
}
interface MissingProductsModalProps {
missingProducts: MissingProduct[];
onClose: () => void;
onComplete: (products: any[]) => void;
}
export const MissingProductsModal: React.FC<MissingProductsModalProps> = ({
missingProducts,
onClose,
onComplete
}) => {
const dispatch = useDispatch();
const products = useSelector((state: RootState) => state.products.products);
const [isProcessing, setIsProcessing] = useState(false);
const [productData, setProductData] = useState<{[key: string]: {
printingCost: number;
category: string;
useExisting?: string; // ID of existing product to use instead of creating new
}}>({});
// Find potential matches for each missing product
const suggestions = useMemo(() => {
const result: {[key: string]: ReturnType<typeof findPotentialMatches>} = {};
missingProducts.forEach(missingProduct => {
result[missingProduct.title] = findPotentialMatches(
missingProduct.title,
products,
3 // Show top 3 suggestions
);
});
return result;
}, [missingProducts, products]);
const handleInputChange = (productTitle: string, field: string, value: string) => {
setProductData(prev => ({
...prev,
[productTitle]: {
...prev[productTitle],
[field]: field === 'category' ? value :
field === 'useExisting' ? value :
parseFloat(value) || 0
}
}));
};
const handleUseExisting = (missingTitle: string, existingProductId: string) => {
setProductData(prev => ({
...prev,
[missingTitle]: {
...prev[missingTitle],
useExisting: existingProductId
}
}));
};
const handleAddProducts = async () => {
// Prevent multiple clicks
if (isProcessing) return;
setIsProcessing(true);
try {
const newProducts: any[] = [];
const existingMatches: any[] = [];
for (const product of missingProducts) {
const data = productData[product.title] || {};
// If user selected to use existing product, add it to matches
if (data.useExisting) {
const existingProduct = products.find(p => p._id === data.useExisting);
if (existingProduct) {
existingMatches.push({
orderItem: {
title: product.title,
quantity: product.quantity,
price: product.price
},
matchedProduct: existingProduct
});
continue;
}
}
// Otherwise create new product
const printingCost = data.printingCost || 0;
const category = data.category || 'Imported Items';
try {
const res = await api.post('/products', {
title: product.title,
description: `Imported from order ${product.orderNumber || 'Unknown'}`,
price: product.price || 0,
costOfGoods: 0,
printingCost,
sku: '',
category,
tags: [],
inventory: { quantity: 0, lowStockAlert: 5 },
isActive: true
});
dispatch(addProduct(res.data));
newProducts.push(res.data);
} catch {
// Fall back to local-only if API fails
const fallback = {
_id: `product_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
title: product.title,
description: `Imported from order ${product.orderNumber || 'Unknown'}`,
price: product.price || 0,
costOfGoods: 0,
printingCost,
sku: '',
category,
tags: [],
inventory: { quantity: 0, lowStockAlert: 5 },
isActive: true
};
dispatch(addProduct(fallback));
newProducts.push(fallback);
}
}
if (newProducts.length > 0) {
toast.success(`Added ${newProducts.length} new products with printing costs`);
}
if (existingMatches.length > 0) {
toast.success(`Matched ${existingMatches.length} items to existing products`);
}
// Close modal immediately after successful processing
onComplete([...newProducts, ...existingMatches]);
onClose(); // Explicitly close the modal
} catch (error) {
console.error('Error processing products:', error);
toast.error('Failed to process products. Please try again.');
} finally {
setIsProcessing(false);
}
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center p-6 border-b">
<h2 className="text-xl font-semibold text-gray-900">
Missing Products - Add Printing Costs
</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600"
>
<X className="w-6 h-6" />
</button>
</div>
<div className="p-6">
<p className="text-gray-600 mb-4">
The following products from your order are not in your product database.
Please add printing costs (including materials/filament) to complete the import:
</p>
<div className="space-y-4">
{missingProducts.map((product, index) => (
<div key={index} className="border rounded-lg p-4 bg-gray-50">
<div className="grid grid-cols-1 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Product Name
</label>
<div className="text-sm text-gray-900 font-medium">
{product.title}
</div>
<div className="text-xs text-gray-500 mt-1">
Quantity ordered: {product.quantity}{product.price ? ` × $${product.price.toFixed(2)} each` : ''}
</div>
</div>
{/* Show suggestions if available */}
{suggestions[product.title] && suggestions[product.title].length > 0 && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<div className="flex items-center gap-2 mb-2">
<AlertCircle className="w-4 h-4 text-blue-600" />
<span className="text-sm font-medium text-blue-900">
Possible matches found:
</span>
</div>
<div className="space-y-2">
{suggestions[product.title].map((suggestion, sugIndex) => (
<div key={sugIndex} className="flex items-center justify-between">
<div className="flex-1">
<div className="text-sm text-gray-900">
{suggestion.product.title}
</div>
<div className="text-xs text-gray-600">
{suggestion.reason} ({(suggestion.confidence * 100).toFixed(0)}% match)
</div>
</div>
<button
onClick={() => handleUseExisting(product.title, suggestion.product._id)}
className={`px-3 py-1 text-xs rounded-md border transition-colors ${
productData[product.title]?.useExisting === suggestion.product._id
? 'bg-green-100 border-green-300 text-green-700'
: 'bg-white border-gray-300 text-gray-700 hover:bg-gray-50'
}`}
>
{productData[product.title]?.useExisting === suggestion.product._id ? (
<span className="flex items-center gap-1">
<CheckCircle className="w-3 h-3" />
Selected
</span>
) : (
'Use This'
)}
</button>
</div>
))}
</div>
</div>
)}
{/* Only show input fields if not using existing product */}
{!productData[product.title]?.useExisting && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Printing Cost ($)
</label>
<input
type="number"
step="0.01"
placeholder="0.00"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={productData[product.title]?.printingCost || ''}
onChange={(e) => handleInputChange(product.title, 'printingCost', e.target.value)}
/>
<p className="text-xs text-gray-500 mt-1">
Include filament, materials, and time costs
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Category
</label>
<select
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={productData[product.title]?.category || ''}
onChange={(e) => handleInputChange(product.title, 'category', e.target.value)}
>
<option value="">Select category</option>
<option value="Imported Items">Imported Items</option>
<option value="3D Printed">3D Printed</option>
<option value="Home & Living">Home & Living</option>
<option value="Art & Collectibles">Art & Collectibles</option>
<option value="Jewelry">Jewelry</option>
<option value="Other">Other</option>
</select>
</div>
</div>
)}
</div>
</div>
))}
</div>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Printing Cost per Item (inc. materials)
</label>
<input
type="number"
step="0.01"
min="0"
placeholder="0.00"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
onChange={(e) => handleInputChange(product.title, 'printingCost', e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Category
</label>
<select
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
onChange={(e) => handleInputChange(product.title, 'category', e.target.value)}
>
<option value="">Select category...</option>
<option value="3D Prints">3D Prints</option>
<option value="Jewelry">Jewelry</option>
<option value="Home Decor">Home Decor</option>
<option value="Organizers">Organizers</option>
<option value="Planters">Planters</option>
<option value="Custom Items">Custom Items</option>
<option value="Other">Other</option>
</select>
</div>
</div>
</div>
))}
</div>
<div className="flex justify-end gap-3 mt-6">
<button
onClick={onClose}
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
>
Cancel
</button>
<button
onClick={handleAddProducts}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
<Plus className="w-4 h-4" />
Add Products to Database
</button>
</div>
</div>
</div>
</div>
);
};

View file

@ -258,61 +258,13 @@ const Analytics = () => {
return sum + getUpdatedPrintingCost(order);
}, 0);
// 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) => {
// 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 (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);
// Order totals are gross sale amounts (fees are NOT deducted from them),
// so every expense — including Etsy transaction fees — counts once
const totalExpenses = filteredExpenses.reduce((sum, expense) => sum + (expense?.amount || 0), 0);
const netProfit = totalRevenue - totalExpenses - totalPrintingCosts;
const profitMargin = totalRevenue > 0 ? (netProfit / totalRevenue) * 100 : 0;
// Debug logging to verify the separation
console.log('=== EXPENSE SEPARATION DEBUG ===');
console.log('Date range filter:', dateRange);
console.log('Filtered orders count:', filteredOrders.length);
console.log('Filtered expenses count:', filteredExpenses.length);
const allExpensesTotal = filteredExpenses.reduce((sum, expense) => sum + (expense.amount || 0), 0);
const transactionFeesTotal = filteredExpenses.reduce((sum, expense) => {
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 (isSaleTransactionFee) {
console.log('Found sale transaction fee:', expense.description, '$' + expense.amount, 'Category:', expense.category);
}
return isSaleTransactionFee ? sum + (expense.amount || 0) : sum;
}, 0);
console.log('All Expenses Total:', allExpensesTotal);
console.log('Etsy Transaction Fees Total:', transactionFeesTotal);
console.log('Expenses for Profit Calc (excluding transaction fees):', totalExpenses);
console.log('Difference (should equal transaction fees):', allExpensesTotal - totalExpenses);
console.log('Total Revenue:', totalRevenue);
console.log('Total Printing Costs:', totalPrintingCosts);
console.log('Net Profit:', netProfit);
console.log('Profit Margin:', profitMargin.toFixed(1) + '%');
const averageOrderValue = filteredOrders.length > 0 ? totalRevenue / filteredOrders.length : 0;
const totalCustomers = customers?.length || 0;
@ -376,18 +328,11 @@ const Analytics = () => {
const current = monthlyMap.get(monthKey);
if (current) {
// For monthly chart, exclude transaction fees to match profit calculation
const isEtsyTransactionFee = (
(expense.vendor?.toLowerCase() === 'etsy' &&
(expense.category?.toLowerCase().includes('transaction fee') ||
expense.description?.toLowerCase().includes('transaction fee')))
);
if (!isEtsyTransactionFee) {
// All expenses count, matching the profit calculation
// (order revenue is gross, so fees are not double-counted)
current.expenses += expense.amount || 0;
}
}
}
});
// Calculate profit for each month
@ -417,8 +362,6 @@ const Analytics = () => {
const categories = new Map();
filteredExpenses.forEach(expense => {
// Show ALL expenses in categories (including Etsy transaction fees for visibility)
// The profit calculation will handle excluding transaction fees separately
const category = expense.category || 'Other';
const current = categories.get(category) || 0;
categories.set(category, current + (expense.amount || 0));
@ -432,23 +375,8 @@ const Analytics = () => {
percentage: totalExpenseAmount > 0 ? (amount / totalExpenseAmount) * 100 : 0
})).sort((a, b) => b.amount - a.amount);
// Debug: Log detailed expense breakdown
console.log('=== DETAILED EXPENSE BREAKDOWN ===');
console.log('Total Expenses for Display (all expenses):', totalExpenseAmount.toFixed(2));
console.log('Total Expenses for Profit Calc (excluding transaction fees):', totalExpenses.toFixed(2));
console.log('Expense categories breakdown:');
categoryData.forEach(cat => {
console.log(` ${cat.category}: $${cat.amount.toFixed(2)} (${cat.percentage.toFixed(1)}%)`);
});
console.log('PROFIT CALCULATION:');
console.log(`Revenue: $${totalRevenue.toFixed(2)}`);
console.log(`Printing Costs: $${totalPrintingCosts.toFixed(2)}`);
console.log(`Other Expenses (excluding transaction fees): $${totalExpenses.toFixed(2)}`);
console.log(`Net Profit: $${totalRevenue.toFixed(2)} - $${totalPrintingCosts.toFixed(2)} - $${totalExpenses.toFixed(2)} = $${netProfit.toFixed(2)}`);
console.log('===');
return categoryData;
}, [filteredExpenses, totalExpenses, totalRevenue, totalPrintingCosts, netProfit]);
}, [filteredExpenses]);
// Calculate top profitable products based on actual sales performance
const topProducts = useMemo(() => {

View file

@ -1,12 +1,12 @@
import React, { useState, useRef } from 'react';
import { csvImportService, ParsedEtsyOrder, ParsedShippingRecord, EtsyFeeRecord } from '../utils/csvImportService';
import { pdfParser, ParsedPackingSlip } from '../utils/pdfParser';
import { matchOrderItemsToProducts } from '../utils/productMatcher';
import { matchOrderItemsToProducts, normalizeTitle } from '../utils/productMatcher';
import { MissingProductsModal, MissingProduct } from '../components/MissingProductsModal';
import { DataManager } from '../utils/dataManager';
import { useDispatch, useSelector } from 'react-redux';
import { RootState } from '../store';
import { addOrder, updateOrder, setOrders } from '../store/slices/orderSlice';
import { updateProduct } from '../store/slices/productSlice';
import { Upload, FileText, Package, Truck, Trash2 } from 'lucide-react';
import toast from 'react-hot-toast';
import api from '../utils/api';
@ -151,15 +151,12 @@ export default function DataImport() {
// Process expenses with bulk API for much faster processing
if (expensesToCreate.length > 0) {
console.log(`Creating ${expensesToCreate.length} expenses via bulk API...`);
setBackgroundProcessing(`Creating ${expensesToCreate.length} expenses...`);
try {
const response = await api.post('/expenses/bulk', expensesToCreate);
const { created, duplicates, errors, message } = response.data;
console.log(`Bulk expense creation completed: ${created} created, ${duplicates} duplicates, ${errors} errors`);
if (created > 0 || duplicates > 0) {
toast.success(`${message}`);
} else if (errors > 0) {
@ -334,7 +331,6 @@ export default function DataImport() {
const parsedSlips: ParsedPackingSlip[] = [];
for (const file of pdfFiles) {
console.log(`Processing PDF: ${file.name}`);
const slip = await pdfParser.parsePackingSlip(file);
if (slip) {
parsedSlips.push(slip);
@ -355,16 +351,35 @@ export default function DataImport() {
}
};
const createOrUpdateOrderFromSlip = async (slip: ParsedPackingSlip, customProducts?: any[]) => {
console.log('Creating/updating order for slip:', slip);
// Save a packing-slip title as an alias on the matched product, so future
// imports of the same title match deterministically instead of fuzzily
const saveProductAlias = async (productId: string, aliasTitle: string, productList: any[]) => {
const product = productList.find(p => p._id === productId);
if (!product) return;
const normalized = normalizeTitle(aliasTitle);
const alreadyKnown =
normalizeTitle(product.title) === normalized ||
(product.aliases || []).some((alias: string) => normalizeTitle(alias) === normalized);
if (alreadyKnown) return;
try {
const res = await api.put(`/products/${productId}`, {
aliases: [...(product.aliases || []), aliasTitle]
});
dispatch(updateProduct(res.data));
} catch (err) {
console.error('Failed to save product alias:', err);
}
};
const createOrUpdateOrderFromSlip = async (slip: ParsedPackingSlip, customProducts?: any[]) => {
const existingOrder = orders.find(order => order.orderNumber === slip.orderNumber);
// Check if we have CSV results for this order number to get revenue data
let csvOrderData = null;
if (results && results.etsyOrders) {
csvOrderData = results.etsyOrders.find(csvOrder => csvOrder.orderNumber === slip.orderNumber);
console.log('Found matching CSV data for order:', csvOrderData);
}
const productsToUse = customProducts || products;
@ -377,12 +392,28 @@ export default function DataImport() {
return;
}
const orderItems = matches.map((match: any) => ({
// Remember fuzzy-matched titles as aliases so future imports of the same
// packing-slip title skip the fuzzy matcher entirely
for (const match of matches) {
if (match.matchedProduct && match.confidence < 1) {
await saveProductAlias(match.matchedProduct._id, match.orderItem.title, productsToUse);
}
}
// Snapshot costs from the catalog onto the order at import time; profit
// analysis reads these stored values so later catalog edits don't rewrite history
const orderItems = matches.map((match: any) => {
const matchedId = match.matchedProduct?._id;
return {
title: match.orderItem.title,
quantity: match.orderItem.quantity,
price: match.orderItem.price || 0,
printingCost: match.matchedProduct?.printingCost || 0
}));
// Locally-created fallback products have non-ObjectId ids; omit those
...(/^[0-9a-f]{24}$/i.test(matchedId || '') && { productId: matchedId }),
printingCost: match.matchedProduct?.printingCost || 0,
costOfGoods: match.matchedProduct?.costOfGoods || 0
};
});
// Parse and format the order date
let formattedOrderDate = new Date().toISOString();
@ -433,8 +464,6 @@ export default function DataImport() {
const handleMissingProductsSubmit = (newProducts: any[]) => {
// Products are already created by the MissingProductsModal
// We need to use the updated product list for matching
console.log('Missing products submitted:', newProducts);
// Close modal immediately and reset state
setShowMissingProductsModal(false);
setMissingProducts([]);
@ -452,26 +481,16 @@ export default function DataImport() {
toast.success('Products processed successfully! Order has been updated.');
};
const handleClearTestData = () => {
if (window.confirm('Clear all existing data for testing? This will download a backup first.')) {
DataManager.clearWithBackup();
toast.success('Data cleared and backup downloaded!');
setTimeout(() => window.location.reload(), 1000);
}
};
const handleClearAllOrders = async () => {
const orderCount = orders.length;
if (window.confirm(`This will permanently delete all ${orderCount} orders from the database. Are you sure?`)) {
let deleted = 0;
for (const order of orders) {
try {
await api.delete(`/orders/${order._id}`);
deleted++;
} catch {}
}
const res = await api.delete('/orders');
dispatch(setOrders([]));
toast.success(`Deleted ${deleted} orders. Re-upload your packing slips to restore.`);
toast.success(`Deleted ${res.data.deleted} orders. Re-upload your packing slips to restore.`);
} catch {
toast.error('Failed to delete orders');
}
}
};
@ -533,80 +552,6 @@ export default function DataImport() {
}
};
const debugDataState = () => {
console.log('=== DEBUGGING DATA STATE ===');
console.log('Current orders in Redux:', orders.length);
orders.forEach((order, i) => {
console.log(`Order ${i + 1}:`, {
orderNumber: order.orderNumber,
total: order.total,
items: order.items?.length || 0,
fees: order.fees,
customer: order.customer?.name
});
});
console.log('CSV Results:', results ? {
etsyOrders: results.etsyOrders?.length || 0,
sampleOrder: results.etsyOrders?.[0]
} : 'No CSV results');
console.log('Products:', products.length);
toast.success(`Debug info logged to console. Orders: ${orders.length}, CSV: ${results?.etsyOrders?.length || 0}, Products: ${products.length}`);
};
const testActualPDF = async () => {
try {
console.log('=== TESTING PDF PARSER ===');
// First, let's check what products we have in the database
console.log('Current products in database:', products.map(p => ({ id: p._id, title: p.title })));
console.log('Testing actual packing slip PDF...');
const response = await fetch('/3748364725.pdf');
const arrayBuffer = await response.arrayBuffer();
const blob = new Blob([arrayBuffer], { type: 'application/pdf' });
const file = new File([blob], '3748364725.pdf', { type: 'application/pdf' });
console.log('File size:', file.size, 'bytes');
// Let's also extract the raw text to see what we're working with
const { getDocument } = await import('pdfjs-dist');
const pdf = await getDocument(arrayBuffer).promise;
let fullText = '';
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i);
const textContent = await page.getTextContent();
const pageText = textContent.items.map((item: any) => item.str).join(' ');
fullText += pageText + '\n';
console.log(`Page ${i} text:`, pageText);
}
console.log('\n=== FULL EXTRACTED TEXT ===');
console.log(fullText);
// Now try our parser
const result = await pdfParser.parsePackingSlip(file);
console.log('\n=== PARSER RESULT ===');
console.log('Parse Result:', JSON.stringify(result, null, 2));
if (result.items && result.items.length > 0) {
console.log('\n=== PARSED ITEMS ===');
console.log('Order Number:', result.orderNumber);
result.items.forEach((item, index) => {
console.log(`Item ${index + 1}: ${item.title} (Qty: ${item.quantity})`);
});
} else {
console.log('❌ No items found - we need to update the parser patterns based on the extracted text above');
}
} catch (error) {
console.error('Error testing PDF:', error);
}
};
return (
<div className="p-6">
<div className="mb-6">
@ -618,8 +563,6 @@ export default function DataImport() {
</p>
</div>
{/* Testing Helper */}
<div className="flex flex-col gap-2">
<button
onClick={handleClearAllOrders}
className="flex items-center gap-2 px-3 py-2 bg-orange-100 text-orange-700 rounded-lg hover:bg-orange-200 text-sm"
@ -627,58 +570,8 @@ export default function DataImport() {
<Trash2 className="w-4 h-4" />
Clear All Orders
</button>
<button
onClick={handleClearTestData}
className="flex items-center gap-2 px-3 py-2 bg-red-100 text-red-700 rounded-lg hover:bg-red-200 text-sm"
>
<Trash2 className="w-4 h-4" />
Clear for Testing
</button>
<button
onClick={testActualPDF}
className="flex items-center gap-2 px-3 py-2 bg-blue-100 text-blue-700 rounded-lg hover:bg-blue-200 text-sm"
>
<FileText className="w-4 h-4" />
Test PDF Parser
</button>
<button
onClick={debugDataState}
className="flex items-center gap-2 px-3 py-2 bg-green-100 text-green-700 rounded-lg hover:bg-green-200 text-sm"
>
<Package className="w-4 h-4" />
Debug Data
</button>
<p className="text-xs text-gray-500 max-w-32 text-center">
Fix dates | Clear all | Test parsing | Debug
</p>
</div>
</div>
</div>
{/* Date Fix Notice */}
{orders.length > 0 && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 mb-6">
<div className="flex">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-yellow-800">
Date Fix Required for Existing Orders
</h3>
<div className="mt-2 text-sm text-yellow-700">
<p>
You have {orders.length} existing orders that may have incorrect dates (showing today's date instead of actual order dates).
To fix this: <strong>Click "Clear All Orders"</strong> above, then re-upload your packing slip PDFs.
The updated date parsing will now extract the correct order dates.
</p>
</div>
</div>
</div>
</div>
)}
{/* Import Options Tabs */}
<div className="bg-white rounded-lg shadow-md mb-6">

View file

@ -10,6 +10,7 @@ export interface Product {
sku: string;
category: string;
tags: string[];
aliases?: string[];
inventory: {
quantity: number;
lowStockAlert: number;

View file

@ -185,33 +185,37 @@ export class CSVImportService {
}
});
// Second pass to calculate fees per order
// Second pass: allocate fees to orders using the order reference in the
// statement's Title/Info columns (e.g. "Order #3748364725" / "order: 3748364725").
// Fees without an order reference (listing fees, ads GST, postage labels)
// are shop-level expenses and are tracked via parseEtsyFees instead.
records.forEach(record => {
if (record.Type === 'Fee' || record.Type === 'GST') {
// Try to match fees to orders by date proximity and context
if (record.Type !== 'Fee' && record.Type !== 'GST') return;
const feeAmount = Math.abs(this.parseAmount(record.Net));
const feeDate = record.Date;
if (feeAmount <= 0) return;
// Find the closest order by date
let closestOrder: ParsedEtsyOrder | null = null;
let closestDateDiff = Infinity;
const refSource = `${record.Title} ${record.Info || ''}`;
const refMatch = refSource.match(/Order\s*#(\d+)/i) || refSource.match(/order:?\s*(\d+)/i);
let order = refMatch ? orderMap.get(refMatch[1]) : undefined;
for (const order of orderMap.values()) {
const dateDiff = Math.abs(new Date(order.date).getTime() - new Date(feeDate).getTime());
if (dateDiff < closestDateDiff && dateDiff <= 7 * 24 * 60 * 60 * 1000) { // Within 7 days
closestDateDiff = dateDiff;
closestOrder = order;
// Unreferenced transaction GST (e.g. "GST: shipping_transaction") belongs
// to an order but carries no reference; only allocate it when exactly one
// order exists on the same date, so it can't land on the wrong order.
if (!order && !refMatch && record.Title.toLowerCase().includes('transaction')) {
const recordDate = this.parseDate(record.Date);
const sameDayOrders = Array.from(orderMap.values()).filter(o => o.date === recordDate);
if (sameDayOrders.length === 1) {
order = sameDayOrders[0];
}
}
if (closestOrder) {
closestOrder.totalFees += feeAmount;
closestOrder.netAmount = closestOrder.saleAmount - closestOrder.totalFees;
if (order) {
order.totalFees += feeAmount;
order.netAmount = order.saleAmount - order.totalFees;
// Check for shipping fees
if (record.Title.toLowerCase().includes('shipping')) {
closestOrder.shippingFee = feeAmount;
}
order.shippingFee = feeAmount;
}
}
});
@ -237,6 +241,9 @@ export class CSVImportService {
let reference: string | undefined;
const description = record.Title;
const lowerTitle = description.toLowerCase();
// References (order/listing/label numbers) usually live in the Info
// column, e.g. Title "Transaction fee: Moai Bookend", Info "Order #3748364725"
const refSource = `${record.Title} ${record.Info || ''}`;
// Categorize different types of Etsy fees
if (record.Type === 'Marketing' || lowerTitle.includes('etsy ads')) {
@ -244,24 +251,26 @@ export class CSVImportService {
} else if (lowerTitle.includes('listing fee')) {
category = 'Listing Fees';
// Extract listing ID if available
const listingMatch = description.match(/listing.*?#?(\d+)/i);
const listingMatch = refSource.match(/listing\s*:?\s*#?(\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);
const orderMatch = refSource.match(/order\s*:?\s*#?(\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);
const orderMatch = refSource.match(/order\s*:?\s*#?(\d+)/i);
if (orderMatch) reference = orderMatch[1];
} else if (record.Type === 'GST' || lowerTitle.includes('gst')) {
category = 'Taxes & GST';
const orderMatch = refSource.match(/order\s*:?\s*#?(\d+)/i);
if (orderMatch) reference = orderMatch[1];
} else if (lowerTitle.includes('shipping') || lowerTitle.includes('postage')) {
category = 'Shipping & Postage';
// Extract label/tracking number if available
const labelMatch = description.match(/label.*?#?(\d+)/i);
const labelMatch = refSource.match(/label\s*:?\s*#?(\d+)/i);
if (labelMatch) reference = `label-${labelMatch[1]}`;
} else {
category = 'Other Etsy Fees';

View file

@ -41,13 +41,19 @@ export const calculateOrderProfit = (items: OrderItem[], orderTotal: number = 0)
return revenue - costs;
};
// Match order items with products to add cost information
// Fill in cost information for order items.
// Costs captured on the order at import time are the source of truth, so that
// later changes to catalog prices don't rewrite historical profit figures.
// Only legacy items with no cost fields at all fall back to a catalog lookup.
export const enrichOrderItemsWithCosts = (
items: OrderItem[],
products: Product[]
): OrderItem[] => {
return items.map(item => {
// Try to find matching product by title (could be improved with SKU matching)
if (item.printingCost !== undefined || item.costOfGoods !== undefined) {
return item;
}
const matchingProduct = products.find(product =>
product.title.toLowerCase().includes(item.title.toLowerCase()) ||
item.title.toLowerCase().includes(product.title.toLowerCase())

View file

@ -32,15 +32,10 @@ export interface ParsedPackingSlip {
export class PDFPackingSlipParser {
async parsePackingSlip(file: File): Promise<ParsedPackingSlip> {
console.log('Starting PDF parsing for file:', file.name, 'Size:', file.size, 'Type:', file.type);
try {
const arrayBuffer = await file.arrayBuffer();
console.log('File read as ArrayBuffer, size:', arrayBuffer.byteLength);
const loadingTask = pdfjsLib.getDocument({ data: arrayBuffer });
const pdf = await loadingTask.promise;
console.log('PDF loaded successfully, pages:', pdf.numPages);
let fullText = '';
@ -53,21 +48,14 @@ export class PDFPackingSlipParser {
.map((item: any) => item.str)
.join(' ');
fullText += pageText + '\n';
console.log(`Page ${i} text length:`, pageText.length);
} catch (pageError) {
console.warn(`Error reading page ${i}:`, pageError);
}
}
console.log('Total extracted text length:', fullText.length);
console.log('First 500 characters:', fullText.substring(0, 500));
const result = this.parsePackingSlipText(fullText);
console.log('Parsing result:', result);
return result;
return this.parsePackingSlipText(fullText);
} catch (error) {
console.error('Detailed PDF parsing error:', error);
console.error('PDF parsing error:', error);
if (error instanceof Error) {
throw new Error(`PDF parsing failed: ${error.message}`);
} else {
@ -77,8 +65,6 @@ export class PDFPackingSlipParser {
}
private parsePackingSlipText(text: string): ParsedPackingSlip {
console.log('Raw PDF text:', text); // For debugging
// Initialize result
const result: ParsedPackingSlip = {
orderNumber: '',
@ -153,17 +139,13 @@ export class PDFPackingSlipParser {
private extractItems(text: string): ParsedItem[] {
const items: ParsedItem[] = [];
console.log('Extracting items from text:', text);
// For Etsy packing slips, look for the pattern after "items" and before "Item total"
const itemsSection = text.match(/(\d+)\s+items?\s+(.*?)Item total/is);
if (itemsSection) {
const itemsText = itemsSection[2];
console.log('Items section:', itemsText);
// Look for price patterns first to identify item boundaries
const priceMatches = [...itemsText.matchAll(/(\d+)\s+x\s+AU\$(\d+\.?\d*)/g)];
console.log('Price matches found:', priceMatches.map(m => m[0]));
if (priceMatches.length > 0) {
// Split the text by price patterns to get item descriptions
@ -208,7 +190,6 @@ export class PDFPackingSlipParser {
.trim();
if (title && quantity > 0) {
console.log('Extracted item:', { title, quantity });
items.push({ title, quantity });
}
}
@ -217,8 +198,6 @@ export class PDFPackingSlipParser {
// Enhanced fallback: Look for the specific Etsy format in the full text
if (items.length === 0) {
console.log('No items found with section method, trying direct pattern matching...');
// Pattern to find complete item lines in the original text
// Look for the specific format: "Item Name Colour: ... Size: ... 1 x AU$15.00"
@ -233,13 +212,11 @@ export class PDFPackingSlipParser {
// Note: We ignore the price from packing slip as it's tracked from Etsy CSV
if (title && quantity > 0) {
console.log('Direct pattern found item:', { title, quantity });
items.push({ title, quantity });
}
}
}
console.log('Final extracted items:', items);
return items;
}

View file

@ -26,6 +26,25 @@ export interface MatchingResult {
}[];
}
/**
* Normalize a title for exact/alias comparison (whitespace and case insensitive)
*/
export const normalizeTitle = (title: string): string =>
title.replace(/\s+/g, ' ').trim().toLowerCase();
/**
* Find a product whose title or saved aliases exactly match the item title.
* Aliases are packing-slip titles that were previously matched (automatically
* or by the user), so this lookup is deterministic and fully confident.
*/
const findAliasMatch = (itemTitle: string, products: any[]): any | null => {
const normalized = normalizeTitle(itemTitle);
return products.find(product =>
normalizeTitle(product.title) === normalized ||
(product.aliases || []).some((alias: string) => normalizeTitle(alias) === normalized)
) || null;
};
/**
* Match order items against existing products in the database
* Considers size as a factor but ignores color variations
@ -39,6 +58,27 @@ export const matchOrderItemsToProducts = (
const missingProducts: any[] = [];
for (const item of orderItems) {
// Deterministic lookup first: exact title or saved alias from a previous import
const aliasMatch = findAliasMatch(item.title, products);
if (aliasMatch) {
matches.push({
orderItem: {
title: item.title,
quantity: item.quantity,
price: item.price
},
matchedProduct: {
_id: aliasMatch._id,
title: aliasMatch.title,
printingCost: aliasMatch.printingCost || 0,
costOfGoods: aliasMatch.costOfGoods || 0,
size: aliasMatch.size
},
confidence: 1
});
continue;
}
let bestMatch: any = null;
let bestConfidence = 0;
@ -69,10 +109,6 @@ export const matchOrderItemsToProducts = (
.trim()
.toLowerCase();
console.log('🔍 Matching item:', item.title);
console.log(' Clean title:', cleanItemTitle);
console.log(' Extracted size:', itemSize);
for (const product of products) {
const productTitle = product.title.toLowerCase();
let productSize = '';
@ -102,7 +138,6 @@ export const matchOrderItemsToProducts = (
let sizeSimilarity = 0;
if (itemSize && productSize) {
sizeSimilarity = itemSize === productSize ? 1 : 0;
console.log(` Size match: ${itemSize} vs ${productSize} = ${sizeSimilarity}`);
} else if (!itemSize && !productSize) {
sizeSimilarity = 0.7; // Both have no size specified
} else if (itemSize && !productSize) {
@ -128,13 +163,6 @@ export const matchOrderItemsToProducts = (
confidence = (titleSimilarity * 0.8) + (sizeSimilarity * 0.2);
}
console.log(` Product: ${product.title}`);
console.log(` Clean: ${cleanProductTitle}`);
console.log(` Product size: ${productSize}`);
console.log(` Title similarity: ${titleSimilarity.toFixed(3)}`);
console.log(` Size similarity: ${sizeSimilarity}`);
console.log(` Final confidence: ${confidence.toFixed(3)}`);
// Require higher confidence for shelf decor products to avoid wrong matches
const minConfidence = isShelfDecor ? 0.7 : 0.5;
@ -160,7 +188,6 @@ export const matchOrderItemsToProducts = (
},
confidence: bestConfidence
});
console.log(`✅ Matched "${item.title}" to "${bestMatch.title}" (${(bestConfidence * 100).toFixed(1)}%)`);
} else {
missingProducts.push({
title: item.title,
@ -168,7 +195,6 @@ export const matchOrderItemsToProducts = (
...(item.price !== undefined && { price: item.price }),
size: itemSize
});
console.log(`❌ No match found for "${item.title}"`);
}
}

View file

@ -104,24 +104,11 @@ export class ProfitAnalysisService {
if (orderProfit > 0) profitableOrderCount++;
});
// Calculate total expenses (excluding only sale-related transaction fees to avoid double-counting)
const totalExpenses = expenses ? expenses.reduce((sum, expense) => {
// 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 (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;
// Order totals are gross sale amounts (fees are NOT deducted from them),
// so every expense counts against revenue exactly once
const totalExpenses = expenses
? expenses.reduce((sum, expense) => sum + (expense?.amount || 0), 0)
: 0;
// Calculate final profit including all expenses
const totalProfit = totalRevenue - totalPrintingCosts - totalExpenses;
@ -235,17 +222,6 @@ 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 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 (isSaleTransactionFee) return;
// Create month entry if it doesn't exist (for expense-only months)
if (!monthlyData.has(monthKey)) {
monthlyData.set(monthKey, {

View file

@ -1,43 +0,0 @@
// Test script to parse the actual packing slip PDF
import { pdfParser } from './pdfParser';
async function testActualPDF() {
try {
// Simulate a File object for the PDF
const response = await fetch('/3748364725.pdf');
const arrayBuffer = await response.arrayBuffer();
const blob = new Blob([arrayBuffer], { type: 'application/pdf' });
const file = new File([blob], '3748364725.pdf', { type: 'application/pdf' });
console.log('Testing actual packing slip PDF...');
console.log('File size:', file.size, 'bytes');
const result = await pdfParser.parsePackingSlip(file);
console.log('Parse Result:', JSON.stringify(result, null, 2));
if (result.items && result.items.length > 0) {
console.log('\n=== PARSED ITEMS ===');
console.log('Order Number:', result.orderNumber);
console.log('Items:', result.items);
result.items.forEach((item, index: number) => {
console.log(`Item ${index + 1}: ${item.title} (Qty: ${item.quantity})`);
});
} else {
console.log('No items found in PDF');
}
} catch (error) {
console.error('Error testing PDF:', error);
}
}
// Export for manual testing
export { testActualPDF };
// If running directly (not imported), execute the test
if (typeof window !== 'undefined') {
// Browser environment
(window as any).testActualPDF = testActualPDF;
console.log('Test function available as window.testActualPDF()');
}

View file

@ -1,54 +0,0 @@
// Quick test for date parsing from packing slip text
export function testDateParsing() {
// Sample text from actual packing slip
const sampleText = `
Packing slip for order #3748364725
Order date 21 Jul, 2025
Ship to:
David L
`;
// Test the regex pattern (same as in pdfParser.ts)
const datePattern = /Order [Dd]ate:?\s*(\d{1,2} [A-Z][a-z]{2}, \d{4})/i;
const dateMatch = sampleText.match(datePattern);
console.log('=== TESTING DATE EXTRACTION ===');
console.log('Sample text contains:', sampleText.trim());
console.log('Date pattern:', datePattern.toString());
console.log('Match found:', dateMatch);
if (dateMatch && dateMatch[1]) {
const extractedDate = dateMatch[1];
console.log('Extracted date string:', extractedDate);
// Test parsing to Date object
try {
const parsedDate = new Date(extractedDate);
console.log('Parsed Date object:', parsedDate);
console.log('Is valid date:', !isNaN(parsedDate.getTime()));
console.log('ISO string:', parsedDate.toISOString());
// Test today's date for comparison
const today = new Date();
console.log('Today for comparison:', today.toISOString());
console.log('Are dates different?', parsedDate.toDateString() !== today.toDateString());
return {
success: true,
extractedDate,
parsedDate: parsedDate.toISOString(),
isValidDate: !isNaN(parsedDate.getTime()),
isDifferentFromToday: parsedDate.toDateString() !== today.toDateString()
};
} catch (error) {
console.error('Date parsing error:', error);
return { success: false, error };
}
} else {
console.log('No date match found - this is the problem!');
return { success: false, error: 'No date match found' };
}
}
// Export for use in components
export const dateTestResults = testDateParsing();

View file

@ -1,18 +0,0 @@
// Simple test component for PDF parsing
import { pdfParser } from '../utils/pdfParser';
export const testPDF = async (file: File) => {
console.log('=== PDF Test Debug ===');
console.log('File name:', file.name);
console.log('File size:', file.size);
console.log('File type:', file.type);
try {
const result = await pdfParser.parsePackingSlip(file);
console.log('Parse result:', result);
return result;
} catch (error) {
console.error('Parse error:', error);
throw error;
}
};

View file

@ -9,6 +9,7 @@ export interface IProduct extends Document {
sku: string;
category: string;
tags: string[];
aliases: string[];
images: string[];
variants: {
name: string;
@ -44,6 +45,9 @@ const ProductSchema: Schema = new Schema({
sku: { type: String, trim: true, default: '' },
category: { type: String, default: 'Other' },
tags: [{ type: String, trim: true }],
// Packing-slip item titles previously matched to this product; used for
// deterministic matching on future imports
aliases: [{ type: String, trim: true }],
images: [{ type: String }],
variants: [{
name: { type: String, required: true },

View file

@ -95,6 +95,15 @@ router.put('/:id', async (req: AuthRequest, res: Response) => {
}
});
router.delete('/', async (req: AuthRequest, res: Response) => {
try {
const result = await Order.deleteMany({ userId: req.userId });
res.json({ message: 'All orders deleted', deleted: result.deletedCount });
} catch (err) {
res.status(500).json({ message: 'Failed to delete orders', error: err });
}
});
router.delete('/:id', async (req: AuthRequest, res: Response) => {
try {
const order = await Order.findOneAndDelete({ _id: req.params.id, userId: req.userId });