Compare commits
No commits in common. "refactoring" and "main" have entirely different histories.
refactorin
...
main
11
.env.example
11
.env.example
|
|
@ -8,14 +8,3 @@ DB_NAME=quotes_db
|
|||
# Server Configuration
|
||||
PORT=3000
|
||||
NODE_ENV=production
|
||||
|
||||
# QBO API Credentials
|
||||
QBO_CLIENT_ID=client_id
|
||||
QBO_CLIENT_SECRET=client_secret
|
||||
QBO_ENVIRONMENT=production
|
||||
QBO_REDIRECT_URI=https://developer.intuit.com/v2/OAuth2Playground/RedirectUrl
|
||||
|
||||
# QBO Tokens (aus dem Playground)
|
||||
QBO_ACCESS_TOKEN=access_token
|
||||
QBO_REFRESH_TOKEN=refresh_token
|
||||
QBO_REALM_ID=realm_id
|
||||
|
|
@ -23,10 +23,9 @@ COPY package*.json ./
|
|||
RUN npm install --omit=dev
|
||||
|
||||
# Copy application files
|
||||
COPY server.js ./
|
||||
COPY qbo_helper.js ./
|
||||
COPY src ./src
|
||||
COPY public ./public
|
||||
COPY templates ./templates
|
||||
|
||||
# Create uploads directory
|
||||
RUN mkdir -p public/uploads && \
|
||||
|
|
@ -39,5 +38,5 @@ EXPOSE 3000
|
|||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD node -e "require('http').get('http://localhost:3000/api/customers', (r) => {if (r.statusCode !== 200) throw new Error(r.statusCode)})"
|
||||
|
||||
# Start server (using modular entry point)
|
||||
CMD ["node", "src/index.js"]
|
||||
# Start server
|
||||
CMD ["node", "server.js"]
|
||||
|
|
|
|||
|
|
@ -39,8 +39,6 @@ services:
|
|||
QBO_REALM_ID: ${QBO_REALM_ID}
|
||||
QBO_ACCESS_TOKEN: ${QBO_ACCESS_TOKEN}
|
||||
QBO_REFRESH_TOKEN: ${QBO_REFRESH_TOKEN}
|
||||
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID}
|
||||
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY}
|
||||
volumes:
|
||||
- ./public/uploads:/app/public/uploads
|
||||
- ./templates:/app/templates # NEU!
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -2,20 +2,17 @@
|
|||
"name": "quote-invoice-system",
|
||||
"version": "2.0.0",
|
||||
"description": "Quote & Invoice Management System for Bay Area Affiliates",
|
||||
"main": "src/index.js",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "nodemon src/index.js"
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sesv2": "^3.1009.0",
|
||||
"csv-parser": "^3.2.0",
|
||||
"dotenv": "^17.3.1",
|
||||
"express": "^4.21.2",
|
||||
"intuit-oauth": "^4.2.2",
|
||||
"mjml": "^4.18.0",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"nodemailer": "^8.0.2",
|
||||
"pg": "^8.13.1",
|
||||
"puppeteer": "^23.11.1"
|
||||
},
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,54 +0,0 @@
|
|||
/* styles.css — Application styles extracted from index.html */
|
||||
|
||||
.modal {
|
||||
display: none;
|
||||
}
|
||||
.modal.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* Invoice/Quote Modal — visible field borders */
|
||||
#invoice-modal input,
|
||||
#invoice-modal select,
|
||||
#invoice-modal textarea,
|
||||
#quote-modal input,
|
||||
#quote-modal select,
|
||||
#quote-modal textarea {
|
||||
border: 1.5px solid #9ca3af !important;
|
||||
}
|
||||
|
||||
#invoice-modal input:focus,
|
||||
#invoice-modal select:focus,
|
||||
#invoice-modal textarea:focus,
|
||||
#quote-modal input:focus,
|
||||
#quote-modal select:focus,
|
||||
#quote-modal textarea:focus {
|
||||
border-color: #3b82f6 !important;
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2) !important;
|
||||
}
|
||||
|
||||
/* Rich Text Editor borders */
|
||||
#invoice-modal .ql-container,
|
||||
#invoice-modal .ql-toolbar,
|
||||
#quote-modal .ql-container,
|
||||
#quote-modal .ql-toolbar {
|
||||
border: 1.5px solid #9ca3af !important;
|
||||
}
|
||||
|
||||
.item-row input,
|
||||
.item-row select,
|
||||
.invoice-item input,
|
||||
.invoice-item select,
|
||||
#invoice-items input,
|
||||
#invoice-items select,
|
||||
#quote-items input,
|
||||
#quote-items select {
|
||||
border: 1.5px solid #9ca3af !important;
|
||||
}
|
||||
|
||||
#invoice-items > div,
|
||||
#quote-items > div,
|
||||
#invoice-items .border,
|
||||
#quote-items .border {
|
||||
border: 1.5px solid #9ca3af !important;
|
||||
}
|
||||
|
|
@ -7,12 +7,61 @@
|
|||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://cdn.quilljs.com/1.3.6/quill.snow.css" rel="stylesheet">
|
||||
<script src="https://cdn.quilljs.com/1.3.6/quill.js"></script>
|
||||
<script src="js/components/customer-search.js"></script>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon.png">
|
||||
<link rel="apple-touch-icon" sizes="192x192" href="/favicon-192.png">
|
||||
<link rel="stylesheet" href="css/styles.css">
|
||||
<style>
|
||||
.modal {
|
||||
display: none;
|
||||
}
|
||||
.modal.active {
|
||||
display: flex;
|
||||
}
|
||||
/* Invoice/Quote Modal - deutlichere Feldumrandungen */
|
||||
#invoice-modal input,
|
||||
#invoice-modal select,
|
||||
#invoice-modal textarea,
|
||||
#quote-modal input,
|
||||
#quote-modal select,
|
||||
#quote-modal textarea {
|
||||
border: 1.5px solid #9ca3af !important;
|
||||
}
|
||||
|
||||
#invoice-modal input:focus,
|
||||
#invoice-modal select:focus,
|
||||
#invoice-modal textarea:focus,
|
||||
#quote-modal input:focus,
|
||||
#quote-modal select:focus,
|
||||
#quote-modal textarea:focus {
|
||||
border-color: #3b82f6 !important;
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.2) !important;
|
||||
}
|
||||
/* Line Item Felder + Rich Text Editor */
|
||||
#invoice-modal .ql-container,
|
||||
#invoice-modal .ql-toolbar,
|
||||
#quote-modal .ql-container,
|
||||
#quote-modal .ql-toolbar {
|
||||
border: 1.5px solid #9ca3af !important;
|
||||
}
|
||||
|
||||
.item-row input,
|
||||
.item-row select,
|
||||
.invoice-item input,
|
||||
.invoice-item select,
|
||||
#invoice-items input,
|
||||
#invoice-items select,
|
||||
#quote-items input,
|
||||
#quote-items select {
|
||||
border: 1.5px solid #9ca3af !important;
|
||||
}
|
||||
#invoice-items > div,
|
||||
#quote-items > div,
|
||||
#invoice-items .border,
|
||||
#quote-items .border {
|
||||
border: 1.5px solid #9ca3af !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-100">
|
||||
<div class="min-h-screen">
|
||||
|
|
@ -62,7 +111,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>
|
||||
|
|
@ -71,6 +120,7 @@
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Toolbar wird von invoice-view.js injiziert -->
|
||||
<div id="invoice-toolbar"></div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
|
|
@ -159,9 +209,6 @@
|
|||
</button>
|
||||
|
||||
<div id="qbo-import-result" class="mt-4 hidden"></div>
|
||||
|
||||
<hr class="my-8 border-gray-200">
|
||||
|
||||
<h3 class="text-xl font-semibold mb-4 text-gray-800">QuickBooks Online Authorization</h3>
|
||||
<p class="text-gray-600 mb-4">
|
||||
Wenn der Token abgelaufen ist oder die Verbindung fehlschlägt,
|
||||
|
|
@ -177,6 +224,7 @@
|
|||
</div>
|
||||
|
||||
<script>
|
||||
// QBO Status beim Laden prüfen
|
||||
fetch('/api/qbo/status')
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
|
|
@ -192,8 +240,6 @@
|
|||
});
|
||||
</script>
|
||||
|
||||
<hr class="my-8 border-gray-200">
|
||||
|
||||
<h3 class="text-xl font-semibold mb-4 text-gray-800">QuickBooks Online Connection Test</h3>
|
||||
<p class="text-gray-600 mb-4">Test the connection and token refresh logic by fetching a report of overdue invoices (> 30 days) directly from QBO.</p>
|
||||
|
||||
|
|
@ -214,7 +260,7 @@
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody id="qbo-result-list" class="divide-y divide-gray-200 text-sm">
|
||||
</tbody>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -315,9 +361,13 @@
|
|||
|
||||
<div class="flex justify-end space-x-3 pt-4">
|
||||
<button type="button" onclick="closeQuoteModal()"
|
||||
class="px-6 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300">Cancel</button>
|
||||
class="px-6 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit"
|
||||
class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">Save Quote</button>
|
||||
class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
|
||||
Save Quote
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -336,7 +386,7 @@
|
|||
</div>
|
||||
|
||||
<form id="invoice-form" class="space-y-6">
|
||||
<div class="grid grid-cols-6 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" pattern="[0-9]*"
|
||||
|
|
@ -401,24 +451,10 @@
|
|||
<input type="text" id="invoice-terms" value="Net 30" required
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div class="flex items-center gap-6 pt-6 w-max">
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" id="invoice-tax-exempt"
|
||||
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||||
<label for="invoice-tax-exempt" class="ml-2 block text-sm text-gray-900">Tax Exempt</label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="checkbox" id="invoice-recurring"
|
||||
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||||
<label for="invoice-recurring" class="text-sm text-gray-900">Recurring</label>
|
||||
<div id="invoice-recurring-group" style="display: none;">
|
||||
<select id="invoice-recurring-interval"
|
||||
class="px-2 py-1 border border-gray-300 rounded-md text-sm bg-white">
|
||||
<option value="monthly">Monthly</option>
|
||||
<option value="yearly">Yearly</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center pt-6">
|
||||
<input type="checkbox" id="invoice-tax-exempt"
|
||||
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||||
<label for="invoice-tax-exempt" class="ml-2 block text-sm text-gray-900">Tax Exempt</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -459,15 +495,35 @@
|
|||
|
||||
<div class="flex justify-end space-x-3 pt-4">
|
||||
<button type="button" onclick="closeInvoiceModal()"
|
||||
class="px-6 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300">Cancel</button>
|
||||
class="px-6 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit"
|
||||
class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">Save Invoice</button>
|
||||
class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
|
||||
Save Invoice
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Single module entry point — all JS loaded from here -->
|
||||
<script type="module" src="js/app.js"></script>
|
||||
<script src="app.js"></script>
|
||||
<script type="module">
|
||||
import { loadCustomers, renderCustomerView, injectToolbar as injectCustomerToolbar } from './customer-view.js';
|
||||
|
||||
// Override showTab to inject customer toolbar
|
||||
const originalShowTab = window.showTab;
|
||||
window.showTab = function(tab) {
|
||||
originalShowTab(tab);
|
||||
if (tab === 'customers') {
|
||||
injectCustomerToolbar();
|
||||
renderCustomerView();
|
||||
}
|
||||
};
|
||||
|
||||
// Load customers on init (needed for quote/invoice dropdowns)
|
||||
loadCustomers();
|
||||
</script>
|
||||
<script type="module" src="invoice-view-init.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
// invoice-view-init.js — Bootstrap-Script (type="module")
|
||||
import { loadInvoices, renderInvoiceView, injectToolbar } from './invoice-view.js';
|
||||
import './payment-modal.js';
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
|
||||
function init() {
|
||||
injectToolbar();
|
||||
window.loadInvoices = loadInvoices;
|
||||
window.renderInvoices = renderInvoiceView;
|
||||
loadInvoices();
|
||||
}
|
||||
|
|
@ -195,13 +195,7 @@ function renderInvoiceRow(invoice) {
|
|||
} else 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 (partial) {
|
||||
// Partial: show delivery status badge + Partial badge
|
||||
if (hasQbo && invoice.email_status === 'sent') {
|
||||
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-cyan-200 text-cyan-800">Sent</span> `;
|
||||
} else if (hasQbo) {
|
||||
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-orange-200 text-orange-800">Open</span> `;
|
||||
}
|
||||
statusBadge += `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800" title="Paid: $${amountPaid.toFixed(2)} / Balance: $${balance.toFixed(2)}">Partial</span>`;
|
||||
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800" title="Paid: $${amountPaid.toFixed(2)} / Balance: $${balance.toFixed(2)}">Partial $${amountPaid.toFixed(2)}</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">Overdue</span>`;
|
||||
} else if (hasQbo && invoice.email_status === 'sent') {
|
||||
|
|
@ -258,18 +252,10 @@ function renderInvoiceRow(invoice) {
|
|||
|
||||
// Mark Sent button (right side) — only when open, not paid/partial
|
||||
let sendBtn = '';
|
||||
// if (hasQbo && !paid && !overdue && invoice.email_status !== 'sent') {
|
||||
// sendBtn = `<button onclick="window.invoiceView.setEmailStatus(${invoice.id}, 'sent')" class="text-indigo-600 hover:text-indigo-800 text-xs font-medium" title="Mark as sent to customer">📤 Mark Sent</button>`;
|
||||
// }
|
||||
if (hasQbo && !paid && !overdue) {
|
||||
sendBtn = `
|
||||
<button onclick="window.invoiceView.setEmailStatus(${invoice.id}, 'sent')" class="text-gray-600 hover:text-gray-900 text-xs font-medium mr-4" title="Nur Status ändern">
|
||||
✔️ Mark Sent
|
||||
</button>
|
||||
<button onclick="window.emailModal.open(${invoice.id})" class="text-indigo-600 hover:text-indigo-800 text-xs font-medium" title="E-Mail via SES versenden">
|
||||
📧 Send Email
|
||||
</button>
|
||||
`; }
|
||||
if (hasQbo && !paid && !overdue && invoice.email_status !== 'sent') {
|
||||
sendBtn = `<button onclick="window.invoiceView.setEmailStatus(${invoice.id}, 'sent')" class="text-indigo-600 hover:text-indigo-800 text-xs font-medium" title="Mark as sent to customer">📤 Mark Sent</button>`;
|
||||
}
|
||||
|
||||
const delBtn = `<button onclick="window.invoiceView.remove(${invoice.id})" class="text-red-600 hover:text-red-900">Del</button>`;
|
||||
|
||||
const rowClass = paid ? (invoice.payment_status === 'Deposited' ? 'bg-blue-50/50' : 'bg-green-50/50') : partial ? 'bg-yellow-50/30' : overdue ? 'bg-red-50/50' : '';
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
/**
|
||||
* app.js — Application Bootstrap
|
||||
*
|
||||
* This is the main entry point. All business logic has been moved to modules:
|
||||
* - js/views/quote-view.js → Quote list
|
||||
* - js/views/invoice-view.js → Invoice list (existing)
|
||||
* - js/views/settings-view.js → Logo, QBO import/test
|
||||
* - js/modals/quote-modal.js → Quote create/edit
|
||||
* - js/modals/invoice-modal.js → Invoice create/edit
|
||||
* - js/modals/payment-modal.js → Payment recording (existing)
|
||||
* - js/components/customer-search.js → Alpine dropdown
|
||||
* - js/utils/item-editor.js → Shared accordion item editor
|
||||
* - js/utils/helpers.js → formatDate, spinner
|
||||
* - js/utils/api.js → API wrapper (existing)
|
||||
*/
|
||||
|
||||
// --- Imports ---
|
||||
import { loadQuotes } from './views/quote-view.js';
|
||||
import { loadInvoices, injectToolbar as injectInvoiceToolbar, renderInvoiceView } from './views/invoice-view.js';
|
||||
import { loadCustomers, renderCustomerView, injectToolbar as injectCustomerToolbar } from './views/customer-view.js';
|
||||
import { checkCurrentLogo, initSettingsView } from './views/settings-view.js';
|
||||
import { initQuoteModal } from './modals/quote-modal.js';
|
||||
import { initInvoiceModal, loadLaborRate } from './modals/invoice-modal.js';
|
||||
import './modals/payment-modal.js';
|
||||
import './modals/email-modal.js';
|
||||
import { setDefaultDate } from './utils/helpers.js';
|
||||
|
||||
// ============================================================
|
||||
// Tab Management
|
||||
// ============================================================
|
||||
|
||||
function showTab(tabName) {
|
||||
document.querySelectorAll('.tab-content').forEach(tab => tab.classList.add('hidden'));
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('bg-blue-800'));
|
||||
|
||||
document.getElementById(`${tabName}-tab`).classList.remove('hidden');
|
||||
document.getElementById(`tab-${tabName}`).classList.add('bg-blue-800');
|
||||
|
||||
localStorage.setItem('activeTab', tabName);
|
||||
|
||||
if (tabName === 'quotes') {
|
||||
loadQuotes();
|
||||
} else if (tabName === 'invoices') {
|
||||
injectInvoiceToolbar();
|
||||
loadInvoices();
|
||||
} else if (tabName === 'customers') {
|
||||
injectCustomerToolbar();
|
||||
renderCustomerView();
|
||||
} else if (tabName === 'settings') {
|
||||
checkCurrentLogo();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Init
|
||||
// ============================================================
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Load shared data
|
||||
loadCustomers();
|
||||
loadLaborRate();
|
||||
setDefaultDate();
|
||||
|
||||
// Init modals (wire up form handlers)
|
||||
initQuoteModal();
|
||||
initInvoiceModal();
|
||||
initSettingsView();
|
||||
|
||||
// Restore saved tab (or default to quotes)
|
||||
const savedTab = localStorage.getItem('activeTab') || 'quotes';
|
||||
showTab(savedTab);
|
||||
|
||||
// Hash-based navigation (e.g. after OAuth redirect /#settings)
|
||||
if (window.location.hash) {
|
||||
const hashTab = window.location.hash.replace('#', '');
|
||||
if (['quotes', 'invoices', 'customers', 'settings'].includes(hashTab)) {
|
||||
showTab(hashTab);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// Expose to HTML onclick handlers
|
||||
// ============================================================
|
||||
|
||||
window.showTab = showTab;
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
/**
|
||||
* customer-search.js — Alpine.js Customer Search Component
|
||||
* Used in Quote and Invoice modals for customer dropdown
|
||||
*/
|
||||
|
||||
function customerSearch(type) {
|
||||
return {
|
||||
search: '',
|
||||
selectedId: '',
|
||||
selectedName: '',
|
||||
open: false,
|
||||
highlighted: 0,
|
||||
|
||||
get filteredCustomers() {
|
||||
const allCustomers = window.getCustomers ? window.getCustomers() : (window.customers || []);
|
||||
|
||||
if (!this.search) {
|
||||
return allCustomers;
|
||||
}
|
||||
|
||||
const searchLower = this.search.toLowerCase();
|
||||
return allCustomers.filter(c =>
|
||||
(c.name || '').toLowerCase().includes(searchLower) ||
|
||||
(c.line1 || '').toLowerCase().includes(searchLower) ||
|
||||
(c.city || '').toLowerCase().includes(searchLower) ||
|
||||
(c.account_number && c.account_number.includes(searchLower))
|
||||
);
|
||||
},
|
||||
|
||||
selectCustomer(customer) {
|
||||
this.selectedId = customer.id;
|
||||
this.selectedName = customer.name;
|
||||
this.search = customer.name;
|
||||
this.open = false;
|
||||
this.highlighted = 0;
|
||||
},
|
||||
|
||||
highlightNext() {
|
||||
if (this.highlighted < this.filteredCustomers.length - 1) {
|
||||
this.highlighted++;
|
||||
}
|
||||
},
|
||||
|
||||
highlightPrev() {
|
||||
if (this.highlighted > 0) {
|
||||
this.highlighted--;
|
||||
}
|
||||
},
|
||||
|
||||
selectHighlighted() {
|
||||
if (this.filteredCustomers[this.highlighted]) {
|
||||
this.selectCustomer(this.filteredCustomers[this.highlighted]);
|
||||
}
|
||||
},
|
||||
|
||||
reset() {
|
||||
this.search = '';
|
||||
this.selectedId = '';
|
||||
this.selectedName = '';
|
||||
this.open = false;
|
||||
this.highlighted = 0;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Make globally available for Alpine x-data
|
||||
window.customerSearch = customerSearch;
|
||||
|
|
@ -1,225 +0,0 @@
|
|||
// email-modal.js — ES Module
|
||||
// Modal to review and send invoice emails via AWS SES
|
||||
|
||||
import { showSpinner, hideSpinner } from '../utils/helpers.js';
|
||||
|
||||
let currentInvoice = null;
|
||||
let quillInstance = null;
|
||||
|
||||
// ============================================================
|
||||
// DOM & Render
|
||||
// ============================================================
|
||||
|
||||
function ensureModalElement() {
|
||||
let modal = document.getElementById('email-modal');
|
||||
if (!modal) {
|
||||
modal = document.createElement('div');
|
||||
modal.id = 'email-modal';
|
||||
modal.className = 'modal fixed inset-0 bg-black bg-opacity-50 z-50 justify-center items-start pt-10 overflow-y-auto hidden';
|
||||
document.body.appendChild(modal);
|
||||
}
|
||||
}
|
||||
|
||||
function renderModalContent() {
|
||||
const modal = document.getElementById('email-modal');
|
||||
if (!modal) return;
|
||||
|
||||
const defaultEmail = currentInvoice.email || '';
|
||||
|
||||
// Editor-Container hat jetzt eine feste, kompaktere Höhe (h-48 = 12rem/192px) und scrollt bei viel Text
|
||||
modal.innerHTML = `
|
||||
<div class="bg-white rounded-lg shadow-2xl w-full max-w-3xl mx-auto p-8">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-2xl font-bold text-gray-800">📤 Send Invoice #${currentInvoice.invoice_number || currentInvoice.id}</h2>
|
||||
<button onclick="window.emailModal.close()" class="text-gray-400 hover:text-gray-600">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="email-send-form" class="space-y-5">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Recipient Email *</label>
|
||||
<input type="email" id="email-recipient" value="${defaultEmail}" required
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||||
<p class="text-xs text-gray-400 mt-1">You can override this for testing.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Melio Payment Link (Optional)</label>
|
||||
<input type="url" id="email-melio-link" placeholder="https://melio.me/..."
|
||||
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Message Body</label>
|
||||
<div id="email-message-editor" class="border border-gray-300 rounded-md bg-white h-48 overflow-y-auto"></div>
|
||||
</div>
|
||||
|
||||
<div class="bg-blue-50 border border-blue-200 p-4 rounded-md flex items-center gap-3">
|
||||
<span class="text-2xl">📎</span>
|
||||
<div class="text-sm text-blue-800">
|
||||
<strong>Invoice_${currentInvoice.invoice_number || currentInvoice.id}.pdf</strong> will be generated and attached automatically.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-3 pt-4">
|
||||
<button type="button" onclick="window.emailModal.close()"
|
||||
class="px-6 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300">Cancel</button>
|
||||
<button type="submit" id="email-submit-btn"
|
||||
class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 font-semibold">
|
||||
Send via AWS SES
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>`;
|
||||
|
||||
// Initialize Quill
|
||||
const editorDiv = document.getElementById('email-message-editor');
|
||||
quillInstance = new Quill(editorDiv, {
|
||||
theme: 'snow',
|
||||
modules: {
|
||||
toolbar: [
|
||||
['bold', 'italic', 'underline'],
|
||||
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
|
||||
['clean']
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
// Variablen für den Text aufbereiten
|
||||
const invoiceNum = currentInvoice.invoice_number || currentInvoice.id;
|
||||
const totalDue = parseFloat(currentInvoice.balance ?? currentInvoice.total).toFixed(2);
|
||||
|
||||
// Datum formatieren
|
||||
let dueDateStr = 'Upon Receipt';
|
||||
if (currentInvoice.due_date) {
|
||||
const d = new Date(currentInvoice.due_date);
|
||||
dueDateStr = d.toLocaleDateString('en-US', { timeZone: 'UTC' });
|
||||
}
|
||||
|
||||
// Dynamischer Text für die Fälligkeit (Löst das "payable by Upon Receipt" Problem)
|
||||
let paymentText = '';
|
||||
if (currentInvoice.terms && currentInvoice.terms.toLowerCase().includes('receipt')) {
|
||||
paymentText = 'which is due upon receipt.';
|
||||
} else if (dueDateStr !== 'Upon Receipt') {
|
||||
paymentText = `payable by <strong>${dueDateStr}</strong>.`;
|
||||
} else {
|
||||
paymentText = 'which is due upon receipt.';
|
||||
}
|
||||
|
||||
// Der neue Standard-Text
|
||||
const defaultHtml = `
|
||||
<p>Good afternoon,</p>
|
||||
<p>Attached is invoice <strong>#${invoiceNum}</strong> for service performed at your location. The total amount due is <strong>$${totalDue}</strong>, ${paymentText}</p>
|
||||
<p>Please pay at your earliest convenience. We appreciate your continued business.</p>
|
||||
<p>If you have any questions about the invoice, feel free to reply to this email.</p>
|
||||
<p>Best regards,</p>
|
||||
<p><strong>Claudia Knuth</strong></p>
|
||||
<p>Bay Area Affiliates, Inc.</p>
|
||||
<p>accounting@bayarea-cc.com</p>
|
||||
`;
|
||||
quillInstance.root.innerHTML = defaultHtml;
|
||||
|
||||
// Bind Submit Handler
|
||||
document.getElementById('email-send-form').addEventListener('submit', submitEmail);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Logic & API
|
||||
// ============================================================
|
||||
|
||||
export async function openEmailModal(invoiceId) {
|
||||
ensureModalElement();
|
||||
|
||||
if (typeof showSpinner === 'function') showSpinner('Loading invoice data...');
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/invoices/${invoiceId}`);
|
||||
const data = await res.json();
|
||||
|
||||
if (!data.invoice) throw new Error('Invoice not found');
|
||||
currentInvoice = data.invoice;
|
||||
|
||||
renderModalContent();
|
||||
|
||||
// Tailwind hidden toggle
|
||||
document.getElementById('email-modal').classList.remove('hidden');
|
||||
document.getElementById('email-modal').classList.add('flex');
|
||||
|
||||
} catch (e) {
|
||||
console.error('Error loading invoice for email:', e);
|
||||
alert('Could not load invoice details.');
|
||||
} finally {
|
||||
if (typeof hideSpinner === 'function') hideSpinner();
|
||||
}
|
||||
}
|
||||
|
||||
export function closeEmailModal() {
|
||||
const modal = document.getElementById('email-modal');
|
||||
if (modal) {
|
||||
modal.classList.add('hidden');
|
||||
modal.classList.remove('flex');
|
||||
}
|
||||
currentInvoice = null;
|
||||
quillInstance = null;
|
||||
}
|
||||
|
||||
async function submitEmail(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const recipientEmail = document.getElementById('email-recipient').value.trim();
|
||||
const melioLink = document.getElementById('email-melio-link').value.trim();
|
||||
const customText = quillInstance.root.innerHTML;
|
||||
|
||||
if (!recipientEmail) {
|
||||
alert('Please enter a recipient email.');
|
||||
return;
|
||||
}
|
||||
|
||||
const submitBtn = document.getElementById('email-submit-btn');
|
||||
submitBtn.innerHTML = '⏳ Sending...';
|
||||
submitBtn.disabled = true;
|
||||
|
||||
if (typeof showSpinner === 'function') showSpinner('Generating PDF and sending email...');
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/invoices/${currentInvoice.id}/send-email`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
recipientEmail,
|
||||
melioLink,
|
||||
customText
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
alert('✅ Invoice sent successfully!');
|
||||
closeEmailModal();
|
||||
// Reload the invoice view so the "Sent" badge updates
|
||||
if (window.invoiceView) window.invoiceView.loadInvoices();
|
||||
} else {
|
||||
alert(`❌ Error: ${result.error}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Send email error:', e);
|
||||
alert('Network error while sending email.');
|
||||
} finally {
|
||||
submitBtn.innerHTML = 'Send via AWS SES';
|
||||
submitBtn.disabled = false;
|
||||
if (typeof hideSpinner === 'function') hideSpinner();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Expose
|
||||
// ============================================================
|
||||
window.emailModal = {
|
||||
open: openEmailModal,
|
||||
close: closeEmailModal
|
||||
};
|
||||
|
|
@ -1,233 +0,0 @@
|
|||
/**
|
||||
* invoice-modal.js — Invoice create/edit modal
|
||||
* Uses shared item-editor for accordion items
|
||||
*
|
||||
* Features:
|
||||
* - Auto-sets tax-exempt based on customer's taxable flag
|
||||
* - Recurring invoice support (monthly/yearly)
|
||||
*/
|
||||
import { addItem, getItems, resetItemCounter } from '../utils/item-editor.js';
|
||||
import { setDefaultDate, showSpinner, hideSpinner } from '../utils/helpers.js';
|
||||
|
||||
let currentInvoiceId = null;
|
||||
let qboLaborRate = null;
|
||||
|
||||
export async function loadLaborRate() {
|
||||
try {
|
||||
const response = await fetch('/api/qbo/labor-rate');
|
||||
const data = await response.json();
|
||||
if (data.rate) {
|
||||
qboLaborRate = data.rate;
|
||||
console.log(`💰 Labor Rate geladen: $${qboLaborRate}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('Labor Rate konnte nicht geladen werden.');
|
||||
}
|
||||
}
|
||||
|
||||
export function getLaborRate() { return qboLaborRate; }
|
||||
|
||||
/**
|
||||
* Auto-set tax exempt based on customer's taxable flag
|
||||
*/
|
||||
function applyCustomerTaxStatus(customerId) {
|
||||
const allCust = window.getCustomers ? window.getCustomers() : (window.customers || []);
|
||||
const customer = allCust.find(c => c.id === parseInt(customerId));
|
||||
if (customer) {
|
||||
const cb = document.getElementById('invoice-tax-exempt');
|
||||
if (cb) {
|
||||
cb.checked = (customer.taxable === false);
|
||||
updateInvoiceTotals();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function openInvoiceModal(invoiceId = null) {
|
||||
currentInvoiceId = invoiceId;
|
||||
if (invoiceId) {
|
||||
await loadInvoiceForEdit(invoiceId);
|
||||
} else {
|
||||
prepareNewInvoice();
|
||||
}
|
||||
document.getElementById('invoice-modal').classList.add('active');
|
||||
}
|
||||
|
||||
export function closeInvoiceModal() {
|
||||
document.getElementById('invoice-modal').classList.remove('active');
|
||||
currentInvoiceId = null;
|
||||
}
|
||||
|
||||
async function loadInvoiceForEdit(invoiceId) {
|
||||
document.getElementById('invoice-modal-title').textContent = 'Edit Invoice';
|
||||
const response = await fetch(`/api/invoices/${invoiceId}`);
|
||||
const data = await response.json();
|
||||
|
||||
// Set customer in Alpine component
|
||||
const allCust = window.getCustomers ? window.getCustomers() : (window.customers || []);
|
||||
const customer = allCust.find(c => c.id === data.invoice.customer_id);
|
||||
if (customer) {
|
||||
const customerInput = document.querySelector('#invoice-modal input[placeholder="Search customer..."]');
|
||||
if (customerInput) {
|
||||
customerInput.value = customer.name;
|
||||
customerInput.dispatchEvent(new Event('input'));
|
||||
const alpineData = Alpine.$data(customerInput.closest('[x-data]'));
|
||||
if (alpineData) {
|
||||
alpineData.search = customer.name;
|
||||
alpineData.selectedId = customer.id;
|
||||
alpineData.selectedName = customer.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('invoice-number').value = data.invoice.invoice_number || '';
|
||||
document.getElementById('invoice-customer').value = data.invoice.customer_id;
|
||||
document.getElementById('invoice-date').value = data.invoice.invoice_date.split('T')[0];
|
||||
document.getElementById('invoice-terms').value = data.invoice.terms;
|
||||
document.getElementById('invoice-authorization').value = data.invoice.auth_code || '';
|
||||
document.getElementById('invoice-tax-exempt').checked = data.invoice.tax_exempt;
|
||||
document.getElementById('invoice-bill-to-name').value = data.invoice.bill_to_name || '';
|
||||
|
||||
const sendDateEl = document.getElementById('invoice-send-date');
|
||||
if (sendDateEl) {
|
||||
sendDateEl.value = data.invoice.scheduled_send_date
|
||||
? data.invoice.scheduled_send_date.split('T')[0] : '';
|
||||
}
|
||||
|
||||
// Recurring fields
|
||||
const recurringCb = document.getElementById('invoice-recurring');
|
||||
const recurringInterval = document.getElementById('invoice-recurring-interval');
|
||||
const recurringGroup = document.getElementById('invoice-recurring-group');
|
||||
if (recurringCb) {
|
||||
recurringCb.checked = data.invoice.is_recurring || false;
|
||||
if (recurringInterval) recurringInterval.value = data.invoice.recurring_interval || 'monthly';
|
||||
if (recurringGroup) recurringGroup.style.display = data.invoice.is_recurring ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// Load items
|
||||
document.getElementById('invoice-items').innerHTML = '';
|
||||
resetItemCounter();
|
||||
data.items.forEach(item => {
|
||||
addItem('invoice-items', { item, type: 'invoice', laborRate: qboLaborRate, onUpdate: updateInvoiceTotals });
|
||||
});
|
||||
updateInvoiceTotals();
|
||||
}
|
||||
|
||||
function prepareNewInvoice() {
|
||||
document.getElementById('invoice-modal-title').textContent = 'New Invoice';
|
||||
document.getElementById('invoice-form').reset();
|
||||
document.getElementById('invoice-items').innerHTML = '';
|
||||
document.getElementById('invoice-terms').value = 'Net 30';
|
||||
document.getElementById('invoice-number').value = '';
|
||||
document.getElementById('invoice-send-date').value = '';
|
||||
|
||||
// Reset recurring
|
||||
const recurringCb = document.getElementById('invoice-recurring');
|
||||
const recurringGroup = document.getElementById('invoice-recurring-group');
|
||||
if (recurringCb) recurringCb.checked = false;
|
||||
if (recurringGroup) recurringGroup.style.display = 'none';
|
||||
|
||||
resetItemCounter();
|
||||
setDefaultDate();
|
||||
addItem('invoice-items', { type: 'invoice', laborRate: qboLaborRate, onUpdate: updateInvoiceTotals });
|
||||
}
|
||||
|
||||
export function addInvoiceItem(item = null) {
|
||||
addItem('invoice-items', { item, type: 'invoice', laborRate: qboLaborRate, onUpdate: updateInvoiceTotals });
|
||||
}
|
||||
|
||||
export function updateInvoiceTotals() {
|
||||
const items = getItems('invoice-items');
|
||||
const taxExempt = document.getElementById('invoice-tax-exempt').checked;
|
||||
let subtotal = 0;
|
||||
items.forEach(item => {
|
||||
const amount = parseFloat(item.amount.replace(/[$,]/g, '')) || 0;
|
||||
subtotal += amount;
|
||||
});
|
||||
const taxAmount = taxExempt ? 0 : (subtotal * 8.25 / 100);
|
||||
const total = subtotal + taxAmount;
|
||||
document.getElementById('invoice-subtotal').textContent = `$${subtotal.toFixed(2)}`;
|
||||
document.getElementById('invoice-tax').textContent = taxExempt ? '$0.00' : `$${taxAmount.toFixed(2)}`;
|
||||
document.getElementById('invoice-total').textContent = `$${total.toFixed(2)}`;
|
||||
document.getElementById('invoice-tax-row').style.display = taxExempt ? 'none' : 'block';
|
||||
}
|
||||
|
||||
export async function handleInvoiceSubmit(e) {
|
||||
e.preventDefault();
|
||||
const isRecurring = document.getElementById('invoice-recurring')?.checked || false;
|
||||
const recurringInterval = isRecurring
|
||||
? (document.getElementById('invoice-recurring-interval')?.value || 'monthly') : null;
|
||||
|
||||
const data = {
|
||||
invoice_number: document.getElementById('invoice-number').value || null,
|
||||
customer_id: 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,
|
||||
bill_to_name: document.getElementById('invoice-bill-to-name')?.value || null,
|
||||
is_recurring: isRecurring,
|
||||
recurring_interval: recurringInterval,
|
||||
items: getItems('invoice-items')
|
||||
};
|
||||
|
||||
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';
|
||||
showSpinner(invoiceId ? 'Saving invoice & syncing QBO...' : 'Creating invoice & exporting to QBO...');
|
||||
|
||||
try {
|
||||
const response = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) });
|
||||
const result = await response.json();
|
||||
if (response.ok) {
|
||||
closeInvoiceModal();
|
||||
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)');
|
||||
if (window.invoiceView) window.invoiceView.loadInvoices();
|
||||
} else {
|
||||
alert(`Error: ${result.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
alert('Error saving invoice');
|
||||
} finally {
|
||||
hideSpinner();
|
||||
}
|
||||
}
|
||||
|
||||
export function initInvoiceModal() {
|
||||
const form = document.getElementById('invoice-form');
|
||||
if (form) form.addEventListener('submit', handleInvoiceSubmit);
|
||||
|
||||
const taxExempt = document.getElementById('invoice-tax-exempt');
|
||||
if (taxExempt) taxExempt.addEventListener('change', updateInvoiceTotals);
|
||||
|
||||
// Recurring toggle
|
||||
const recurringCb = document.getElementById('invoice-recurring');
|
||||
const recurringGroup = document.getElementById('invoice-recurring-group');
|
||||
if (recurringCb && recurringGroup) {
|
||||
recurringCb.addEventListener('change', () => {
|
||||
recurringGroup.style.display = recurringCb.checked ? 'block' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
// Watch for customer selection → auto-set tax exempt (only for new invoices)
|
||||
const customerHidden = document.getElementById('invoice-customer');
|
||||
if (customerHidden) {
|
||||
const observer = new MutationObserver(() => {
|
||||
// Only auto-apply when creating new (not editing existing)
|
||||
if (!currentInvoiceId && customerHidden.value) {
|
||||
applyCustomerTaxStatus(customerHidden.value);
|
||||
}
|
||||
});
|
||||
observer.observe(customerHidden, { attributes: true, attributeFilter: ['value'] });
|
||||
}
|
||||
}
|
||||
|
||||
window.openInvoiceModal = openInvoiceModal;
|
||||
window.closeInvoiceModal = closeInvoiceModal;
|
||||
window.addInvoiceItem = addInvoiceItem;
|
||||
|
|
@ -1,180 +0,0 @@
|
|||
/**
|
||||
* quote-modal.js — Quote create/edit modal
|
||||
* Uses shared item-editor for accordion items
|
||||
*/
|
||||
import { addItem, getItems, resetItemCounter } from '../utils/item-editor.js';
|
||||
import { setDefaultDate, showSpinner, hideSpinner } from '../utils/helpers.js';
|
||||
|
||||
let currentQuoteId = null;
|
||||
|
||||
/**
|
||||
* Auto-set tax exempt based on customer's taxable flag
|
||||
*/
|
||||
function applyCustomerTaxStatus(customerId) {
|
||||
const allCust = window.getCustomers ? window.getCustomers() : (window.customers || []);
|
||||
const customer = allCust.find(c => c.id === parseInt(customerId));
|
||||
if (customer) {
|
||||
const cb = document.getElementById('quote-tax-exempt');
|
||||
if (cb) {
|
||||
cb.checked = (customer.taxable === false);
|
||||
updateQuoteTotals();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function openQuoteModal(quoteId = null) {
|
||||
currentQuoteId = quoteId;
|
||||
|
||||
if (quoteId) {
|
||||
loadQuoteForEdit(quoteId);
|
||||
} else {
|
||||
prepareNewQuote();
|
||||
}
|
||||
|
||||
document.getElementById('quote-modal').classList.add('active');
|
||||
}
|
||||
|
||||
export function closeQuoteModal() {
|
||||
document.getElementById('quote-modal').classList.remove('active');
|
||||
currentQuoteId = null;
|
||||
}
|
||||
|
||||
async function loadQuoteForEdit(quoteId) {
|
||||
document.getElementById('quote-modal-title').textContent = 'Edit Quote';
|
||||
|
||||
const response = await fetch(`/api/quotes/${quoteId}`);
|
||||
const data = await response.json();
|
||||
|
||||
// Set customer in Alpine component
|
||||
const allCust = window.getCustomers ? window.getCustomers() : (window.customers || []);
|
||||
const customer = allCust.find(c => c.id === data.quote.customer_id);
|
||||
if (customer) {
|
||||
const customerInput = document.querySelector('#quote-modal input[placeholder="Search customer..."]');
|
||||
if (customerInput) {
|
||||
customerInput.value = customer.name;
|
||||
customerInput.dispatchEvent(new Event('input'));
|
||||
const alpineData = Alpine.$data(customerInput.closest('[x-data]'));
|
||||
if (alpineData) {
|
||||
alpineData.search = customer.name;
|
||||
alpineData.selectedId = customer.id;
|
||||
alpineData.selectedName = customer.name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('quote-customer').value = data.quote.customer_id;
|
||||
document.getElementById('quote-date').value = data.quote.quote_date.split('T')[0];
|
||||
document.getElementById('quote-tax-exempt').checked = data.quote.tax_exempt;
|
||||
|
||||
// Load items using shared editor
|
||||
document.getElementById('quote-items').innerHTML = '';
|
||||
resetItemCounter();
|
||||
data.items.forEach(item => {
|
||||
addItem('quote-items', { item, type: 'quote', onUpdate: updateQuoteTotals });
|
||||
});
|
||||
|
||||
updateQuoteTotals();
|
||||
}
|
||||
|
||||
function prepareNewQuote() {
|
||||
document.getElementById('quote-modal-title').textContent = 'New Quote';
|
||||
document.getElementById('quote-form').reset();
|
||||
document.getElementById('quote-items').innerHTML = '';
|
||||
resetItemCounter();
|
||||
setDefaultDate();
|
||||
|
||||
// Add one default item
|
||||
addItem('quote-items', { type: 'quote', onUpdate: updateQuoteTotals });
|
||||
}
|
||||
|
||||
export function addQuoteItem(item = null) {
|
||||
addItem('quote-items', { item, type: 'quote', onUpdate: updateQuoteTotals });
|
||||
}
|
||||
|
||||
export function updateQuoteTotals() {
|
||||
const items = getItems('quote-items');
|
||||
const taxExempt = document.getElementById('quote-tax-exempt').checked;
|
||||
|
||||
let subtotal = 0;
|
||||
let hasTbd = false;
|
||||
|
||||
items.forEach(item => {
|
||||
if (item.rate.toUpperCase() === 'TBD' || item.amount.toUpperCase() === 'TBD') {
|
||||
hasTbd = true;
|
||||
} else {
|
||||
const amount = parseFloat(item.amount.replace(/[$,]/g, '')) || 0;
|
||||
subtotal += amount;
|
||||
}
|
||||
});
|
||||
|
||||
const taxAmount = taxExempt ? 0 : (subtotal * 8.25 / 100);
|
||||
const total = subtotal + taxAmount;
|
||||
|
||||
document.getElementById('quote-subtotal').textContent = `$${subtotal.toFixed(2)}`;
|
||||
document.getElementById('quote-tax').textContent = taxExempt ? '$0.00' : `$${taxAmount.toFixed(2)}`;
|
||||
document.getElementById('quote-total').textContent = hasTbd ? `$${total.toFixed(2)}*` : `$${total.toFixed(2)}`;
|
||||
document.getElementById('quote-tax-row').style.display = taxExempt ? 'none' : 'block';
|
||||
}
|
||||
|
||||
export async function handleQuoteSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const items = getItems('quote-items');
|
||||
if (items.length === 0) {
|
||||
alert('Please add at least one item');
|
||||
return;
|
||||
}
|
||||
|
||||
const data = {
|
||||
customer_id: parseInt(document.getElementById('quote-customer').value),
|
||||
quote_date: document.getElementById('quote-date').value,
|
||||
tax_exempt: document.getElementById('quote-tax-exempt').checked,
|
||||
items: items
|
||||
};
|
||||
|
||||
try {
|
||||
const url = currentQuoteId ? `/api/quotes/${currentQuoteId}` : '/api/quotes';
|
||||
const method = currentQuoteId ? 'PUT' : 'POST';
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
closeQuoteModal();
|
||||
if (window.quoteView) window.quoteView.loadQuotes();
|
||||
} else {
|
||||
alert('Error saving quote');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
alert('Error saving quote');
|
||||
}
|
||||
}
|
||||
|
||||
// Wire up form submit and tax-exempt checkbox
|
||||
export function initQuoteModal() {
|
||||
const form = document.getElementById('quote-form');
|
||||
if (form) form.addEventListener('submit', handleQuoteSubmit);
|
||||
|
||||
const taxExempt = document.getElementById('quote-tax-exempt');
|
||||
if (taxExempt) taxExempt.addEventListener('change', updateQuoteTotals);
|
||||
|
||||
// Watch for customer selection → auto-set tax exempt (only for new quotes)
|
||||
const customerHidden = document.getElementById('quote-customer');
|
||||
if (customerHidden) {
|
||||
const observer = new MutationObserver(() => {
|
||||
if (!currentQuoteId && customerHidden.value) {
|
||||
applyCustomerTaxStatus(customerHidden.value);
|
||||
}
|
||||
});
|
||||
observer.observe(customerHidden, { attributes: true, attributeFilter: ['value'] });
|
||||
}
|
||||
}
|
||||
|
||||
// Expose for onclick handlers
|
||||
window.openQuoteModal = openQuoteModal;
|
||||
window.closeQuoteModal = closeQuoteModal;
|
||||
window.addQuoteItem = addQuoteItem;
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
/**
|
||||
* API Utility
|
||||
* Centralized API calls for the frontend
|
||||
*/
|
||||
|
||||
const API = {
|
||||
// Customer API
|
||||
customers: {
|
||||
getAll: () => fetch('/api/customers').then(r => r.json()),
|
||||
get: (id) => fetch(`/api/customers/${id}`).then(r => r.json()),
|
||||
create: (data) => fetch('/api/customers', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
}).then(r => r.json()),
|
||||
update: (id, data) => fetch(`/api/customers/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
}).then(r => r.json()),
|
||||
delete: (id) => fetch(`/api/customers/${id}`, { method: 'DELETE' }).then(r => r.json()),
|
||||
exportToQbo: (id) => fetch(`/api/customers/${id}/export-qbo`, { method: 'POST' }).then(r => r.json())
|
||||
},
|
||||
|
||||
// Quote API
|
||||
quotes: {
|
||||
getAll: () => fetch('/api/quotes').then(r => r.json()),
|
||||
get: (id) => fetch(`/api/quotes/${id}`).then(r => r.json()),
|
||||
create: (data) => fetch('/api/quotes', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
}).then(r => r.json()),
|
||||
update: (id, data) => fetch(`/api/quotes/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
}).then(r => r.json()),
|
||||
delete: (id) => fetch(`/api/quotes/${id}`, { method: 'DELETE' }).then(r => r.json()),
|
||||
convertToInvoice: (id) => fetch(`/api/quotes/${id}/convert-to-invoice`, { method: 'POST' }).then(r => r.json()),
|
||||
getPdf: (id) => window.open(`/api/quotes/${id}/pdf`, '_blank'),
|
||||
getHtml: (id) => window.open(`/api/quotes/${id}/html`, '_blank')
|
||||
},
|
||||
|
||||
// Invoice API
|
||||
invoices: {
|
||||
getAll: () => fetch('/api/invoices').then(r => r.json()),
|
||||
get: (id) => fetch(`/api/invoices/${id}`).then(r => r.json()),
|
||||
getNextNumber: () => fetch('/api/invoices/next-number').then(r => r.json()),
|
||||
create: (data) => fetch('/api/invoices', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
}).then(r => r.json()),
|
||||
update: (id, data) => fetch(`/api/invoices/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
}).then(r => r.json()),
|
||||
delete: (id) => fetch(`/api/invoices/${id}`, { method: 'DELETE' }).then(r => r.json()),
|
||||
exportToQbo: (id) => fetch(`/api/invoices/${id}/export`, { method: 'POST' }).then(r => r.json()),
|
||||
updateQbo: (id) => fetch(`/api/invoices/${id}/update-qbo`, { method: 'POST' }).then(r => r.json()),
|
||||
markPaid: (id, paidDate) => fetch(`/api/invoices/${id}/mark-paid`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ paid_date: paidDate })
|
||||
}).then(r => r.json()),
|
||||
markUnpaid: (id) => fetch(`/api/invoices/${id}/mark-unpaid`, { method: 'PATCH' }).then(r => r.json()),
|
||||
resetQbo: (id) => fetch(`/api/invoices/${id}/reset-qbo`, { method: 'PATCH' }).then(r => r.json()),
|
||||
setEmailStatus: (id, status) => fetch(`/api/invoices/${id}/email-status`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status })
|
||||
}).then(r => r.json()),
|
||||
getPdf: (id) => window.open(`/api/invoices/${id}/pdf`, '_blank'),
|
||||
getHtml: (id) => window.open(`/api/invoices/${id}/html`, '_blank')
|
||||
},
|
||||
|
||||
// Payment API
|
||||
payments: {
|
||||
getAll: () => fetch('/api/payments').then(r => r.json()),
|
||||
record: (data) => fetch('/api/qbo/record-payment', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
}).then(r => r.json())
|
||||
},
|
||||
|
||||
// QBO API
|
||||
qbo: {
|
||||
getStatus: () => fetch('/api/qbo/status').then(r => r.json()),
|
||||
getAccounts: () => fetch('/api/qbo/accounts').then(r => r.json()),
|
||||
getPaymentMethods: () => fetch('/api/qbo/payment-methods').then(r => r.json()),
|
||||
getLaborRate: () => fetch('/api/qbo/labor-rate').then(r => r.json()),
|
||||
getLastSync: () => fetch('/api/qbo/last-sync').then(r => r.json()),
|
||||
getOverdue: () => fetch('/api/qbo/overdue').then(r => r.json()),
|
||||
importUnpaid: () => fetch('/api/qbo/import-unpaid', { method: 'POST' }).then(r => r.json()),
|
||||
syncPayments: () => fetch('/api/qbo/sync-payments', { method: 'POST' }).then(r => r.json()),
|
||||
auth: () => window.location.href = '/auth/qbo'
|
||||
},
|
||||
|
||||
// Settings API
|
||||
settings: {
|
||||
getLogo: () => fetch('/api/logo-info').then(r => r.json()),
|
||||
uploadLogo: (file) => {
|
||||
const formData = new FormData();
|
||||
formData.append('logo', file);
|
||||
return fetch('/api/upload-logo', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
}).then(r => r.json());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Make globally available
|
||||
window.API = API;
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
/**
|
||||
* helpers.js — Shared UI utility functions
|
||||
* Extracted from app.js
|
||||
*/
|
||||
|
||||
export function formatDate(date) {
|
||||
const d = new Date(date);
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
const year = d.getFullYear();
|
||||
return `${month}/${day}/${year}`;
|
||||
}
|
||||
|
||||
export function setDefaultDate() {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const quoteDateEl = document.getElementById('quote-date');
|
||||
const invoiceDateEl = document.getElementById('invoice-date');
|
||||
if (quoteDateEl) quoteDateEl.value = today;
|
||||
if (invoiceDateEl) invoiceDateEl.value = today;
|
||||
}
|
||||
|
||||
export function showSpinner(message = 'Bitte warten...') {
|
||||
let overlay = document.getElementById('qbo-spinner');
|
||||
if (!overlay) {
|
||||
overlay = document.createElement('div');
|
||||
overlay.id = 'qbo-spinner';
|
||||
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.4);display:flex;align-items:center;justify-content:center;z-index:9999;';
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
overlay.innerHTML = `
|
||||
<div class="bg-white rounded-xl shadow-2xl px-8 py-6 flex items-center gap-4">
|
||||
<svg class="animate-spin h-8 w-8 text-blue-600" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span class="text-lg font-medium text-gray-700" id="qbo-spinner-text">${message}</span>
|
||||
</div>`;
|
||||
overlay.style.display = 'flex';
|
||||
}
|
||||
|
||||
export function hideSpinner() {
|
||||
const overlay = document.getElementById('qbo-spinner');
|
||||
if (overlay) overlay.style.display = 'none';
|
||||
}
|
||||
|
||||
// Keep backward compat for onclick handlers and modules using typeof check
|
||||
window.showSpinner = showSpinner;
|
||||
window.hideSpinner = hideSpinner;
|
||||
|
|
@ -1,293 +0,0 @@
|
|||
/**
|
||||
* item-editor.js — Shared accordion item editor for Quotes and Invoices
|
||||
*
|
||||
* Replaces the duplicated addQuoteItem/addInvoiceItem logic (~300 lines → 1 function).
|
||||
*
|
||||
* Usage:
|
||||
* import { addItem, getItems, removeItem, moveItemUp, moveItemDown, updateTotals } from './item-editor.js';
|
||||
* addItem('quote-items', { item: existingItem, type: 'quote', laborRate: 125 });
|
||||
*/
|
||||
|
||||
let itemCounter = 0;
|
||||
|
||||
export function resetItemCounter() {
|
||||
itemCounter = 0;
|
||||
}
|
||||
|
||||
export function getItemCounter() {
|
||||
return itemCounter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an item row to the specified container.
|
||||
*
|
||||
* @param {string} containerId - DOM id of the items container ('quote-items' or 'invoice-items')
|
||||
* @param {object} options
|
||||
* @param {object|null} options.item - Existing item data (null for new empty item)
|
||||
* @param {string} options.type - 'quote' or 'invoice'
|
||||
* @param {number|null} options.laborRate - QBO labor rate for auto-fill (invoice only)
|
||||
* @param {function} options.onUpdate - Callback after any change (for recalculating totals)
|
||||
*/
|
||||
export function addItem(containerId, { item = null, type = 'invoice', laborRate = null, onUpdate = () => {} } = {}) {
|
||||
const itemId = itemCounter++;
|
||||
const itemsDiv = document.getElementById(containerId);
|
||||
if (!itemsDiv) return;
|
||||
|
||||
const prefix = type; // 'quote' or 'invoice'
|
||||
const cssClass = `${prefix}-item-input`;
|
||||
const editorClass = `${prefix}-item-description-editor`;
|
||||
const amountClass = `${prefix}-item-amount`;
|
||||
|
||||
// Preview defaults
|
||||
const previewQty = item ? item.quantity : '';
|
||||
const previewAmount = item ? item.amount : '$0.00';
|
||||
let previewDesc = 'New item';
|
||||
if (item && item.description) {
|
||||
const temp = document.createElement('div');
|
||||
temp.innerHTML = item.description;
|
||||
previewDesc = temp.textContent.substring(0, 50) + (temp.textContent.length > 50 ? '...' : '');
|
||||
}
|
||||
const typeLabel = (item && item.qbo_item_id == '5') ? 'Labor' : 'Parts';
|
||||
|
||||
const itemDiv = document.createElement('div');
|
||||
itemDiv.className = 'border border-gray-300 rounded-lg mb-3 bg-white';
|
||||
itemDiv.id = `${prefix}-item-${itemId}`;
|
||||
itemDiv.setAttribute('x-data', `{ open: ${item ? 'false' : 'true'} }`);
|
||||
|
||||
itemDiv.innerHTML = `
|
||||
<div class="flex items-center p-4">
|
||||
<div class="flex flex-col mr-3" onclick="event.stopPropagation()">
|
||||
<button type="button" onclick="window.itemEditor.moveUp('${prefix}', ${itemId})" class="text-blue-600 hover:text-blue-800 text-lg leading-none mb-1">↑</button>
|
||||
<button type="button" onclick="window.itemEditor.moveDown('${prefix}', ${itemId})" class="text-blue-600 hover:text-blue-800 text-lg leading-none">↓</button>
|
||||
</div>
|
||||
|
||||
<div @click="open = !open" class="flex items-center flex-1 cursor-pointer hover:bg-gray-50 rounded px-3 py-2">
|
||||
<svg x-show="!open" class="w-5 h-5 text-gray-500 mr-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /></svg>
|
||||
<svg x-show="open" class="w-5 h-5 text-gray-500 mr-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7" /></svg>
|
||||
|
||||
<span class="text-sm font-medium mr-4">Qty: <span class="item-qty-preview">${previewQty}</span></span>
|
||||
<span class="text-xs font-bold px-2 py-1 rounded bg-gray-200 text-gray-700 mr-4 item-type-preview">${typeLabel}</span>
|
||||
|
||||
<span class="text-sm text-gray-600 flex-1 truncate mx-4 item-desc-preview">${previewDesc}</span>
|
||||
<span class="text-sm font-semibold item-amount-preview">${previewAmount}</span>
|
||||
</div>
|
||||
|
||||
<button type="button" onclick="window.itemEditor.remove('${prefix}', ${itemId}); event.stopPropagation();" class="ml-3 px-3 py-2 bg-red-500 hover:bg-red-600 text-white rounded-md text-sm">×</button>
|
||||
</div>
|
||||
|
||||
<div x-show="open" x-transition class="p-4 border-t border-gray-200">
|
||||
<div class="grid grid-cols-12 gap-3 items-start">
|
||||
<div class="col-span-1">
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">Qty</label>
|
||||
<input type="text" data-item="${itemId}" data-field="quantity" value="${item ? item.quantity : ''}" class="${cssClass} w-full px-2 py-2 border border-gray-300 rounded-md text-sm">
|
||||
</div>
|
||||
|
||||
<div class="col-span-2">
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">Type (Internal)</label>
|
||||
<select data-item="${itemId}" data-field="qbo_item_id" class="w-full px-2 py-2 border border-gray-300 rounded-md text-sm bg-white" onchange="window.itemEditor.handleTypeChange(this, '${prefix}', ${itemId})">
|
||||
<option value="9" ${item && item.qbo_item_id == '9' ? 'selected' : ''}>Parts</option>
|
||||
<option value="5" ${item && item.qbo_item_id == '5' ? 'selected' : ''}>Labor</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="col-span-4">
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">Description</label>
|
||||
<div data-item="${itemId}" data-field="description" class="${editorClass} border border-gray-300 rounded-md bg-white" style="min-height: 60px;"></div>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">Rate</label>
|
||||
<input type="text" data-item="${itemId}" data-field="rate" value="${item ? item.rate : ''}" class="${cssClass} w-full px-2 py-2 border border-gray-300 rounded-md text-sm">
|
||||
</div>
|
||||
<div class="col-span-3">
|
||||
<label class="block text-xs font-medium text-gray-700 mb-1">Amount</label>
|
||||
<input type="text" data-item="${itemId}" data-field="amount" value="${item ? item.amount : ''}" class="${amountClass} w-full px-2 py-2 border border-gray-300 rounded-md text-sm">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
itemsDiv.appendChild(itemDiv);
|
||||
|
||||
// --- Quill Rich Text Editor ---
|
||||
const editorDiv = itemDiv.querySelector(`.${editorClass}`);
|
||||
const quill = new Quill(editorDiv, {
|
||||
theme: 'snow',
|
||||
modules: {
|
||||
toolbar: [['bold', 'italic', 'underline'], [{ 'list': 'ordered' }, { 'list': 'bullet' }], ['clean']]
|
||||
}
|
||||
});
|
||||
if (item && item.description) quill.root.innerHTML = item.description;
|
||||
|
||||
quill.on('text-change', () => {
|
||||
updateItemPreview(itemDiv);
|
||||
onUpdate();
|
||||
});
|
||||
editorDiv.quillInstance = quill;
|
||||
|
||||
// --- Auto-calculate Amount ---
|
||||
const qtyInput = itemDiv.querySelector('[data-field="quantity"]');
|
||||
const rateInput = itemDiv.querySelector('[data-field="rate"]');
|
||||
const amountInput = itemDiv.querySelector('[data-field="amount"]');
|
||||
|
||||
const calculateAmount = () => {
|
||||
if (qtyInput.value && rateInput.value) {
|
||||
// Quote supports TBD
|
||||
if (type === 'quote' && rateInput.value.toUpperCase() === 'TBD') {
|
||||
// Don't auto-calculate for TBD
|
||||
} else {
|
||||
const qty = parseFloat(qtyInput.value) || 0;
|
||||
const rateValue = parseFloat(rateInput.value.replace(/[^0-9.]/g, '')) || 0;
|
||||
amountInput.value = (qty * rateValue).toFixed(2);
|
||||
}
|
||||
}
|
||||
updateItemPreview(itemDiv);
|
||||
onUpdate();
|
||||
};
|
||||
|
||||
qtyInput.addEventListener('input', calculateAmount);
|
||||
rateInput.addEventListener('input', calculateAmount);
|
||||
amountInput.addEventListener('input', () => {
|
||||
updateItemPreview(itemDiv);
|
||||
onUpdate();
|
||||
});
|
||||
|
||||
// Store metadata on the div for later retrieval
|
||||
itemDiv._itemEditor = { type, laborRate, onUpdate };
|
||||
|
||||
updateItemPreview(itemDiv);
|
||||
onUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the collapsed preview bar of an item
|
||||
*/
|
||||
function updateItemPreview(itemDiv) {
|
||||
const qtyInput = itemDiv.querySelector('[data-field="quantity"]');
|
||||
const amountInput = itemDiv.querySelector('[data-field="amount"]');
|
||||
const typeInput = itemDiv.querySelector('[data-field="qbo_item_id"]');
|
||||
const editorDivs = itemDiv.querySelectorAll('[data-field="description"]');
|
||||
const editorDiv = editorDivs.length > 0 ? editorDivs[0] : null;
|
||||
|
||||
const qtyPreview = itemDiv.querySelector('.item-qty-preview');
|
||||
const descPreview = itemDiv.querySelector('.item-desc-preview');
|
||||
const amountPreview = itemDiv.querySelector('.item-amount-preview');
|
||||
const typePreview = itemDiv.querySelector('.item-type-preview');
|
||||
|
||||
if (qtyPreview && qtyInput) qtyPreview.textContent = qtyInput.value || '0';
|
||||
if (amountPreview && amountInput) amountPreview.textContent = amountInput.value || '$0.00';
|
||||
|
||||
if (typePreview && typeInput) {
|
||||
typePreview.textContent = typeInput.value == '5' ? 'Labor' : 'Parts';
|
||||
}
|
||||
|
||||
if (descPreview && editorDiv && editorDiv.quillInstance) {
|
||||
const plainText = editorDiv.quillInstance.getText().trim();
|
||||
const preview = plainText.substring(0, 50) + (plainText.length > 50 ? '...' : '');
|
||||
descPreview.textContent = preview || 'New item';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle type change (Labor/Parts).
|
||||
* When Labor is selected and rate is empty, auto-fill with labor rate.
|
||||
*/
|
||||
export function handleTypeChange(selectEl, prefix, itemId) {
|
||||
const itemDiv = document.getElementById(`${prefix}-item-${itemId}`);
|
||||
if (!itemDiv) return;
|
||||
|
||||
const meta = itemDiv._itemEditor || {};
|
||||
const laborRate = meta.laborRate;
|
||||
const onUpdate = meta.onUpdate || (() => {});
|
||||
|
||||
// Auto-fill labor rate when switching to Labor and rate is empty
|
||||
if (selectEl.value === '5' && laborRate) {
|
||||
const rateInput = itemDiv.querySelector('[data-field="rate"]');
|
||||
if (rateInput && (!rateInput.value || rateInput.value === '0')) {
|
||||
rateInput.value = laborRate;
|
||||
// Recalculate amount
|
||||
const qtyInput = itemDiv.querySelector('[data-field="quantity"]');
|
||||
const amountInput = itemDiv.querySelector('[data-field="amount"]');
|
||||
if (qtyInput.value) {
|
||||
const qty = parseFloat(qtyInput.value) || 0;
|
||||
amountInput.value = (qty * laborRate).toFixed(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateItemPreview(itemDiv);
|
||||
onUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all items from a container as an array of objects.
|
||||
*/
|
||||
export function getItems(containerId) {
|
||||
const items = [];
|
||||
const itemDivs = document.querySelectorAll(`#${containerId} > div`);
|
||||
|
||||
itemDivs.forEach(div => {
|
||||
const descEditor = div.querySelector('[data-field="description"]');
|
||||
const descriptionHTML = descEditor && descEditor.quillInstance
|
||||
? descEditor.quillInstance.root.innerHTML
|
||||
: '';
|
||||
|
||||
items.push({
|
||||
quantity: div.querySelector('[data-field="quantity"]').value,
|
||||
qbo_item_id: div.querySelector('[data-field="qbo_item_id"]').value,
|
||||
description: descriptionHTML,
|
||||
rate: div.querySelector('[data-field="rate"]').value,
|
||||
amount: div.querySelector('[data-field="amount"]').value
|
||||
});
|
||||
});
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an item by prefix and itemId
|
||||
*/
|
||||
export function removeItem(prefix, itemId) {
|
||||
const el = document.getElementById(`${prefix}-item-${itemId}`);
|
||||
if (!el) return;
|
||||
const meta = el._itemEditor || {};
|
||||
el.remove();
|
||||
if (meta.onUpdate) meta.onUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Move an item up
|
||||
*/
|
||||
export function moveItemUp(prefix, itemId) {
|
||||
const item = document.getElementById(`${prefix}-item-${itemId}`);
|
||||
if (!item) return;
|
||||
const prevItem = item.previousElementSibling;
|
||||
if (prevItem) {
|
||||
item.parentNode.insertBefore(item, prevItem);
|
||||
const meta = item._itemEditor || {};
|
||||
if (meta.onUpdate) meta.onUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Move an item down
|
||||
*/
|
||||
export function moveItemDown(prefix, itemId) {
|
||||
const item = document.getElementById(`${prefix}-item-${itemId}`);
|
||||
if (!item) return;
|
||||
const nextItem = item.nextElementSibling;
|
||||
if (nextItem) {
|
||||
item.parentNode.insertBefore(nextItem, item);
|
||||
const meta = item._itemEditor || {};
|
||||
if (meta.onUpdate) meta.onUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Expose to window for onclick handlers in HTML
|
||||
// ============================================================
|
||||
|
||||
window.itemEditor = {
|
||||
moveUp: moveItemUp,
|
||||
moveDown: moveItemDown,
|
||||
remove: removeItem,
|
||||
handleTypeChange: handleTypeChange
|
||||
};
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
/**
|
||||
* quote-view.js — Quote list rendering and actions
|
||||
* Analog to invoice-view.js
|
||||
*/
|
||||
import { formatDate } from '../utils/helpers.js';
|
||||
|
||||
let quotes = [];
|
||||
|
||||
export async function loadQuotes() {
|
||||
try {
|
||||
const response = await fetch('/api/quotes');
|
||||
quotes = await response.json();
|
||||
renderQuotes();
|
||||
} catch (error) {
|
||||
console.error('Error loading quotes:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export function getQuotesData() {
|
||||
return quotes;
|
||||
}
|
||||
|
||||
export function renderQuotes() {
|
||||
const tbody = document.getElementById('quotes-list');
|
||||
if (!tbody) return;
|
||||
|
||||
tbody.innerHTML = quotes.map(quote => {
|
||||
const total = quote.has_tbd
|
||||
? `$${parseFloat(quote.total).toFixed(2)}*`
|
||||
: `$${parseFloat(quote.total).toFixed(2)}`;
|
||||
return `
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">${quote.quote_number}</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-500">${quote.customer_name || 'N/A'}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">${formatDate(quote.quote_date)}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 font-semibold">${total}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2">
|
||||
<button onclick="window.quoteView.viewPDF(${quote.id})" class="text-green-600 hover:text-green-900">PDF</button>
|
||||
<button onclick="window.quoteView.convertToInvoice(${quote.id})" class="text-purple-600 hover:text-purple-900">→ Invoice</button>
|
||||
<button onclick="window.quoteView.edit(${quote.id})" class="text-blue-600 hover:text-blue-900">Edit</button>
|
||||
<button onclick="window.quoteView.remove(${quote.id})" class="text-red-600 hover:text-red-900">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
if (quotes.length === 0) {
|
||||
tbody.innerHTML = `<tr><td colspan="5" class="px-6 py-8 text-center text-gray-500">No quotes found.</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
export function viewPDF(id) {
|
||||
window.open(`/api/quotes/${id}/pdf`, '_blank');
|
||||
}
|
||||
|
||||
export async function edit(id) {
|
||||
if (typeof window.openQuoteModal === 'function') {
|
||||
await window.openQuoteModal(id);
|
||||
}
|
||||
}
|
||||
|
||||
export async function remove(id) {
|
||||
if (!confirm('Are you sure you want to delete this quote?')) return;
|
||||
try {
|
||||
const response = await fetch(`/api/quotes/${id}`, { method: 'DELETE' });
|
||||
if (response.ok) loadQuotes();
|
||||
else alert('Error deleting quote');
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
alert('Error deleting quote');
|
||||
}
|
||||
}
|
||||
|
||||
export async function convertToInvoice(quoteId) {
|
||||
if (!confirm('Convert this quote to an invoice?')) return;
|
||||
try {
|
||||
const response = await fetch(`/api/quotes/${quoteId}/convert-to-invoice`, { method: 'POST' });
|
||||
if (response.ok) {
|
||||
const invoice = await response.json();
|
||||
alert(`Invoice ${invoice.invoice_number || '(Draft)'} created successfully!`);
|
||||
if (window.invoiceView) window.invoiceView.loadInvoices();
|
||||
if (typeof window.showTab === 'function') window.showTab('invoices');
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(error.error || 'Error converting quote to invoice');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
alert('Error converting quote to invoice');
|
||||
}
|
||||
}
|
||||
|
||||
// Expose for onclick handlers
|
||||
window.quoteView = {
|
||||
loadQuotes,
|
||||
renderQuotes,
|
||||
viewPDF,
|
||||
edit,
|
||||
remove,
|
||||
convertToInvoice
|
||||
};
|
||||
|
|
@ -1,182 +0,0 @@
|
|||
/**
|
||||
* settings-view.js — Logo upload, QBO import, QBO connection test
|
||||
* Extracted from app.js
|
||||
*/
|
||||
|
||||
let currentLogoFile = null;
|
||||
|
||||
export async function checkCurrentLogo() {
|
||||
try {
|
||||
const response = await fetch('/api/logo-info');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.hasLogo) {
|
||||
document.getElementById('logo-preview').classList.remove('hidden');
|
||||
document.getElementById('logo-image').src = data.logoPath + '?t=' + Date.now();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking logo:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function uploadLogo() {
|
||||
if (!currentLogoFile) {
|
||||
alert('Please select a file first');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('logo', currentLogoFile);
|
||||
|
||||
const statusDiv = document.getElementById('upload-status');
|
||||
statusDiv.innerHTML = '<p class="text-blue-600">Uploading...</p>';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/upload-logo', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
statusDiv.innerHTML = '<p class="text-green-600">✓ Logo uploaded successfully!</p>';
|
||||
document.getElementById('logo-preview').classList.remove('hidden');
|
||||
document.getElementById('logo-image').src = data.path + '?t=' + Date.now();
|
||||
document.getElementById('upload-btn').disabled = true;
|
||||
currentLogoFile = null;
|
||||
document.getElementById('logo-filename').textContent = '';
|
||||
document.getElementById('logo-upload').value = '';
|
||||
} else {
|
||||
const error = await response.json();
|
||||
statusDiv.innerHTML = `<p class="text-red-600">✗ Error: ${error.error}</p>`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
statusDiv.innerHTML = '<p class="text-red-600">✗ Upload failed</p>';
|
||||
}
|
||||
}
|
||||
|
||||
export function initSettingsView() {
|
||||
const logoUpload = document.getElementById('logo-upload');
|
||||
if (logoUpload) {
|
||||
logoUpload.addEventListener('change', (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
currentLogoFile = file;
|
||||
document.getElementById('logo-filename').textContent = file.name;
|
||||
document.getElementById('upload-btn').disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkQboOverdue() {
|
||||
const btn = document.querySelector('button[onclick="checkQboOverdue()"]');
|
||||
const resultDiv = document.getElementById('qbo-result');
|
||||
const tbody = document.getElementById('qbo-result-list');
|
||||
|
||||
const originalText = btn.innerHTML;
|
||||
btn.innerHTML = '⏳ Connecting to QBO...';
|
||||
btn.disabled = true;
|
||||
resultDiv.classList.add('hidden');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/qbo/overdue');
|
||||
const invoices = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
resultDiv.classList.remove('hidden');
|
||||
|
||||
if (invoices.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="4" class="px-4 py-4 text-center text-gray-500">✅ Good news! No overdue invoices found older than 30 days.</td></tr>';
|
||||
} else {
|
||||
tbody.innerHTML = invoices.map(inv => `
|
||||
<tr>
|
||||
<td class="px-4 py-2 font-medium text-gray-900">${inv.DocNumber || '(No Num)'}</td>
|
||||
<td class="px-4 py-2 text-gray-600">${inv.CustomerRef?.name || 'Unknown'}</td>
|
||||
<td class="px-4 py-2 text-red-600 font-medium">${inv.DueDate}</td>
|
||||
<td class="px-4 py-2 text-right font-bold text-gray-800">$${inv.Balance}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
alert(`Success! Connection working. Found ${invoices.length} overdue invoices.`);
|
||||
} else {
|
||||
throw new Error(invoices.error || 'Unknown error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('QBO Test Error:', error);
|
||||
alert('❌ Connection Test Failed: ' + error.message);
|
||||
tbody.innerHTML = `<tr><td colspan="4" class="px-4 py-4 text-center text-red-600">Error: ${error.message}</td></tr>`;
|
||||
resultDiv.classList.remove('hidden');
|
||||
} finally {
|
||||
btn.innerHTML = originalText;
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function importFromQBO() {
|
||||
if (!confirm(
|
||||
'Alle unbezahlten Rechnungen aus QBO importieren?\n\n' +
|
||||
'• Bereits importierte werden übersprungen\n' +
|
||||
'• Nur Kunden die lokal verknüpft sind\n\n' +
|
||||
'Fortfahren?'
|
||||
)) return;
|
||||
|
||||
const btn = document.querySelector('button[onclick="importFromQBO()"]');
|
||||
const resultDiv = document.getElementById('qbo-import-result');
|
||||
|
||||
const originalText = btn.innerHTML;
|
||||
btn.innerHTML = '⏳ Importiere aus QBO...';
|
||||
btn.disabled = true;
|
||||
resultDiv.classList.add('hidden');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/qbo/import-unpaid', { method: 'POST' });
|
||||
const result = await response.json();
|
||||
|
||||
resultDiv.classList.remove('hidden');
|
||||
|
||||
if (response.ok) {
|
||||
let html = `<div class="p-4 rounded-lg ${result.imported > 0 ? 'bg-green-50 border border-green-200' : 'bg-blue-50 border border-blue-200'}">`;
|
||||
html += `<p class="font-semibold text-gray-800 mb-2">Import abgeschlossen</p>`;
|
||||
html += `<ul class="text-sm text-gray-700 space-y-1">`;
|
||||
html += `<li>✅ <strong>${result.imported}</strong> Rechnungen importiert</li>`;
|
||||
|
||||
if (result.skipped > 0) {
|
||||
html += `<li>⏭️ <strong>${result.skipped}</strong> bereits vorhanden (übersprungen)</li>`;
|
||||
}
|
||||
if (result.skippedNoCustomer > 0) {
|
||||
html += `<li>⚠️ <strong>${result.skippedNoCustomer}</strong> übersprungen — Kunde nicht verknüpft:</li>`;
|
||||
html += `<li class="ml-4 text-xs text-gray-500">${result.skippedCustomerNames.join(', ')}</li>`;
|
||||
}
|
||||
|
||||
html += `</ul></div>`;
|
||||
resultDiv.innerHTML = html;
|
||||
|
||||
if (result.imported > 0 && window.invoiceView) {
|
||||
window.invoiceView.loadInvoices();
|
||||
}
|
||||
} else {
|
||||
resultDiv.innerHTML = `<div class="p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p class="font-semibold text-red-800">Import fehlgeschlagen</p>
|
||||
<p class="text-sm text-red-600 mt-1">${result.error}</p>
|
||||
</div>`;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Import Error:', error);
|
||||
resultDiv.classList.remove('hidden');
|
||||
resultDiv.innerHTML = `<div class="p-4 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p class="text-red-600">Netzwerkfehler beim Import.</p>
|
||||
</div>`;
|
||||
} finally {
|
||||
btn.innerHTML = originalText;
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Expose for onclick handlers
|
||||
window.uploadLogo = uploadLogo;
|
||||
window.checkQboOverdue = checkQboOverdue;
|
||||
window.importFromQBO = importFromQBO;
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
const { Pool } = require('pg');
|
||||
|
||||
const pool = new Pool({
|
||||
user: process.env.DB_USER || 'postgres',
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
database: process.env.DB_NAME || 'quotes_db',
|
||||
password: process.env.DB_PASSWORD || 'postgres',
|
||||
port: process.env.DB_PORT || 5432,
|
||||
});
|
||||
|
||||
module.exports = { pool };
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
// src/config/qbo.js
|
||||
const OAuthClient = require('intuit-oauth');
|
||||
const {
|
||||
getOAuthClient: getClient,
|
||||
saveTokens,
|
||||
resetOAuthClient,
|
||||
makeQboApiCall // <-- NEU: Direkt hier mit importieren
|
||||
} = require('../../qbo_helper');
|
||||
|
||||
function getOAuthClient() {
|
||||
return getClient();
|
||||
}
|
||||
|
||||
function getQboBaseUrl() {
|
||||
return process.env.QBO_ENVIRONMENT === 'production'
|
||||
? 'https://quickbooks.api.intuit.com'
|
||||
: 'https://sandbox-quickbooks.api.intuit.com';
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
OAuthClient,
|
||||
getOAuthClient,
|
||||
getQboBaseUrl,
|
||||
saveTokens,
|
||||
resetOAuthClient,
|
||||
makeQboApiCall // <-- NEU: Und sauber weiterreichen
|
||||
};
|
||||
135
src/index.js
135
src/index.js
|
|
@ -1,135 +0,0 @@
|
|||
/**
|
||||
* Quote & Invoice System - Main Entry Point
|
||||
* Modularized Backend
|
||||
*/
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const puppeteer = require('puppeteer');
|
||||
|
||||
// Import config
|
||||
const { pool } = require('./config/database');
|
||||
const { OAuthClient, getOAuthClient, saveTokens } = require('./config/qbo');
|
||||
|
||||
// Import routes
|
||||
const customerRoutes = require('./routes/customers');
|
||||
const quoteRoutes = require('./routes/quotes');
|
||||
const invoiceRoutes = require('./routes/invoices');
|
||||
const paymentRoutes = require('./routes/payments');
|
||||
const qboRoutes = require('./routes/qbo');
|
||||
const settingsRoutes = require('./routes/settings');
|
||||
|
||||
// Import PDF service for browser initialization
|
||||
const { setBrowser } = require('./services/pdf-service');
|
||||
|
||||
// Import recurring invoice scheduler
|
||||
const { startRecurringScheduler } = require('./services/recurring-service');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
|
||||
// Global browser instance
|
||||
let browser = null;
|
||||
|
||||
// Initialize browser on startup
|
||||
async function initBrowser() {
|
||||
if (!browser) {
|
||||
console.log('[BROWSER] Launching persistent browser...');
|
||||
browser = await puppeteer.launch({
|
||||
headless: 'new',
|
||||
args: [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-gpu',
|
||||
'--disable-software-rasterizer',
|
||||
'--no-zygote',
|
||||
'--single-process'
|
||||
],
|
||||
protocolTimeout: 180000,
|
||||
timeout: 180000
|
||||
});
|
||||
console.log('[BROWSER] Browser launched and ready');
|
||||
|
||||
// Pass browser to PDF service
|
||||
setBrowser(browser);
|
||||
|
||||
// Restart browser if it crashes
|
||||
browser.on('disconnected', () => {
|
||||
console.log('[BROWSER] Browser disconnected, restarting...');
|
||||
browser = null;
|
||||
setBrowser(null);
|
||||
initBrowser();
|
||||
});
|
||||
}
|
||||
return browser;
|
||||
}
|
||||
|
||||
// Middleware
|
||||
app.use(express.json());
|
||||
app.use(express.static(path.join(__dirname, '..', 'public')));
|
||||
|
||||
// =====================================================
|
||||
// QBO OAuth Routes — mounted at root level (not under /api/qbo)
|
||||
// These must match the Intuit callback URL configuration
|
||||
// =====================================================
|
||||
app.get('/auth/qbo', (req, res) => {
|
||||
const client = getOAuthClient();
|
||||
const authUri = client.authorizeUri({
|
||||
scope: [OAuthClient.scopes.Accounting],
|
||||
state: 'intuit-qbo-auth'
|
||||
});
|
||||
console.log('🔗 Redirecting to QBO Authorization:', authUri);
|
||||
res.redirect(authUri);
|
||||
});
|
||||
|
||||
app.get('/auth/qbo/callback', async (req, res) => {
|
||||
const client = getOAuthClient();
|
||||
try {
|
||||
const authResponse = await client.createToken(req.url);
|
||||
console.log('✅ QBO Authorization erfolgreich!');
|
||||
saveTokens();
|
||||
res.redirect('/#settings');
|
||||
} catch (e) {
|
||||
console.error('❌ QBO Authorization fehlgeschlagen:', e);
|
||||
res.status(500).send(`
|
||||
<h2>QBO Authorization Failed</h2>
|
||||
<p>${e.message || e}</p>
|
||||
<a href="/">Zurück zur App</a>
|
||||
`);
|
||||
}
|
||||
});
|
||||
|
||||
// =====================================================
|
||||
// API Routes
|
||||
// =====================================================
|
||||
app.use('/api/customers', customerRoutes);
|
||||
app.use('/api/quotes', quoteRoutes);
|
||||
app.use('/api/invoices', invoiceRoutes);
|
||||
app.use('/api/payments', paymentRoutes);
|
||||
app.use('/api/qbo', qboRoutes);
|
||||
app.use('/api', settingsRoutes);
|
||||
|
||||
// Start server
|
||||
async function startServer() {
|
||||
await initBrowser();
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Quote System running on port ${PORT}`);
|
||||
});
|
||||
|
||||
// Start recurring invoice scheduler (checks every 24h)
|
||||
startRecurringScheduler();
|
||||
}
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGTERM', async () => {
|
||||
if (browser) {
|
||||
await browser.close();
|
||||
}
|
||||
await pool.end();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
startServer();
|
||||
|
||||
module.exports = app;
|
||||
|
|
@ -1,270 +0,0 @@
|
|||
/**
|
||||
* Customer Routes
|
||||
* Handles customer CRUD operations and QBO sync
|
||||
*/
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { pool } = require('../config/database');
|
||||
const { getOAuthClient, getQboBaseUrl, makeQboApiCall } = require('../config/qbo');
|
||||
|
||||
// GET all customers
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query('SELECT * FROM customers ORDER BY name');
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
console.error('Error fetching customers:', error);
|
||||
res.status(500).json({ error: 'Error fetching customers' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST create customer
|
||||
router.post('/', async (req, res) => {
|
||||
const {
|
||||
name, contact, line1, line2, line3, line4, city, state, zip_code,
|
||||
account_number, email, phone, phone2, taxable, remarks
|
||||
} = req.body;
|
||||
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`INSERT INTO customers (name, contact, line1, line2, line3, line4, city, state, zip_code, account_number, email, phone, phone2, taxable, remarks)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING *`,
|
||||
[name, contact || null, line1 || null, line2 || null, line3 || null, line4 || null,
|
||||
city || null, state || null, zip_code || null, account_number || null,
|
||||
email || null, phone || null, phone2 || null,
|
||||
taxable !== undefined ? taxable : true, remarks || null]
|
||||
);
|
||||
res.json(result.rows[0]);
|
||||
} catch (error) {
|
||||
console.error('Error creating customer:', error);
|
||||
res.status(500).json({ error: 'Error creating customer' });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT update customer
|
||||
router.put('/:id', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const {
|
||||
name, contact, line1, line2, line3, line4, city, state, zip_code,
|
||||
account_number, email, phone, phone2, taxable, remarks
|
||||
} = req.body;
|
||||
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`UPDATE customers
|
||||
SET name = $1, contact = $2, line1 = $3, line2 = $4, line3 = $5, line4 = $6,
|
||||
city = $7, state = $8, zip_code = $9, account_number = $10, email = $11,
|
||||
phone = $12, phone2 = $13, taxable = $14, remarks = $15, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $16
|
||||
RETURNING *`,
|
||||
[name, contact || null, line1 || null, line2 || null, line3 || null, line4 || null,
|
||||
city || null, state || null, zip_code || null, account_number || null,
|
||||
email || null, phone || null, phone2 || null,
|
||||
taxable !== undefined ? taxable : true, remarks || null, id]
|
||||
);
|
||||
|
||||
const customer = result.rows[0];
|
||||
|
||||
// QBO Update
|
||||
if (customer.qbo_id) {
|
||||
try {
|
||||
const oauthClient = getOAuthClient();
|
||||
const companyId = oauthClient.getToken().realmId;
|
||||
const baseUrl = getQboBaseUrl();
|
||||
|
||||
// Get SyncToken
|
||||
const qboRes = await makeQboApiCall({
|
||||
url: `${baseUrl}/v3/company/${companyId}/customer/${customer.qbo_id}`,
|
||||
method: 'GET'
|
||||
});
|
||||
const qboData = qboRes.getJson ? qboRes.getJson() : qboRes.json;
|
||||
const syncToken = qboData.Customer?.SyncToken;
|
||||
|
||||
if (syncToken !== undefined) {
|
||||
const updatePayload = {
|
||||
Id: customer.qbo_id,
|
||||
SyncToken: syncToken,
|
||||
sparse: true,
|
||||
DisplayName: name,
|
||||
CompanyName: name,
|
||||
PrimaryEmailAddr: email ? { Address: email } : undefined,
|
||||
PrimaryPhone: phone ? { FreeFormNumber: phone } : undefined,
|
||||
Taxable: taxable !== false,
|
||||
Notes: remarks || undefined
|
||||
};
|
||||
|
||||
// Contact → GivenName / FamilyName
|
||||
if (contact) {
|
||||
const parts = contact.trim().split(/\s+/);
|
||||
if (parts.length >= 2) {
|
||||
updatePayload.GivenName = parts[0];
|
||||
updatePayload.FamilyName = parts.slice(1).join(' ');
|
||||
} else {
|
||||
updatePayload.GivenName = parts[0];
|
||||
}
|
||||
}
|
||||
|
||||
// Address
|
||||
const addr = {};
|
||||
if (line1) addr.Line1 = line1;
|
||||
if (line2) addr.Line2 = line2;
|
||||
if (line3) addr.Line3 = line3;
|
||||
if (line4) addr.Line4 = line4;
|
||||
if (city) addr.City = city;
|
||||
if (state) addr.CountrySubDivisionCode = state;
|
||||
if (zip_code) addr.PostalCode = zip_code;
|
||||
if (Object.keys(addr).length > 0) updatePayload.BillAddr = addr;
|
||||
|
||||
console.log(`📤 Updating QBO Customer ${customer.qbo_id} (${name})...`);
|
||||
|
||||
await makeQboApiCall({
|
||||
url: `${baseUrl}/v3/company/${companyId}/customer`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updatePayload)
|
||||
});
|
||||
|
||||
console.log(`✅ QBO Customer ${customer.qbo_id} updated.`);
|
||||
}
|
||||
} catch (qboError) {
|
||||
console.error(`⚠️ QBO update failed for Customer ${customer.qbo_id}:`, qboError.message);
|
||||
}
|
||||
}
|
||||
|
||||
res.json(customer);
|
||||
} catch (error) {
|
||||
console.error('Error updating customer:', error);
|
||||
res.status(500).json({ error: 'Error updating customer' });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE customer
|
||||
router.delete('/:id', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
try {
|
||||
// Load customer
|
||||
const custResult = await pool.query('SELECT * FROM customers WHERE id = $1', [id]);
|
||||
if (custResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Customer not found' });
|
||||
}
|
||||
|
||||
const customer = custResult.rows[0];
|
||||
|
||||
// Deactivate in QBO if present
|
||||
if (customer.qbo_id) {
|
||||
try {
|
||||
const oauthClient = getOAuthClient();
|
||||
const companyId = oauthClient.getToken().realmId;
|
||||
const baseUrl = getQboBaseUrl();
|
||||
|
||||
// Get SyncToken
|
||||
const qboRes = await makeQboApiCall({
|
||||
url: `${baseUrl}/v3/company/${companyId}/customer/${customer.qbo_id}`,
|
||||
method: 'GET'
|
||||
});
|
||||
const qboData = qboRes.getJson ? qboRes.getJson() : qboRes.json;
|
||||
const syncToken = qboData.Customer?.SyncToken;
|
||||
|
||||
if (syncToken !== undefined) {
|
||||
console.log(`🗑️ Deactivating QBO Customer ${customer.qbo_id} (${customer.name})...`);
|
||||
|
||||
await makeQboApiCall({
|
||||
url: `${baseUrl}/v3/company/${companyId}/customer`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
Id: customer.qbo_id,
|
||||
SyncToken: syncToken,
|
||||
sparse: true,
|
||||
Active: false
|
||||
})
|
||||
});
|
||||
|
||||
console.log(`✅ QBO Customer ${customer.qbo_id} deactivated.`);
|
||||
}
|
||||
} catch (qboError) {
|
||||
console.error(`⚠️ QBO deactivate failed for Customer ${customer.qbo_id}:`, qboError.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Delete locally
|
||||
await pool.query('DELETE FROM customers WHERE id = $1', [id]);
|
||||
res.json({ success: true });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error deleting customer:', error);
|
||||
res.status(500).json({ error: 'Error deleting customer' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST export customer to QBO
|
||||
router.post('/:id/export-qbo', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
try {
|
||||
const custResult = await pool.query('SELECT * FROM customers WHERE id = $1', [id]);
|
||||
if (custResult.rows.length === 0) return res.status(404).json({ error: 'Customer not found' });
|
||||
const customer = custResult.rows[0];
|
||||
|
||||
if (customer.qbo_id) return res.status(400).json({ error: 'Customer already in QBO' });
|
||||
|
||||
const oauthClient = getOAuthClient();
|
||||
const companyId = oauthClient.getToken().realmId;
|
||||
const baseUrl = getQboBaseUrl();
|
||||
|
||||
const qboCustomer = {
|
||||
DisplayName: customer.name,
|
||||
CompanyName: customer.name,
|
||||
PrimaryEmailAddr: customer.email ? { Address: customer.email } : undefined,
|
||||
PrimaryPhone: customer.phone ? { FreeFormNumber: customer.phone } : undefined,
|
||||
Taxable: customer.taxable !== false,
|
||||
Notes: customer.remarks || undefined
|
||||
};
|
||||
|
||||
// Contact
|
||||
if (customer.contact) {
|
||||
const parts = customer.contact.trim().split(/\s+/);
|
||||
if (parts.length >= 2) {
|
||||
qboCustomer.GivenName = parts[0];
|
||||
qboCustomer.FamilyName = parts.slice(1).join(' ');
|
||||
} else {
|
||||
qboCustomer.GivenName = parts[0];
|
||||
}
|
||||
}
|
||||
|
||||
// Address
|
||||
const addr = {};
|
||||
if (customer.line1) addr.Line1 = customer.line1;
|
||||
if (customer.line2) addr.Line2 = customer.line2;
|
||||
if (customer.line3) addr.Line3 = customer.line3;
|
||||
if (customer.line4) addr.Line4 = customer.line4;
|
||||
if (customer.city) addr.City = customer.city;
|
||||
if (customer.state) addr.CountrySubDivisionCode = customer.state;
|
||||
if (customer.zip_code) addr.PostalCode = customer.zip_code;
|
||||
if (Object.keys(addr).length > 0) qboCustomer.BillAddr = addr;
|
||||
|
||||
const response = await makeQboApiCall({
|
||||
url: `${baseUrl}/v3/company/${companyId}/customer`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(qboCustomer)
|
||||
});
|
||||
|
||||
const data = response.getJson ? response.getJson() : response.json;
|
||||
const qboId = data.Customer?.Id;
|
||||
|
||||
if (!qboId) throw new Error('QBO returned no ID');
|
||||
|
||||
await pool.query('UPDATE customers SET qbo_id = $1 WHERE id = $2', [qboId, id]);
|
||||
|
||||
console.log(`✅ Customer "${customer.name}" exported to QBO (ID: ${qboId})`);
|
||||
res.json({ success: true, qbo_id: qboId, name: customer.name });
|
||||
|
||||
} catch (error) {
|
||||
console.error('QBO Customer Export Error:', error);
|
||||
res.status(500).json({ error: 'Export failed: ' + error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
@ -1,881 +0,0 @@
|
|||
/**
|
||||
* 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');
|
||||
|
||||
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];
|
||||
}
|
||||
|
||||
// GET all invoices
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
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
|
||||
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.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];
|
||||
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 } = 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.` });
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) 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]
|
||||
);
|
||||
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) 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']
|
||||
);
|
||||
}
|
||||
|
||||
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 } = 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.` });
|
||||
}
|
||||
}
|
||||
|
||||
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, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $13`,
|
||||
[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, 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, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $12`,
|
||||
[customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, scheduled_send_date || null, bill_to_name || null, id]
|
||||
);
|
||||
}
|
||||
const next_recurring_date = is_recurring ? calculateNextRecurringDate(invoice_date, recurring_interval) : null;
|
||||
await client.query(
|
||||
'UPDATE invoices SET is_recurring = $1, recurring_interval = $2, next_recurring_date = $3 WHERE id = $4',
|
||||
[is_recurring || false, 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) VALUES ($1, $2, $3, $4, $5, $6, $7)',
|
||||
[id, items[i].quantity, items[i].description, items[i].rate, items[i].amount, i, items[i].qbo_item_id || '9']
|
||||
);
|
||||
}
|
||||
|
||||
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 == '5' ? "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 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" : "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);
|
||||
|
||||
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);
|
||||
|
||||
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;
|
||||
const { recipientEmail, customText, melioLink } = req.body;
|
||||
|
||||
if (!recipientEmail) {
|
||||
return res.status(400).json({ error: 'Recipient email is required.' });
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Rechnungsdaten und Items laden (analog zu deiner PDF-Route)
|
||||
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, aber nur im Speicher halten
|
||||
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);
|
||||
|
||||
const pdfBuffer = await generatePdfFromHtml(html);
|
||||
|
||||
// 3. E-Mail über SES versenden
|
||||
const info = await sendInvoiceEmail(invoice, recipientEmail, customText, melioLink, pdfBuffer);
|
||||
|
||||
// 4. (Optional) Status in der DB aktualisieren
|
||||
//await pool.query('UPDATE invoices SET email_status = $1 WHERE id = $2', ['sent', id]);
|
||||
|
||||
res.json({ success: true, messageId: info.messageId });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error sending invoice email:', error);
|
||||
res.status(500).json({ error: 'Failed to send email: ' + error.message });
|
||||
}
|
||||
});
|
||||
module.exports = router;
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
/**
|
||||
* Payment Routes
|
||||
* Handles payment recording and listing
|
||||
*/
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { pool } = require('../config/database');
|
||||
|
||||
// GET all payments
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
SELECT p.*, c.name as customer_name,
|
||||
COALESCE(json_agg(json_build_object(
|
||||
'invoice_id', pi.invoice_id, 'amount', pi.amount, 'invoice_number', i.invoice_number
|
||||
)) FILTER (WHERE pi.id IS NOT NULL), '[]') as invoices
|
||||
FROM payments p
|
||||
LEFT JOIN customers c ON p.customer_id = c.id
|
||||
LEFT JOIN payment_invoices pi ON pi.payment_id = p.id
|
||||
LEFT JOIN invoices i ON i.id = pi.invoice_id
|
||||
GROUP BY p.id, c.name ORDER BY p.payment_date DESC
|
||||
`);
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Error fetching payments' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
@ -1,572 +0,0 @@
|
|||
/**
|
||||
* QBO Routes
|
||||
* Handles QBO sync and data operations
|
||||
* NOTE: OAuth auth/callback routes are in index.js (root-level paths)
|
||||
*/
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { pool } = require('../config/database');
|
||||
const { getOAuthClient, getQboBaseUrl, makeQboApiCall } = require('../config/qbo');
|
||||
|
||||
// GET QBO status
|
||||
router.get('/status', (req, res) => {
|
||||
try {
|
||||
const client = getOAuthClient();
|
||||
const token = client.getToken();
|
||||
const hasToken = !!(token && token.refresh_token);
|
||||
res.json({
|
||||
connected: hasToken,
|
||||
realmId: token?.realmId || null
|
||||
});
|
||||
} catch (e) {
|
||||
res.json({ connected: false });
|
||||
}
|
||||
});
|
||||
|
||||
// GET bank accounts from QBO
|
||||
router.get('/accounts', async (req, res) => {
|
||||
try {
|
||||
const oauthClient = getOAuthClient();
|
||||
const companyId = oauthClient.getToken().realmId;
|
||||
const baseUrl = getQboBaseUrl();
|
||||
const query = "SELECT * FROM Account WHERE AccountType = 'Bank' AND Active = true";
|
||||
const response = await makeQboApiCall({
|
||||
url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(query)}`,
|
||||
method: 'GET'
|
||||
});
|
||||
const data = response.getJson ? response.getJson() : response.json;
|
||||
res.json((data.QueryResponse?.Account || []).map(a => ({ id: a.Id, name: a.Name })));
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET payment methods from QBO
|
||||
router.get('/payment-methods', async (req, res) => {
|
||||
try {
|
||||
const oauthClient = getOAuthClient();
|
||||
const companyId = oauthClient.getToken().realmId;
|
||||
const baseUrl = getQboBaseUrl();
|
||||
const query = "SELECT * FROM PaymentMethod WHERE Active = true";
|
||||
const response = await makeQboApiCall({
|
||||
url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(query)}`,
|
||||
method: 'GET'
|
||||
});
|
||||
const data = response.getJson ? response.getJson() : response.json;
|
||||
res.json((data.QueryResponse?.PaymentMethod || []).map(p => ({ id: p.Id, name: p.Name })));
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET labor rate from QBO
|
||||
router.get('/labor-rate', async (req, res) => {
|
||||
try {
|
||||
const oauthClient = getOAuthClient();
|
||||
const companyId = oauthClient.getToken().realmId;
|
||||
const baseUrl = getQboBaseUrl();
|
||||
|
||||
const response = await makeQboApiCall({
|
||||
url: `${baseUrl}/v3/company/${companyId}/item/5`,
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
const data = response.getJson ? response.getJson() : response.json;
|
||||
const rate = data.Item?.UnitPrice || null;
|
||||
|
||||
console.log(`💰 QBO Labor Rate: $${rate}`);
|
||||
res.json({ rate });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching labor rate:', error);
|
||||
res.json({ rate: null });
|
||||
}
|
||||
});
|
||||
|
||||
// GET last sync timestamp
|
||||
router.get('/last-sync', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query("SELECT value FROM settings WHERE key = 'last_payment_sync'");
|
||||
res.json({ last_sync: result.rows[0]?.value || null });
|
||||
} catch (error) {
|
||||
res.json({ last_sync: null });
|
||||
}
|
||||
});
|
||||
|
||||
// GET overdue invoices from QBO
|
||||
router.get('/overdue', async (req, res) => {
|
||||
try {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() - 30);
|
||||
const dateStr = date.toISOString().split('T')[0];
|
||||
|
||||
console.log(`🔍 Suche in QBO nach unbezahlten Rechnungen fällig vor ${dateStr}...`);
|
||||
|
||||
const query = `SELECT DocNumber, TxnDate, DueDate, Balance, CustomerRef, TotalAmt FROM Invoice WHERE Balance > '0' AND DueDate < '${dateStr}' ORDERBY DueDate ASC`;
|
||||
|
||||
const oauthClient = getOAuthClient();
|
||||
const companyId = oauthClient.getToken().realmId;
|
||||
const baseUrl = getQboBaseUrl();
|
||||
|
||||
const response = await makeQboApiCall({
|
||||
url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(query)}`,
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
const data = response.getJson ? response.getJson() : response.json;
|
||||
const invoices = data.QueryResponse?.Invoice || [];
|
||||
|
||||
console.log(`✅ ${invoices.length} überfällige Rechnungen gefunden.`);
|
||||
res.json(invoices);
|
||||
|
||||
} catch (error) {
|
||||
console.error("QBO Report Error:", error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// POST import unpaid invoices from QBO
|
||||
router.post('/import-unpaid', async (req, res) => {
|
||||
const dbClient = await pool.connect();
|
||||
|
||||
try {
|
||||
const oauthClient = getOAuthClient();
|
||||
const companyId = oauthClient.getToken().realmId;
|
||||
const baseUrl = getQboBaseUrl();
|
||||
|
||||
console.log('📥 QBO Import: Lade unbezahlte Rechnungen...');
|
||||
|
||||
const query = "SELECT * FROM Invoice WHERE Balance > '0' ORDERBY DocNumber ASC MAXRESULTS 1000";
|
||||
const response = await makeQboApiCall({
|
||||
url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(query)}`,
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
const data = response.getJson ? response.getJson() : response.json;
|
||||
const qboInvoices = data.QueryResponse?.Invoice || [];
|
||||
|
||||
console.log(`📋 ${qboInvoices.length} unbezahlte Rechnungen in QBO gefunden.`);
|
||||
|
||||
if (qboInvoices.length === 0) {
|
||||
return res.json({
|
||||
success: true,
|
||||
imported: 0,
|
||||
skipped: 0,
|
||||
skippedNoCustomer: 0,
|
||||
message: 'Keine unbezahlten Rechnungen in QBO gefunden.'
|
||||
});
|
||||
}
|
||||
|
||||
// Load local customers
|
||||
const customersResult = await dbClient.query(
|
||||
'SELECT id, qbo_id, name, taxable FROM customers WHERE qbo_id IS NOT NULL'
|
||||
);
|
||||
const customerMap = new Map();
|
||||
customersResult.rows.forEach(c => customerMap.set(c.qbo_id, c));
|
||||
|
||||
// Get already imported QBO invoices
|
||||
const existingResult = await dbClient.query(
|
||||
'SELECT qbo_id FROM invoices WHERE qbo_id IS NOT NULL'
|
||||
);
|
||||
const existingQboIds = new Set(existingResult.rows.map(r => r.qbo_id));
|
||||
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
let skippedNoCustomer = 0;
|
||||
const skippedCustomerNames = [];
|
||||
|
||||
await dbClient.query('BEGIN');
|
||||
|
||||
for (const qboInv of qboInvoices) {
|
||||
const qboId = String(qboInv.Id);
|
||||
|
||||
if (existingQboIds.has(qboId)) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const customerQboId = String(qboInv.CustomerRef?.value || '');
|
||||
const localCustomer = customerMap.get(customerQboId);
|
||||
|
||||
if (!localCustomer) {
|
||||
skippedNoCustomer++;
|
||||
const custName = qboInv.CustomerRef?.name || 'Unbekannt';
|
||||
if (!skippedCustomerNames.includes(custName)) {
|
||||
skippedCustomerNames.push(custName);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const docNumber = qboInv.DocNumber || '';
|
||||
const txnDate = qboInv.TxnDate || new Date().toISOString().split('T')[0];
|
||||
const syncToken = qboInv.SyncToken || '';
|
||||
|
||||
let terms = 'Net 30';
|
||||
if (qboInv.SalesTermRef?.name) {
|
||||
terms = qboInv.SalesTermRef.name;
|
||||
}
|
||||
|
||||
const taxAmount = qboInv.TxnTaxDetail?.TotalTax || 0;
|
||||
const taxExempt = taxAmount === 0;
|
||||
|
||||
const total = parseFloat(qboInv.TotalAmt) || 0;
|
||||
const subtotal = total - taxAmount;
|
||||
const taxRate = subtotal > 0 && !taxExempt ? (taxAmount / subtotal * 100) : 8.25;
|
||||
|
||||
const authCode = qboInv.CustomerMemo?.value || '';
|
||||
|
||||
const invoiceResult = await dbClient.query(
|
||||
`INSERT INTO invoices
|
||||
(invoice_number, customer_id, invoice_date, terms, auth_code,
|
||||
tax_exempt, tax_rate, subtotal, tax_amount, total,
|
||||
qbo_id, qbo_sync_token, qbo_doc_number)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
RETURNING id`,
|
||||
[docNumber, localCustomer.id, txnDate, terms, authCode,
|
||||
taxExempt, taxRate, subtotal, taxAmount, total,
|
||||
qboId, syncToken, docNumber]
|
||||
);
|
||||
|
||||
const localInvoiceId = invoiceResult.rows[0].id;
|
||||
|
||||
const lines = qboInv.Line || [];
|
||||
let itemOrder = 0;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.DetailType !== 'SalesItemLineDetail') continue;
|
||||
|
||||
const detail = line.SalesItemLineDetail || {};
|
||||
const qty = String(detail.Qty || 1);
|
||||
const rate = String(detail.UnitPrice || 0);
|
||||
const amount = String(line.Amount || 0);
|
||||
const description = line.Description || '';
|
||||
|
||||
const itemRefValue = detail.ItemRef?.value || '9';
|
||||
const itemRefName = (detail.ItemRef?.name || '').toLowerCase();
|
||||
let qboItemId = '9';
|
||||
if (itemRefValue === '5' || itemRefName.includes('labor')) {
|
||||
qboItemId = '5';
|
||||
}
|
||||
|
||||
await dbClient.query(
|
||||
`INSERT INTO invoice_items
|
||||
(invoice_id, quantity, description, rate, amount, item_order, qbo_item_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
||||
[localInvoiceId, qty, description, rate, amount, itemOrder, qboItemId]
|
||||
);
|
||||
itemOrder++;
|
||||
}
|
||||
|
||||
imported++;
|
||||
console.log(` ✅ Importiert: #${docNumber} (${localCustomer.name}) - $${total}`);
|
||||
}
|
||||
|
||||
await dbClient.query('COMMIT');
|
||||
|
||||
const message = [
|
||||
`${imported} Rechnungen importiert.`,
|
||||
skipped > 0 ? `${skipped} bereits vorhanden (übersprungen).` : '',
|
||||
skippedNoCustomer > 0 ? `${skippedNoCustomer} übersprungen (Kunde nicht verknüpft: ${skippedCustomerNames.join(', ')}).` : ''
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
console.log(`📥 QBO Import abgeschlossen: ${message}`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
imported,
|
||||
skipped,
|
||||
skippedNoCustomer,
|
||||
skippedCustomerNames,
|
||||
message
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
await dbClient.query('ROLLBACK');
|
||||
console.error('❌ QBO Import Error:', error);
|
||||
res.status(500).json({ error: 'Import fehlgeschlagen: ' + error.message });
|
||||
} finally {
|
||||
dbClient.release();
|
||||
}
|
||||
});
|
||||
|
||||
// POST record payment in QBO
|
||||
router.post('/record-payment', async (req, res) => {
|
||||
const {
|
||||
invoice_payments,
|
||||
payment_date,
|
||||
reference_number,
|
||||
payment_method_id,
|
||||
payment_method_name,
|
||||
deposit_to_account_id,
|
||||
deposit_to_account_name
|
||||
} = req.body;
|
||||
|
||||
if (!invoice_payments || invoice_payments.length === 0) {
|
||||
return res.status(400).json({ error: 'No invoices selected.' });
|
||||
}
|
||||
|
||||
const dbClient = await pool.connect();
|
||||
try {
|
||||
const oauthClient = getOAuthClient();
|
||||
const companyId = oauthClient.getToken().realmId;
|
||||
const baseUrl = getQboBaseUrl();
|
||||
|
||||
const ids = invoice_payments.map(ip => ip.invoice_id);
|
||||
const result = await dbClient.query(
|
||||
`SELECT i.*, c.qbo_id as customer_qbo_id, c.name as customer_name
|
||||
FROM invoices i LEFT JOIN customers c ON i.customer_id = c.id
|
||||
WHERE i.id = ANY($1)`, [ids]
|
||||
);
|
||||
const invoicesData = result.rows;
|
||||
|
||||
const notInQbo = invoicesData.filter(inv => !inv.qbo_id);
|
||||
if (notInQbo.length > 0) {
|
||||
return res.status(400).json({
|
||||
error: `Not in QBO: ${notInQbo.map(i => i.invoice_number || `ID:${i.id}`).join(', ')}`
|
||||
});
|
||||
}
|
||||
const custIds = [...new Set(invoicesData.map(inv => inv.customer_qbo_id))];
|
||||
if (custIds.length > 1) {
|
||||
return res.status(400).json({ error: 'All invoices must belong to the same customer.' });
|
||||
}
|
||||
|
||||
const paymentMap = new Map(invoice_payments.map(ip => [ip.invoice_id, parseFloat(ip.amount)]));
|
||||
const totalAmt = invoice_payments.reduce((s, ip) => s + parseFloat(ip.amount), 0);
|
||||
|
||||
const qboPayment = {
|
||||
CustomerRef: { value: custIds[0] },
|
||||
TotalAmt: totalAmt,
|
||||
TxnDate: payment_date,
|
||||
PaymentRefNum: reference_number || '',
|
||||
PaymentMethodRef: { value: payment_method_id },
|
||||
DepositToAccountRef: { value: deposit_to_account_id },
|
||||
Line: invoicesData.map(inv => ({
|
||||
Amount: paymentMap.get(inv.id) || parseFloat(inv.total),
|
||||
LinkedTxn: [{ TxnId: inv.qbo_id, TxnType: 'Invoice' }]
|
||||
}))
|
||||
};
|
||||
|
||||
console.log(`💰 Payment: $${totalAmt.toFixed(2)} for ${invoicesData.length} invoice(s)`);
|
||||
|
||||
const response = await makeQboApiCall({
|
||||
url: `${baseUrl}/v3/company/${companyId}/payment`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(qboPayment)
|
||||
});
|
||||
const data = response.getJson ? response.getJson() : response.json;
|
||||
|
||||
if (!data.Payment) {
|
||||
return res.status(500).json({
|
||||
error: 'QBO Error: ' + (data.Fault?.Error?.[0]?.Message || JSON.stringify(data))
|
||||
});
|
||||
}
|
||||
|
||||
const qboPaymentId = data.Payment.Id;
|
||||
console.log(`✅ QBO Payment ID: ${qboPaymentId}`);
|
||||
|
||||
await dbClient.query('BEGIN');
|
||||
const payResult = await dbClient.query(
|
||||
`INSERT INTO payments (payment_date, reference_number, payment_method, deposit_to_account, total_amount, customer_id, qbo_payment_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id`,
|
||||
[payment_date, reference_number || null, payment_method_name || 'Check',
|
||||
deposit_to_account_name || '', totalAmt, invoicesData[0].customer_id, qboPaymentId]
|
||||
);
|
||||
const localPaymentId = payResult.rows[0].id;
|
||||
|
||||
for (const ip of invoice_payments) {
|
||||
const payAmt = parseFloat(ip.amount);
|
||||
const inv = invoicesData.find(i => i.id === ip.invoice_id);
|
||||
const invTotal = inv ? parseFloat(inv.total) : 0;
|
||||
|
||||
await dbClient.query(
|
||||
'INSERT INTO payment_invoices (payment_id, invoice_id, amount) VALUES ($1, $2, $3)',
|
||||
[localPaymentId, ip.invoice_id, payAmt]
|
||||
);
|
||||
if (payAmt >= invTotal) {
|
||||
await dbClient.query(
|
||||
'UPDATE invoices SET paid_date = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
|
||||
[payment_date, ip.invoice_id]
|
||||
);
|
||||
}
|
||||
}
|
||||
await dbClient.query('COMMIT');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
payment_id: localPaymentId,
|
||||
qbo_payment_id: qboPaymentId,
|
||||
total: totalAmt,
|
||||
invoices_paid: invoice_payments.length,
|
||||
message: `Payment $${totalAmt.toFixed(2)} recorded (QBO: ${qboPaymentId}).`
|
||||
});
|
||||
} catch (error) {
|
||||
await dbClient.query('ROLLBACK').catch(() => {});
|
||||
console.error('❌ Payment Error:', error);
|
||||
res.status(500).json({ error: 'Payment failed: ' + error.message });
|
||||
} finally {
|
||||
dbClient.release();
|
||||
}
|
||||
});
|
||||
|
||||
// POST sync payments from QBO
|
||||
router.post('/sync-payments', async (req, res) => {
|
||||
const dbClient = await pool.connect();
|
||||
try {
|
||||
const openResult = await dbClient.query(`
|
||||
SELECT i.id, i.qbo_id, i.invoice_number, i.total, i.paid_date, i.payment_status,
|
||||
COALESCE((SELECT SUM(pi.amount) FROM payment_invoices pi WHERE pi.invoice_id = i.id), 0) as local_paid
|
||||
FROM invoices i
|
||||
WHERE i.qbo_id IS NOT NULL
|
||||
`);
|
||||
|
||||
const openInvoices = openResult.rows;
|
||||
if (openInvoices.length === 0) {
|
||||
await dbClient.query("UPDATE settings SET value = $1 WHERE key = 'last_payment_sync'", [new Date().toISOString()]);
|
||||
return res.json({ synced: 0, message: 'All invoices up to date.' });
|
||||
}
|
||||
|
||||
const oauthClient = getOAuthClient();
|
||||
const companyId = oauthClient.getToken().realmId;
|
||||
const baseUrl = getQboBaseUrl();
|
||||
|
||||
const batchSize = 50;
|
||||
const qboInvoices = new Map();
|
||||
|
||||
for (let i = 0; i < openInvoices.length; i += batchSize) {
|
||||
const batch = openInvoices.slice(i, i + batchSize);
|
||||
const ids = batch.map(inv => `'${inv.qbo_id}'`).join(',');
|
||||
const query = `SELECT Id, DocNumber, Balance, TotalAmt, LinkedTxn FROM Invoice WHERE Id IN (${ids})`;
|
||||
|
||||
const response = await makeQboApiCall({
|
||||
url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(query)}`,
|
||||
method: 'GET'
|
||||
});
|
||||
const data = response.getJson ? response.getJson() : response.json;
|
||||
const invoices = data.QueryResponse?.Invoice || [];
|
||||
invoices.forEach(inv => qboInvoices.set(inv.Id, inv));
|
||||
}
|
||||
|
||||
console.log(`🔍 QBO Sync: ${openInvoices.length} offene Invoices, ${qboInvoices.size} aus QBO geladen`);
|
||||
|
||||
let updated = 0;
|
||||
let newPayments = 0;
|
||||
|
||||
await dbClient.query('BEGIN');
|
||||
|
||||
for (const localInv of openInvoices) {
|
||||
const qboInv = qboInvoices.get(localInv.qbo_id);
|
||||
if (!qboInv) continue;
|
||||
|
||||
const qboBalance = parseFloat(qboInv.Balance) || 0;
|
||||
const qboTotal = parseFloat(qboInv.TotalAmt) || 0;
|
||||
const localPaid = parseFloat(localInv.local_paid) || 0;
|
||||
|
||||
if (qboBalance === 0 && qboTotal > 0) {
|
||||
const UNDEPOSITED_FUNDS_ID = '221';
|
||||
let status = 'Paid';
|
||||
|
||||
if (qboInv.LinkedTxn) {
|
||||
for (const txn of qboInv.LinkedTxn) {
|
||||
if (txn.TxnType === 'Payment') {
|
||||
try {
|
||||
const pmRes = await makeQboApiCall({
|
||||
url: `${baseUrl}/v3/company/${companyId}/payment/${txn.TxnId}`,
|
||||
method: 'GET'
|
||||
});
|
||||
const pmData = pmRes.getJson ? pmRes.getJson() : pmRes.json;
|
||||
const payment = pmData.Payment;
|
||||
if (payment && payment.DepositToAccountRef &&
|
||||
payment.DepositToAccountRef.value !== UNDEPOSITED_FUNDS_ID) {
|
||||
status = 'Deposited';
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const needsUpdate = !localInv.paid_date || localInv.payment_status !== status;
|
||||
if (needsUpdate) {
|
||||
await dbClient.query(
|
||||
`UPDATE invoices SET
|
||||
paid_date = COALESCE(paid_date, CURRENT_DATE),
|
||||
payment_status = $1,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $2`,
|
||||
[status, localInv.id]
|
||||
);
|
||||
updated++;
|
||||
console.log(` ✅ #${localInv.invoice_number}: ${status}`);
|
||||
}
|
||||
|
||||
const diff = qboTotal - localPaid;
|
||||
if (diff > 0.01) {
|
||||
const payResult = await dbClient.query(
|
||||
`INSERT INTO payments (payment_date, payment_method, total_amount, customer_id, notes, created_at)
|
||||
VALUES (CURRENT_DATE, 'Synced from QBO', $1, (SELECT customer_id FROM invoices WHERE id = $2), 'Synced from QBO', CURRENT_TIMESTAMP)
|
||||
RETURNING id`,
|
||||
[diff, localInv.id]
|
||||
);
|
||||
await dbClient.query(
|
||||
'INSERT INTO payment_invoices (payment_id, invoice_id, amount) VALUES ($1, $2, $3)',
|
||||
[payResult.rows[0].id, localInv.id, diff]
|
||||
);
|
||||
newPayments++;
|
||||
console.log(` 💰 #${localInv.invoice_number}: +$${diff.toFixed(2)} payment synced`);
|
||||
}
|
||||
|
||||
} else if (qboBalance > 0 && qboBalance < qboTotal) {
|
||||
const qboPaid = qboTotal - qboBalance;
|
||||
const diff = qboPaid - localPaid;
|
||||
|
||||
const needsUpdate = localInv.payment_status !== 'Partial';
|
||||
if (needsUpdate) {
|
||||
await dbClient.query(
|
||||
'UPDATE invoices SET payment_status = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
|
||||
['Partial', localInv.id]
|
||||
);
|
||||
updated++;
|
||||
}
|
||||
|
||||
if (diff > 0.01) {
|
||||
const payResult = await dbClient.query(
|
||||
`INSERT INTO payments (payment_date, payment_method, total_amount, customer_id, notes, created_at)
|
||||
VALUES (CURRENT_DATE, 'Synced from QBO', $1, (SELECT customer_id FROM invoices WHERE id = $2), 'Synced from QBO', CURRENT_TIMESTAMP)
|
||||
RETURNING id`,
|
||||
[diff, localInv.id]
|
||||
);
|
||||
await dbClient.query(
|
||||
'INSERT INTO payment_invoices (payment_id, invoice_id, amount) VALUES ($1, $2, $3)',
|
||||
[payResult.rows[0].id, localInv.id, diff]
|
||||
);
|
||||
newPayments++;
|
||||
console.log(` 📎 #${localInv.invoice_number}: Partial +$${diff.toFixed(2)} ($${qboPaid.toFixed(2)} of $${qboTotal.toFixed(2)})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await dbClient.query(`
|
||||
INSERT INTO settings (key, value) VALUES ('last_payment_sync', $1)
|
||||
ON CONFLICT (key) DO UPDATE SET value = $1
|
||||
`, [new Date().toISOString()]);
|
||||
|
||||
await dbClient.query('COMMIT');
|
||||
|
||||
console.log(`✅ Sync abgeschlossen: ${updated} aktualisiert, ${newPayments} neue Payments`);
|
||||
res.json({
|
||||
synced: updated,
|
||||
new_payments: newPayments,
|
||||
total_checked: openInvoices.length,
|
||||
message: `${updated} invoice(s) updated, ${newPayments} new payment(s) synced.`
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
await dbClient.query('ROLLBACK').catch(() => {});
|
||||
console.error('❌ Sync Error:', error);
|
||||
res.status(500).json({ error: 'Sync failed: ' + error.message });
|
||||
} finally {
|
||||
dbClient.release();
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
@ -1,370 +0,0 @@
|
|||
/**
|
||||
* Quote Routes
|
||||
* Handles quote CRUD operations 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 { getNextQuoteNumber } = require('../utils/numberGenerators');
|
||||
const { formatDate, formatMoney } = require('../utils/helpers');
|
||||
const { getBrowser, generatePdfFromHtml, getLogoHtml, renderQuoteItems, formatAddressLines } = require('../services/pdf-service');
|
||||
|
||||
// GET all quotes
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
SELECT q.*, c.name as customer_name
|
||||
FROM quotes q
|
||||
LEFT JOIN customers c ON q.customer_id = c.id
|
||||
ORDER BY q.created_at DESC
|
||||
`);
|
||||
res.json(result.rows);
|
||||
} catch (error) {
|
||||
console.error('Error fetching quotes:', error);
|
||||
res.status(500).json({ error: 'Error fetching quotes' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET single quote
|
||||
router.get('/:id', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
try {
|
||||
const quoteResult = await pool.query(`
|
||||
SELECT q.*, c.name as customer_name, c.line1, c.line2, c.line3, c.line4, c.city, c.state, c.zip_code, c.account_number
|
||||
FROM quotes q
|
||||
LEFT JOIN customers c ON q.customer_id = c.id
|
||||
WHERE q.id = $1
|
||||
`, [id]);
|
||||
|
||||
if (quoteResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Quote not found' });
|
||||
}
|
||||
|
||||
const itemsResult = await pool.query(
|
||||
'SELECT * FROM quote_items WHERE quote_id = $1 ORDER BY item_order',
|
||||
[id]
|
||||
);
|
||||
|
||||
res.json({
|
||||
quote: quoteResult.rows[0],
|
||||
items: itemsResult.rows
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching quote:', error);
|
||||
res.status(500).json({ error: 'Error fetching quote' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST create quote
|
||||
router.post('/', async (req, res) => {
|
||||
const { customer_id, quote_date, tax_exempt, items } = req.body;
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const quote_number = await getNextQuoteNumber();
|
||||
|
||||
let subtotal = 0;
|
||||
let has_tbd = false;
|
||||
|
||||
for (const item of items) {
|
||||
if (item.rate.toUpperCase() === 'TBD' || item.amount.toUpperCase() === 'TBD') {
|
||||
has_tbd = true;
|
||||
} else {
|
||||
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 quoteResult = await client.query(
|
||||
`INSERT INTO quotes (quote_number, customer_id, quote_date, tax_exempt, tax_rate, subtotal, tax_amount, total, has_tbd)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *`,
|
||||
[quote_number, customer_id, quote_date, tax_exempt, tax_rate, subtotal, tax_amount, total, has_tbd]
|
||||
);
|
||||
|
||||
const quoteId = quoteResult.rows[0].id;
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
await client.query(
|
||||
'INSERT INTO quote_items (quote_id, quantity, description, rate, amount, item_order, qbo_item_id) VALUES ($1, $2, $3, $4, $5, $6, $7)',
|
||||
[quoteId, items[i].quantity, items[i].description, items[i].rate, items[i].amount, i, items[i].qbo_item_id || '9']
|
||||
);
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
res.json(quoteResult.rows[0]);
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error('Error creating quote:', error);
|
||||
res.status(500).json({ error: 'Error creating quote' });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
// PUT update quote
|
||||
router.put('/:id', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { customer_id, quote_date, tax_exempt, items } = req.body;
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
let subtotal = 0;
|
||||
let has_tbd = false;
|
||||
|
||||
for (const item of items) {
|
||||
if (item.rate.toUpperCase() === 'TBD' || item.amount.toUpperCase() === 'TBD') {
|
||||
has_tbd = true;
|
||||
} else {
|
||||
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;
|
||||
|
||||
await client.query(
|
||||
`UPDATE quotes SET customer_id = $1, quote_date = $2, tax_exempt = $3, tax_rate = $4,
|
||||
subtotal = $5, tax_amount = $6, total = $7, has_tbd = $8, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $9`,
|
||||
[customer_id, quote_date, tax_exempt, tax_rate, subtotal, tax_amount, total, has_tbd, id]
|
||||
);
|
||||
|
||||
await client.query('DELETE FROM quote_items WHERE quote_id = $1', [id]);
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
await client.query(
|
||||
'INSERT INTO quote_items (quote_id, quantity, description, rate, amount, item_order, qbo_item_id) VALUES ($1, $2, $3, $4, $5, $6, $7)',
|
||||
[id, items[i].quantity, items[i].description, items[i].rate, items[i].amount, i, items[i].qbo_item_id || '9']
|
||||
);
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error('Error updating quote:', error);
|
||||
res.status(500).json({ error: 'Error updating quote' });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE quote
|
||||
router.delete('/:id', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
await client.query('DELETE FROM quote_items WHERE quote_id = $1', [id]);
|
||||
await client.query('DELETE FROM quotes WHERE id = $1', [id]);
|
||||
await client.query('COMMIT');
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error('Error deleting quote:', error);
|
||||
res.status(500).json({ error: 'Error deleting quote' });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
// GET quote PDF
|
||||
router.get('/:id/pdf', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
console.log(`[PDF] Starting quote PDF generation for ID: ${id}`);
|
||||
|
||||
try {
|
||||
const quoteResult = await pool.query(`
|
||||
SELECT q.*, c.name as customer_name, c.line1, c.line2, c.line3, c.line4, c.city, c.state, c.zip_code, c.account_number
|
||||
FROM quotes q
|
||||
LEFT JOIN customers c ON q.customer_id = c.id
|
||||
WHERE q.id = $1
|
||||
`, [id]);
|
||||
|
||||
if (quoteResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Quote not found' });
|
||||
}
|
||||
|
||||
const quote = quoteResult.rows[0];
|
||||
const itemsResult = await pool.query(
|
||||
'SELECT * FROM quote_items WHERE quote_id = $1 ORDER BY item_order',
|
||||
[id]
|
||||
);
|
||||
|
||||
const templatePath = path.join(__dirname, '..', '..', 'templates', 'quote-template.html');
|
||||
let html = await fs.readFile(templatePath, 'utf-8');
|
||||
|
||||
const logoHTML = await getLogoHtml();
|
||||
const itemsHTML = renderQuoteItems(itemsResult.rows, quote);
|
||||
|
||||
let tbdNote = quote.has_tbd ? '<p style="font-size: 12px; margin-top: 20px;"><em>* Note: This quote contains items marked as "TBD". The final total may vary.</em></p>' : '';
|
||||
|
||||
const streetBlock = formatAddressLines(quote.line1, quote.line2, quote.line3, quote.line4, quote.customer_name);
|
||||
|
||||
html = html
|
||||
.replace('{{LOGO_HTML}}', logoHTML)
|
||||
.replace('{{CUSTOMER_NAME}}', quote.customer_name || '')
|
||||
.replace('{{CUSTOMER_STREET}}', streetBlock)
|
||||
.replace('{{CUSTOMER_CITY}}', quote.city || '')
|
||||
.replace('{{CUSTOMER_STATE}}', quote.state || '')
|
||||
.replace('{{CUSTOMER_ZIP}}', quote.zip_code || '')
|
||||
.replace('{{QUOTE_NUMBER}}', quote.quote_number)
|
||||
.replace('{{ACCOUNT_NUMBER}}', quote.account_number || '')
|
||||
.replace('{{QUOTE_DATE}}', formatDate(quote.quote_date))
|
||||
.replace('{{ITEMS}}', itemsHTML)
|
||||
.replace('{{TBD_NOTE}}', tbdNote);
|
||||
|
||||
const pdf = await generatePdfFromHtml(html);
|
||||
|
||||
res.set({
|
||||
'Content-Type': 'application/pdf',
|
||||
'Content-Length': pdf.length,
|
||||
'Content-Disposition': `attachment; filename="Quote-${quote.quote_number}.pdf"`
|
||||
});
|
||||
res.end(pdf, 'binary');
|
||||
console.log('[PDF] Quote PDF sent successfully');
|
||||
|
||||
} catch (error) {
|
||||
console.error('[PDF] ERROR:', error);
|
||||
res.status(500).json({ error: 'Error generating PDF', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET quote HTML (debug)
|
||||
router.get('/:id/html', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
try {
|
||||
const quoteResult = await pool.query(`
|
||||
SELECT q.*, c.name as customer_name, c.line1, c.line2, c.line3, c.line4, c.city, c.state, c.zip_code, c.account_number
|
||||
FROM quotes q
|
||||
LEFT JOIN customers c ON q.customer_id = c.id
|
||||
WHERE q.id = $1
|
||||
`, [id]);
|
||||
|
||||
if (quoteResult.rows.length === 0) {
|
||||
return res.status(404).json({ error: 'Quote not found' });
|
||||
}
|
||||
|
||||
const quote = quoteResult.rows[0];
|
||||
const itemsResult = await pool.query(
|
||||
'SELECT * FROM quote_items WHERE quote_id = $1 ORDER BY item_order',
|
||||
[id]
|
||||
);
|
||||
|
||||
const templatePath = path.join(__dirname, '..', '..', 'templates', 'quote-template.html');
|
||||
let html = await fs.readFile(templatePath, 'utf-8');
|
||||
|
||||
const logoHTML = await getLogoHtml();
|
||||
const itemsHTML = renderQuoteItems(itemsResult.rows, quote);
|
||||
|
||||
let tbdNote = quote.has_tbd ? '<p style="font-size: 12px; margin-top: 20px;"><em>* Note: This quote contains items marked as "TBD". The final total may vary.</em></p>' : '';
|
||||
|
||||
const streetBlock = formatAddressLines(quote.line1, quote.line2, quote.line3, quote.line4, quote.customer_name);
|
||||
|
||||
html = html
|
||||
.replace('{{LOGO_HTML}}', logoHTML)
|
||||
.replace('{{CUSTOMER_NAME}}', quote.customer_name || '')
|
||||
.replace('{{CUSTOMER_STREET}}', streetBlock)
|
||||
.replace('{{CUSTOMER_CITY}}', quote.city || '')
|
||||
.replace('{{CUSTOMER_STATE}}', quote.state || '')
|
||||
.replace('{{CUSTOMER_ZIP}}', quote.zip_code || '')
|
||||
.replace('{{QUOTE_NUMBER}}', quote.quote_number)
|
||||
.replace('{{ACCOUNT_NUMBER}}', quote.account_number || '')
|
||||
.replace('{{QUOTE_DATE}}', formatDate(quote.quote_date))
|
||||
.replace('{{ITEMS}}', itemsHTML)
|
||||
.replace('{{TBD_NOTE}}', tbdNote);
|
||||
|
||||
res.setHeader('Content-Type', 'text/html');
|
||||
res.send(html);
|
||||
|
||||
} catch (error) {
|
||||
console.error('[HTML] ERROR:', error);
|
||||
res.status(500).json({ error: 'Error generating HTML' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST convert quote to invoice
|
||||
router.post('/:id/convert-to-invoice', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const quoteResult = await pool.query(`
|
||||
SELECT q.*, c.name as customer_name, c.line1, c.line2, c.line3, c.line4, c.city, c.state, c.zip_code, c.account_number
|
||||
FROM quotes q
|
||||
LEFT JOIN customers c ON q.customer_id = c.id
|
||||
WHERE q.id = $1
|
||||
`, [id]);
|
||||
|
||||
if (quoteResult.rows.length === 0) {
|
||||
await client.query('ROLLBACK');
|
||||
return res.status(404).json({ error: 'Quote not found' });
|
||||
}
|
||||
|
||||
const quote = quoteResult.rows[0];
|
||||
|
||||
const itemsResult = await pool.query(
|
||||
'SELECT * FROM quote_items WHERE quote_id = $1 ORDER BY item_order',
|
||||
[id]
|
||||
);
|
||||
|
||||
const hasTBD = itemsResult.rows.some(item =>
|
||||
item.rate.toUpperCase() === 'TBD' || item.amount.toUpperCase() === 'TBD'
|
||||
);
|
||||
|
||||
if (hasTBD) {
|
||||
await client.query('ROLLBACK');
|
||||
return res.status(400).json({ error: 'Cannot convert quote with TBD items to invoice. Please update all TBD items first.' });
|
||||
}
|
||||
|
||||
const invoice_number = null;
|
||||
const invoiceDate = new Date().toISOString().split('T')[0];
|
||||
|
||||
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, quote.customer_id, invoiceDate, 'Net 30', '', quote.tax_exempt, quote.tax_rate, quote.subtotal, quote.tax_amount, quote.total, id]
|
||||
);
|
||||
|
||||
const invoiceId = invoiceResult.rows[0].id;
|
||||
|
||||
for (let i = 0; i < itemsResult.rows.length; i++) {
|
||||
const item = itemsResult.rows[i];
|
||||
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)',
|
||||
[invoiceId, item.quantity, item.description, item.rate, item.amount, i, item.qbo_item_id || '9']
|
||||
);
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
res.json(invoiceResult.rows[0]);
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error('Error converting quote to invoice:', error);
|
||||
res.status(500).json({ error: 'Error converting quote to invoice' });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
/**
|
||||
* Settings Routes
|
||||
* Handles logo upload and settings
|
||||
*/
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
|
||||
// Configure multer for logo upload
|
||||
const storage = multer.diskStorage({
|
||||
destination: async (req, file, cb) => {
|
||||
const uploadDir = path.join(__dirname, '..', '..', 'public', 'uploads');
|
||||
try {
|
||||
await fs.mkdir(uploadDir, { recursive: true });
|
||||
} catch (err) {
|
||||
console.error('Error creating upload directory:', err);
|
||||
}
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
cb(null, 'company-logo.png');
|
||||
}
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage: storage,
|
||||
limits: { fileSize: 5 * 1024 * 1024 },
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (file.mimetype.startsWith('image/')) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only image files are allowed'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// GET logo info
|
||||
router.get('/logo-info', async (req, res) => {
|
||||
try {
|
||||
const logoPath = path.join(__dirname, '..', '..', 'public', 'uploads', 'company-logo.png');
|
||||
try {
|
||||
await fs.access(logoPath);
|
||||
res.json({ hasLogo: true, logoPath: '/uploads/company-logo.png' });
|
||||
} catch {
|
||||
res.json({ hasLogo: false });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking logo:', error);
|
||||
res.status(500).json({ error: 'Error checking logo' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST upload logo
|
||||
router.post('/upload-logo', upload.single('logo'), async (req, res) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'No file uploaded' });
|
||||
}
|
||||
res.json({
|
||||
message: 'Logo uploaded successfully',
|
||||
path: '/uploads/company-logo.png'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
res.status(500).json({ error: 'Error uploading logo' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
@ -1,112 +0,0 @@
|
|||
// src/services/email-service.js
|
||||
const { SESv2Client, SendEmailCommand } = require('@aws-sdk/client-sesv2');
|
||||
const nodemailer = require('nodemailer');
|
||||
const mjml2html = require('mjml');
|
||||
|
||||
const sesClient = new SESv2Client({
|
||||
region: process.env.AWS_REGION || 'us-east-2'
|
||||
});
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
SES: {
|
||||
sesClient,
|
||||
SendEmailCommand
|
||||
}
|
||||
});
|
||||
|
||||
function generateInvoiceEmailHtml(invoice, customText, melioLink) {
|
||||
const formattedText = customText || '';
|
||||
|
||||
const buttonMjml = melioLink
|
||||
? `<mj-button background-color="#2563eb" color="white" border-radius="6px" href="${melioLink}" font-weight="600" font-size="16px" padding-top="25px">
|
||||
Pay Now (Free ACH)
|
||||
</mj-button>`
|
||||
: '';
|
||||
|
||||
const template = `
|
||||
<mjml>
|
||||
<mj-head>
|
||||
<mj-attributes>
|
||||
<mj-all font-family="ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif" />
|
||||
</mj-attributes>
|
||||
<mj-style inline="inline">
|
||||
.email-body p {
|
||||
margin: 0 0 14px 0 !important;
|
||||
}
|
||||
.email-body p:last-child {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
</mj-style>
|
||||
</mj-head>
|
||||
<mj-body background-color="#f4f4f5">
|
||||
|
||||
<mj-section padding="0">
|
||||
<mj-column>
|
||||
<mj-spacer height="20px" />
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
|
||||
<mj-section background-color="#ffffff" padding="30px" border-radius="8px 8px 0 0">
|
||||
<mj-column>
|
||||
<mj-text font-size="22px" font-weight="700" color="#1e3a8a" padding="0">
|
||||
Bay Area Affiliates, Inc.
|
||||
</mj-text>
|
||||
<mj-text font-size="15px" color="#64748b" padding="5px 0 0 0">
|
||||
Invoice #${invoice.invoice_number || invoice.id}
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
|
||||
<mj-section background-color="#ffffff" padding="0 30px 30px 30px">
|
||||
<mj-column>
|
||||
<mj-text css-class="email-body" font-size="15px" color="#334155" line-height="1.5" padding="0">
|
||||
${formattedText}
|
||||
</mj-text>
|
||||
|
||||
${buttonMjml}
|
||||
|
||||
<mj-divider border-color="#e2e8f0" border-width="1px" padding-top="30px" padding-bottom="20px" />
|
||||
|
||||
<mj-text font-size="14px" color="#64748b" line-height="1.5" padding="0">
|
||||
<strong>Prefer to pay by check?</strong><br/>
|
||||
Please make checks payable to Bay Area Affiliates, Inc. and mail to:<br/>
|
||||
1001 Blucher Street<br/>
|
||||
Corpus Christi, Texas 78401
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
</mj-body>
|
||||
</mjml>
|
||||
`;
|
||||
|
||||
// validationLevel: 'strict' fängt falsche Attribute ab, bevor sie an den Kunden gehen
|
||||
const result = mjml2html(template, { validationLevel: 'strict' });
|
||||
|
||||
if (result.errors && result.errors.length > 0) {
|
||||
console.error('MJML Parse Errors:', result.errors);
|
||||
}
|
||||
|
||||
return result.html;
|
||||
}
|
||||
|
||||
async function sendInvoiceEmail(invoice, recipientEmail, customText, melioLink, pdfBuffer) {
|
||||
const htmlContent = generateInvoiceEmailHtml(invoice, customText, melioLink);
|
||||
|
||||
const mailOptions = {
|
||||
from: '"Bay Area Affiliates Inc. Accounting" <accounting@bayarea-cc.com>',
|
||||
to: recipientEmail,
|
||||
subject: `Invoice #${invoice.invoice_number || invoice.id} from Bay Area Affiliates, Inc.`,
|
||||
html: htmlContent,
|
||||
attachments: [
|
||||
{
|
||||
filename: `Invoice_${invoice.invoice_number || invoice.id}_BayAreaAffiliates.pdf`,
|
||||
content: pdfBuffer,
|
||||
contentType: 'application/pdf'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
return await transporter.sendMail(mailOptions);
|
||||
}
|
||||
|
||||
module.exports = { sendInvoiceEmail };
|
||||
|
|
@ -1,206 +0,0 @@
|
|||
/**
|
||||
* PDF Generation Service
|
||||
* Handles HTML to PDF conversion using Puppeteer
|
||||
*/
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const { formatMoney, formatDate } = require('../utils/helpers');
|
||||
|
||||
// Initialize browser - will be set from main app
|
||||
let browserInstance = null;
|
||||
|
||||
function setBrowser(browser) {
|
||||
browserInstance = browser;
|
||||
}
|
||||
|
||||
async function getBrowser() {
|
||||
return browserInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate PDF from HTML template
|
||||
*/
|
||||
async function generatePdfFromHtml(html, options = {}) {
|
||||
const {
|
||||
format = 'Letter',
|
||||
margin = { top: '0.5in', right: '0.5in', bottom: '0.5in', left: '0.5in' },
|
||||
printBackground = true
|
||||
} = options;
|
||||
|
||||
const browser = await getBrowser();
|
||||
if (!browser) {
|
||||
throw new Error('Browser not initialized');
|
||||
}
|
||||
|
||||
const page = await browser.newPage();
|
||||
//await page.setContent(html, { waitUntil: 'networkidle0', timeout: 60000 });
|
||||
await page.setContent(html, { waitUntil: 'load', timeout: 5000 });
|
||||
|
||||
const pdf = await page.pdf({
|
||||
format,
|
||||
printBackground,
|
||||
margin
|
||||
});
|
||||
|
||||
await page.close();
|
||||
return pdf;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get company logo as base64 HTML
|
||||
*/
|
||||
async function getLogoHtml() {
|
||||
let logoHTML = '';
|
||||
try {
|
||||
const logoPath = path.join(__dirname, '..', '..', 'public', 'uploads', 'company-logo.png');
|
||||
const logoData = await fs.readFile(logoPath);
|
||||
const logoBase64 = logoData.toString('base64');
|
||||
logoHTML = `<img src="data:image/png;base64,${logoBase64}" alt="Company Logo" class="logo logo-size">`;
|
||||
} catch (err) {
|
||||
// No logo found
|
||||
}
|
||||
return logoHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render invoice items to HTML table rows
|
||||
*/
|
||||
function renderInvoiceItems(items, invoice = null) {
|
||||
let itemsHTML = items.map(item => {
|
||||
let rateFormatted = item.rate;
|
||||
if (item.rate.toUpperCase() !== 'TBD' && !item.rate.includes('/')) {
|
||||
const rateNum = parseFloat(item.rate.replace(/[^0-9.]/g, ''));
|
||||
if (!isNaN(rateNum)) rateFormatted = rateNum.toFixed(2);
|
||||
}
|
||||
return `
|
||||
<tr>
|
||||
<td class="qty">${item.quantity}</td>
|
||||
<td class="description">${item.description}</td>
|
||||
<td class="rate">${rateFormatted}</td>
|
||||
<td class="amount">${item.amount}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
// Add subtotal
|
||||
const subtotal = invoice ? invoice.subtotal : 0;
|
||||
itemsHTML += `
|
||||
<tr class="footer-row">
|
||||
<td colspan="3" class="total-label">Subtotal:</td>
|
||||
<td class="total-amount">$${formatMoney(subtotal)}</td>
|
||||
</tr>`;
|
||||
|
||||
// Add tax if not exempt
|
||||
if (invoice && !invoice.tax_exempt) {
|
||||
itemsHTML += `
|
||||
<tr class="footer-row">
|
||||
<td colspan="3" class="total-label">Tax (${invoice.tax_rate}%):</td>
|
||||
<td class="total-amount">$${formatMoney(invoice.tax_amount)}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
// Add total
|
||||
const amountPaid = invoice ? (parseFloat(invoice.amount_paid) || 0) : 0;
|
||||
const total = invoice ? parseFloat(invoice.total) : 0;
|
||||
const balanceDue = total - amountPaid;
|
||||
|
||||
itemsHTML += `
|
||||
<tr class="footer-row">
|
||||
<td colspan="3" class="total-label" style="font-size: 16px;">TOTAL:</td>
|
||||
<td class="total-amount" style="font-size: 16px;">$${formatMoney(total)}</td>
|
||||
</tr>`;
|
||||
|
||||
// Add downpayment/balance if partial
|
||||
if (amountPaid > 0) {
|
||||
itemsHTML += `
|
||||
<tr class="footer-row">
|
||||
<td colspan="3" class="total-label" style="color: #059669;">Downpayment:</td>
|
||||
<td class="total-amount" style="color: #059669;">-$${formatMoney(amountPaid)}</td>
|
||||
</tr>
|
||||
<tr class="footer-row">
|
||||
<td colspan="3" class="total-label" style="font-weight: bold; font-size: 16px; border-top: 2px solid #333; padding-top: 8px;">BALANCE DUE:</td>
|
||||
<td class="total-amount" style="font-weight: bold; font-size: 16px; border-top: 2px solid #333; padding-top: 8px;">$${formatMoney(balanceDue)}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
// Thank you message
|
||||
itemsHTML += `
|
||||
<tr class="footer-row">
|
||||
<td colspan="4" class="thank-you">Thank you for your business!</td>
|
||||
</tr>`;
|
||||
|
||||
return itemsHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render quote items to HTML table rows
|
||||
*/
|
||||
function renderQuoteItems(items, quote = null) {
|
||||
let itemsHTML = items.map(item => {
|
||||
let rateFormatted = item.rate;
|
||||
if (item.rate.toUpperCase() !== 'TBD' && !item.rate.includes('/')) {
|
||||
const rateNum = parseFloat(item.rate.replace(/[^0-9.]/g, ''));
|
||||
if (!isNaN(rateNum)) rateFormatted = rateNum.toFixed(2);
|
||||
}
|
||||
return `
|
||||
<tr>
|
||||
<td class="qty">${item.quantity}</td>
|
||||
<td class="description">${item.description}</td>
|
||||
<td class="rate">${rateFormatted}</td>
|
||||
<td class="amount">${item.amount}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
// Add subtotal
|
||||
const subtotal = quote ? quote.subtotal : 0;
|
||||
itemsHTML += `
|
||||
<tr class="footer-row">
|
||||
<td colspan="3" class="total-label">Subtotal</td>
|
||||
<td class="total-amount">$${formatMoney(subtotal)}</td>
|
||||
</tr>`;
|
||||
|
||||
// Add tax if not exempt
|
||||
if (quote && !quote.tax_exempt) {
|
||||
itemsHTML += `
|
||||
<tr class="footer-row">
|
||||
<td colspan="3" class="total-label">Tax (${quote.tax_rate}%)</td>
|
||||
<td class="total-amount">$${formatMoney(quote.tax_amount)}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
// Add total
|
||||
const total = quote ? quote.total : 0;
|
||||
itemsHTML += `
|
||||
<tr class="footer-row">
|
||||
<td colspan="3" class="total-label" style="font-size: 16px;">TOTAL</td>
|
||||
<td class="total-amount" style="font-size: 16px;">$${formatMoney(total)}</td>
|
||||
</tr>
|
||||
<tr class="footer-row">
|
||||
<td colspan="4" class="thank-you">This quote is valid for 14 days. We appreciate your business </td>
|
||||
</tr>`;
|
||||
|
||||
return itemsHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format address lines for template
|
||||
*/
|
||||
function formatAddressLines(line1, line2, line3, line4, customerName) {
|
||||
const addressLines = [];
|
||||
if (line1 && line1.trim().toLowerCase() !== (customerName || '').trim().toLowerCase()) {
|
||||
addressLines.push(line1);
|
||||
}
|
||||
if (line2) addressLines.push(line2);
|
||||
if (line3) addressLines.push(line3);
|
||||
if (line4) addressLines.push(line4);
|
||||
return addressLines.join('<br>');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
setBrowser,
|
||||
getBrowser,
|
||||
generatePdfFromHtml,
|
||||
getLogoHtml,
|
||||
renderInvoiceItems,
|
||||
renderQuoteItems,
|
||||
formatAddressLines
|
||||
};
|
||||
|
|
@ -1,222 +0,0 @@
|
|||
// src/services/qbo-service.js
|
||||
/**
|
||||
* QuickBooks Online Service
|
||||
* Handles QBO API interactions
|
||||
*/
|
||||
const { getOAuthClient, getQboBaseUrl, makeQboApiCall } = require('../config/qbo'); // Sauberer Import
|
||||
|
||||
// QBO Item IDs
|
||||
const QBO_LABOR_ID = '5';
|
||||
const QBO_PARTS_ID = '9';
|
||||
|
||||
function getClientInfo() {
|
||||
const oauthClient = getOAuthClient();
|
||||
const companyId = oauthClient.getToken().realmId;
|
||||
const baseUrl = getQboBaseUrl();
|
||||
return { oauthClient, companyId, baseUrl };
|
||||
}
|
||||
|
||||
/**
|
||||
* Export invoice to QBO
|
||||
*/
|
||||
async function exportInvoiceToQbo(invoiceId, dbClient) { // <-- Nutzt jetzt dbClient statt pool
|
||||
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
|
||||
`, [invoiceId]);
|
||||
|
||||
const invoice = invoiceRes.rows[0];
|
||||
if (!invoice.customer_qbo_id) return { skipped: true, reason: 'Customer not in QBO' };
|
||||
|
||||
const itemsRes = await dbClient.query('SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY item_order', [invoiceId]);
|
||||
const items = itemsRes.rows;
|
||||
|
||||
const { companyId, baseUrl } = getClientInfo();
|
||||
|
||||
// Get next DocNumber
|
||||
const maxNumResult = await dbClient.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 parseNum = (val) => {
|
||||
if (val === null || val === undefined) return 0;
|
||||
if (typeof val === 'number') return val;
|
||||
return parseFloat(String(val).replace(/[^0-9.\-]/g, '')) || 0;
|
||||
};
|
||||
const rate = parseNum(item.rate);
|
||||
const qty = parseNum(item.quantity) || 1;
|
||||
const amount = rate * qty;
|
||||
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": qty
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
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": "NotSet",
|
||||
"BillEmail": { "Address": invoice.email || "" }
|
||||
};
|
||||
|
||||
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') {
|
||||
console.log(` ⚠️ DocNumber ${qboPayload.DocNumber} exists, retrying...`);
|
||||
qboPayload.DocNumber = (parseInt(qboPayload.DocNumber) + 1).toString();
|
||||
continue;
|
||||
}
|
||||
if (data.Fault) {
|
||||
const errMsg = data.Fault.Error?.map(e => `${e.code}: ${e.Message} - ${e.Detail}`).join('; ') || JSON.stringify(data.Fault);
|
||||
console.error(`❌ QBO Export Fault:`, errMsg);
|
||||
throw new Error('QBO export failed: ' + errMsg);
|
||||
}
|
||||
qboInvoice = data.Invoice || data;
|
||||
if (qboInvoice.Id) break;
|
||||
|
||||
throw new Error("QBO returned no ID: " + JSON.stringify(data).substring(0, 500));
|
||||
}
|
||||
|
||||
if (!qboInvoice?.Id) throw new Error('Could not find free DocNumber after 5 attempts.');
|
||||
|
||||
await dbClient.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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync invoice to QBO (update)
|
||||
*/
|
||||
async function syncInvoiceToQbo(invoiceId, dbClient) { // <-- Nutzt jetzt dbClient statt pool
|
||||
const invoiceRes = await dbClient.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 dbClient.query('SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY item_order', [invoiceId]);
|
||||
|
||||
const { companyId, baseUrl } = getClientInfo();
|
||||
|
||||
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 parseNum = (val) => {
|
||||
if (val === null || val === undefined) return 0;
|
||||
if (typeof val === 'number') return val;
|
||||
return parseFloat(String(val).replace(/[^0-9.\-]/g, '')) || 0;
|
||||
};
|
||||
const rate = parseNum(item.rate);
|
||||
const qty = parseNum(item.quantity) || 1;
|
||||
const amount = rate * qty;
|
||||
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": qty
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
if (updateData.Fault) {
|
||||
const errMsg = updateData.Fault.Error?.map(e => `${e.code}: ${e.Message} - ${e.Detail}`).join('; ') || JSON.stringify(updateData.Fault);
|
||||
console.error(`❌ QBO Sync Fault:`, errMsg);
|
||||
throw new Error('QBO sync failed: ' + errMsg);
|
||||
}
|
||||
|
||||
const updated = updateData.Invoice || updateData;
|
||||
|
||||
if (!updated.Id) {
|
||||
throw new Error('QBO update returned no ID');
|
||||
}
|
||||
|
||||
await dbClient.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 };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
QBO_LABOR_ID,
|
||||
QBO_PARTS_ID,
|
||||
getClientInfo,
|
||||
exportInvoiceToQbo,
|
||||
syncInvoiceToQbo
|
||||
};
|
||||
|
|
@ -1,174 +0,0 @@
|
|||
/**
|
||||
* Recurring Invoice Service
|
||||
* Checks daily for recurring invoices that are due and creates new copies.
|
||||
*
|
||||
* Logic:
|
||||
* - Runs every 24h (and once on startup after 60s delay)
|
||||
* - Finds invoices where is_recurring=true AND next_recurring_date <= today
|
||||
* - Creates a copy with updated invoice_date = next_recurring_date
|
||||
* - Advances next_recurring_date by the interval (monthly/yearly)
|
||||
* - Auto-exports to QBO if customer is linked
|
||||
*/
|
||||
const { pool } = require('../config/database');
|
||||
const { exportInvoiceToQbo } = require('./qbo-service');
|
||||
|
||||
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
const STARTUP_DELAY_MS = 60 * 1000; // 60 seconds after boot
|
||||
|
||||
/**
|
||||
* Calculate next date based on interval
|
||||
*/
|
||||
function advanceDate(dateStr, interval) {
|
||||
const d = new Date(dateStr);
|
||||
if (interval === 'monthly') {
|
||||
d.setMonth(d.getMonth() + 1);
|
||||
} else if (interval === 'yearly') {
|
||||
d.setFullYear(d.getFullYear() + 1);
|
||||
}
|
||||
return d.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Process all due recurring invoices
|
||||
*/
|
||||
async function processRecurringInvoices() {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
console.log(`🔄 [RECURRING] Checking for due recurring invoices (today: ${today})...`);
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
// Find all recurring invoices that are due
|
||||
const dueResult = await client.query(`
|
||||
SELECT i.*, c.qbo_id as customer_qbo_id, c.name as customer_name
|
||||
FROM invoices i
|
||||
LEFT JOIN customers c ON i.customer_id = c.id
|
||||
WHERE i.is_recurring = true
|
||||
AND i.next_recurring_date IS NOT NULL
|
||||
AND i.next_recurring_date <= $1
|
||||
`, [today]);
|
||||
|
||||
if (dueResult.rows.length === 0) {
|
||||
console.log('🔄 [RECURRING] No recurring invoices due.');
|
||||
return { created: 0 };
|
||||
}
|
||||
|
||||
console.log(`🔄 [RECURRING] Found ${dueResult.rows.length} recurring invoice(s) due.`);
|
||||
|
||||
let created = 0;
|
||||
|
||||
for (const source of dueResult.rows) {
|
||||
await client.query('BEGIN');
|
||||
|
||||
try {
|
||||
// Load items from the source invoice
|
||||
const itemsResult = await client.query(
|
||||
'SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY item_order',
|
||||
[source.id]
|
||||
);
|
||||
|
||||
const newInvoiceDate = source.next_recurring_date.toISOString().split('T')[0];
|
||||
|
||||
// Create the new invoice (no invoice_number — QBO will assign one)
|
||||
const newInvoice = await client.query(
|
||||
`INSERT INTO invoices (
|
||||
invoice_number, customer_id, invoice_date, terms, auth_code,
|
||||
tax_exempt, tax_rate, subtotal, tax_amount, total,
|
||||
bill_to_name, recurring_source_id
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
RETURNING *`,
|
||||
[
|
||||
`DRAFT-${Date.now()}`, // Temporary, QBO export will assign real number
|
||||
source.customer_id,
|
||||
newInvoiceDate,
|
||||
source.terms,
|
||||
source.auth_code,
|
||||
source.tax_exempt,
|
||||
source.tax_rate,
|
||||
source.subtotal,
|
||||
source.tax_amount,
|
||||
source.total,
|
||||
source.bill_to_name,
|
||||
source.id
|
||||
]
|
||||
);
|
||||
|
||||
const newInvoiceId = newInvoice.rows[0].id;
|
||||
|
||||
// Copy items
|
||||
for (let i = 0; i < itemsResult.rows.length; i++) {
|
||||
const item = itemsResult.rows[i];
|
||||
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)`,
|
||||
[newInvoiceId, item.quantity, item.description, item.rate, item.amount, i, item.qbo_item_id || '9']
|
||||
);
|
||||
}
|
||||
|
||||
// Advance the source invoice's next_recurring_date
|
||||
const nextDate = advanceDate(source.next_recurring_date, source.recurring_interval);
|
||||
await client.query(
|
||||
'UPDATE invoices SET next_recurring_date = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
|
||||
[nextDate, source.id]
|
||||
);
|
||||
|
||||
await client.query('COMMIT');
|
||||
|
||||
console.log(` ✅ Created recurring invoice from #${source.invoice_number || source.id} → new ID ${newInvoiceId} (date: ${newInvoiceDate}), next due: ${nextDate}`);
|
||||
|
||||
// Auto-export to QBO (outside transaction, non-blocking)
|
||||
try {
|
||||
const dbClient = await pool.connect();
|
||||
try {
|
||||
const qboResult = await exportInvoiceToQbo(newInvoiceId, dbClient);
|
||||
if (qboResult.success) {
|
||||
console.log(` 📤 Auto-exported to QBO: #${qboResult.qbo_doc_number}`);
|
||||
} else if (qboResult.skipped) {
|
||||
console.log(` ℹ️ QBO export skipped: ${qboResult.reason}`);
|
||||
}
|
||||
} finally {
|
||||
dbClient.release();
|
||||
}
|
||||
} catch (qboErr) {
|
||||
console.error(` ⚠️ QBO auto-export failed for recurring invoice ${newInvoiceId}:`, qboErr.message);
|
||||
}
|
||||
|
||||
created++;
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error(` ❌ Failed to create recurring invoice from #${source.invoice_number || source.id}:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`🔄 [RECURRING] Done. Created ${created} invoice(s).`);
|
||||
return { created };
|
||||
} catch (error) {
|
||||
console.error('❌ [RECURRING] Error:', error.message);
|
||||
return { created: 0, error: error.message };
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the recurring invoice scheduler
|
||||
*/
|
||||
function startRecurringScheduler() {
|
||||
// First check after startup delay
|
||||
setTimeout(() => {
|
||||
console.log('🔄 [RECURRING] Initial check...');
|
||||
processRecurringInvoices();
|
||||
}, STARTUP_DELAY_MS);
|
||||
|
||||
// Then every 24 hours
|
||||
setInterval(() => {
|
||||
processRecurringInvoices();
|
||||
}, CHECK_INTERVAL_MS);
|
||||
|
||||
console.log(`🔄 [RECURRING] Scheduler started (checks every 24h, first check in ${STARTUP_DELAY_MS / 1000}s)`);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
processRecurringInvoices,
|
||||
startRecurringScheduler,
|
||||
advanceDate
|
||||
};
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
/**
|
||||
* Utility helper functions for the Quote & Invoice System
|
||||
*/
|
||||
|
||||
function formatDate(date) {
|
||||
const d = new Date(date);
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
const year = d.getFullYear();
|
||||
return `${month}/${day}/${year}`;
|
||||
}
|
||||
|
||||
function formatMoney(val) {
|
||||
return parseFloat(val).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function parseNumericValue(val) {
|
||||
if (val === null || val === undefined) return 0;
|
||||
if (typeof val === 'number') return val;
|
||||
return parseFloat(String(val).replace(/[^0-9.\-]/g, '')) || 0;
|
||||
}
|
||||
|
||||
function formatAddress(address) {
|
||||
if (!address) return '';
|
||||
const lines = [];
|
||||
if (address.line1) lines.push(address.line1);
|
||||
if (address.line2) lines.push(address.line2);
|
||||
if (address.line3) lines.push(address.line3);
|
||||
if (address.line4) lines.push(address.line4);
|
||||
return lines.join('<br>');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
formatDate,
|
||||
formatMoney,
|
||||
parseNumericValue,
|
||||
formatAddress
|
||||
};
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
/**
|
||||
* Number generation utilities for quotes and invoices
|
||||
*/
|
||||
const { pool } = require('../config/database');
|
||||
|
||||
async function getNextQuoteNumber() {
|
||||
const year = new Date().getFullYear();
|
||||
const result = await pool.query(
|
||||
'SELECT quote_number FROM quotes WHERE quote_number LIKE $1 ORDER BY quote_number DESC LIMIT 1',
|
||||
[`${year}-%`]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return `${year}-001`;
|
||||
}
|
||||
|
||||
const lastNumber = parseInt(result.rows[0].quote_number.split('-')[1]);
|
||||
const nextNumber = String(lastNumber + 1).padStart(3, '0');
|
||||
return `${year}-${nextNumber}`;
|
||||
}
|
||||
|
||||
async function getNextInvoiceNumber() {
|
||||
const result = await pool.query(
|
||||
'SELECT MAX(CAST(invoice_number AS INTEGER)) as max_number FROM invoices WHERE invoice_number ~ \'^[0-9]+$\''
|
||||
);
|
||||
|
||||
if (result.rows.length === 0 || result.rows[0].max_number === null) {
|
||||
return '110508';
|
||||
}
|
||||
|
||||
return String(parseInt(result.rows[0].max_number) + 1);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getNextQuoteNumber,
|
||||
getNextInvoiceNumber
|
||||
};
|
||||
Loading…
Reference in New Issue