Preview: invoiceController.js
Size: 25.39 KB
/home/byroehnu/easepay.easetack.com/controllers/invoiceController.js
const { prisma } = require('../config/prisma');
const QRCode = require('qrcode');
const PDFDocument = require('pdfkit');
const fs = require('fs');
const path = require('path');
const invoiceController = {
// Get next invoice number
getNextInvoiceNumber: async (req, res) => {
try {
const userId = req.user.id;
const businessId = req.user.businessId;
let profile;
if (businessId) {
profile = await prisma.businessProfile.findUnique({
where: { businessId }
});
}
// If no profile exists, create default one
if (!profile && businessId) {
profile = await prisma.businessProfile.create({
data: {
businessId,
nextInvoiceNumber: 1
}
});
}
const nextNumber = profile ? profile.nextInvoiceNumber : 1;
const prefix = profile?.invoicePrefix || 'INV';
res.json({
success: true,
data: {
nextInvoiceNumber: `${prefix}-${String(nextNumber).padStart(5, '0')}`
}
});
} catch (error) {
console.error('Error getting next invoice number:', error);
res.status(500).json({
success: false,
message: 'Error getting next invoice number',
error: error.message
});
}
},
// Create new invoice
createInvoice: async (req, res) => {
try {
const userId = req.user.id;
const businessId = req.user.businessId;
const {
customerName,
customerEmail,
customerPhone,
customerAddress,
invoiceNumber,
invoiceDate,
dueDate,
paymentMethod,
items,
subtotal,
discount,
discountType = 'FIXED',
vatRate,
vatAmount,
total,
notes,
currency = 'NGN'
} = req.body;
// Validate required fields
if (!customerName || !items || items.length === 0 || !total) {
return res.status(400).json({
success: false,
message: 'Missing required fields: customerName, items, and total are required'
});
}
// Create invoice
const invoice = await prisma.invoice.create({
data: {
invoiceNumber,
customerName,
customerEmail,
customerPhone,
customerAddress,
businessId,
userId,
invoiceDate: invoiceDate ? new Date(invoiceDate) : new Date(),
dueDate: dueDate ? new Date(dueDate) : null,
paymentMethod,
subtotal: parseFloat(subtotal),
discount: parseFloat(discount) || 0,
discountType,
vatRate: parseFloat(vatRate) || 0,
vatAmount: parseFloat(vatAmount) || 0,
total: parseFloat(total),
notes,
currency,
status: 'CREATED',
items: {
create: items.map(item => ({
itemName: item.name,
description: item.description,
quantity: parseFloat(item.quantity),
unitPrice: parseFloat(item.unitPrice),
total: parseFloat(item.total),
inventoryItemId: item.inventoryItemId || null
}))
}
},
include: {
items: true,
business: true
}
});
// Generate QR Code
const shareUrl = `${process.env.FRONTEND_URL}/invoice/${invoice.id}`;
const qrCodeData = await QRCode.toDataURL(shareUrl);
// Update invoice with QR code
const updatedInvoice = await prisma.invoice.update({
where: { id: invoice.id },
data: {
qrCodeUrl: shareUrl,
qrCodeData,
shareUrl,
downloadUrl: `${process.env.API_URL}/api/invoices/${invoice.id}/pdf`
}
});
// Update next invoice number
if (businessId) {
const profile = await prisma.businessProfile.findUnique({
where: { businessId }
});
if (profile) {
await prisma.businessProfile.update({
where: { businessId },
data: {
nextInvoiceNumber: profile.nextInvoiceNumber + 1
}
});
}
}
// Update inventory if items are linked
for (const item of items) {
if (item.inventoryItemId) {
await prisma.inventoryItem.update({
where: { id: item.inventoryItemId },
data: {
stock: {
decrement: parseFloat(item.quantity)
}
}
});
}
}
res.status(201).json({
success: true,
message: 'Invoice created successfully',
data: {
id: updatedInvoice.id,
invoiceNumber: updatedInvoice.invoiceNumber,
status: updatedInvoice.status,
qrCode: updatedInvoice.qrCodeData,
downloadUrl: updatedInvoice.downloadUrl,
shareUrl: updatedInvoice.shareUrl,
total: updatedInvoice.total,
createdAt: updatedInvoice.createdAt
}
});
} catch (error) {
console.error('Error creating invoice:', error);
res.status(500).json({
success: false,
message: 'Error creating invoice',
error: error.message
});
}
},
// Get all invoices
getAllInvoices: async (req, res) => {
try {
const userId = req.user.id;
const businessId = req.user.businessId;
const { page = 1, limit = 10, status, startDate, endDate } = req.query;
const whereClause = {
userId,
...(businessId && { businessId }),
...(status && { status }),
...(startDate && endDate && {
invoiceDate: {
gte: new Date(startDate),
lte: new Date(endDate)
}
})
};
const invoices = await prisma.invoice.findMany({
where: whereClause,
include: {
items: true,
business: true
},
orderBy: { createdAt: 'desc' },
skip: (parseInt(page) - 1) * parseInt(limit),
take: parseInt(limit)
});
const totalInvoices = await prisma.invoice.count({ where: whereClause });
res.json({
success: true,
data: {
invoices,
pagination: {
currentPage: parseInt(page),
totalPages: Math.ceil(totalInvoices / parseInt(limit)),
totalInvoices,
limit: parseInt(limit)
}
}
});
} catch (error) {
console.error('Error getting invoices:', error);
res.status(500).json({
success: false,
message: 'Error getting invoices',
error: error.message
});
}
},
// Get single invoice
getInvoice: async (req, res) => {
try {
const { id } = req.params;
const userId = req.user?.id; // Optional for public invoice view
const invoice = await prisma.invoice.findFirst({
where: {
id,
...(userId && { userId })
},
include: {
items: true,
business: {
include: {
profile: true
}
},
payments: true
}
});
if (!invoice) {
return res.status(404).json({
success: false,
message: 'Invoice not found'
});
}
res.json({
success: true,
data: invoice
});
} catch (error) {
console.error('Error getting invoice:', error);
res.status(500).json({
success: false,
message: 'Error getting invoice',
error: error.message
});
}
},
// Update invoice
updateInvoice: async (req, res) => {
try {
const { id } = req.params;
const userId = req.user.id;
const updateData = req.body;
// Check if invoice exists and belongs to user
const existingInvoice = await prisma.invoice.findFirst({
where: { id, userId }
});
if (!existingInvoice) {
return res.status(404).json({
success: false,
message: 'Invoice not found'
});
}
// Prevent updating paid invoices
if (existingInvoice.status === 'PAID') {
return res.status(400).json({
success: false,
message: 'Cannot update paid invoice'
});
}
const updatedInvoice = await prisma.invoice.update({
where: { id },
data: updateData,
include: {
items: true,
business: true
}
});
res.json({
success: true,
message: 'Invoice updated successfully',
data: updatedInvoice
});
} catch (error) {
console.error('Error updating invoice:', error);
res.status(500).json({
success: false,
message: 'Error updating invoice',
error: error.message
});
}
},
// Delete invoice
deleteInvoice: async (req, res) => {
try {
const { id } = req.params;
const userId = req.user.id;
const invoice = await prisma.invoice.findFirst({
where: { id, userId }
});
if (!invoice) {
return res.status(404).json({
success: false,
message: 'Invoice not found'
});
}
await prisma.invoice.delete({
where: { id }
});
res.json({
success: true,
message: 'Invoice deleted successfully'
});
} catch (error) {
console.error('Error deleting invoice:', error);
res.status(500).json({
success: false,
message: 'Error deleting invoice',
error: error.message
});
}
},
// Generate PDF (Legacy method - kept for backward compatibility)
generateInvoice: async (req, res) => {
try {
const {
senderDetails,
clientDetails,
items,
invoiceNumber,
date,
dueDate,
tax,
total,
notes
} = req.body;
const doc = new PDFDocument({ margin: 50, size: 'A4' });
// Stream the PDF to the response
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename=invoice-${invoiceNumber || 'draft'}.pdf`);
doc.pipe(res);
// --- Header Section ---
generateHeader(doc, senderDetails);
// --- Client & Invoice Info ---
generateCustomerInformation(doc, clientDetails, invoiceNumber, date, dueDate);
// --- Items Table ---
generateInvoiceTable(doc, items, tax, total);
// --- Footer ---
generateFooter(doc, notes);
doc.end();
} catch (error) {
console.error('Error generating invoice:', error);
if (!res.headersSent) {
res.status(500).json({ error: 'Failed to generate invoice' });
}
}
},
// Generate PDF for specific invoice
generatePDF: async (req, res) => {
try {
const { id } = req.params;
const userId = req.user?.id;
const invoice = await prisma.invoice.findFirst({
where: {
id,
...(userId && { userId })
},
include: {
items: true,
business: {
include: {
profile: true
}
}
}
});
if (!invoice) {
return res.status(404).json({
success: false,
message: 'Invoice not found'
});
}
// Create PDF
const doc = new PDFDocument();
let filename = `invoice-${invoice.invoiceNumber}.pdf`;
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
doc.pipe(res);
// PDF Content
doc.fontSize(20).text('INVOICE', 50, 50);
doc.fontSize(12).text(`Invoice #: ${invoice.invoiceNumber}`, 50, 80);
doc.text(`Date: ${invoice.invoiceDate.toDateString()}`, 50, 100);
// Business details
if (invoice.business?.profile) {
const profile = invoice.business.profile;
doc.text(`From: ${profile.registrationName || invoice.business.name}`, 50, 140);
if (profile.email) doc.text(`Email: ${profile.email}`, 50, 160);
if (profile.phone) doc.text(`Phone: ${profile.phone}`, 50, 180);
if (profile.address) doc.text(`Address: ${profile.address}`, 50, 200);
}
// Customer details
doc.text(`Bill To: ${invoice.customerName}`, 300, 140);
if (invoice.customerEmail) doc.text(`Email: ${invoice.customerEmail}`, 300, 160);
if (invoice.customerPhone) doc.text(`Phone: ${invoice.customerPhone}`, 300, 180);
// Items table
let yPosition = 240;
doc.text('Description', 50, yPosition);
doc.text('Qty', 200, yPosition);
doc.text('Price', 250, yPosition);
doc.text('Total', 300, yPosition);
yPosition += 20;
invoice.items.forEach(item => {
doc.text(item.itemName, 50, yPosition);
doc.text(item.quantity.toString(), 200, yPosition);
doc.text(`${invoice.currency} ${item.unitPrice}`, 250, yPosition);
doc.text(`${invoice.currency} ${item.total}`, 300, yPosition);
yPosition += 20;
});
// Totals
yPosition += 20;
doc.text(`Subtotal: ${invoice.currency} ${invoice.subtotal}`, 300, yPosition);
if (invoice.discount > 0) {
yPosition += 20;
doc.text(`Discount: ${invoice.currency} ${invoice.discount}`, 300, yPosition);
}
if (invoice.vatAmount > 0) {
yPosition += 20;
doc.text(`VAT (${invoice.vatRate}%): ${invoice.currency} ${invoice.vatAmount}`, 300, yPosition);
}
yPosition += 20;
doc.fontSize(14).text(`Total: ${invoice.currency} ${invoice.total}`, 300, yPosition);
// Notes
if (invoice.notes) {
yPosition += 40;
doc.fontSize(12).text('Notes:', 50, yPosition);
doc.text(invoice.notes, 50, yPosition + 20);
}
doc.end();
} catch (error) {
console.error('Error generating PDF:', error);
res.status(500).json({
success: false,
message: 'Error generating PDF',
error: error.message
});
}
},
// Send invoice via email
sendInvoiceEmail: async (req, res) => {
try {
const { id } = req.params;
const { recipientEmail, message } = req.body;
const userId = req.user.id;
const invoice = await prisma.invoice.findFirst({
where: { id, userId },
include: {
business: {
include: {
profile: true
}
}
}
});
if (!invoice) {
return res.status(404).json({
success: false,
message: 'Invoice not found'
});
}
// Here you would integrate with your email service
// For now, just update invoice status
await prisma.invoice.update({
where: { id },
data: { status: 'SENT' }
});
res.json({
success: true,
message: 'Invoice sent successfully',
data: {
emailSent: true,
sentAt: new Date()
}
});
} catch (error) {
console.error('Error sending invoice email:', error);
res.status(500).json({
success: false,
message: 'Error sending invoice email',
error: error.message
});
}
},
// Get QR code for invoice
getQRCode: async (req, res) => {
try {
const { id } = req.params;
const userId = req.user?.id;
const invoice = await prisma.invoice.findFirst({
where: {
id,
...(userId && { userId })
}
});
if (!invoice) {
return res.status(404).json({
success: false,
message: 'Invoice not found'
});
}
const qrData = {
qrCodeUrl: invoice.qrCodeUrl,
qrCodeData: invoice.qrCodeData,
paymentUrl: invoice.shareUrl
};
res.json({
success: true,
data: qrData
});
} catch (error) {
console.error('Error getting QR code:', error);
res.status(500).json({
success: false,
message: 'Error getting QR code',
error: error.message
});
}
},
// Get invoice analytics
getInvoiceAnalytics: async (req, res) => {
try {
const userId = req.user.id;
const businessId = req.user.businessId;
const { period = 'month' } = req.query;
let dateFilter = {};
const now = new Date();
switch (period) {
case 'week':
dateFilter = {
gte: new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7)
};
break;
case 'month':
dateFilter = {
gte: new Date(now.getFullYear(), now.getMonth(), 1)
};
break;
case 'year':
dateFilter = {
gte: new Date(now.getFullYear(), 0, 1)
};
break;
}
const whereClause = {
userId,
...(businessId && { businessId }),
...(Object.keys(dateFilter).length && { invoiceDate: dateFilter })
};
const analytics = await prisma.invoice.aggregate({
where: whereClause,
_count: { id: true },
_sum: { total: true, paidAmount: true }
});
const statusBreakdown = await prisma.invoice.groupBy({
by: ['status'],
where: whereClause,
_count: { status: true },
_sum: { total: true }
});
res.json({
success: true,
data: {
totalInvoices: analytics._count.id,
totalValue: analytics._sum.total || 0,
totalPaid: analytics._sum.paidAmount || 0,
statusBreakdown,
period
}
});
} catch (error) {
console.error('Error getting invoice analytics:', error);
res.status(500).json({
success: false,
message: 'Error getting invoice analytics',
error: error.message
});
}
}
};
function generateHeader(doc, sender) {
// doc.image('logo.png', 50, 45, { width: 50 })
// .fillColor('#444444')
// .fontSize(20)
// .text(sender.name || 'Company Name', 110, 57)
// .fontSize(10)
// .text(sender.address || '', 200, 65, { align: 'right' })
// .text(sender.city || '', 200, 80, { align: 'right' })
// .moveDown();
doc
.fillColor('#444444')
.fontSize(20)
.text(sender?.name || 'EasePay', 50, 57)
.fontSize(10)
.text(sender?.name || 'EasePay Inc.', 200, 50, { align: 'right' })
.text(sender?.address || '123 Business Rd', 200, 65, { align: 'right' })
.text(`${sender?.city || 'City'}, ${sender?.country || 'Country'}`, 200, 80, { align: 'right' })
.moveDown();
// Draw a line under header
generateHr(doc, 100);
}
function generateCustomerInformation(doc, client, invoiceNumber, date, dueDate) {
doc
.fillColor('#444444')
.fontSize(20)
.text('Invoice', 50, 160);
generateHr(doc, 185);
const customerInformationTop = 200;
doc
.fontSize(10)
.text('Invoice Number:', 50, customerInformationTop)
.font('Helvetica-Bold')
.text(invoiceNumber || 'INV-001', 150, customerInformationTop)
.font('Helvetica')
.text('Invoice Date:', 50, customerInformationTop + 15)
.text(date || new Date().toISOString().split('T')[0], 150, customerInformationTop + 15)
.text('Balance Due:', 50, customerInformationTop + 30)
.text(formatCurrency(0), 150, customerInformationTop + 30) // Assuming paid or partial logic later
.font('Helvetica-Bold')
.text(client?.name || 'Client Name', 300, customerInformationTop)
.font('Helvetica')
.text(client?.address || 'Client Address', 300, customerInformationTop + 15)
.text(
`${client?.city || 'City'}, ${client?.country || 'Country'}`,
300,
customerInformationTop + 30
)
.moveDown();
generateHr(doc, 252);
}
function generateInvoiceTable(doc, items, tax, total) {
let i;
const invoiceTableTop = 330;
doc.font('Helvetica-Bold');
generateTableRow(
doc,
invoiceTableTop,
'Item',
'Description',
'Unit Cost',
'Quantity',
'Line Total'
);
generateHr(doc, invoiceTableTop + 20);
doc.font('Helvetica');
let position = 0;
// Fallback if no items
const invoiceItems = items && items.length > 0 ? items : [
{ name: 'Service A', description: 'Consulting', amount: 100, quantity: 2 },
{ name: 'Service B', description: 'Development', amount: 50, quantity: 5 }
];
for (i = 0; i < invoiceItems.length; i++) {
const item = invoiceItems[i];
position = invoiceTableTop + (i + 1) * 30;
generateTableRow(
doc,
position,
item.name,
item.description,
formatCurrency(item.amount),
item.quantity,
formatCurrency(item.amount * item.quantity)
);
generateHr(doc, position + 20);
}
const subtotalPosition = invoiceTableTop + (invoiceItems.length + 1) * 30;
generateTableRow(
doc,
subtotalPosition,
'',
'',
'Subtotal',
'',
formatCurrency(total || 0) // Should calculate sum if total not provided
);
}
// Helper functions for legacy PDF generation
function generateHeader(doc, sender) {
doc
.fillColor('#444444')
.fontSize(20)
.text(sender?.name || 'Your Company', 50, 57)
.fontSize(10)
.text(sender?.name || 'Your Company', 200, 50, { align: 'right' })
.text(sender?.address || '123 Main Street', 200, 65, { align: 'right' })
.text(`${sender?.city || 'New York'}, ${sender?.state || 'NY'} ${sender?.country || 'USA'}`, 200, 80, { align: 'right' })
.moveDown();
}
function generateCustomerInformation(doc, customer, invoiceNumber, date, dueDate) {
doc
.fillColor('#444444')
.fontSize(20)
.text('Invoice', 50, 160);
generateHr(doc, 185);
const customerInformationTop = 200;
doc
.fontSize(10)
.text('Invoice Number:', 50, customerInformationTop)
.font('Helvetica-Bold')
.text(invoiceNumber || 'INV-001', 150, customerInformationTop)
.font('Helvetica')
.text('Invoice Date:', 50, customerInformationTop + 15)
.text(date || new Date().toLocaleDateString(), 150, customerInformationTop + 15)
.text('Due Date:', 50, customerInformationTop + 30)
.text(dueDate || 'N/A', 150, customerInformationTop + 30)
.font('Helvetica-Bold')
.text('Bill To:', 300, customerInformationTop)
.font('Helvetica')
.text(customer?.name || 'Customer Name', 300, customerInformationTop + 15)
.text(customer?.address || 'Customer Address', 300, customerInformationTop + 30)
.text(`${customer?.city || 'City'}, ${customer?.state || 'State'} ${customer?.country || 'Country'}`, 300, customerInformationTop + 45)
.moveDown();
generateHr(doc, 252);
}
function generateInvoiceTable(doc, items, tax, total) {
let i;
const invoiceTableTop = 330;
doc.font('Helvetica-Bold');
generateTableRow(
doc,
invoiceTableTop,
'Item',
'Description',
'Unit Price',
'Quantity',
'Line Total'
);
generateHr(doc, invoiceTableTop + 20);
doc.font('Helvetica');
for (i = 0; i < (items?.length || 0); i++) {
const item = items[i];
const position = invoiceTableTop + (i + 1) * 30;
generateTableRow(
doc,
position,
item?.name || 'Item',
item?.description || 'Description',
formatCurrency(item?.rate || 0),
item?.quantity || 1,
formatCurrency((item?.rate || 0) * (item?.quantity || 1))
);
generateHr(doc, position + 20);
}
const subtotalPosition = invoiceTableTop + (i + 1) * 30;
generateTableRow(
doc,
subtotalPosition,
'',
'',
'Subtotal',
'',
formatCurrency(total || 0)
);
const taxPosition = subtotalPosition + 20;
generateTableRow(
doc,
taxPosition,
'',
'',
'Tax',
'',
formatCurrency(tax || 0)
);
const duePosition = taxPosition + 25;
doc.font('Helvetica-Bold');
generateTableRow(
doc,
duePosition,
'',
'',
'Total Due',
'',
formatCurrency((total || 0) + (tax || 0))
);
doc.font('Helvetica');
}
function generateFooter(doc, notes) {
doc
.fontSize(10)
.text(
notes || 'Payment is due within 15 days. Thank you for your business.',
50,
780,
{ align: 'center', width: 500 }
);
}
function generateTableRow(
doc,
y,
item,
description,
unitCost,
quantity,
lineTotal
) {
doc
.fontSize(10)
.text(item, 50, y)
.text(description, 150, y)
.text(unitCost, 280, y, { width: 90, align: 'right' })
.text(quantity, 370, y, { width: 90, align: 'right' })
.text(lineTotal, 0, y, { align: 'right' });
}
function generateHr(doc, y) {
doc
.strokeColor('#aaaaaa')
.lineWidth(1)
.moveTo(50, y)
.lineTo(550, y)
.stroke();
}
function formatCurrency(cents) {
return "₦" + (cents).toFixed(2);
}
module.exports = invoiceController;
Directory Contents
Dirs: 1 × Files: 17