Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 127 additions & 0 deletions app/api/invoices/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { NextRequest, NextResponse } from 'next/server';
import { db, schema } from '@/db';
import { eq } from 'drizzle-orm';

interface Params {
id: string;
}

export async function GET(
request: NextRequest,
{ params }: { params: Params }
) {
try {
const { id } = params;

// Validate ID format (should be UUID)
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!uuidRegex.test(id)) {
return NextResponse.json(
{ error: 'Invalid invoice ID format' },
{ status: 400 }
);
}

// Get invoice details
const invoice = await db
.select()
.from(schema.invoice)
.where(eq(schema.invoice.id, id))
.limit(1);

if (invoice.length === 0) {
return NextResponse.json(
{ error: 'Invoice not found' },
{ status: 404 }
);
}

// Get invoice line items
const lineItems = await db
.select()
.from(schema.invoiceItem)
.where(eq(schema.invoiceItem.invoiceId, id))
.orderBy(schema.invoiceItem.createdAt);

// Transform the response
const invoiceData = invoice[0];
const transformedInvoice = {
id: invoiceData.id,
invoiceNumber: invoiceData.invoiceNumber,
date: invoiceData.date,
vendor: invoiceData.vendor,
totalAmount: parseFloat(invoiceData.totalAmount),
createdAt: invoiceData.createdAt,
updatedAt: invoiceData.updatedAt,
lineItems: lineItems.map(item => ({
id: item.id,
description: item.description,
quantity: parseFloat(item.quantity),
unitPrice: parseFloat(item.unitPrice),
lineTotal: parseFloat(item.lineTotal),
createdAt: item.createdAt,
updatedAt: item.updatedAt
}))
};

return NextResponse.json({
invoice: transformedInvoice
});

} catch (error) {
console.error('Error fetching invoice details:', error);
return NextResponse.json(
{ error: 'Failed to fetch invoice details' },
{ status: 500 }
);
}
}

export async function DELETE(
request: NextRequest,
{ params }: { params: Params }
) {
try {
const { id } = params;

// Validate ID format (should be UUID)
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (!uuidRegex.test(id)) {
return NextResponse.json(
{ error: 'Invalid invoice ID format' },
{ status: 400 }
);
}

// Check if invoice exists
const existingInvoice = await db
.select({ id: schema.invoice.id })
.from(schema.invoice)
.where(eq(schema.invoice.id, id))
.limit(1);

if (existingInvoice.length === 0) {
return NextResponse.json(
{ error: 'Invoice not found' },
{ status: 404 }
);
}

// Delete invoice (line items will be deleted due to CASCADE)
await db
.delete(schema.invoice)
.where(eq(schema.invoice.id, id));

return NextResponse.json({
message: 'Invoice deleted successfully',
deletedId: id
});

} catch (error) {
console.error('Error deleting invoice:', error);
return NextResponse.json(
{ error: 'Failed to delete invoice' },
{ status: 500 }
);
}
}
129 changes: 129 additions & 0 deletions app/api/invoices/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { NextRequest, NextResponse } from 'next/server';
import { db, schema } from '@/db';
import { desc, gte, lte, ilike, and } from 'drizzle-orm';

export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);

// Parse query parameters
const page = parseInt(searchParams.get('page') || '1');
const limit = parseInt(searchParams.get('limit') || '10');
const vendor = searchParams.get('vendor');
const startDate = searchParams.get('startDate');
const endDate = searchParams.get('endDate');
const sortBy = searchParams.get('sortBy') || 'date';
const sortOrder = searchParams.get('sortOrder') || 'desc';

// Validate pagination parameters
const validatedPage = Math.max(1, page);
const validatedLimit = Math.min(100, Math.max(1, limit)); // Max 100 items per page
const offset = (validatedPage - 1) * validatedLimit;

// Build where conditions
const conditions = [];

if (vendor) {
conditions.push(ilike(schema.invoice.vendor, `%${vendor}%`));
}

if (startDate) {
const start = new Date(startDate);
if (!isNaN(start.getTime())) {
conditions.push(gte(schema.invoice.date, start));
}
}

if (endDate) {
const end = new Date(endDate);
if (!isNaN(end.getTime())) {
// Set to end of day
end.setHours(23, 59, 59, 999);
conditions.push(lte(schema.invoice.date, end));
}
}

const whereClause = conditions.length > 0 ? and(...conditions) : undefined;

// Build order clause
let orderClause;
switch (sortBy) {
case 'invoiceNumber':
orderClause = sortOrder === 'asc'
? schema.invoice.invoiceNumber
: desc(schema.invoice.invoiceNumber);
break;
case 'vendor':
orderClause = sortOrder === 'asc'
? schema.invoice.vendor
: desc(schema.invoice.vendor);
break;
case 'totalAmount':
orderClause = sortOrder === 'asc'
? schema.invoice.totalAmount
: desc(schema.invoice.totalAmount);
break;
default: // date
orderClause = sortOrder === 'asc'
? schema.invoice.date
: desc(schema.invoice.date);
}

