This commit is contained in:
Andreas Knuth 2026-02-17 20:53:00 -06:00
parent 84b0836234
commit a0c62d639e
4 changed files with 191 additions and 99 deletions

View File

@ -817,7 +817,7 @@ async function openInvoiceModal(invoiceId = null) {
}
}
document.getElementById('invoice-number').value = data.invoice.invoice_number;
document.getElementById('invoice-number').value = data.invoice.invoice_number || '';
document.getElementById('invoice-customer').value = data.invoice.customer_id;
const dateOnly = data.invoice.invoice_date.split('T')[0];
document.getElementById('invoice-date').value = dateOnly;
@ -825,6 +825,14 @@ async function openInvoiceModal(invoiceId = null) {
document.getElementById('invoice-authorization').value = data.invoice.auth_code || '';
document.getElementById('invoice-tax-exempt').checked = data.invoice.tax_exempt;
// Scheduled Send Date
const sendDateEl = document.getElementById('invoice-send-date');
if (sendDateEl) {
sendDateEl.value = data.invoice.scheduled_send_date
? data.invoice.scheduled_send_date.split('T')[0]
: '';
}
// Load items
document.getElementById('invoice-items').innerHTML = '';
itemCounter = 0;
@ -838,11 +846,12 @@ async function openInvoiceModal(invoiceId = null) {
document.getElementById('invoice-form').reset();
document.getElementById('invoice-items').innerHTML = '';
document.getElementById('invoice-terms').value = 'Net 30';
document.getElementById('invoice-number').value = ''; // Leer lassen!
document.getElementById('invoice-send-date').value = '';
itemCounter = 0;
setDefaultDate();
// Fetch next invoice number
fetchNextInvoiceNumber();
// KEIN fetchNextInvoiceNumber() mehr — Nummer kommt von QBO
// Add one default item
addInvoiceItem();
@ -1068,39 +1077,49 @@ async function handleInvoiceSubmit(e) {
return;
}
const invoiceNumber = document.getElementById('invoice-number').value;
const invoiceNumber = document.getElementById('invoice-number').value.trim();
if (!invoiceNumber || !/^\d+$/.test(invoiceNumber)) {
alert('Invalid invoice number. Must be a numeric value.');
// Invoice Number ist jetzt OPTIONAL
// Wenn angegeben, muss sie numerisch sein
if (invoiceNumber && !/^\d+$/.test(invoiceNumber)) {
alert('Invalid invoice number. Must be a numeric value or left empty.');
return;
}
const data = {
invoice_number: invoiceNumber,
invoice_number: invoiceNumber || null, // null wenn leer
customer_id: parseInt(document.getElementById('invoice-customer').value),
invoice_date: document.getElementById('invoice-date').value,
terms: document.getElementById('invoice-terms').value,
auth_code: document.getElementById('invoice-authorization').value,
tax_exempt: document.getElementById('invoice-tax-exempt').checked,
scheduled_send_date: document.getElementById('invoice-send-date').value || null,
items: items
};
try {
const url = currentInvoiceId ? `/api/invoices/${currentInvoiceId}` : '/api/invoices';
const method = currentInvoiceId ? 'PUT' : 'POST';
let response;
if (currentInvoiceId) {
response = await fetch(`/api/invoices/${currentInvoiceId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
} else {
response = await fetch('/api/invoices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
}
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const result = await response.json();
if (response.ok) {
closeInvoiceModal();
loadInvoices();
} else {
const error = await response.json();
alert(error.error || 'Error saving invoice');
alert(result.error || 'Error saving invoice');
}
} catch (error) {
console.error('Error:', error);

View File

@ -65,7 +65,7 @@
</div>
</div>
<!-- Invoices Tab -->
<!-- Invoices Tab -->
<div id="invoices-tab" class="tab-content hidden">
<div class="flex justify-between items-center mb-4">
<h2 class="text-3xl font-bold text-gray-800">Invoices</h2>
@ -81,12 +81,13 @@
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Invoice #</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Customer</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Terms</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Total</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Invoice #</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Customer</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Send Date</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Terms</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Total</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody id="invoices-list" class="bg-white divide-y divide-gray-200">
@ -444,12 +445,13 @@
</div>
<form id="invoice-form" class="space-y-6">
<div class="grid grid-cols-5 gap-4">
<div class="grid grid-cols-6 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Invoice #</label>
<input type="text" id="invoice-number" required pattern="[0-9]+"
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
title="Must be a numeric value">
<input type="text" id="invoice-number" pattern="[0-9]*"
placeholder="Auto (QBO)"
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500 bg-gray-50"
title="Optional — wird beim QBO Export automatisch vergeben">
</div>
<div x-data="customerSearch('invoice')" class="relative">
<label class="block text-sm font-medium text-gray-700 mb-1">Customer</label>
@ -492,6 +494,12 @@
<input type="date" id="invoice-date" required
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Send Date</label>
<input type="date" id="invoice-send-date"
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500"
title="Wann soll die Rechnung versendet werden?">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Terms</label>
<input type="text" id="invoice-terms" value="Net 30" required

View File

@ -1,6 +1,5 @@
// invoice-view.js — ES Module für die Invoice View
// Features: Status Filter (all/unpaid/paid/overdue), Customer Filter,
// Group by (none/week/month), Sortierung neueste zuerst, Mark Paid/Unpaid
// Features: Status Filter, Customer Filter, Group by, Send Date, Mark Paid, Reset QBO
// ============================================================
// State
@ -17,6 +16,7 @@ const OVERDUE_DAYS = 30;
// ============================================================
function formatDate(date) {
if (!date) return '—';
const d = new Date(date);
const month = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
@ -97,7 +97,6 @@ function getFilteredInvoices() {
} else if (filterStatus === 'overdue') {
filtered = filtered.filter(inv => isOverdue(inv));
}
// 'all' → kein Filter
// Customer Filter
if (filterCustomer.trim()) {
@ -142,12 +141,10 @@ function groupInvoices(filtered) {
group.total += parseFloat(inv.total) || 0;
});
// Innerhalb jeder Gruppe nochmal nach Datum sortieren (neueste zuerst)
for (const group of groups.values()) {
group.invoices.sort((a, b) => new Date(b.invoice_date) - new Date(a.invoice_date));
}
// Gruppen nach Key sortieren (neueste zuerst)
return new Map([...groups.entries()].sort((a, b) => b[0].localeCompare(a[0])));
}
@ -160,12 +157,17 @@ function renderInvoiceRow(invoice) {
const paid = isPaid(invoice);
const overdue = isOverdue(invoice);
// QBO Button
// Invoice Number Display
const invNumDisplay = invoice.invoice_number
? invoice.invoice_number
: `<span class="text-gray-400 italic text-xs">Draft</span>`;
// QBO Button — if already in QBO, show checkmark + optional reset
const qboButton = hasQbo
? `<span class="text-gray-400 text-xs" title="Already in QBO (ID: ${invoice.qbo_id})">✓ QBO</span>`
? `<span class="text-gray-400 text-xs cursor-pointer" title="In QBO (ID: ${invoice.qbo_id}) — Click to reset" onclick="window.invoiceView.resetQbo(${invoice.id})">✓ QBO</span>`
: `<button onclick="window.invoiceView.exportToQBO(${invoice.id})" class="text-orange-600 hover:text-orange-900" title="Export to QuickBooks">QBO Export</button>`;
// Paid/Unpaid Toggle Button
// Paid/Unpaid Toggle
const paidButton = paid
? `<button onclick="window.invoiceView.markUnpaid(${invoice.id})" class="text-yellow-600 hover:text-yellow-800" title="Mark as unpaid">↩ Unpaid</button>`
: `<button onclick="window.invoiceView.markPaid(${invoice.id})" class="text-emerald-600 hover:text-emerald-800" title="Mark as paid">💰 Paid</button>`;
@ -175,28 +177,50 @@ function renderInvoiceRow(invoice) {
if (paid) {
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-green-100 text-green-800" title="Paid ${formatDate(invoice.paid_date)}">Paid</span>`;
} else if (overdue) {
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-red-100 text-red-800" title="${daysSince(invoice.invoice_date)} days old">Overdue</span>`;
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-red-100 text-red-800" title="${daysSince(invoice.invoice_date)} days">Overdue</span>`;
}
// Send Date display
let sendDateDisplay = '—';
if (invoice.scheduled_send_date) {
const sendDate = new Date(invoice.scheduled_send_date);
const today = new Date();
today.setHours(0,0,0,0);
sendDate.setHours(0,0,0,0);
const daysUntil = Math.floor((sendDate - today) / 86400000);
sendDateDisplay = formatDate(invoice.scheduled_send_date);
if (!paid) {
if (daysUntil < 0) {
sendDateDisplay += ` <span class="text-xs text-red-500">(${Math.abs(daysUntil)}d ago)</span>`;
} else if (daysUntil === 0) {
sendDateDisplay += ` <span class="text-xs text-orange-500 font-semibold">(today)</span>`;
} else if (daysUntil <= 3) {
sendDateDisplay += ` <span class="text-xs text-yellow-600">(in ${daysUntil}d)</span>`;
}
}
}
// Row styling
const rowClass = paid ? 'bg-green-50/50' : overdue ? 'bg-red-50/50' : '';
return `
<tr class="${rowClass}">
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
${invoice.invoice_number} ${statusBadge}
<td class="px-4 py-3 whitespace-nowrap text-sm font-medium text-gray-900">
${invNumDisplay} ${statusBadge}
</td>
<td class="px-6 py-4 text-sm text-gray-500">${invoice.customer_name || 'N/A'}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${formatDate(invoice.invoice_date)}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${invoice.terms}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 font-semibold">$${parseFloat(invoice.total).toFixed(2)}</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2">
<td class="px-4 py-3 text-sm text-gray-500">${invoice.customer_name || 'N/A'}</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500">${formatDate(invoice.invoice_date)}</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500">${sendDateDisplay}</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500">${invoice.terms}</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 font-semibold">$${parseFloat(invoice.total).toFixed(2)}</td>
<td class="px-4 py-3 whitespace-nowrap text-sm font-medium space-x-1">
<button onclick="window.invoiceView.viewPDF(${invoice.id})" class="text-green-600 hover:text-green-900">PDF</button>
<button onclick="window.invoiceView.viewHTML(${invoice.id})" class="text-teal-600 hover:text-teal-900">HTML</button>
${qboButton}
${paidButton}
<button onclick="window.invoiceView.edit(${invoice.id})" class="text-blue-600 hover:text-blue-900">Edit</button>
<button onclick="window.invoiceView.remove(${invoice.id})" class="text-red-600 hover:text-red-900">Delete</button>
<button onclick="window.invoiceView.remove(${invoice.id})" class="text-red-600 hover:text-red-900">Del</button>
</td>
</tr>
`;
@ -205,7 +229,7 @@ function renderInvoiceRow(invoice) {
function renderGroupHeader(label) {
return `
<tr class="bg-blue-50">
<td colspan="6" class="px-6 py-3 text-sm font-bold text-blue-800">
<td colspan="7" class="px-4 py-3 text-sm font-bold text-blue-800">
📅 ${label}
</td>
</tr>
@ -215,8 +239,8 @@ function renderGroupHeader(label) {
function renderGroupFooter(total, count) {
return `
<tr class="bg-gray-50 border-t-2 border-gray-300">
<td colspan="4" class="px-6 py-3 text-sm font-bold text-gray-700 text-right">Group Total (${count} invoices):</td>
<td class="px-6 py-3 text-sm font-bold text-gray-900">$${total.toFixed(2)}</td>
<td colspan="5" class="px-4 py-3 text-sm font-bold text-gray-700 text-right">Group Total (${count} invoices):</td>
<td class="px-4 py-3 text-sm font-bold text-gray-900">$${total.toFixed(2)}</td>
<td></td>
</tr>
`;
@ -245,8 +269,8 @@ export function renderInvoiceView() {
if (groups.size > 1) {
html += `
<tr class="bg-blue-100 border-t-4 border-blue-400">
<td colspan="4" class="px-6 py-4 text-base font-bold text-blue-900 text-right">Grand Total (${filtered.length} invoices):</td>
<td class="px-6 py-4 text-base font-bold text-blue-900">$${grandTotal.toFixed(2)}</td>
<td colspan="5" class="px-4 py-4 text-base font-bold text-blue-900 text-right">Grand Total (${filtered.length} invoices):</td>
<td class="px-4 py-4 text-base font-bold text-blue-900">$${grandTotal.toFixed(2)}</td>
<td></td>
</tr>
`;
@ -260,8 +284,8 @@ export function renderInvoiceView() {
if (filtered.length > 0) {
html += `
<tr class="bg-gray-100 border-t-2 border-gray-300">
<td colspan="4" class="px-6 py-3 text-sm font-bold text-gray-700 text-right">Total (${filtered.length} invoices):</td>
<td class="px-6 py-3 text-sm font-bold text-gray-900">$${grandTotal.toFixed(2)}</td>
<td colspan="5" class="px-4 py-3 text-sm font-bold text-gray-700 text-right">Total (${filtered.length} invoices):</td>
<td class="px-4 py-3 text-sm font-bold text-gray-900">$${grandTotal.toFixed(2)}</td>
<td></td>
</tr>
`;
@ -269,16 +293,14 @@ export function renderInvoiceView() {
}
if (filtered.length === 0) {
html = `<tr><td colspan="6" class="px-6 py-8 text-center text-gray-500">No invoices found.</td></tr>`;
html = `<tr><td colspan="7" class="px-6 py-8 text-center text-gray-500">No invoices found.</td></tr>`;
}
tbody.innerHTML = html;
// Update count badge
const countEl = document.getElementById('invoice-count');
if (countEl) countEl.textContent = filtered.length;
// Update status button active states
updateStatusButtons();
}
@ -294,7 +316,6 @@ function updateStatusButtons() {
}
});
// Update overdue count badge
const overdueCount = invoices.filter(inv => isOverdue(inv)).length;
const overdueBadge = document.getElementById('overdue-badge');
if (overdueBadge) {
@ -306,7 +327,6 @@ function updateStatusButtons() {
}
}
// Update unpaid count
const unpaidCount = invoices.filter(inv => !isPaid(inv)).length;
const unpaidBadge = document.getElementById('unpaid-badge');
if (unpaidBadge) {
@ -315,7 +335,7 @@ function updateStatusButtons() {
}
// ============================================================
// Toolbar HTML
// Toolbar
// ============================================================
export function injectToolbar() {
@ -377,7 +397,6 @@ export function injectToolbar() {
</div>
`;
// Event Listeners
document.getElementById('invoice-filter-customer').addEventListener('input', (e) => {
filterCustomer = e.target.value;
renderInvoiceView();
@ -433,6 +452,22 @@ export async function exportToQBO(id) {
}
}
export async function resetQbo(id) {
if (!confirm('QBO-Verknüpfung zurücksetzen?\nDie Rechnung muss zuerst in QBO gelöscht werden!')) return;
try {
const response = await fetch(`/api/invoices/${id}/reset-qbo`, { method: 'PATCH' });
if (response.ok) {
loadInvoices();
} else {
const err = await response.json();
alert('Error: ' + (err.error || 'Unknown'));
}
} catch (error) {
console.error('Error resetting QBO:', error);
}
}
export async function markPaid(id) {
try {
const response = await fetch(`/api/invoices/${id}/mark-paid`, {
@ -493,6 +528,7 @@ window.invoiceView = {
viewPDF,
viewHTML,
exportToQBO,
resetQbo,
markPaid,
markUnpaid,
edit,

109
server.js
View File

@ -457,31 +457,32 @@ app.get('/api/invoices/:id', async (req, res) => {
});
app.post('/api/invoices', async (req, res) => {
const { invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, items, created_from_quote_id } = req.body;
const { invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, items, created_from_quote_id, scheduled_send_date } = req.body;
const client = await pool.connect();
try {
await client.query('BEGIN');
// Validate invoice_number is provided and is numeric
if (!invoice_number || !/^\d+$/.test(invoice_number)) {
await client.query('ROLLBACK');
return res.status(400).json({ error: 'Invalid invoice number. Must be a numeric value.' });
}
// Check if invoice number already exists
const existingInvoice = await client.query(
'SELECT id FROM invoices WHERE invoice_number = $1',
[invoice_number]
);
if (existingInvoice.rows.length > 0) {
await client.query('ROLLBACK');
return res.status(400).json({ error: `Invoice number ${invoice_number} already exists.` });
// invoice_number ist jetzt OPTIONAL — wird erst beim QBO Export vergeben
// Wenn angegeben, muss sie numerisch sein und darf nicht existieren
if (invoice_number && invoice_number.trim() !== '') {
if (!/^\d+$/.test(invoice_number)) {
await client.query('ROLLBACK');
return res.status(400).json({ error: 'Invalid invoice number. Must be a numeric value.' });
}
const existingInvoice = await client.query(
'SELECT id FROM invoices WHERE invoice_number = $1',
[invoice_number]
);
if (existingInvoice.rows.length > 0) {
await client.query('ROLLBACK');
return res.status(400).json({ error: `Invoice number ${invoice_number} already exists.` });
}
}
let subtotal = 0;
for (const item of items) {
const amount = parseFloat(item.amount.replace(/[$,]/g, ''));
if (!isNaN(amount)) {
@ -493,19 +494,22 @@ app.post('/api/invoices', async (req, res) => {
const tax_amount = tax_exempt ? 0 : (subtotal * tax_rate / 100);
const total = subtotal + tax_amount;
// invoice_number kann NULL sein
const invNum = (invoice_number && invoice_number.trim() !== '') ? invoice_number : null;
const sendDate = scheduled_send_date || 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, created_from_quote_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) RETURNING *`,
[invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, created_from_quote_id]
`INSERT INTO invoices (invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, created_from_quote_id, scheduled_send_date)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING *`,
[invNum, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, created_from_quote_id, sendDate]
);
const invoiceId = invoiceResult.rows[0].id;
for (let i = 0; i < items.length; i++) {
await client.query(
// qbo_item_id hinzugefügt
'INSERT INTO invoice_items (invoice_id, quantity, description, rate, amount, item_order, qbo_item_id) VALUES ($1, $2, $3, $4, $5, $6, $7)',
[invoiceId, items[i].quantity, items[i].description, items[i].rate, items[i].amount, i, items[i].qbo_item_id || '9'] // Default '9' (Parts)
[invoiceId, items[i].quantity, items[i].description, items[i].rate, items[i].amount, i, items[i].qbo_item_id || '9']
);
}
@ -556,7 +560,7 @@ app.post('/api/quotes/:id/convert-to-invoice', async (req, res) => {
return res.status(400).json({ error: 'Cannot convert quote with TBD items to invoice. Please update all TBD items first.' });
}
const invoice_number = await getNextInvoiceNumber();
const invoice_number = null;
const invoiceDate = new Date().toISOString().split('T')[0];
const invoiceResult = await client.query(
@ -588,31 +592,32 @@ app.post('/api/quotes/:id/convert-to-invoice', async (req, res) => {
app.put('/api/invoices/:id', async (req, res) => {
const { id } = req.params;
const { invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, items } = req.body;
const { invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, items, scheduled_send_date } = req.body;
const client = await pool.connect();
try {
await client.query('BEGIN');
// Validate invoice_number is provided and is numeric
if (!invoice_number || !/^\d+$/.test(invoice_number)) {
// invoice_number ist optional. Wenn angegeben und nicht leer, muss sie numerisch sein
const invNum = (invoice_number && invoice_number.trim() !== '') ? invoice_number : null;
if (invNum && !/^\d+$/.test(invNum)) {
await client.query('ROLLBACK');
return res.status(400).json({ error: 'Invalid invoice number. Must be a numeric value.' });
}
// Check if invoice number already exists (excluding current invoice)
const existingInvoice = await client.query(
'SELECT id FROM invoices WHERE invoice_number = $1 AND id != $2',
[invoice_number, id]
);
if (existingInvoice.rows.length > 0) {
await client.query('ROLLBACK');
return res.status(400).json({ error: `Invoice number ${invoice_number} already exists.` });
if (invNum) {
const existingInvoice = await client.query(
'SELECT id FROM invoices WHERE invoice_number = $1 AND id != $2',
[invNum, id]
);
if (existingInvoice.rows.length > 0) {
await client.query('ROLLBACK');
return res.status(400).json({ error: `Invoice number ${invNum} already exists.` });
}
}
let subtotal = 0;
for (const item of items) {
const amount = parseFloat(item.amount.replace(/[$,]/g, ''));
if (!isNaN(amount)) {
@ -623,12 +628,13 @@ app.put('/api/invoices/:id', async (req, res) => {
const tax_rate = 8.25;
const tax_amount = tax_exempt ? 0 : (subtotal * tax_rate / 100);
const total = subtotal + tax_amount;
const sendDate = scheduled_send_date || null;
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, updated_at = CURRENT_TIMESTAMP
WHERE id = $11`,
[invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, id]
tax_rate = $7, subtotal = $8, tax_amount = $9, total = $10, scheduled_send_date = $11, updated_at = CURRENT_TIMESTAMP
WHERE id = $12`,
[invNum, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, sendDate, id]
);
await client.query('DELETE FROM invoice_items WHERE invoice_id = $1', [id]);
@ -651,6 +657,7 @@ app.put('/api/invoices/:id', async (req, res) => {
}
});
app.delete('/api/invoices/:id', async (req, res) => {
const { id } = req.params;
const client = await pool.connect();
@ -1580,7 +1587,29 @@ app.patch('/api/invoices/:id/mark-unpaid', async (req, res) => {
}
});
app.patch('/api/invoices/: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' });
}
});