update
This commit is contained in:
parent
be834fa9a0
commit
29a37ad98a
|
|
@ -941,60 +941,64 @@ function getInvoiceItems() {
|
||||||
async function handleInvoiceSubmit(e) {
|
async function handleInvoiceSubmit(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const items = getInvoiceItems();
|
|
||||||
|
|
||||||
if (items.length === 0) {
|
|
||||||
alert('Please add at least one item');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const invoiceNumber = document.getElementById('invoice-number').value.trim();
|
|
||||||
|
|
||||||
// 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 = {
|
const data = {
|
||||||
invoice_number: invoiceNumber || null, // null wenn leer
|
invoice_number: document.getElementById('invoice-number').value || null,
|
||||||
customer_id: parseInt(document.getElementById('invoice-customer').value),
|
customer_id: document.getElementById('invoice-customer').value,
|
||||||
invoice_date: document.getElementById('invoice-date').value,
|
invoice_date: document.getElementById('invoice-date').value,
|
||||||
terms: document.getElementById('invoice-terms').value,
|
terms: document.getElementById('invoice-terms').value,
|
||||||
auth_code: document.getElementById('invoice-authorization').value,
|
auth_code: document.getElementById('invoice-authorization').value,
|
||||||
tax_exempt: document.getElementById('invoice-tax-exempt').checked,
|
tax_exempt: document.getElementById('invoice-tax-exempt').checked,
|
||||||
scheduled_send_date: document.getElementById('invoice-send-date').value || null,
|
scheduled_send_date: document.getElementById('invoice-send-date')?.value || null,
|
||||||
items: items
|
items: getInvoiceItems() // Deine bestehende Funktion
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!data.customer_id) {
|
||||||
|
alert('Please select a customer.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!data.items || data.items.length === 0) {
|
||||||
|
alert('Please add at least one item.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const invoiceId = currentInvoiceId;
|
||||||
|
const url = invoiceId ? `/api/invoices/${invoiceId}` : '/api/invoices';
|
||||||
|
const method = invoiceId ? 'PUT' : 'POST';
|
||||||
|
|
||||||
|
// Spinner anzeigen
|
||||||
|
showSpinner(invoiceId ? 'Saving invoice & syncing QBO...' : 'Creating invoice & exporting to QBO...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let response;
|
const response = await fetch(url, {
|
||||||
if (currentInvoiceId) {
|
method,
|
||||||
response = await fetch(`/api/invoices/${currentInvoiceId}`, {
|
headers: { 'Content-Type': 'application/json' },
|
||||||
method: 'PUT',
|
body: JSON.stringify(data)
|
||||||
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 result = await response.json();
|
const result = await response.json();
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
closeInvoiceModal();
|
closeInvoiceModal();
|
||||||
loadInvoices();
|
|
||||||
|
// Info über QBO Status
|
||||||
|
if (result.qbo_doc_number) {
|
||||||
|
console.log(`✅ Invoice saved & exported to QBO: #${result.qbo_doc_number}`);
|
||||||
|
} else if (result.qbo_synced) {
|
||||||
|
console.log('✅ Invoice saved & synced to QBO');
|
||||||
|
} else {
|
||||||
|
console.log('✅ Invoice saved locally (QBO sync pending)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invoices neu laden
|
||||||
|
if (window.invoiceView) window.invoiceView.loadInvoices();
|
||||||
} else {
|
} else {
|
||||||
alert(result.error || 'Error saving invoice');
|
alert(`Error: ${result.error}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error:', error);
|
console.error('Error:', error);
|
||||||
alert('Error saving invoice');
|
alert('Error saving invoice');
|
||||||
|
} finally {
|
||||||
|
hideSpinner();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -313,6 +313,8 @@ async function handleSubmit(e) {
|
||||||
const url = customerId ? `/api/customers/${customerId}` : '/api/customers';
|
const url = customerId ? `/api/customers/${customerId}` : '/api/customers';
|
||||||
const method = customerId ? 'PUT' : 'POST';
|
const method = customerId ? 'PUT' : 'POST';
|
||||||
|
|
||||||
|
if (typeof showSpinner === 'function') showSpinner(customerId ? 'Saving customer & syncing QBO...' : 'Creating customer...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method,
|
method,
|
||||||
|
|
@ -330,6 +332,8 @@ async function handleSubmit(e) {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving customer:', error);
|
console.error('Error saving customer:', error);
|
||||||
alert('Network error saving customer.');
|
alert('Network error saving customer.');
|
||||||
|
} finally {
|
||||||
|
if (typeof hideSpinner === 'function') hideSpinner();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -221,11 +221,11 @@ function renderInvoiceRow(invoice) {
|
||||||
const customerHasQbo = !!invoice.customer_qbo_id;
|
const customerHasQbo = !!invoice.customer_qbo_id;
|
||||||
let qboBtn;
|
let qboBtn;
|
||||||
if (hasQbo) {
|
if (hasQbo) {
|
||||||
qboBtn = `<button onclick="window.invoiceView.syncToQBO(${invoice.id})" class="text-purple-600 hover:text-purple-900" title="Sync changes to QBO (ID: ${invoice.qbo_id})">⟳ QBO Sync</button>`;
|
qboBtn = `<span class="text-green-600 text-xs" title="QBO ID: ${invoice.qbo_id}">✓ QBO</span>`;
|
||||||
} else if (!customerHasQbo) {
|
} else if (!customerHasQbo) {
|
||||||
qboBtn = `<span class="text-gray-300 text-xs cursor-not-allowed" title="Customer must be exported to QBO first">QBO ⚠</span>`;
|
qboBtn = `<span class="text-gray-300 text-xs cursor-not-allowed" title="Customer must be exported to QBO first">QBO ⚠</span>`;
|
||||||
} else {
|
} else {
|
||||||
qboBtn = `<button onclick="window.invoiceView.exportToQBO(${invoice.id})" class="text-orange-600 hover:text-orange-900" title="Export to QuickBooks">QBO Export</button>`;
|
qboBtn = `<span class="text-gray-400 text-xs" title="Will be exported to QBO on save">QBO pending</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pdfBtn = draft
|
const pdfBtn = draft
|
||||||
|
|
|
||||||
293
server.js
293
server.js
|
|
@ -10,6 +10,9 @@ const { makeQboApiCall, getOAuthClient, saveTokens, resetOAuthClient } = require
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
|
const QBO_LABOR_ID = '5';
|
||||||
|
const QBO_PARTS_ID = '9';
|
||||||
|
|
||||||
// Global browser instance
|
// Global browser instance
|
||||||
let browser = null;
|
let browser = null;
|
||||||
|
|
||||||
|
|
@ -121,6 +124,174 @@ async function getNextInvoiceNumber() {
|
||||||
return String(parseInt(result.rows[0].max_number) + 1);
|
return String(parseInt(result.rows[0].max_number) + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Helper: QBO Invoice Export (create) ---
|
||||||
|
async function exportInvoiceToQbo(invoiceId, client) {
|
||||||
|
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
|
||||||
|
`, [invoiceId]);
|
||||||
|
|
||||||
|
const invoice = invoiceRes.rows[0];
|
||||||
|
if (!invoice.customer_qbo_id) return { skipped: true, reason: 'Customer not in QBO' };
|
||||||
|
|
||||||
|
const itemsRes = await client.query('SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY item_order', [invoiceId]);
|
||||||
|
const items = itemsRes.rows;
|
||||||
|
|
||||||
|
const oauthClient = getOAuthClient();
|
||||||
|
const companyId = oauthClient.getToken().realmId;
|
||||||
|
const baseUrl = process.env.QBO_ENVIRONMENT === 'production'
|
||||||
|
? 'https://quickbooks.api.intuit.com'
|
||||||
|
: 'https://sandbox-quickbooks.api.intuit.com';
|
||||||
|
|
||||||
|
// Nächste DocNumber ermitteln (aus lokaler DB)
|
||||||
|
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 || QBO_PARTS_ID;
|
||||||
|
const itemRefName = itemRefId == QBO_LABOR_ID ? "Labor:Labor" : "Parts:Parts";
|
||||||
|
return {
|
||||||
|
"DetailType": "SalesItemLineDetail",
|
||||||
|
"Amount": amount,
|
||||||
|
"Description": item.description,
|
||||||
|
"SalesItemLineDetail": {
|
||||||
|
"ItemRef": { "value": itemRefId, "name": itemRefName },
|
||||||
|
"UnitPrice": rate,
|
||||||
|
"Qty": parseFloat(item.quantity) || 1
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const qboPayload = {
|
||||||
|
"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 || "" }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Retry bei Duplicate
|
||||||
|
let qboInvoice = null;
|
||||||
|
for (let attempt = 0; attempt < 5; attempt++) {
|
||||||
|
console.log(`📤 QBO Export Invoice (DocNumber: ${qboPayload.DocNumber})...`);
|
||||||
|
const response = await makeQboApiCall({
|
||||||
|
url: `${baseUrl}/v3/company/${companyId}/invoice`,
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(qboPayload)
|
||||||
|
});
|
||||||
|
const data = response.getJson ? response.getJson() : response.json;
|
||||||
|
|
||||||
|
if (data.Fault?.Error?.[0]?.code === '6140') {
|
||||||
|
qboPayload.DocNumber = (parseInt(qboPayload.DocNumber) + 1).toString();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
qboInvoice = data.Invoice || data;
|
||||||
|
if (qboInvoice.Id) break;
|
||||||
|
throw new Error("QBO returned no ID: " + (data.Fault?.Error?.[0]?.Message || JSON.stringify(data)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!qboInvoice?.Id) throw new Error('Could not find free DocNumber after 5 attempts.');
|
||||||
|
|
||||||
|
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, invoiceId]
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`✅ QBO Invoice created: ID ${qboInvoice.Id}, DocNumber ${qboInvoice.DocNumber}`);
|
||||||
|
return { success: true, qbo_id: qboInvoice.Id, qbo_doc_number: qboInvoice.DocNumber };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- Helper: QBO Invoice Update (sync) ---
|
||||||
|
async function syncInvoiceToQbo(invoiceId, client) {
|
||||||
|
const invoiceRes = await client.query(`
|
||||||
|
SELECT i.*, c.qbo_id as customer_qbo_id
|
||||||
|
FROM invoices i
|
||||||
|
LEFT JOIN customers c ON i.customer_id = c.id
|
||||||
|
WHERE i.id = $1
|
||||||
|
`, [invoiceId]);
|
||||||
|
|
||||||
|
const invoice = invoiceRes.rows[0];
|
||||||
|
if (!invoice.qbo_id) return { skipped: true, reason: 'Not in QBO' };
|
||||||
|
|
||||||
|
const itemsRes = await client.query('SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY item_order', [invoiceId]);
|
||||||
|
|
||||||
|
const oauthClient = getOAuthClient();
|
||||||
|
const companyId = oauthClient.getToken().realmId;
|
||||||
|
const baseUrl = process.env.QBO_ENVIRONMENT === 'production'
|
||||||
|
? 'https://quickbooks.api.intuit.com'
|
||||||
|
: 'https://sandbox-quickbooks.api.intuit.com';
|
||||||
|
|
||||||
|
// Aktuellen SyncToken holen
|
||||||
|
const qboRes = await makeQboApiCall({
|
||||||
|
url: `${baseUrl}/v3/company/${companyId}/invoice/${invoice.qbo_id}`,
|
||||||
|
method: 'GET'
|
||||||
|
});
|
||||||
|
const qboData = qboRes.getJson ? qboRes.getJson() : qboRes.json;
|
||||||
|
const currentSyncToken = qboData.Invoice?.SyncToken;
|
||||||
|
if (currentSyncToken === undefined) throw new Error('Could not get SyncToken from QBO');
|
||||||
|
|
||||||
|
const lineItems = itemsRes.rows.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" : "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(`📤 QBO Sync Invoice ${invoice.qbo_id}...`);
|
||||||
|
const updateRes = await makeQboApiCall({
|
||||||
|
url: `${baseUrl}/v3/company/${companyId}/invoice`,
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(updatePayload)
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateData = updateRes.getJson ? updateRes.getJson() : updateRes.json;
|
||||||
|
const updated = updateData.Invoice || updateData;
|
||||||
|
if (!updated.Id) throw new Error('QBO update returned no ID');
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
'UPDATE invoices SET qbo_sync_token = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
|
||||||
|
[updated.SyncToken, invoiceId]
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`✅ QBO Invoice ${invoice.qbo_id} synced (SyncToken: ${updated.SyncToken})`);
|
||||||
|
return { success: true, sync_token: updated.SyncToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Logo endpoints
|
// Logo endpoints
|
||||||
app.get('/api/logo-info', async (req, res) => {
|
app.get('/api/logo-info', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -584,26 +755,25 @@ app.get('/api/invoices/:id', async (req, res) => {
|
||||||
|
|
||||||
|
|
||||||
app.post('/api/invoices', 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, scheduled_send_date } = req.body;
|
const { invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, items, scheduled_send_date, created_from_quote_id } = req.body;
|
||||||
|
|
||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
try {
|
try {
|
||||||
await client.query('BEGIN');
|
await client.query('BEGIN');
|
||||||
|
|
||||||
// invoice_number ist jetzt OPTIONAL — wird erst beim QBO Export vergeben
|
// invoice_number kann leer sein — wird von QBO vergeben
|
||||||
// Wenn angegeben, muss sie numerisch sein und darf nicht existieren
|
// Falls angegeben, validieren
|
||||||
if (invoice_number && invoice_number.trim() !== '') {
|
if (invoice_number && !/^\d+$/.test(invoice_number)) {
|
||||||
if (!/^\d+$/.test(invoice_number)) {
|
await client.query('ROLLBACK');
|
||||||
await client.query('ROLLBACK');
|
return res.status(400).json({ error: 'Invoice number must be numeric.' });
|
||||||
return res.status(400).json({ error: 'Invalid invoice number. Must be a numeric value.' });
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const existingInvoice = await client.query(
|
// Temporäre Nummer falls leer
|
||||||
'SELECT id FROM invoices WHERE invoice_number = $1',
|
const tempNumber = invoice_number || `DRAFT-${Date.now()}`;
|
||||||
[invoice_number]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existingInvoice.rows.length > 0) {
|
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');
|
await client.query('ROLLBACK');
|
||||||
return res.status(400).json({ error: `Invoice number ${invoice_number} already exists.` });
|
return res.status(400).json({ error: `Invoice number ${invoice_number} already exists.` });
|
||||||
}
|
}
|
||||||
|
|
@ -612,25 +782,18 @@ app.post('/api/invoices', async (req, res) => {
|
||||||
let subtotal = 0;
|
let subtotal = 0;
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const amount = parseFloat(item.amount.replace(/[$,]/g, ''));
|
const amount = parseFloat(item.amount.replace(/[$,]/g, ''));
|
||||||
if (!isNaN(amount)) {
|
if (!isNaN(amount)) subtotal += amount;
|
||||||
subtotal += amount;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const tax_rate = 8.25;
|
const tax_rate = 8.25;
|
||||||
const tax_amount = tax_exempt ? 0 : (subtotal * tax_rate / 100);
|
const tax_amount = tax_exempt ? 0 : (subtotal * tax_rate / 100);
|
||||||
const total = subtotal + tax_amount;
|
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(
|
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, scheduled_send_date)
|
`INSERT INTO invoices (invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, scheduled_send_date, created_from_quote_id)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING *`,
|
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]
|
[tempNumber, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, scheduled_send_date || null, created_from_quote_id]
|
||||||
);
|
);
|
||||||
|
|
||||||
const invoiceId = invoiceResult.rows[0].id;
|
const invoiceId = invoiceResult.rows[0].id;
|
||||||
|
|
||||||
for (let i = 0; i < items.length; i++) {
|
for (let i = 0; i < items.length; i++) {
|
||||||
|
|
@ -641,7 +804,25 @@ app.post('/api/invoices', async (req, res) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
await client.query('COMMIT');
|
await client.query('COMMIT');
|
||||||
res.json(invoiceResult.rows[0]);
|
|
||||||
|
// Auto QBO Export (falls Kunde in QBO)
|
||||||
|
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);
|
||||||
|
// Nicht abbrechen — lokal wurde gespeichert
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
...invoiceResult.rows[0],
|
||||||
|
qbo_id: qboResult?.qbo_id || null,
|
||||||
|
qbo_doc_number: qboResult?.qbo_doc_number || null
|
||||||
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await client.query('ROLLBACK');
|
await client.query('ROLLBACK');
|
||||||
console.error('Error creating invoice:', error);
|
console.error('Error creating invoice:', error);
|
||||||
|
|
@ -725,47 +906,49 @@ app.put('/api/invoices/:id', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
await client.query('BEGIN');
|
await client.query('BEGIN');
|
||||||
|
|
||||||
// invoice_number ist optional. Wenn angegeben und nicht leer, muss sie numerisch sein
|
// Invoice-Nummer validieren (falls angegeben)
|
||||||
const invNum = (invoice_number && invoice_number.trim() !== '') ? invoice_number : null;
|
if (invoice_number && !/^\d+$/.test(invoice_number)) {
|
||||||
|
|
||||||
if (invNum && !/^\d+$/.test(invNum)) {
|
|
||||||
await client.query('ROLLBACK');
|
await client.query('ROLLBACK');
|
||||||
return res.status(400).json({ error: 'Invalid invoice number. Must be a numeric value.' });
|
return res.status(400).json({ error: 'Invoice number must be numeric.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (invNum) {
|
if (invoice_number) {
|
||||||
const existingInvoice = await client.query(
|
const existing = await client.query('SELECT id FROM invoices WHERE invoice_number = $1 AND id != $2', [invoice_number, id]);
|
||||||
'SELECT id FROM invoices WHERE invoice_number = $1 AND id != $2',
|
if (existing.rows.length > 0) {
|
||||||
[invNum, id]
|
|
||||||
);
|
|
||||||
if (existingInvoice.rows.length > 0) {
|
|
||||||
await client.query('ROLLBACK');
|
await client.query('ROLLBACK');
|
||||||
return res.status(400).json({ error: `Invoice number ${invNum} already exists.` });
|
return res.status(400).json({ error: `Invoice number ${invoice_number} already exists.` });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let subtotal = 0;
|
let subtotal = 0;
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const amount = parseFloat(item.amount.replace(/[$,]/g, ''));
|
const amount = parseFloat(item.amount.replace(/[$,]/g, ''));
|
||||||
if (!isNaN(amount)) {
|
if (!isNaN(amount)) subtotal += amount;
|
||||||
subtotal += amount;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const tax_rate = 8.25;
|
const tax_rate = 8.25;
|
||||||
const tax_amount = tax_exempt ? 0 : (subtotal * tax_rate / 100);
|
const tax_amount = tax_exempt ? 0 : (subtotal * tax_rate / 100);
|
||||||
const total = subtotal + tax_amount;
|
const total = subtotal + tax_amount;
|
||||||
const sendDate = scheduled_send_date || null;
|
|
||||||
|
|
||||||
await client.query(
|
// Update lokal — invoice_number nur ändern wenn angegeben
|
||||||
`UPDATE invoices SET invoice_number = $1, customer_id = $2, invoice_date = $3, terms = $4, auth_code = $5, tax_exempt = $6,
|
if (invoice_number) {
|
||||||
tax_rate = $7, subtotal = $8, tax_amount = $9, total = $10, scheduled_send_date = $11, updated_at = CURRENT_TIMESTAMP
|
await client.query(
|
||||||
WHERE id = $12`,
|
`UPDATE invoices SET invoice_number = $1, customer_id = $2, invoice_date = $3, terms = $4, auth_code = $5, tax_exempt = $6,
|
||||||
[invNum, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, sendDate, id]
|
tax_rate = $7, subtotal = $8, tax_amount = $9, total = $10, scheduled_send_date = $11, updated_at = CURRENT_TIMESTAMP
|
||||||
);
|
WHERE id = $12`,
|
||||||
|
[invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, scheduled_send_date || 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, updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $11`,
|
||||||
|
[customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, scheduled_send_date || null, id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Items neu schreiben
|
||||||
await client.query('DELETE FROM invoice_items WHERE invoice_id = $1', [id]);
|
await client.query('DELETE FROM invoice_items WHERE invoice_id = $1', [id]);
|
||||||
|
|
||||||
for (let i = 0; i < items.length; i++) {
|
for (let i = 0; i < items.length; i++) {
|
||||||
await client.query(
|
await client.query(
|
||||||
'INSERT INTO invoice_items (invoice_id, quantity, description, rate, amount, item_order, qbo_item_id) VALUES ($1, $2, $3, $4, $5, $6, $7)',
|
'INSERT INTO invoice_items (invoice_id, quantity, description, rate, amount, item_order, qbo_item_id) VALUES ($1, $2, $3, $4, $5, $6, $7)',
|
||||||
|
|
@ -774,7 +957,20 @@ app.put('/api/invoices/:id', async (req, res) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
await client.query('COMMIT');
|
await client.query('COMMIT');
|
||||||
res.json({ success: true });
|
|
||||||
|
// Auto QBO Sync (falls bereits in QBO)
|
||||||
|
let qboResult = null;
|
||||||
|
try {
|
||||||
|
qboResult = await syncInvoiceToQbo(id, client);
|
||||||
|
if (qboResult.skipped) {
|
||||||
|
console.log(`ℹ️ Invoice ${id} not synced to QBO: ${qboResult.reason}`);
|
||||||
|
}
|
||||||
|
} catch (qboErr) {
|
||||||
|
console.error(`⚠️ Auto QBO sync failed for Invoice ${id}:`, qboErr.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, qbo_synced: !!qboResult?.success });
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await client.query('ROLLBACK');
|
await client.query('ROLLBACK');
|
||||||
console.error('Error updating invoice:', error);
|
console.error('Error updating invoice:', error);
|
||||||
|
|
@ -784,7 +980,6 @@ app.put('/api/invoices/:id', async (req, res) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
app.delete('/api/invoices/:id', async (req, res) => {
|
app.delete('/api/invoices/:id', async (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue