Add automatic invoice parsing to extract purchase items

- Install pdf-parse and tesseract.js for PDF/image processing
- Create /api/admin/parse-invoice endpoint to extract line items from invoices
- Parser extracts quantity and description from invoice text
- Attempts to match extracted items to products in database
- Update PurchaseForm with "Extract items" button
- Pre-fill purchase form with parsed items (user can edit/add more)
- Supports PDF files and image scans (JPG, PNG, etc)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Andymick
2026-07-16 21:14:49 +01:00
co-authored by Claude Haiku 4.5
parent 48017facf0
commit d596cbe836
4 changed files with 547 additions and 9 deletions
+124
View File
@@ -0,0 +1,124 @@
import { NextResponse } from 'next/server';
import { isAdminAuthenticated } from '@/lib/adminAuth';
import { prisma } from '@/lib/prisma';
import pdfParse from 'pdf-parse';
import * as Tesseract from 'tesseract.js';
type ExtractedItem = {
description: string;
quantity: number;
productId?: string;
};
async function extractTextFromPDF(buffer: Buffer): Promise<string> {
try {
const data = await pdfParse(buffer);
return data.text;
} catch (err) {
throw new Error('Failed to extract text from PDF');
}
}
async function extractTextFromImage(buffer: Buffer): Promise<string> {
try {
const result = await Tesseract.recognize(buffer, 'eng');
return result.data.text;
} catch (err) {
throw new Error('Failed to OCR image');
}
}
function parseInvoiceText(text: string, products: any[]): ExtractedItem[] {
const items: ExtractedItem[] = [];
const lines = text.split('\n');
// Build product search index (lowercase for matching)
const productIndex = new Map(products.map((p) => [p.name.toLowerCase(), p.id]));
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.length < 5) continue;
// Look for patterns like: quantity x product or product x quantity
// Examples: "10 x T-Shirt", "T-Shirt - 10", "10x hoodies"
const matches = trimmed.match(/(\d+)\s*x?\s*(.+?)(?:\s*-\s*£?[\d.]+)?$/i);
if (!matches) continue;
const quantity = parseInt(matches[1], 10);
let description = matches[2].trim();
// Clean up description (remove common noise)
description = description.replace(/\s*-\s*\d+\s*$/, ''); // Remove trailing numbers
description = description.replace(/£[\d.]+/, '').trim(); // Remove prices
if (quantity > 0 && description.length > 2) {
// Try to match to a product
const lowerDesc = description.toLowerCase();
let productId: string | undefined;
for (const [productName, pId] of productIndex) {
if (lowerDesc.includes(productName)) {
productId = pId;
break;
}
}
items.push({
description,
quantity,
productId,
});
}
}
return items;
}
export async function POST(req: Request) {
const isAdmin = await isAdminAuthenticated();
if (!isAdmin) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
try {
const formData = await req.formData();
const file = formData.get('invoice') as File;
if (!file) {
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
}
if (file.size > 8 * 1024 * 1024) {
return NextResponse.json({ error: 'File too large (max 8MB)' }, { status: 400 });
}
const buffer = Buffer.from(await file.arrayBuffer());
let text = '';
if (file.type === 'application/pdf') {
text = await extractTextFromPDF(buffer);
} else if (file.type.startsWith('image/')) {
text = await extractTextFromImage(buffer);
} else {
return NextResponse.json(
{ error: 'Unsupported file type. Use PDF or image.' },
{ status: 400 }
);
}
// Get all products for matching
const products = await prisma.product.findMany({
select: { id: true, name: true },
});
const extractedItems = parseInvoiceText(text, products);
return NextResponse.json({
items: extractedItems,
rawText: text.substring(0, 500), // First 500 chars for debugging
});
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
return NextResponse.json({ error: message }, { status: 500 });
}
}
+80 -9
View File
@@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useState, useRef } from 'react';
import { createPurchase } from '@/app/admin/accounts/purchases/actions';
import Price from './Price';
@@ -30,6 +30,9 @@ export default function PurchaseForm({ products }: { products: ProductOption[] }
{ key: 1, productId: '', description: '', color: '', size: '', quantity: 1, unitCostInput: '' },
]);
const [nextKey, setNextKey] = useState(2);
const [parsing, setParsing] = useState(false);
const [parseError, setParseError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const updateRow = (key: number, patch: Partial<Row>) => {
setRows((prev) => prev.map((r) => (r.key === key ? { ...r, ...patch } : r)));
@@ -61,6 +64,56 @@ export default function PurchaseForm({ products }: { products: ProductOption[] }
setRows((prev) => (prev.length > 1 ? prev.filter((r) => r.key !== key) : prev));
};
const parseInvoice = async () => {
if (!fileInputRef.current?.files?.[0]) {
setParseError('No file selected');
return;
}
setParsing(true);
setParseError(null);
try {
const formData = new FormData();
formData.append('invoice', fileInputRef.current.files[0]);
const res = await fetch('/api/admin/parse-invoice', {
method: 'POST',
body: formData,
});
const data = await res.json();
if (!res.ok) {
setParseError(data.error || 'Failed to parse invoice');
return;
}
if (!data.items || data.items.length === 0) {
setParseError('No items found in invoice. Please add them manually.');
return;
}
// Convert extracted items to form rows
const newRows: Row[] = data.items.map((item: any, index: number) => ({
key: nextKey + index,
productId: item.productId || '',
description: item.description,
color: '',
size: '',
quantity: item.quantity,
unitCostInput: '',
}));
setRows(newRows);
setNextKey(nextKey + newRows.length);
} catch (err) {
setParseError(err instanceof Error ? err.message : 'Failed to parse invoice');
} finally {
setParsing(false);
}
};
const pence = (input: string) => Math.max(0, Math.round(Number(input || '0') * 100));
const total = rows.reduce((sum, r) => sum + r.quantity * pence(r.unitCostInput), 0);
@@ -113,14 +166,32 @@ export default function PurchaseForm({ products }: { products: ProductOption[] }
<label htmlFor="invoice" className="tag-label mb-2 block">
Invoice file (optional)
</label>
<input
id="invoice"
name="invoice"
type="file"
accept="application/pdf,image/*"
className="w-full border border-line bg-paper px-3 py-2 text-sm"
/>
<p className="mt-1 text-xs text-muted">PDF or photo, up to 8 MB kept with the purchase for your records.</p>
<div className="flex gap-2">
<input
ref={fileInputRef}
id="invoice"
name="invoice"
type="file"
accept="application/pdf,image/*"
className="flex-1 border border-line bg-paper px-3 py-2 text-sm"
/>
<button
type="button"
onClick={parseInvoice}
disabled={parsing}
className="border border-ink px-4 py-2 text-sm hover:border-clay hover:bg-clay hover:text-paper disabled:opacity-50"
>
{parsing ? 'Parsing…' : 'Extract items'}
</button>
</div>
<p className="mt-1 text-xs text-muted">
PDF or photo, up to 8 MB. Click "Extract items" to auto-fill line items from the invoice.
</p>
{parseError && (
<p className="mt-2 border border-splash-pink/40 bg-splash-pink/10 px-3 py-2 text-xs text-splash-pink">
{parseError}
</p>
)}
</div>
<div>