// Get total count for pagination
const [{ count }] = await db
.select({ count: schema.invoice.id })
.from(schema.invoice)
.where(whereClause);

// Get invoices with pagination
const invoices = await db
.select()
.from(schema.invoice)
.where(whereClause)
.orderBy(orderClause)
.limit(validatedLimit)
.offset(offset);

// Transform the response to include proper numeric values
const transformedInvoices = invoices.map(invoice => ({
id: invoice.id,
invoiceNumber: invoice.invoiceNumber,
date: invoice.date,
vendor: invoice.vendor,
totalAmount: parseFloat(invoice.totalAmount),
createdAt: invoice.createdAt,
updatedAt: invoice.updatedAt
}));

// Calculate pagination metadata
const totalPages = Math.ceil(parseInt(count) / validatedLimit);
const hasNextPage = validatedPage < totalPages;
const hasPrevPage = validatedPage > 1;

return NextResponse.json({
invoices: transformedInvoices,
pagination: {
page: validatedPage,
limit: validatedLimit,
total: parseInt(count),
totalPages,
hasNextPage,
hasPrevPage
},
filters: {
vendor,
startDate,
endDate,
sortBy,
sortOrder
}
});

} catch (error) {
console.error('Error fetching invoices:', error);
return NextResponse.json(
{ error: 'Failed to fetch invoices' },
{ status: 500 }
);
}
}
129 changes: 129 additions & 0 deletions app/api/invoices/upload/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { NextRequest, NextResponse } from 'next/server';
import { db, schema } from '@/db';
import { processInvoiceImage } from '@/lib/services/ocrService';

export async function POST(request: NextRequest) {
try {
const formData = await request.formData();
const file = formData.get('invoice') as File;

if (!file) {
return NextResponse.json(
{ error: 'No file provided' },
{ status: 400 }
);
}

// Validate file type
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'application/pdf'];
if (!allowedTypes.includes(file.type)) {
return NextResponse.json(
{ error: 'Invalid file type. Only images (JPEG, PNG, GIF) and PDF files are allowed.' },
{ status: 400 }
);
}

// Validate file size (5MB limit)
const maxSize = 5 * 1024 * 1024; // 5MB
if (file.size > maxSize) {
return NextResponse.json(
{ error: 'File too large. Maximum size is 5MB.' },
{ status: 400 }
);
}

// Convert file to buffer
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);

// Process image with OCR
let invoiceData;
try {
invoiceData = await processInvoiceImage(buffer);
} catch (ocrError) {
console.error('OCR processing failed:', ocrError);
return NextResponse.json(
{ error: 'Failed to extract invoice data from the uploaded file. Please ensure the image is clear and contains valid invoice information.' },
{ status: 400 }
);
}

// Validate extracted data
if (!invoiceData.invoiceNumber || !invoiceData.vendor || invoiceData.totalAmount === null) {
return NextResponse.json(
{ error: 'Could not extract essential invoice information (invoice number, vendor, or total amount). Please verify the image quality and try again.' },
{ status: 400 }
);
}

// Generate unique invoice number if extraction failed
let finalInvoiceNumber = invoiceData.invoiceNumber;
if (!finalInvoiceNumber) {
finalInvoiceNumber = `INV-${Date.now()}`;
}

try {
// Insert invoice into database
const [invoice] = await db
.insert(schema.invoice)
.values({
invoiceNumber: finalInvoiceNumber,
date: invoiceData.date || new Date(),
vendor: invoiceData.vendor!,
totalAmount: invoiceData.totalAmount!.toString(),
})
.returning();

// Insert line items if any
if (invoiceData.lineItems.length > 0) {
await db
.insert(schema.invoiceItem)
.values(
invoiceData.lineItems.map(item => ({
invoiceId: invoice.id,
description: item.description,
quantity: item.quantity.toString(),
unitPrice: item.unitPrice.toString(),
lineTotal: item.lineTotal.toString(),
}))
);
}

// Return success response with created invoice
return NextResponse.json({
message: 'Invoice uploaded and processed successfully',
invoice: {
id: invoice.id,
invoiceNumber: invoice.invoiceNumber,
date: invoice.date,
vendor: invoice.vendor,
totalAmount: parseFloat(invoice.totalAmount),
lineItems: invoiceData.lineItems
}
}, { status: 201 });

} catch (dbError: any) {
console.error('Database error:', dbError);

// Handle duplicate invoice number
if (dbError.code === '23505' && dbError.constraint?.includes('invoice_number_unique')) {
return NextResponse.json(
{ error: 'An invoice with this number already exists.' },
{ status: 409 }
);
}

return NextResponse.json(
{ error: 'Failed to save invoice to database' },
{ status: 500 }
);
}

} catch (error) {
console.error('Upload processing error:', error);
return NextResponse.json(
{ error: 'Internal server error during file processing' },
{ status: 500 }
);
}
}
5 changes: 5 additions & 0 deletions app/invoices/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import InvoiceDashboard from '@/components/InvoiceDashboard';

export default function InvoicesPage() {
return <InvoiceDashboard />;
}
Loading