1284 lines
52 KiB
JavaScript
1284 lines
52 KiB
JavaScript
/**
|
||
* Invoice Routes
|
||
* Handles invoice CRUD operations, QBO sync, and PDF generation
|
||
*/
|
||
const express = require('express');
|
||
const router = express.Router();
|
||
const path = require('path');
|
||
const fs = require('fs').promises;
|
||
const { pool } = require('../config/database');
|
||
const { getNextInvoiceNumber } = require('../utils/numberGenerators');
|
||
const { formatDate, formatMoney } = require('../utils/helpers');
|
||
const { getBrowser, generatePdfFromHtml, getLogoHtml, renderInvoiceItems, formatAddressLines } = require('../services/pdf-service');
|
||
const { exportInvoiceToQbo, syncInvoiceToQbo } = require('../services/qbo-service');
|
||
const { getOAuthClient, getQboBaseUrl, makeQboApiCall } = require('../config/qbo');
|
||
const { sendInvoiceEmail } = require('../services/email-service');
|
||
const { createPaymentLink, checkPaymentStatus, deactivatePaymentLink } = require('../services/stripe-service');
|
||
const { recordStripePaymentInQbo } = require('../services/qbo-service');
|
||
|
||
function calculateNextRecurringDate(invoiceDate, interval) {
|
||
const d = new Date(invoiceDate);
|
||
if (interval === 'monthly') {
|
||
d.setMonth(d.getMonth() + 1);
|
||
} else if (interval === 'yearly') {
|
||
d.setFullYear(d.getFullYear() + 1);
|
||
}
|
||
return d.toISOString().split('T')[0];
|
||
}
|
||
/**
|
||
* Build HTML block for Stripe Payment Link on PDF invoice.
|
||
* Returns empty string if no link or invoice is already paid.
|
||
*/
|
||
function buildPaymentLinkHtml(invoice) {
|
||
if (!invoice.stripe_payment_link_url) return '';
|
||
if (invoice.paid_date || invoice.stripe_payment_status === 'paid') return '';
|
||
|
||
return `
|
||
<div style="margin-top: 30px; padding: 16px 20px; border: 2px solid #635bff; border-radius: 8px; text-align: center;">
|
||
<p style="font-size: 14px; font-weight: bold; color: #635bff; margin: 0 0 8px 0;">
|
||
Pay Online — Credit Card or ACH
|
||
</p>
|
||
<a href="${invoice.stripe_payment_link_url}"
|
||
style="font-size: 13px; color: #635bff; word-break: break-all;">
|
||
${invoice.stripe_payment_link_url}
|
||
</a>
|
||
<p style="font-size: 11px; color: #888; margin: 8px 0 0 0;">
|
||
Secure payment powered by Stripe. ACH payments incur lower processing fees.
|
||
</p>
|
||
</div>`;
|
||
}
|
||
// GET all invoices
|
||
router.get('/', async (req, res) => {
|
||
try {
|
||
const { has_parts, empty_cost_only } = req.query;
|
||
|
||
let whereClauses = [];
|
||
if (has_parts === 'true') {
|
||
const costCondition = empty_cost_only === 'true'
|
||
? `AND (ii.unit_cost IS NULL OR ii.unit_cost = '')`
|
||
: '';
|
||
whereClauses.push(`EXISTS (
|
||
SELECT 1 FROM invoice_items ii
|
||
WHERE ii.invoice_id = i.id
|
||
AND ii.qbo_item_id = '9'
|
||
${costCondition}
|
||
)`);
|
||
}
|
||
const whereSQL = whereClauses.length > 0 ? 'WHERE ' + whereClauses.join(' AND ') : '';
|
||
|
||
const result = await pool.query(`
|
||
SELECT i.*, c.name as customer_name, c.qbo_id as customer_qbo_id,
|
||
COALESCE((SELECT SUM(pi.amount) FROM payment_invoices pi WHERE pi.invoice_id = i.id), 0) as amount_paid
|
||
FROM invoices i
|
||
LEFT JOIN customers c ON i.customer_id = c.id
|
||
${whereSQL}
|
||
ORDER BY i.created_at DESC
|
||
`);
|
||
const rows = result.rows.map(r => ({
|
||
...r,
|
||
amount_paid: parseFloat(r.amount_paid) || 0,
|
||
balance: (parseFloat(r.total) || 0) - (parseFloat(r.amount_paid) || 0)
|
||
}));
|
||
res.json(rows);
|
||
} catch (error) {
|
||
console.error('Error fetching invoices:', error);
|
||
res.status(500).json({ error: 'Error fetching invoices' });
|
||
}
|
||
});
|
||
|
||
// GET next invoice number
|
||
router.get('/next-number', async (req, res) => {
|
||
try {
|
||
const nextNumber = await getNextInvoiceNumber();
|
||
res.json({ next_number: nextNumber });
|
||
} catch (error) {
|
||
console.error('Error getting next invoice number:', error);
|
||
res.status(500).json({ error: 'Error getting next invoice number' });
|
||
}
|
||
});
|
||
|
||
// GET single invoice
|
||
router.get('/:id', async (req, res) => {
|
||
const { id } = req.params;
|
||
try {
|
||
const invoiceResult = await pool.query(`
|
||
SELECT
|
||
i.*,
|
||
c.name as customer_name,
|
||
c.qbo_id as customer_qbo_id,
|
||
c.email,
|
||
c.secondary_email,
|
||
c.line1,
|
||
c.line2,
|
||
c.line3,
|
||
c.line4,
|
||
c.city,
|
||
c.state,
|
||
c.zip_code,
|
||
c.account_number,
|
||
src.invoice_number AS recurring_source_invoice_number,
|
||
COALESCE((
|
||
SELECT SUM(pi.amount)
|
||
FROM payment_invoices pi
|
||
WHERE pi.invoice_id = i.id
|
||
), 0) as amount_paid
|
||
FROM invoices i
|
||
LEFT JOIN customers c
|
||
ON i.customer_id = c.id
|
||
LEFT JOIN invoices src
|
||
ON src.id = i.recurring_source_id
|
||
WHERE i.id = $1
|
||
`, [id]);
|
||
|
||
if (invoiceResult.rows.length === 0) {
|
||
return res.status(404).json({ error: 'Invoice not found' });
|
||
}
|
||
|
||
const invoice = invoiceResult.rows[0];
|
||
invoice.amount_paid = parseFloat(invoice.amount_paid) || 0;
|
||
invoice.balance = (parseFloat(invoice.total) || 0) - invoice.amount_paid;
|
||
|
||
const itemsResult = await pool.query(
|
||
'SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY item_order',
|
||
[id]
|
||
);
|
||
|
||
res.json({ invoice, items: itemsResult.rows });
|
||
} catch (error) {
|
||
console.error('Error fetching invoice:', error);
|
||
res.status(500).json({ error: 'Error fetching invoice' });
|
||
}
|
||
});
|
||
|
||
// POST create invoice
|
||
router.post('/', async (req, res) => {
|
||
const { invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, items, scheduled_send_date, bill_to_name, created_from_quote_id, is_recurring, recurring_interval, worker } = req.body;
|
||
|
||
const client = await pool.connect();
|
||
try {
|
||
await client.query('BEGIN');
|
||
|
||
// Validate invoice_number if provided
|
||
if (invoice_number && !/^\d+$/.test(invoice_number)) {
|
||
await client.query('ROLLBACK');
|
||
return res.status(400).json({ error: 'Invoice number must be numeric.' });
|
||
}
|
||
|
||
const tempNumber = invoice_number || `DRAFT-${Date.now()}`;
|
||
|
||
if (invoice_number) {
|
||
const existing = await client.query('SELECT id FROM invoices WHERE invoice_number = $1', [invoice_number]);
|
||
if (existing.rows.length > 0) {
|
||
await client.query('ROLLBACK');
|
||
return res.status(400).json({ error: `Invoice number ${invoice_number} already exists.` });
|
||
}
|
||
}
|
||
// Validate items
|
||
if (!Array.isArray(items) || items.length === 0) {
|
||
await client.query('ROLLBACK');
|
||
return res.status(400).json({ error: 'Please add at least one item.' });
|
||
}
|
||
|
||
// Recurring invoices must contain at least one Subscription item
|
||
if (is_recurring) {
|
||
const subscriptionItems = items.filter(item => String(item.qbo_item_id) === '115');
|
||
|
||
if (subscriptionItems.length === 0) {
|
||
await client.query('ROLLBACK');
|
||
return res.status(400).json({
|
||
error: 'Recurring invoices must contain at least one Subscription item.'
|
||
});
|
||
}
|
||
}
|
||
let subtotal = 0;
|
||
for (const item of items) {
|
||
const amount = parseFloat(item.amount.replace(/[$,]/g, ''));
|
||
if (!isNaN(amount)) subtotal += amount;
|
||
}
|
||
|
||
const tax_rate = 8.25;
|
||
const tax_amount = tax_exempt ? 0 : (subtotal * tax_rate / 100);
|
||
const total = subtotal + tax_amount;
|
||
const next_recurring_date = is_recurring ? calculateNextRecurringDate(invoice_date, recurring_interval) : null;
|
||
const invoiceResult = await client.query(
|
||
`INSERT INTO invoices (invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, scheduled_send_date, bill_to_name, created_from_quote_id, is_recurring, recurring_interval, next_recurring_date, worker)
|
||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) RETURNING *`,
|
||
[tempNumber, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, scheduled_send_date || null, bill_to_name || null, created_from_quote_id, is_recurring || false, recurring_interval || null, next_recurring_date, worker || null]
|
||
);
|
||
const invoiceId = invoiceResult.rows[0].id;
|
||
|
||
for (let i = 0; i < items.length; i++) {
|
||
await client.query(
|
||
'INSERT INTO invoice_items (invoice_id, quantity, description, rate, amount, item_order, qbo_item_id, unit_cost) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)',
|
||
[invoiceId, items[i].quantity, items[i].description, items[i].rate, items[i].amount, i, items[i].qbo_item_id || '9', items[i].unit_cost || null]
|
||
);
|
||
}
|
||
|
||
await client.query('COMMIT');
|
||
|
||
// Auto QBO Export
|
||
let qboResult = null;
|
||
try {
|
||
qboResult = await exportInvoiceToQbo(invoiceId, client);
|
||
if (qboResult.skipped) {
|
||
console.log(`ℹ️ Invoice ${invoiceId} not exported to QBO: ${qboResult.reason}`);
|
||
}
|
||
} catch (qboErr) {
|
||
console.error(`⚠️ Auto QBO export failed for Invoice ${invoiceId}:`, qboErr.message);
|
||
}
|
||
|
||
res.json({
|
||
...invoiceResult.rows[0],
|
||
qbo_id: qboResult?.qbo_id || null,
|
||
qbo_doc_number: qboResult?.qbo_doc_number || null
|
||
});
|
||
|
||
} catch (error) {
|
||
await client.query('ROLLBACK');
|
||
console.error('Error creating invoice:', error);
|
||
res.status(500).json({ error: 'Error creating invoice' });
|
||
} finally {
|
||
client.release();
|
||
}
|
||
});
|
||
|
||
// PUT update invoice
|
||
router.put('/:id', async (req, res) => {
|
||
const { id } = req.params;
|
||
const { invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, items, scheduled_send_date, bill_to_name, is_recurring, recurring_interval, worker } = req.body;
|
||
const client = await pool.connect();
|
||
try {
|
||
await client.query('BEGIN');
|
||
|
||
// Validate invoice_number if provided
|
||
if (invoice_number && !/^\d+$/.test(invoice_number)) {
|
||
await client.query('ROLLBACK');
|
||
return res.status(400).json({ error: 'Invoice number must be numeric.' });
|
||
}
|
||
|
||
if (invoice_number) {
|
||
const existing = await client.query('SELECT id FROM invoices WHERE invoice_number = $1 AND id != $2', [invoice_number, id]);
|
||
if (existing.rows.length > 0) {
|
||
await client.query('ROLLBACK');
|
||
return res.status(400).json({ error: `Invoice number ${invoice_number} already exists.` });
|
||
}
|
||
}
|
||
// Validate items
|
||
if (!Array.isArray(items) || items.length === 0) {
|
||
await client.query('ROLLBACK');
|
||
return res.status(400).json({ error: 'Please add at least one item.' });
|
||
}
|
||
|
||
// Recurring invoices must contain at least one Subscription item
|
||
if (is_recurring) {
|
||
const subscriptionItems = items.filter(item => String(item.qbo_item_id) === '115');
|
||
|
||
if (subscriptionItems.length === 0) {
|
||
await client.query('ROLLBACK');
|
||
return res.status(400).json({
|
||
error: 'Recurring invoices must contain at least one Subscription item.'
|
||
});
|
||
}
|
||
}
|
||
let subtotal = 0;
|
||
for (const item of items) {
|
||
const amount = parseFloat(item.amount.replace(/[$,]/g, ''));
|
||
if (!isNaN(amount)) subtotal += amount;
|
||
}
|
||
|
||
const tax_rate = 8.25;
|
||
const tax_amount = tax_exempt ? 0 : (subtotal * tax_rate / 100);
|
||
const total = subtotal + tax_amount;
|
||
|
||
// Update local
|
||
if (invoice_number) {
|
||
await client.query(
|
||
`UPDATE invoices SET invoice_number = $1, customer_id = $2, invoice_date = $3, terms = $4, auth_code = $5, tax_exempt = $6,
|
||
tax_rate = $7, subtotal = $8, tax_amount = $9, total = $10, scheduled_send_date = $11, bill_to_name = $12, worker = $13, updated_at = CURRENT_TIMESTAMP
|
||
WHERE id = $14`,
|
||
[invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, scheduled_send_date || null, bill_to_name || null, worker || null, id]
|
||
);
|
||
} else {
|
||
await client.query(
|
||
`UPDATE invoices SET customer_id = $1, invoice_date = $2, terms = $3, auth_code = $4, tax_exempt = $5,
|
||
tax_rate = $6, subtotal = $7, tax_amount = $8, total = $9, scheduled_send_date = $10, bill_to_name = $11, worker = $12, updated_at = CURRENT_TIMESTAMP
|
||
WHERE id = $13`,
|
||
[customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, scheduled_send_date || null, bill_to_name || null, worker || null, id]
|
||
);
|
||
}
|
||
|
||
// Preserve existing next_recurring_date when editing an already-recurring invoice.
|
||
// Otherwise editing an old invoice can move next_recurring_date backwards and create duplicates.
|
||
const existingRecurringResult = await client.query(
|
||
'SELECT is_recurring, next_recurring_date, recurring_source_id FROM invoices WHERE id = $1',
|
||
[id]
|
||
);
|
||
|
||
const existingRecurring = existingRecurringResult.rows[0];
|
||
|
||
let next_recurring_date = null;
|
||
|
||
// Automatically generated child invoices should not become recurring masters.
|
||
// recurring_source_id != null means: this invoice was created from another recurring invoice.
|
||
const isGeneratedRecurringChild = !!existingRecurring?.recurring_source_id;
|
||
|
||
if (isGeneratedRecurringChild) {
|
||
next_recurring_date = null;
|
||
} else if (is_recurring) {
|
||
next_recurring_date = existingRecurring?.next_recurring_date
|
||
? existingRecurring.next_recurring_date
|
||
: calculateNextRecurringDate(invoice_date, recurring_interval);
|
||
}
|
||
|
||
await client.query(
|
||
`
|
||
UPDATE invoices
|
||
SET is_recurring = $1,
|
||
recurring_interval = $2,
|
||
next_recurring_date = $3
|
||
WHERE id = $4
|
||
`,
|
||
[
|
||
isGeneratedRecurringChild ? false : (is_recurring || false),
|
||
isGeneratedRecurringChild ? null : (recurring_interval || null),
|
||
next_recurring_date,
|
||
id
|
||
]
|
||
);
|
||
|
||
// Delete and re-insert items
|
||
await client.query('DELETE FROM invoice_items WHERE invoice_id = $1', [id]);
|
||
for (let i = 0; i < items.length; i++) {
|
||
await client.query(
|
||
'INSERT INTO invoice_items (invoice_id, quantity, description, rate, amount, item_order, qbo_item_id, unit_cost) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)',
|
||
[id, items[i].quantity, items[i].description, items[i].rate, items[i].amount, i, items[i].qbo_item_id || '9', items[i].unit_cost || null]
|
||
);
|
||
}
|
||
|
||
await client.query('COMMIT');
|
||
|
||
// Auto QBO: Export if not yet in QBO, Sync if already in QBO
|
||
let qboResult = null;
|
||
try {
|
||
const checkRes = await client.query('SELECT qbo_id FROM invoices WHERE id = $1', [id]);
|
||
const hasQboId = !!checkRes.rows[0]?.qbo_id;
|
||
|
||
if (hasQboId) {
|
||
qboResult = await syncInvoiceToQbo(id, client);
|
||
} else {
|
||
qboResult = await exportInvoiceToQbo(id, client);
|
||
}
|
||
|
||
if (qboResult.skipped) {
|
||
console.log(`ℹ️ Invoice ${id}: ${qboResult.reason}`);
|
||
}
|
||
} catch (qboErr) {
|
||
console.error(`⚠️ Auto QBO failed for Invoice ${id}:`, qboErr.message);
|
||
}
|
||
|
||
res.json({ success: true, qbo_synced: !!qboResult?.success, qbo_id: qboResult?.qbo_id || null, qbo_doc_number: qboResult?.qbo_doc_number || null });
|
||
|
||
} catch (error) {
|
||
await client.query('ROLLBACK');
|
||
console.error('Error updating invoice:', error);
|
||
res.status(500).json({ error: 'Error updating invoice' });
|
||
} finally {
|
||
client.release();
|
||
}
|
||
});
|
||
|
||
// DELETE invoice
|
||
router.delete('/:id', async (req, res) => {
|
||
const { id } = req.params;
|
||
const client = await pool.connect();
|
||
try {
|
||
await client.query('BEGIN');
|
||
|
||
// Load invoice to check qbo_id
|
||
const invResult = await client.query('SELECT qbo_id, qbo_sync_token, invoice_number FROM invoices WHERE id = $1', [id]);
|
||
if (invResult.rows.length === 0) {
|
||
await client.query('ROLLBACK');
|
||
return res.status(404).json({ error: 'Invoice not found' });
|
||
}
|
||
|
||
const invoice = invResult.rows[0];
|
||
|
||
// Delete in QBO if present
|
||
if (invoice.qbo_id) {
|
||
try {
|
||
const oauthClient = getOAuthClient();
|
||
const companyId = oauthClient.getToken().realmId;
|
||
const baseUrl = getQboBaseUrl();
|
||
|
||
const qboRes = await makeQboApiCall({
|
||
url: `${baseUrl}/v3/company/${companyId}/invoice/${invoice.qbo_id}`,
|
||
method: 'GET'
|
||
});
|
||
const qboData = qboRes.getJson ? qboRes.getJson() : qboRes.json;
|
||
const syncToken = qboData.Invoice?.SyncToken;
|
||
|
||
if (syncToken !== undefined) {
|
||
console.log(`🗑️ Voiding QBO Invoice ${invoice.qbo_id} (DocNumber: ${invoice.invoice_number})...`);
|
||
|
||
await makeQboApiCall({
|
||
url: `${baseUrl}/v3/company/${companyId}/invoice?operation=void`,
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
Id: invoice.qbo_id,
|
||
SyncToken: syncToken
|
||
})
|
||
});
|
||
|
||
console.log(`✅ QBO Invoice ${invoice.qbo_id} voided.`);
|
||
}
|
||
} catch (qboError) {
|
||
console.error(`⚠️ QBO void failed for Invoice ${invoice.qbo_id}:`, qboError.message);
|
||
}
|
||
}
|
||
|
||
// Delete locally
|
||
await client.query('DELETE FROM invoice_items WHERE invoice_id = $1', [id]);
|
||
await client.query('DELETE FROM payment_invoices WHERE invoice_id = $1', [id]);
|
||
await client.query('DELETE FROM invoices WHERE id = $1', [id]);
|
||
await client.query('COMMIT');
|
||
|
||
res.json({ success: true });
|
||
} catch (error) {
|
||
await client.query('ROLLBACK');
|
||
console.error('Error deleting invoice:', error);
|
||
res.status(500).json({ error: 'Error deleting invoice' });
|
||
} finally {
|
||
client.release();
|
||
}
|
||
});
|
||
|
||
// PATCH invoice email status
|
||
router.patch('/:id/email-status', async (req, res) => {
|
||
const { id } = req.params;
|
||
const { status } = req.body;
|
||
|
||
if (!['sent', 'open'].includes(status)) {
|
||
return res.status(400).json({ error: 'Status must be "sent" or "open".' });
|
||
}
|
||
|
||
try {
|
||
const invResult = await pool.query('SELECT qbo_id FROM invoices WHERE id = $1', [id]);
|
||
if (invResult.rows.length === 0) return res.status(404).json({ error: 'Invoice not found' });
|
||
|
||
const invoice = invResult.rows[0];
|
||
|
||
// Update QBO if present
|
||
if (invoice.qbo_id) {
|
||
const oauthClient = getOAuthClient();
|
||
const companyId = oauthClient.getToken().realmId;
|
||
const baseUrl = getQboBaseUrl();
|
||
|
||
const qboRes = await makeQboApiCall({
|
||
url: `${baseUrl}/v3/company/${companyId}/invoice/${invoice.qbo_id}`,
|
||
method: 'GET'
|
||
});
|
||
const qboData = qboRes.getJson ? qboRes.getJson() : qboRes.json;
|
||
const syncToken = qboData.Invoice?.SyncToken;
|
||
|
||
if (syncToken !== undefined) {
|
||
const emailStatus = status === 'sent' ? 'EmailSent' : 'NotSet';
|
||
|
||
await makeQboApiCall({
|
||
url: `${baseUrl}/v3/company/${companyId}/invoice`,
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
Id: invoice.qbo_id,
|
||
SyncToken: syncToken,
|
||
sparse: true,
|
||
EmailStatus: emailStatus
|
||
})
|
||
});
|
||
|
||
console.log(`✅ QBO Invoice ${invoice.qbo_id} email status → ${emailStatus}`);
|
||
}
|
||
}
|
||
|
||
// Update local
|
||
await pool.query(
|
||
'UPDATE invoices SET email_status = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
|
||
[status, id]
|
||
);
|
||
|
||
res.json({ success: true, status });
|
||
|
||
} catch (error) {
|
||
console.error('Error updating email status:', error);
|
||
res.status(500).json({ error: 'Failed to update status: ' + error.message });
|
||
}
|
||
});
|
||
|
||
// PATCH mark invoice as paid
|
||
router.patch('/:id/mark-paid', async (req, res) => {
|
||
const { id } = req.params;
|
||
const { paid_date } = req.body;
|
||
|
||
try {
|
||
const dateToUse = paid_date || new Date().toISOString().split('T')[0];
|
||
const result = await pool.query(
|
||
'UPDATE invoices SET paid_date = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2 RETURNING *',
|
||
[dateToUse, id]
|
||
);
|
||
|
||
if (result.rows.length === 0) {
|
||
return res.status(404).json({ error: 'Invoice not found' });
|
||
}
|
||
|
||
console.log(`💰 Invoice #${result.rows[0].invoice_number} als bezahlt markiert (${dateToUse})`);
|
||
res.json(result.rows[0]);
|
||
} catch (error) {
|
||
console.error('Error marking invoice as paid:', error);
|
||
res.status(500).json({ error: 'Error marking invoice as paid' });
|
||
}
|
||
});
|
||
|
||
// PATCH mark invoice as unpaid
|
||
router.patch('/:id/mark-unpaid', async (req, res) => {
|
||
const { id } = req.params;
|
||
|
||
try {
|
||
const result = await pool.query(
|
||
'UPDATE invoices SET paid_date = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = $1 RETURNING *',
|
||
[id]
|
||
);
|
||
|
||
if (result.rows.length === 0) {
|
||
return res.status(404).json({ error: 'Invoice not found' });
|
||
}
|
||
|
||
console.log(`↩️ Invoice #${result.rows[0].invoice_number} als unbezahlt markiert`);
|
||
res.json(result.rows[0]);
|
||
} catch (error) {
|
||
console.error('Error marking invoice as unpaid:', error);
|
||
res.status(500).json({ error: 'Error marking invoice as unpaid' });
|
||
}
|
||
});
|
||
|
||
// PATCH reset QBO link
|
||
router.patch('/:id/reset-qbo', async (req, res) => {
|
||
const { id } = req.params;
|
||
|
||
try {
|
||
const result = await pool.query(
|
||
`UPDATE invoices
|
||
SET qbo_id = NULL, qbo_sync_token = NULL, qbo_doc_number = NULL, invoice_number = NULL,
|
||
updated_at = CURRENT_TIMESTAMP
|
||
WHERE id = $1 RETURNING *`,
|
||
[id]
|
||
);
|
||
|
||
if (result.rows.length === 0) {
|
||
return res.status(404).json({ error: 'Invoice not found' });
|
||
}
|
||
|
||
console.log(`🔄 Invoice ID ${id} QBO-Verknüpfung zurückgesetzt`);
|
||
res.json(result.rows[0]);
|
||
} catch (error) {
|
||
console.error('Error resetting QBO link:', error);
|
||
res.status(500).json({ error: 'Error resetting QBO link' });
|
||
}
|
||
});
|
||
|
||
// POST export to QBO
|
||
router.post('/:id/export', async (req, res) => {
|
||
const { id } = req.params;
|
||
|
||
const client = await pool.connect();
|
||
try {
|
||
const invoiceRes = await client.query(`
|
||
SELECT i.*, c.qbo_id as customer_qbo_id, c.name as customer_name, c.email
|
||
FROM invoices i
|
||
LEFT JOIN customers c ON i.customer_id = c.id
|
||
WHERE i.id = $1
|
||
`, [id]);
|
||
|
||
if (invoiceRes.rows.length === 0) return res.status(404).json({ error: 'Invoice not found' });
|
||
const invoice = invoiceRes.rows[0];
|
||
|
||
if (!invoice.customer_qbo_id) {
|
||
return res.status(400).json({ error: `Kunde "${invoice.customer_name}" ist noch nicht mit QBO verknüpft.` });
|
||
}
|
||
|
||
const itemsRes = await client.query('SELECT * FROM invoice_items WHERE invoice_id = $1', [id]);
|
||
const items = itemsRes.rows;
|
||
|
||
const oauthClient = getOAuthClient();
|
||
const companyId = oauthClient.getToken().realmId;
|
||
const baseUrl = getQboBaseUrl();
|
||
|
||
const maxNumResult = await client.query(`
|
||
SELECT GREATEST(
|
||
COALESCE((SELECT MAX(CAST(qbo_doc_number AS INTEGER)) FROM invoices WHERE qbo_doc_number ~ '^[0-9]+$'), 0),
|
||
COALESCE((SELECT MAX(CAST(invoice_number AS INTEGER)) FROM invoices WHERE invoice_number ~ '^[0-9]+$'), 0)
|
||
) as max_num
|
||
`);
|
||
let nextDocNumber = (parseInt(maxNumResult.rows[0].max_num) + 1).toString();
|
||
|
||
const lineItems = items.map(item => {
|
||
const rate = parseFloat(item.rate.replace(/[^0-9.]/g, '')) || 0;
|
||
const amount = parseFloat(item.amount.replace(/[^0-9.]/g, '')) || 0;
|
||
const itemRefId = item.qbo_item_id || '9';
|
||
const itemRefName = itemRefId == QBO_LABOR_ID ? "Labor:Labor"
|
||
: itemRefId == '115' ? "Subscription"
|
||
: "Parts:Parts";
|
||
|
||
return {
|
||
"DetailType": "SalesItemLineDetail",
|
||
"Amount": amount,
|
||
"Description": item.description,
|
||
"SalesItemLineDetail": {
|
||
"ItemRef": { "value": itemRefId, "name": itemRefName },
|
||
"UnitPrice": rate,
|
||
"Qty": parseFloat(item.quantity) || 1
|
||
}
|
||
};
|
||
});
|
||
|
||
const qboInvoicePayload = {
|
||
"CustomerRef": { "value": invoice.customer_qbo_id },
|
||
"DocNumber": nextDocNumber,
|
||
"TxnDate": invoice.invoice_date.toISOString().split('T')[0],
|
||
"Line": lineItems,
|
||
"CustomerMemo": { "value": invoice.auth_code ? `Auth: ${invoice.auth_code}` : "" },
|
||
"EmailStatus": "EmailSent",
|
||
"BillEmail": { "Address": invoice.email || "" }
|
||
};
|
||
|
||
let qboInvoice = null;
|
||
const MAX_RETRIES = 5;
|
||
|
||
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
||
console.log(`📤 Sende Rechnung an QBO (DocNumber: ${qboInvoicePayload.DocNumber})...`);
|
||
|
||
const createResponse = await makeQboApiCall({
|
||
url: `${baseUrl}/v3/company/${companyId}/invoice`,
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(qboInvoicePayload)
|
||
});
|
||
|
||
const responseData = createResponse.getJson ? createResponse.getJson() : createResponse.json;
|
||
|
||
if (responseData.Fault?.Error?.[0]?.code === '6140') {
|
||
const oldNum = parseInt(qboInvoicePayload.DocNumber);
|
||
qboInvoicePayload.DocNumber = (oldNum + 1).toString();
|
||
console.log(`⚠️ DocNumber ${oldNum} existiert bereits. Versuche ${qboInvoicePayload.DocNumber}...`);
|
||
continue;
|
||
}
|
||
|
||
qboInvoice = responseData.Invoice || responseData;
|
||
|
||
if (qboInvoice.Id) {
|
||
break;
|
||
} else {
|
||
console.error("FULL RESPONSE DUMP:", JSON.stringify(responseData, null, 2));
|
||
throw new Error("QBO hat keine ID zurückgegeben: " +
|
||
(responseData.Fault?.Error?.[0]?.Message || JSON.stringify(responseData)));
|
||
}
|
||
}
|
||
|
||
if (!qboInvoice || !qboInvoice.Id) {
|
||
throw new Error(`Konnte nach ${MAX_RETRIES} Versuchen keine freie DocNumber finden.`);
|
||
}
|
||
|
||
console.log(`✅ QBO Rechnung erstellt! ID: ${qboInvoice.Id}, DocNumber: ${qboInvoice.DocNumber}`);
|
||
|
||
await client.query(
|
||
`UPDATE invoices SET qbo_id = $1, qbo_sync_token = $2, qbo_doc_number = $3, invoice_number = $4 WHERE id = $5`,
|
||
[qboInvoice.Id, qboInvoice.SyncToken, qboInvoice.DocNumber, qboInvoice.DocNumber, id]
|
||
);
|
||
|
||
res.json({ success: true, qbo_id: qboInvoice.Id, qbo_doc_number: qboInvoice.DocNumber });
|
||
|
||
} catch (error) {
|
||
console.error("QBO Export Error:", error);
|
||
let errorDetails = error.message;
|
||
if (error.response?.data?.Fault?.Error?.[0]) {
|
||
errorDetails = error.response.data.Fault.Error[0].Message + ": " + error.response.data.Fault.Error[0].Detail;
|
||
}
|
||
res.status(500).json({ error: "QBO Export failed: " + errorDetails });
|
||
} finally {
|
||
client.release();
|
||
}
|
||
});
|
||
|
||
// POST update in QBO
|
||
router.post('/:id/update-qbo', async (req, res) => {
|
||
const { id } = req.params;
|
||
const QBO_LABOR_ID = '5';
|
||
const QBO_PARTS_ID = '9';
|
||
|
||
const dbClient = await pool.connect();
|
||
try {
|
||
const invoiceRes = await dbClient.query(`
|
||
SELECT i.*, c.qbo_id as customer_qbo_id, c.name as customer_name, c.email
|
||
FROM invoices i
|
||
LEFT JOIN customers c ON i.customer_id = c.id
|
||
WHERE i.id = $1
|
||
`, [id]);
|
||
|
||
if (invoiceRes.rows.length === 0) return res.status(404).json({ error: 'Invoice not found' });
|
||
const invoice = invoiceRes.rows[0];
|
||
|
||
if (!invoice.qbo_id) {
|
||
return res.status(400).json({ error: 'Invoice has not been exported to QBO yet. Use QBO Export first.' });
|
||
}
|
||
if (!invoice.qbo_sync_token && invoice.qbo_sync_token !== '0') {
|
||
return res.status(400).json({ error: 'Missing QBO SyncToken. Try resetting and re-exporting.' });
|
||
}
|
||
|
||
const itemsRes = await dbClient.query('SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY item_order', [id]);
|
||
const items = itemsRes.rows;
|
||
|
||
const oauthClient = getOAuthClient();
|
||
const companyId = oauthClient.getToken().realmId;
|
||
const baseUrl = getQboBaseUrl();
|
||
|
||
console.log(`🔍 Lade aktuelle QBO Invoice ${invoice.qbo_id}...`);
|
||
const currentQboRes = await makeQboApiCall({
|
||
url: `${baseUrl}/v3/company/${companyId}/invoice/${invoice.qbo_id}`,
|
||
method: 'GET'
|
||
});
|
||
const currentQboData = currentQboRes.getJson ? currentQboRes.getJson() : currentQboRes.json;
|
||
const currentQboInvoice = currentQboData.Invoice;
|
||
|
||
if (!currentQboInvoice) {
|
||
return res.status(500).json({ error: 'Could not load current invoice from QBO.' });
|
||
}
|
||
|
||
const currentSyncToken = currentQboInvoice.SyncToken;
|
||
console.log(` SyncToken: lokal=${invoice.qbo_sync_token}, QBO=${currentSyncToken}`);
|
||
|
||
const lineItems = items.map(item => {
|
||
const rate = parseFloat(item.rate.replace(/[^0-9.]/g, '')) || 0;
|
||
const amount = parseFloat(item.amount.replace(/[^0-9.]/g, '')) || 0;
|
||
const itemRefId = item.qbo_item_id || QBO_PARTS_ID;
|
||
const itemRefName = itemRefId == QBO_LABOR_ID ? "Labor:Labor"
|
||
: itemRefId == '115' ? "Subscription"
|
||
: "Parts:Parts";
|
||
|
||
return {
|
||
"DetailType": "SalesItemLineDetail",
|
||
"Amount": amount,
|
||
"Description": item.description,
|
||
"SalesItemLineDetail": {
|
||
"ItemRef": { "value": itemRefId, "name": itemRefName },
|
||
"UnitPrice": rate,
|
||
"Qty": parseFloat(item.quantity) || 1
|
||
}
|
||
};
|
||
});
|
||
|
||
const updatePayload = {
|
||
"Id": invoice.qbo_id,
|
||
"SyncToken": currentSyncToken,
|
||
"sparse": true,
|
||
"Line": lineItems,
|
||
"CustomerRef": { "value": invoice.customer_qbo_id },
|
||
"TxnDate": invoice.invoice_date.toISOString().split('T')[0],
|
||
"CustomerMemo": { "value": invoice.auth_code ? `Auth: ${invoice.auth_code}` : "" }
|
||
};
|
||
|
||
console.log(`📤 Update QBO Invoice ${invoice.qbo_id} (DocNumber: ${invoice.qbo_doc_number})...`);
|
||
|
||
const updateResponse = await makeQboApiCall({
|
||
url: `${baseUrl}/v3/company/${companyId}/invoice`,
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(updatePayload)
|
||
});
|
||
|
||
const updateData = updateResponse.getJson ? updateResponse.getJson() : updateResponse.json;
|
||
const updatedInvoice = updateData.Invoice || updateData;
|
||
|
||
if (!updatedInvoice.Id) {
|
||
console.error("QBO Update Response:", JSON.stringify(updateData, null, 2));
|
||
throw new Error("QBO did not return an updated invoice.");
|
||
}
|
||
|
||
console.log(`✅ QBO Invoice updated! New SyncToken: ${updatedInvoice.SyncToken}`);
|
||
|
||
await dbClient.query(
|
||
'UPDATE invoices SET qbo_sync_token = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
|
||
[updatedInvoice.SyncToken, id]
|
||
);
|
||
|
||
res.json({
|
||
success: true,
|
||
qbo_id: updatedInvoice.Id,
|
||
sync_token: updatedInvoice.SyncToken,
|
||
message: `Invoice #${invoice.qbo_doc_number || invoice.invoice_number} updated in QBO.`
|
||
});
|
||
|
||
} catch (error) {
|
||
console.error("QBO Update Error:", error);
|
||
let errorDetails = error.message;
|
||
if (error.response?.data?.Fault?.Error?.[0]) {
|
||
errorDetails = error.response.data.Fault.Error[0].Message + ": " + error.response.data.Fault.Error[0].Detail;
|
||
}
|
||
res.status(500).json({ error: "QBO Update failed: " + errorDetails });
|
||
} finally {
|
||
dbClient.release();
|
||
}
|
||
});
|
||
|
||
// GET invoice PDF
|
||
router.get('/:id/pdf', async (req, res) => {
|
||
const { id } = req.params;
|
||
console.log(`[INVOICE-PDF] Starting invoice PDF generation for ID: ${id}`);
|
||
|
||
try {
|
||
const invoiceResult = await pool.query(`
|
||
SELECT i.*, c.name as customer_name, c.line1, c.line2, c.line3, c.line4, c.city, c.state, c.zip_code, c.account_number,
|
||
COALESCE((SELECT SUM(pi.amount) FROM payment_invoices pi WHERE pi.invoice_id = i.id), 0) as amount_paid
|
||
FROM invoices i
|
||
LEFT JOIN customers c ON i.customer_id = c.id
|
||
WHERE i.id = $1
|
||
`, [id]);
|
||
|
||
if (invoiceResult.rows.length === 0) {
|
||
return res.status(404).json({ error: 'Invoice not found' });
|
||
}
|
||
|
||
const invoice = invoiceResult.rows[0];
|
||
const itemsResult = await pool.query(
|
||
'SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY item_order',
|
||
[id]
|
||
);
|
||
|
||
const templatePath = path.join(__dirname, '..', '..', 'templates', 'invoice-template.html');
|
||
let html = await fs.readFile(templatePath, 'utf-8');
|
||
|
||
const logoHTML = await getLogoHtml();
|
||
const itemsHTML = renderInvoiceItems(itemsResult.rows, invoice);
|
||
|
||
const authHTML = invoice.auth_code ? `<p style="margin-bottom: 20px; font-size: 13px;"><strong>Authorization:</strong> ${invoice.auth_code}</p>` : '';
|
||
|
||
const streetBlock = formatAddressLines(invoice.line1, invoice.line2, invoice.line3, invoice.line4, invoice.customer_name);
|
||
|
||
html = html
|
||
.replace('{{LOGO_HTML}}', logoHTML)
|
||
.replace('{{CUSTOMER_NAME}}', invoice.bill_to_name || invoice.customer_name || '')
|
||
.replace('{{CUSTOMER_STREET}}', streetBlock)
|
||
.replace('{{CUSTOMER_CITY}}', invoice.city || '')
|
||
.replace('{{CUSTOMER_STATE}}', invoice.state || '')
|
||
.replace('{{CUSTOMER_ZIP}}', invoice.zip_code || '')
|
||
.replace('{{INVOICE_NUMBER}}', invoice.invoice_number || '')
|
||
.replace('{{ACCOUNT_NUMBER}}', invoice.account_number || '')
|
||
.replace('{{INVOICE_DATE}}', formatDate(invoice.invoice_date))
|
||
.replace('{{TERMS}}', invoice.terms)
|
||
.replace('{{AUTHORIZATION}}', authHTML)
|
||
.replace('{{ITEMS}}', itemsHTML)
|
||
.replace('{{PAYMENT_LINK}}', buildPaymentLinkHtml(invoice));
|
||
|
||
const pdf = await generatePdfFromHtml(html);
|
||
|
||
res.set({
|
||
'Content-Type': 'application/pdf',
|
||
'Content-Length': pdf.length,
|
||
'Content-Disposition': `attachment; filename="Invoice-${invoice.invoice_number}.pdf"`
|
||
});
|
||
res.end(pdf, 'binary');
|
||
console.log('[INVOICE-PDF] Invoice PDF sent successfully');
|
||
|
||
} catch (error) {
|
||
console.error('[INVOICE-PDF] ERROR:', error);
|
||
res.status(500).json({ error: 'Error generating PDF', details: error.message });
|
||
}
|
||
});
|
||
|
||
// GET invoice HTML (debug)
|
||
router.get('/:id/html', async (req, res) => {
|
||
const { id } = req.params;
|
||
|
||
try {
|
||
const invoiceResult = await pool.query(`
|
||
SELECT i.*, c.name as customer_name, c.line1, c.line2, c.line3, c.line4, c.city, c.state, c.zip_code, c.account_number,
|
||
COALESCE((SELECT SUM(pi.amount) FROM payment_invoices pi WHERE pi.invoice_id = i.id), 0) as amount_paid
|
||
FROM invoices i
|
||
LEFT JOIN customers c ON i.customer_id = c.id
|
||
WHERE i.id = $1
|
||
`, [id]);
|
||
|
||
if (invoiceResult.rows.length === 0) {
|
||
return res.status(404).json({ error: 'Invoice not found' });
|
||
}
|
||
|
||
const invoice = invoiceResult.rows[0];
|
||
const itemsResult = await pool.query(
|
||
'SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY item_order',
|
||
[id]
|
||
);
|
||
|
||
const templatePath = path.join(__dirname, '..', '..', 'templates', 'invoice-template.html');
|
||
let html = await fs.readFile(templatePath, 'utf-8');
|
||
|
||
const logoHTML = await getLogoHtml();
|
||
const itemsHTML = renderInvoiceItems(itemsResult.rows, invoice);
|
||
|
||
const authHTML = invoice.auth_code ? `<p style="margin-bottom: 20px; font-size: 13px;"><strong>Authorization:</strong> ${invoice.auth_code}</p>` : '';
|
||
|
||
const streetBlock = formatAddressLines(invoice.line1, invoice.line2, invoice.line3, invoice.line4, invoice.customer_name);
|
||
|
||
html = html
|
||
.replace('{{LOGO_HTML}}', logoHTML)
|
||
.replace('{{CUSTOMER_NAME}}', invoice.bill_to_name || invoice.customer_name || '')
|
||
.replace('{{CUSTOMER_STREET}}', streetBlock)
|
||
.replace('{{CUSTOMER_CITY}}', invoice.city || '')
|
||
.replace('{{CUSTOMER_STATE}}', invoice.state || '')
|
||
.replace('{{CUSTOMER_ZIP}}', invoice.zip_code || '')
|
||
.replace('{{INVOICE_NUMBER}}', invoice.invoice_number || '')
|
||
.replace('{{ACCOUNT_NUMBER}}', invoice.account_number || '')
|
||
.replace('{{INVOICE_DATE}}', formatDate(invoice.invoice_date))
|
||
.replace('{{TERMS}}', invoice.terms)
|
||
.replace('{{AUTHORIZATION}}', authHTML)
|
||
.replace('{{ITEMS}}', itemsHTML)
|
||
.replace('{{PAYMENT_LINK}}', buildPaymentLinkHtml(invoice));
|
||
|
||
res.setHeader('Content-Type', 'text/html');
|
||
res.send(html);
|
||
|
||
} catch (error) {
|
||
console.error('[HTML] ERROR:', error);
|
||
res.status(500).json({ error: 'Error generating HTML' });
|
||
}
|
||
});
|
||
|
||
router.post('/:id/send-email', async (req, res) => {
|
||
const { id } = req.params;
|
||
// Akzeptiert entweder recipientEmail (Legacy, String) oder recipientEmails (Array).
|
||
const { recipientEmail, recipientEmails, customText } = req.body;
|
||
|
||
// Normalisiere zu einem Array. Strings werden gesplittet und gefiltert.
|
||
let recipients = [];
|
||
if (Array.isArray(recipientEmails)) {
|
||
recipients = recipientEmails;
|
||
} else if (recipientEmail) {
|
||
recipients = [recipientEmail];
|
||
}
|
||
|
||
recipients = recipients
|
||
.map(e => (e || '').trim())
|
||
.filter(Boolean);
|
||
|
||
// Dedupe, case-insensitive
|
||
const seen = new Set();
|
||
recipients = recipients.filter(e => {
|
||
const key = e.toLowerCase();
|
||
if (seen.has(key)) return false;
|
||
seen.add(key);
|
||
return true;
|
||
});
|
||
|
||
if (recipients.length === 0) {
|
||
return res.status(400).json({ error: 'At least one recipient email is required.' });
|
||
}
|
||
|
||
// Einfache E-Mail-Validierung pro Empfänger
|
||
const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||
const invalid = recipients.filter(e => !emailRe.test(e));
|
||
if (invalid.length > 0) {
|
||
return res.status(400).json({
|
||
error: `Invalid email address(es): ${invalid.join(', ')}`
|
||
});
|
||
}
|
||
|
||
try {
|
||
// 1. Rechnungsdaten und Items laden
|
||
const invoiceResult = await pool.query(`
|
||
SELECT i.*, c.name as customer_name, c.line1, c.line2, c.line3, c.line4, c.city, c.state, c.zip_code, c.account_number,
|
||
COALESCE((SELECT SUM(pi.amount) FROM payment_invoices pi WHERE pi.invoice_id = i.id), 0) as amount_paid
|
||
FROM invoices i
|
||
LEFT JOIN customers c ON i.customer_id = c.id
|
||
WHERE i.id = $1
|
||
`, [id]);
|
||
|
||
if (invoiceResult.rows.length === 0) return res.status(404).json({ error: 'Invoice not found' });
|
||
const invoice = invoiceResult.rows[0];
|
||
|
||
const itemsResult = await pool.query('SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY item_order', [id]);
|
||
|
||
// 2. PDF generieren
|
||
const templatePath = path.join(__dirname, '..', '..', 'templates', 'invoice-template.html');
|
||
let html = await fs.readFile(templatePath, 'utf-8');
|
||
|
||
const logoHTML = await getLogoHtml();
|
||
const itemsHTML = renderInvoiceItems(itemsResult.rows, invoice);
|
||
const authHTML = invoice.auth_code ? `<p style="margin-bottom: 20px; font-size: 13px;"><strong>Authorization:</strong> ${invoice.auth_code}</p>` : '';
|
||
const streetBlock = formatAddressLines(invoice.line1, invoice.line2, invoice.line3, invoice.line4, invoice.customer_name);
|
||
|
||
html = html
|
||
.replace('{{LOGO_HTML}}', logoHTML)
|
||
.replace('{{CUSTOMER_NAME}}', invoice.bill_to_name || invoice.customer_name || '')
|
||
.replace('{{CUSTOMER_STREET}}', streetBlock)
|
||
.replace('{{CUSTOMER_CITY}}', invoice.city || '')
|
||
.replace('{{CUSTOMER_STATE}}', invoice.state || '')
|
||
.replace('{{CUSTOMER_ZIP}}', invoice.zip_code || '')
|
||
.replace('{{INVOICE_NUMBER}}', invoice.invoice_number || '')
|
||
.replace('{{ACCOUNT_NUMBER}}', invoice.account_number || '')
|
||
.replace('{{INVOICE_DATE}}', formatDate(invoice.invoice_date))
|
||
.replace('{{TERMS}}', invoice.terms)
|
||
.replace('{{AUTHORIZATION}}', authHTML)
|
||
.replace('{{ITEMS}}', itemsHTML)
|
||
.replace('{{PAYMENT_LINK}}', buildPaymentLinkHtml(invoice));
|
||
|
||
const pdfBuffer = await generatePdfFromHtml(html);
|
||
|
||
// 3. E-Mail über SES versenden — alle Empfänger im To-Feld
|
||
const stripeLink = invoice.stripe_payment_link_url || null;
|
||
const info = await sendInvoiceEmail(invoice, recipients, customText, stripeLink, pdfBuffer);
|
||
|
||
// 4. Status in der DB aktualisieren
|
||
await pool.query(
|
||
`UPDATE invoices
|
||
SET email_status = 'sent',
|
||
sent_dates = array_append(COALESCE(sent_dates, '{}'), CURRENT_DATE),
|
||
updated_at = CURRENT_TIMESTAMP
|
||
WHERE id = $1`,
|
||
[id]
|
||
);
|
||
|
||
console.log(`✉️ Invoice #${invoice.invoice_number} sent to: ${recipients.join(', ')}`);
|
||
|
||
res.json({
|
||
success: true,
|
||
messageId: info.messageId,
|
||
recipients
|
||
});
|
||
|
||
} catch (error) {
|
||
console.error('Error sending invoice email:', error);
|
||
res.status(500).json({ error: 'Failed to send email: ' + error.message });
|
||
}
|
||
});
|
||
|
||
// POST create Stripe Payment Link
|
||
router.post('/:id/create-payment-link', async (req, res) => {
|
||
const { id } = req.params;
|
||
|
||
try {
|
||
// Load invoice with balance
|
||
const invoiceResult = await pool.query(`
|
||
SELECT i.*, c.name as customer_name,
|
||
COALESCE((SELECT SUM(pi.amount) FROM payment_invoices pi WHERE pi.invoice_id = i.id), 0) as amount_paid
|
||
FROM invoices i
|
||
LEFT JOIN customers c ON i.customer_id = c.id
|
||
WHERE i.id = $1
|
||
`, [id]);
|
||
|
||
if (invoiceResult.rows.length === 0) {
|
||
return res.status(404).json({ error: 'Invoice not found' });
|
||
}
|
||
|
||
const invoice = invoiceResult.rows[0];
|
||
invoice.amount_paid = parseFloat(invoice.amount_paid) || 0;
|
||
invoice.balance = (parseFloat(invoice.total) || 0) - invoice.amount_paid;
|
||
|
||
if (invoice.balance <= 0) {
|
||
return res.status(400).json({ error: 'Invoice has no balance due.' });
|
||
}
|
||
|
||
// Deactivate existing payment link if present
|
||
if (invoice.stripe_payment_link_id) {
|
||
await deactivatePaymentLink(invoice.stripe_payment_link_id);
|
||
console.log(`♻️ Old payment link deactivated for Invoice #${invoice.invoice_number}`);
|
||
}
|
||
|
||
// Create new payment link
|
||
const { paymentLinkId, paymentLinkUrl } = await createPaymentLink(invoice);
|
||
|
||
// Save to DB
|
||
await pool.query(
|
||
`UPDATE invoices
|
||
SET stripe_payment_link_id = $1,
|
||
stripe_payment_link_url = $2,
|
||
stripe_payment_status = 'pending',
|
||
updated_at = CURRENT_TIMESTAMP
|
||
WHERE id = $3`,
|
||
[paymentLinkId, paymentLinkUrl, id]
|
||
);
|
||
|
||
res.json({
|
||
success: true,
|
||
paymentLinkId,
|
||
paymentLinkUrl,
|
||
amount: invoice.balance
|
||
});
|
||
|
||
} catch (error) {
|
||
console.error('Stripe Payment Link Error:', error);
|
||
res.status(500).json({ error: 'Failed to create payment link: ' + error.message });
|
||
}
|
||
});
|
||
// POST check Stripe payment status
|
||
// POST check Stripe payment status (with QBO payment + fee booking)
|
||
router.post('/:id/check-payment', async (req, res) => {
|
||
const { id } = req.params;
|
||
const dbClient = await pool.connect();
|
||
|
||
try {
|
||
const invoiceResult = await dbClient.query(`
|
||
SELECT i.*, c.name as customer_name, c.qbo_id as customer_qbo_id,
|
||
COALESCE((SELECT SUM(pi.amount) FROM payment_invoices pi WHERE pi.invoice_id = i.id), 0) as amount_paid
|
||
FROM invoices i
|
||
LEFT JOIN customers c ON i.customer_id = c.id
|
||
WHERE i.id = $1
|
||
`, [id]);
|
||
|
||
if (invoiceResult.rows.length === 0) {
|
||
return res.status(404).json({ error: 'Invoice not found' });
|
||
}
|
||
|
||
const invoice = invoiceResult.rows[0];
|
||
invoice.amount_paid = parseFloat(invoice.amount_paid) || 0;
|
||
|
||
if (!invoice.stripe_payment_link_id) {
|
||
return res.status(400).json({ error: 'No Stripe payment link exists for this invoice.' });
|
||
}
|
||
|
||
// Already fully processed?
|
||
if (invoice.stripe_payment_status === 'paid') {
|
||
return res.json({
|
||
status: 'paid',
|
||
message: 'Stripe payment already recorded.',
|
||
alreadyProcessed: true
|
||
});
|
||
}
|
||
|
||
const result = await checkPaymentStatus(invoice.stripe_payment_link_id);
|
||
|
||
// Update stripe_payment_status in DB regardless
|
||
if (result.status !== invoice.stripe_payment_status) {
|
||
await dbClient.query(
|
||
'UPDATE invoices SET stripe_payment_status = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
|
||
[result.status, id]
|
||
);
|
||
}
|
||
|
||
// If not paid yet, return current status
|
||
if (!result.paid) {
|
||
return res.json({
|
||
status: result.status,
|
||
paid: false,
|
||
details: result.details,
|
||
message: result.status === 'processing'
|
||
? 'ACH payment is processing (3-5 business days).'
|
||
: 'No payment received yet.'
|
||
});
|
||
}
|
||
|
||
// === PAID — full processing ===
|
||
const amountReceived = result.details.amountReceived;
|
||
const paymentMethod = result.details.paymentMethod;
|
||
const stripeFee = result.details.stripeFee;
|
||
const methodLabel = paymentMethod === 'us_bank_account' ? 'ACH' : 'Credit Card';
|
||
|
||
await dbClient.query('BEGIN');
|
||
|
||
// 1. Record local payment (payment + payment_invoices)
|
||
const payResult = await dbClient.query(
|
||
`INSERT INTO payments (payment_date, payment_method, total_amount, customer_id, reference_number, notes, created_at)
|
||
VALUES (CURRENT_DATE, $1, $2, $3, $4, $5, CURRENT_TIMESTAMP)
|
||
RETURNING id`,
|
||
[
|
||
`Stripe ${methodLabel}`,
|
||
amountReceived,
|
||
invoice.customer_id,
|
||
result.details.paymentIntentId || result.details.sessionId,
|
||
`Stripe ${methodLabel} — Fee: $${stripeFee.toFixed(2)}`
|
||
]
|
||
);
|
||
const paymentId = payResult.rows[0].id;
|
||
|
||
await dbClient.query(
|
||
'INSERT INTO payment_invoices (payment_id, invoice_id, amount) VALUES ($1, $2, $3)',
|
||
[paymentId, id, amountReceived]
|
||
);
|
||
|
||
// 2. Check if invoice is fully paid
|
||
const newTotalPaid = invoice.amount_paid + amountReceived;
|
||
const invoiceTotal = parseFloat(invoice.total) || 0;
|
||
const fullyPaid = newTotalPaid >= (invoiceTotal - 0.01); // Cent-Toleranz
|
||
|
||
await dbClient.query(
|
||
`UPDATE invoices SET
|
||
stripe_payment_status = 'paid',
|
||
paid_date = ${fullyPaid ? 'COALESCE(paid_date, CURRENT_DATE)' : 'paid_date'},
|
||
payment_status = $1,
|
||
updated_at = CURRENT_TIMESTAMP
|
||
WHERE id = $2`,
|
||
[fullyPaid ? 'Stripe' : 'Partial', id]
|
||
);
|
||
|
||
// 3. Deactivate the payment link
|
||
await deactivatePaymentLink(invoice.stripe_payment_link_id);
|
||
|
||
// 4. QBO: Record Payment + Expense (if QBO-linked)
|
||
qboResult = await recordStripePaymentInQbo(
|
||
invoice, amountReceived, methodLabel, stripeFee,
|
||
result.details.paymentIntentId || ''
|
||
// kein source-Parameter — default ist 'manual'
|
||
);
|
||
|
||
await dbClient.query('COMMIT');
|
||
|
||
console.log(`✅ Invoice #${invoice.invoice_number}: Stripe ${methodLabel} $${amountReceived.toFixed(2)} recorded (Fee: $${stripeFee.toFixed(2)})`);
|
||
|
||
res.json({
|
||
status: 'paid',
|
||
paid: true,
|
||
fullyPaid,
|
||
details: result.details,
|
||
qbo: qboResult,
|
||
message: `Payment received: $${amountReceived.toFixed(2)} via Stripe ${methodLabel}. Fee: $${stripeFee.toFixed(2)}.`
|
||
});
|
||
|
||
} catch (error) {
|
||
await dbClient.query('ROLLBACK').catch(() => {});
|
||
console.error('Stripe Check Payment Error:', error);
|
||
res.status(500).json({ error: 'Failed to check payment: ' + error.message });
|
||
} finally {
|
||
dbClient.release();
|
||
}
|
||
});
|
||
|
||
|
||
// PATCH update sent dates only
|
||
router.patch('/:id/sent-dates', async (req, res) => {
|
||
const { id } = req.params;
|
||
const { sent_dates } = req.body;
|
||
|
||
if (!Array.isArray(sent_dates)) {
|
||
return res.status(400).json({ error: 'sent_dates must be an array of date strings.' });
|
||
}
|
||
|
||
// Validate each date
|
||
for (const d of sent_dates) {
|
||
if (!/^\d{4}-\d{2}-\d{2}$/.test(d)) {
|
||
return res.status(400).json({ error: `Invalid date format: ${d}. Expected YYYY-MM-DD.` });
|
||
}
|
||
}
|
||
|
||
try {
|
||
// Sort chronologically
|
||
const sorted = [...sent_dates].sort();
|
||
|
||
await pool.query(
|
||
'UPDATE invoices SET sent_dates = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
|
||
[sorted, id]
|
||
);
|
||
|
||
res.json({ success: true, sent_dates: sorted });
|
||
} catch (error) {
|
||
console.error('Error updating sent dates:', error);
|
||
res.status(500).json({ error: 'Failed to update sent dates.' });
|
||
}
|
||
});
|
||
module.exports = router;
|