Compare commits
26 Commits
7226883a2e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e45ce6cf9 | |||
| 0b738ba530 | |||
| 96ed8c7141 | |||
| 5e792ab96f | |||
| fe7a9f6dd4 | |||
| a9173acc8d | |||
| 453f8654b7 | |||
| acd5b7d605 | |||
| cc154141bd | |||
| 81ab5df13f | |||
| 01ee278e03 | |||
| 041103be04 | |||
| a18c47112b | |||
| a4a79f3eb2 | |||
| b4a442954f | |||
| fb09a0b7e1 | |||
| 229e658831 | |||
| 5a7ba66c27 | |||
| b9f9df74c0 | |||
| d38195eae5 | |||
| e9d88b1400 | |||
| e333628f1c | |||
| 27ecafea5f | |||
| 15d33a116c | |||
| 0fbb298e89 | |||
| 6d0f4c49be |
@@ -23,7 +23,6 @@ COPY package*.json ./
|
|||||||
RUN npm install --omit=dev
|
RUN npm install --omit=dev
|
||||||
|
|
||||||
# Copy application files
|
# Copy application files
|
||||||
COPY server.js ./
|
|
||||||
COPY qbo_helper.js ./
|
COPY qbo_helper.js ./
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
COPY public ./public
|
COPY public ./public
|
||||||
|
|||||||
38
backup-invoice-db.sh
Executable file
38
backup-invoice-db.sh
Executable file
@@ -0,0 +1,38 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# /home/aknuth/scripts/backup-invoice-db.sh
|
||||||
|
# Daily PostgreSQL backup for quote-invoice-system → iDrive e2
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BACKUP_DIR="/tmp/invoice-backups"
|
||||||
|
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||||
|
BACKUP_FILE="invoice_db_${TIMESTAMP}.sql.gz"
|
||||||
|
CONTAINER="quote_db"
|
||||||
|
DB_USER="quoteuser"
|
||||||
|
DB_NAME="quotes_db"
|
||||||
|
RCLONE_REMOTE="invoice-backup:invoice-postgresdb"
|
||||||
|
RETAIN_DAYS=30
|
||||||
|
|
||||||
|
echo "🗄️ [BACKUP] Starting invoice DB backup..."
|
||||||
|
|
||||||
|
# Create temp dir
|
||||||
|
mkdir -p "$BACKUP_DIR"
|
||||||
|
|
||||||
|
# Dump and compress
|
||||||
|
docker exec "$CONTAINER" pg_dump -U "$DB_USER" "$DB_NAME" | gzip > "${BACKUP_DIR}/${BACKUP_FILE}"
|
||||||
|
|
||||||
|
FILESIZE=$(du -h "${BACKUP_DIR}/${BACKUP_FILE}" | cut -f1)
|
||||||
|
echo "📦 Dump complete: ${BACKUP_FILE} (${FILESIZE})"
|
||||||
|
|
||||||
|
# Upload to iDrive e2
|
||||||
|
rclone copy "${BACKUP_DIR}/${BACKUP_FILE}" "$RCLONE_REMOTE/" --log-level INFO
|
||||||
|
echo "☁️ Uploaded to ${RCLONE_REMOTE}/${BACKUP_FILE}"
|
||||||
|
|
||||||
|
# Clean up local temp
|
||||||
|
rm -f "${BACKUP_DIR}/${BACKUP_FILE}"
|
||||||
|
|
||||||
|
# Remove remote backups older than 30 days
|
||||||
|
rclone delete "$RCLONE_REMOTE" --min-age "${RETAIN_DAYS}d" --log-level INFO
|
||||||
|
echo "🧹 Remote cleanup done (>${RETAIN_DAYS} days)"
|
||||||
|
|
||||||
|
echo "✅ [BACKUP] Invoice DB backup complete."
|
||||||
@@ -38,7 +38,11 @@ services:
|
|||||||
QBO_REDIRECT_URI: ${QBO_REDIRECT_URI}
|
QBO_REDIRECT_URI: ${QBO_REDIRECT_URI}
|
||||||
QBO_REALM_ID: ${QBO_REALM_ID}
|
QBO_REALM_ID: ${QBO_REALM_ID}
|
||||||
QBO_ACCESS_TOKEN: ${QBO_ACCESS_TOKEN}
|
QBO_ACCESS_TOKEN: ${QBO_ACCESS_TOKEN}
|
||||||
QBO_REFRESH_TOKEN: ${QBO_REFRESH_TOKEN}
|
QBO_REFRESH_TOKEN: ${QBO_REFRESH_TOKEN}
|
||||||
|
AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID}
|
||||||
|
AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY}
|
||||||
|
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY}
|
||||||
|
STRIPE_PUBLISHABLE_KEY: ${STRIPE_PUBLISHABLE_KEY}
|
||||||
volumes:
|
volumes:
|
||||||
- ./public/uploads:/app/public/uploads
|
- ./public/uploads:/app/public/uploads
|
||||||
- ./templates:/app/templates # NEU!
|
- ./templates:/app/templates # NEU!
|
||||||
|
|||||||
2865
package-lock.json
generated
2865
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -8,13 +8,17 @@
|
|||||||
"dev": "nodemon src/index.js"
|
"dev": "nodemon src/index.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-sesv2": "^3.1009.0",
|
||||||
"csv-parser": "^3.2.0",
|
"csv-parser": "^3.2.0",
|
||||||
"dotenv": "^17.3.1",
|
"dotenv": "^17.3.1",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"intuit-oauth": "^4.2.2",
|
"intuit-oauth": "^4.2.2",
|
||||||
|
"mjml": "^4.18.0",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"nodemailer": "^8.0.2",
|
||||||
"pg": "^8.13.1",
|
"pg": "^8.13.1",
|
||||||
"puppeteer": "^23.11.1"
|
"puppeteer": "^23.11.1",
|
||||||
|
"stripe": "^20.4.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.0.2"
|
"nodemon": "^3.0.2"
|
||||||
|
|||||||
1192
public/app.js
1192
public/app.js
File diff suppressed because it is too large
Load Diff
54
public/css/styles.css
Normal file
54
public/css/styles.css
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
/* 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,61 +7,12 @@
|
|||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
<link href="https://cdn.quilljs.com/1.3.6/quill.snow.css" rel="stylesheet">
|
<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="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>
|
<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/x-icon" href="/favicon.ico">
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon.png">
|
<link rel="icon" type="image/png" sizes="32x32" href="/favicon.png">
|
||||||
<link rel="apple-touch-icon" sizes="192x192" href="/favicon-192.png">
|
<link rel="apple-touch-icon" sizes="192x192" href="/favicon-192.png">
|
||||||
<style>
|
<link rel="stylesheet" href="css/styles.css">
|
||||||
.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>
|
</head>
|
||||||
<body class="bg-gray-100">
|
<body class="bg-gray-100">
|
||||||
<div class="min-h-screen">
|
<div class="min-h-screen">
|
||||||
@@ -111,7 +62,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Invoices Tab -->
|
<!-- Invoices Tab -->
|
||||||
<div id="invoices-tab" class="tab-content hidden">
|
<div id="invoices-tab" class="tab-content hidden">
|
||||||
<div class="flex justify-between items-center mb-4">
|
<div class="flex justify-between items-center mb-4">
|
||||||
<h2 class="text-3xl font-bold text-gray-800">Invoices</h2>
|
<h2 class="text-3xl font-bold text-gray-800">Invoices</h2>
|
||||||
@@ -120,7 +71,6 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Toolbar wird von invoice-view.js injiziert -->
|
|
||||||
<div id="invoice-toolbar"></div>
|
<div id="invoice-toolbar"></div>
|
||||||
|
|
||||||
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
||||||
@@ -209,6 +159,9 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div id="qbo-import-result" class="mt-4 hidden"></div>
|
<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>
|
<h3 class="text-xl font-semibold mb-4 text-gray-800">QuickBooks Online Authorization</h3>
|
||||||
<p class="text-gray-600 mb-4">
|
<p class="text-gray-600 mb-4">
|
||||||
Wenn der Token abgelaufen ist oder die Verbindung fehlschlägt,
|
Wenn der Token abgelaufen ist oder die Verbindung fehlschlägt,
|
||||||
@@ -224,7 +177,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// QBO Status beim Laden prüfen
|
|
||||||
fetch('/api/qbo/status')
|
fetch('/api/qbo/status')
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
@@ -240,6 +192,8 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<hr class="my-8 border-gray-200">
|
||||||
|
|
||||||
<h3 class="text-xl font-semibold mb-4 text-gray-800">QuickBooks Online Connection Test</h3>
|
<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>
|
<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>
|
||||||
|
|
||||||
@@ -260,10 +214,10 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="qbo-result-list" class="divide-y divide-gray-200 text-sm">
|
<tbody id="qbo-result-list" class="divide-y divide-gray-200 text-sm">
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -361,13 +315,9 @@
|
|||||||
|
|
||||||
<div class="flex justify-end space-x-3 pt-4">
|
<div class="flex justify-end space-x-3 pt-4">
|
||||||
<button type="button" onclick="closeQuoteModal()"
|
<button type="button" onclick="closeQuoteModal()"
|
||||||
class="px-6 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300">
|
class="px-6 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300">Cancel</button>
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
|
class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">Save Quote</button>
|
||||||
Save Quote
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -386,7 +336,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form id="invoice-form" class="space-y-6">
|
<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>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Invoice #</label>
|
<label class="block text-sm font-medium text-gray-700 mb-1">Invoice #</label>
|
||||||
<input type="text" id="invoice-number" pattern="[0-9]*"
|
<input type="text" id="invoice-number" pattern="[0-9]*"
|
||||||
@@ -451,10 +401,24 @@
|
|||||||
<input type="text" id="invoice-terms" value="Net 30" required
|
<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">
|
class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-blue-500 focus:border-blue-500">
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center pt-6">
|
<div class="flex items-center gap-6 pt-6 w-max">
|
||||||
<input type="checkbox" id="invoice-tax-exempt"
|
<div class="flex items-center">
|
||||||
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
<input type="checkbox" id="invoice-tax-exempt"
|
||||||
<label for="invoice-tax-exempt" class="ml-2 block text-sm text-gray-900">Tax Exempt</label>
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -473,7 +437,10 @@
|
|||||||
+ Add Item
|
+ Add Item
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="invoice-items"></div>
|
<div id="invoice-items"
|
||||||
|
class="overflow-y-auto pr-1"
|
||||||
|
style="max-height: 40vh; min-height: 80px;">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-gray-50 p-4 rounded-lg">
|
<div class="bg-gray-50 p-4 rounded-lg">
|
||||||
@@ -495,35 +462,15 @@
|
|||||||
|
|
||||||
<div class="flex justify-end space-x-3 pt-4">
|
<div class="flex justify-end space-x-3 pt-4">
|
||||||
<button type="button" onclick="closeInvoiceModal()"
|
<button type="button" onclick="closeInvoiceModal()"
|
||||||
class="px-6 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300">
|
class="px-6 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300">Cancel</button>
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button type="submit"
|
<button type="submit"
|
||||||
class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">
|
class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">Save Invoice</button>
|
||||||
Save Invoice
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="app.js"></script>
|
<!-- Single module entry point — all JS loaded from here -->
|
||||||
<script type="module">
|
<script type="module" src="js/app.js"></script>
|
||||||
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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
// 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();
|
|
||||||
}
|
|
||||||
86
public/js/app.js
Normal file
86
public/js/app.js
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
67
public/js/components/customer-search.js
Normal file
67
public/js/components/customer-search.js
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
326
public/js/modals/email-modal.js
Normal file
326
public/js/modals/email-modal.js
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
// email-modal.js — ES Module
|
||||||
|
// Modal to review and send invoice emails via AWS SES
|
||||||
|
// With Stripe Payment Link integration
|
||||||
|
|
||||||
|
import { showSpinner, hideSpinner, formatDate } 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 || '';
|
||||||
|
const existingStripeUrl = currentInvoice.stripe_payment_link_url || '';
|
||||||
|
const stripeStatus = currentInvoice.stripe_payment_status || '';
|
||||||
|
|
||||||
|
// Status indicator for existing link
|
||||||
|
let stripeBadgeHtml = '';
|
||||||
|
if (existingStripeUrl && stripeStatus === 'paid') {
|
||||||
|
stripeBadgeHtml = '<span class="ml-2 inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-green-100 text-green-800">Paid</span>';
|
||||||
|
} else if (existingStripeUrl && stripeStatus === 'processing') {
|
||||||
|
stripeBadgeHtml = '<span class="ml-2 inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800">Processing</span>';
|
||||||
|
} else if (existingStripeUrl) {
|
||||||
|
stripeBadgeHtml = '<span class="ml-2 inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-purple-100 text-purple-800">Active</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
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">
|
||||||
|
Stripe Payment Link${stripeBadgeHtml}
|
||||||
|
</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input type="url" id="email-stripe-link" value="${existingStripeUrl}" readonly
|
||||||
|
placeholder="Click Generate to create link..."
|
||||||
|
class="flex-1 px-3 py-2 border border-gray-300 rounded-md bg-gray-50 text-sm text-gray-600 focus:ring-purple-500 focus:border-purple-500">
|
||||||
|
<button type="button" id="stripe-generate-btn" onclick="window.emailModal.generateStripeLink()"
|
||||||
|
class="px-4 py-2 bg-purple-600 text-white rounded-md hover:bg-purple-700 text-sm font-semibold whitespace-nowrap">
|
||||||
|
${existingStripeUrl ? '♻️ Regenerate' : '💳 Generate'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-400 mt-1" id="stripe-link-info">
|
||||||
|
${existingStripeUrl
|
||||||
|
? 'Link exists. Regenerate will create a new link for the current balance.'
|
||||||
|
: 'Generates a Stripe Payment Link for Card and ACH payments.'}
|
||||||
|
</p>
|
||||||
|
</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);
|
||||||
|
const customerName = currentInvoice.customer_name || 'Valued Customer';
|
||||||
|
|
||||||
|
// 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
|
||||||
|
let paymentText = '';
|
||||||
|
if (currentInvoice.terms && currentInvoice.terms.toLowerCase().includes('receipt')) {
|
||||||
|
paymentText = 'Our terms are Net 30.';
|
||||||
|
} else if (dueDateStr !== 'Upon Receipt') {
|
||||||
|
paymentText = `payable by <strong>${dueDateStr}</strong>.`;
|
||||||
|
} else {
|
||||||
|
paymentText = 'Our terms are Net 30.';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect overdue: unpaid + older than 30 days
|
||||||
|
const invoiceDateParsed = currentInvoice.invoice_date
|
||||||
|
? new Date(currentInvoice.invoice_date.split('T')[0])
|
||||||
|
: null;
|
||||||
|
const daysSinceInvoice = invoiceDateParsed
|
||||||
|
? Math.floor((new Date() - invoiceDateParsed) / 86400000)
|
||||||
|
: 0;
|
||||||
|
const isOverdue = !currentInvoice.paid_date && daysSinceInvoice > 30;
|
||||||
|
|
||||||
|
let defaultHtml = '';
|
||||||
|
|
||||||
|
if (isOverdue) {
|
||||||
|
// Reminder / Overdue template
|
||||||
|
defaultHtml = `
|
||||||
|
<p>Dear ${customerName},</p>
|
||||||
|
<p>We hope this message finds you well. Our records indicate that invoice <strong>#${invoiceNum}</strong> in the amount of <strong>$${totalDue}</strong>, dated ${formatDate(currentInvoice.invoice_date)}, remains unpaid.</p>
|
||||||
|
<p>This invoice is now <strong>${daysSinceInvoice} days past the invoice date</strong>. We kindly request prompt payment at your earliest convenience.</p>
|
||||||
|
<p>For your convenience, you can pay securely online using the payment link included below. We accept both Credit Card and ACH bank transfer.</p>
|
||||||
|
<p>If payment has already been sent, please disregard this notice. Should you have any questions or need to discuss payment arrangements, please do not hesitate to reply to this email.</p>
|
||||||
|
<p>Thank you for your attention to this matter. We value your business and look forward to continuing our partnership.</p>
|
||||||
|
<p>Best regards,</p>
|
||||||
|
<p><strong>Claudia Knuth</strong></p>
|
||||||
|
<p>Bay Area Affiliates, Inc.</p>
|
||||||
|
<p>accounting@bayarea-cc.com</p>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
// Standard template
|
||||||
|
defaultHtml = `
|
||||||
|
<p>Dear ${customerName},</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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Stripe Payment Link Generation
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
async function generateStripeLink() {
|
||||||
|
const btn = document.getElementById('stripe-generate-btn');
|
||||||
|
const input = document.getElementById('email-stripe-link');
|
||||||
|
const info = document.getElementById('stripe-link-info');
|
||||||
|
|
||||||
|
const originalBtnText = btn.innerHTML;
|
||||||
|
btn.innerHTML = '⏳ Creating...';
|
||||||
|
btn.disabled = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/invoices/${currentInvoice.id}/create-payment-link`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
input.value = result.paymentLinkUrl;
|
||||||
|
currentInvoice.stripe_payment_link_url = result.paymentLinkUrl;
|
||||||
|
currentInvoice.stripe_payment_link_id = result.paymentLinkId;
|
||||||
|
currentInvoice.stripe_payment_status = 'pending';
|
||||||
|
|
||||||
|
btn.innerHTML = '♻️ Regenerate';
|
||||||
|
info.innerHTML = `✅ Payment link created for <strong>$${result.amount.toFixed(2)}</strong>. Will be included in the email.`;
|
||||||
|
info.classList.remove('text-gray-400');
|
||||||
|
info.classList.add('text-green-600');
|
||||||
|
} else {
|
||||||
|
info.textContent = `❌ ${result.error}`;
|
||||||
|
info.classList.remove('text-gray-400');
|
||||||
|
info.classList.add('text-red-500');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Stripe link generation error:', e);
|
||||||
|
info.textContent = '❌ Network error creating payment link.';
|
||||||
|
info.classList.remove('text-gray-400');
|
||||||
|
info.classList.add('text-red-500');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
if (btn.innerHTML === '⏳ Creating...') {
|
||||||
|
btn.innerHTML = originalBtnText;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 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();
|
||||||
|
|
||||||
|
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 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,
|
||||||
|
customText
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
alert('✅ Invoice sent successfully!');
|
||||||
|
closeEmailModal();
|
||||||
|
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,
|
||||||
|
generateStripeLink
|
||||||
|
};
|
||||||
233
public/js/modals/invoice-modal.js
Normal file
233
public/js/modals/invoice-modal.js
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
180
public/js/modals/quote-modal.js
Normal file
180
public/js/modals/quote-modal.js
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
@@ -73,7 +73,12 @@ const API = {
|
|||||||
body: JSON.stringify({ status })
|
body: JSON.stringify({ status })
|
||||||
}).then(r => r.json()),
|
}).then(r => r.json()),
|
||||||
getPdf: (id) => window.open(`/api/invoices/${id}/pdf`, '_blank'),
|
getPdf: (id) => window.open(`/api/invoices/${id}/pdf`, '_blank'),
|
||||||
getHtml: (id) => window.open(`/api/invoices/${id}/html`, '_blank')
|
getHtml: (id) => window.open(`/api/invoices/${id}/html`, '_blank'),
|
||||||
|
updateSentDates: (id, dates) => fetch(`/api/invoices/${id}/sent-dates`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ sent_dates: dates })
|
||||||
|
}).then(r => r.json())
|
||||||
},
|
},
|
||||||
|
|
||||||
// Payment API
|
// Payment API
|
||||||
@@ -86,6 +91,18 @@ const API = {
|
|||||||
}).then(r => r.json())
|
}).then(r => r.json())
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// NEU: Stripe API
|
||||||
|
stripe: {
|
||||||
|
createPaymentLink: (invoiceId) => fetch(`/api/invoices/${invoiceId}/create-payment-link`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
}).then(r => r.json()),
|
||||||
|
checkPayment: (invoiceId) => fetch(`/api/invoices/${invoiceId}/check-payment`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
}).then(r => r.json())
|
||||||
|
},
|
||||||
|
|
||||||
// QBO API
|
// QBO API
|
||||||
qbo: {
|
qbo: {
|
||||||
getStatus: () => fetch('/api/qbo/status').then(r => r.json()),
|
getStatus: () => fetch('/api/qbo/status').then(r => r.json()),
|
||||||
|
|||||||
48
public/js/utils/helpers.js
Normal file
48
public/js/utils/helpers.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
293
public/js/utils/item-editor.js
Normal file
293
public/js/utils/item-editor.js
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
/**
|
||||||
|
* 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
|
||||||
|
};
|
||||||
@@ -184,18 +184,32 @@ function renderInvoiceRow(invoice) {
|
|||||||
const balance = parseFloat(invoice.balance) ?? ((parseFloat(invoice.total) || 0) - amountPaid);
|
const balance = parseFloat(invoice.balance) ?? ((parseFloat(invoice.total) || 0) - amountPaid);
|
||||||
const partial = isPartiallyPaid(invoice);
|
const partial = isPartiallyPaid(invoice);
|
||||||
|
|
||||||
|
const stripeIndicator = invoice.stripe_payment_link_id
|
||||||
|
? (invoice.stripe_payment_status === 'paid'
|
||||||
|
? ' <span title="Stripe payment received" class="text-purple-500 text-xs">💳✓</span>'
|
||||||
|
: ' <span title="Stripe payment link active" class="text-purple-400 text-xs">💳</span>')
|
||||||
|
: '';
|
||||||
|
|
||||||
const invNumDisplay = invoice.invoice_number
|
const invNumDisplay = invoice.invoice_number
|
||||||
? invoice.invoice_number
|
? invoice.invoice_number + stripeIndicator
|
||||||
: `<span class="text-gray-400 italic text-xs">Draft</span>`;
|
: `<span class="text-gray-400 italic text-xs">Draft</span>`;
|
||||||
|
|
||||||
// Status Badge (left side, next to invoice number)
|
// Status Badge (left side, next to invoice number)
|
||||||
let statusBadge = '';
|
let statusBadge = '';
|
||||||
if (paid && invoice.payment_status === 'Deposited') {
|
if (paid && invoice.payment_status === 'Deposited') {
|
||||||
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-blue-100 text-blue-800" title="Deposited ${formatDate(invoice.paid_date)}">Deposited</span>`;
|
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-blue-100 text-blue-800" title="Deposited ${formatDate(invoice.paid_date)}">Deposited</span>`;
|
||||||
|
} else if (paid && invoice.payment_status === 'Stripe') {
|
||||||
|
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-purple-100 text-purple-800" title="Stripe payment ${formatDate(invoice.paid_date)}">Stripe</span>`;
|
||||||
} else if (paid) {
|
} 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>`;
|
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) {
|
} else if (partial) {
|
||||||
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>`;
|
// 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>`;
|
||||||
} else if (overdue) {
|
} 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>`;
|
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') {
|
} else if (hasQbo && invoice.email_status === 'sent') {
|
||||||
@@ -204,19 +218,39 @@ function renderInvoiceRow(invoice) {
|
|||||||
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-orange-200 text-orange-800">Open</span>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send Date
|
// Send Date — show actual sent dates if available, otherwise scheduled
|
||||||
let sendDateDisplay = '—';
|
let sendDateDisplay = '—';
|
||||||
if (invoice.scheduled_send_date) {
|
const sentDates = invoice.sent_dates || [];
|
||||||
|
|
||||||
|
if (sentDates.length > 0) {
|
||||||
|
// Show most recent sent date
|
||||||
|
const lastSent = sentDates[sentDates.length - 1];
|
||||||
|
sendDateDisplay = formatDate(lastSent);
|
||||||
|
|
||||||
|
if (sentDates.length > 1) {
|
||||||
|
// Tooltip with all dates
|
||||||
|
const allDates = sentDates.map(d => formatDate(d)).join(' ');
|
||||||
|
sendDateDisplay = `<span title="All send dates: ${allDates}" class="cursor-help border-b border-dotted border-gray-400">${formatDate(lastSent)}</span>`;
|
||||||
|
sendDateDisplay += ` <span class="text-xs text-gray-400">(${sentDates.length}x)</span>`;
|
||||||
|
}
|
||||||
|
} else if (invoice.scheduled_send_date) {
|
||||||
|
// No actual sends yet — show scheduled date with indicators
|
||||||
const sendDate = parseLocalDate(invoice.scheduled_send_date);
|
const sendDate = parseLocalDate(invoice.scheduled_send_date);
|
||||||
const today = new Date(); today.setHours(0, 0, 0, 0);
|
const today = new Date(); today.setHours(0, 0, 0, 0);
|
||||||
const daysUntil = Math.floor((sendDate - today) / 86400000);
|
const daysUntil = Math.floor((sendDate - today) / 86400000);
|
||||||
sendDateDisplay = formatDate(invoice.scheduled_send_date);
|
sendDateDisplay = formatDate(invoice.scheduled_send_date);
|
||||||
if (!paid && invoice.email_status !== 'sent') {
|
if (!paid && invoice.email_status !== 'sent' && !overdue) {
|
||||||
if (daysUntil < 0) sendDateDisplay += ` <span class="text-xs text-red-500">(${Math.abs(daysUntil)}d ago)</span>`;
|
if (daysUntil < 0) sendDateDisplay += ` <span class="text-xs text-red-500">(${Math.abs(daysUntil)}d ago)</span>`;
|
||||||
else if (daysUntil === 0) sendDateDisplay += ` <span class="text-xs text-orange-500 font-semibold">(today)</span>`;
|
else if (daysUntil === 0) sendDateDisplay += ` <span class="text-xs text-orange-500 font-semibold">(today)</span>`;
|
||||||
else if (daysUntil <= 3) sendDateDisplay += ` <span class="text-xs text-yellow-600">(in ${daysUntil}d)</span>`;
|
else if (daysUntil <= 3) sendDateDisplay += ` <span class="text-xs text-yellow-600">(in ${daysUntil}d)</span>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Send Date cell — only clickable if actually sent
|
||||||
|
const sendDateClickable = sentDates.length > 0;
|
||||||
|
const sendDateCell = sendDateClickable
|
||||||
|
? `<span class="cursor-pointer hover:text-blue-600" onclick="window.invoiceView.editSentDates(${invoice.id})" title="Click to edit sent dates">${sendDateDisplay}</span>`
|
||||||
|
: sendDateDisplay;
|
||||||
|
|
||||||
// Amount column — show balance when partially paid
|
// Amount column — show balance when partially paid
|
||||||
let amountDisplay;
|
let amountDisplay;
|
||||||
@@ -252,12 +286,20 @@ function renderInvoiceRow(invoice) {
|
|||||||
|
|
||||||
// Mark Sent button (right side) — only when open, not paid/partial
|
// Mark Sent button (right side) — only when open, not paid/partial
|
||||||
let sendBtn = '';
|
let sendBtn = '';
|
||||||
if (hasQbo && !paid && !partial && !overdue && invoice.email_status !== 'sent') {
|
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>`;
|
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 delBtn = `<button onclick="window.invoiceView.remove(${invoice.id})" class="text-red-600 hover:text-red-900">Del</button>`;
|
||||||
|
|
||||||
|
const stripeEmailBtn = (hasQbo && !paid && (invoice.email_status !== 'sent' || ((invoice.email_status === 'sent' && overdue))))
|
||||||
|
? `<button onclick="window.emailModal.open(${invoice.id})" title="Email with Stripe Payment Link" class="px-2 py-1 bg-purple-100 text-purple-700 rounded hover:bg-purple-200 text-xs font-semibold">💳 Pay Link</button>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const stripeCheckBtn = (invoice.stripe_payment_link_id && !paid)
|
||||||
|
? `<button onclick="window.invoiceView.checkStripePayment(${invoice.id})" title="Check Stripe Payment Status" class="px-2 py-1 bg-purple-50 text-purple-600 rounded hover:bg-purple-100 text-xs font-semibold">🔍 Check</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' : '';
|
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' : '';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
@@ -265,11 +307,12 @@ function renderInvoiceRow(invoice) {
|
|||||||
<td class="px-4 py-3 whitespace-nowrap text-sm font-medium text-gray-900">${invNumDisplay} ${statusBadge}</td>
|
<td class="px-4 py-3 whitespace-nowrap text-sm font-medium text-gray-900">${invNumDisplay} ${statusBadge}</td>
|
||||||
<td class="px-4 py-3 text-sm text-gray-500">${invoice.customer_name || 'N/A'}</td>
|
<td class="px-4 py-3 text-sm text-gray-500">${invoice.customer_name || 'N/A'}</td>
|
||||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500">${formatDate(invoice.invoice_date)}</td>
|
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500">${formatDate(invoice.invoice_date)}</td>
|
||||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500">${sendDateDisplay}</td>
|
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-600">
|
||||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500">${invoice.terms}</td>
|
${sendDateCell}
|
||||||
|
</td> <td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500">${invoice.terms}</td>
|
||||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 font-semibold">${amountDisplay}</td>
|
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 font-semibold">${amountDisplay}</td>
|
||||||
<td class="px-4 py-3 whitespace-nowrap text-sm font-medium space-x-1">
|
<td class="px-4 py-3 whitespace-nowrap text-sm font-medium space-x-1">
|
||||||
${editBtn} ${qboBtn} ${pdfBtn} ${htmlBtn} ${sendBtn} ${paidBtn} ${delBtn}
|
${editBtn} ${qboBtn} ${pdfBtn} ${htmlBtn} ${sendBtn} ${stripeEmailBtn} ${stripeCheckBtn} ${paidBtn} ${delBtn}
|
||||||
</td>
|
</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}
|
}
|
||||||
@@ -504,12 +547,134 @@ export async function remove(id) {
|
|||||||
try { const r = await fetch(`/api/invoices/${id}`, { method: 'DELETE' }); if (r.ok) loadInvoices(); }
|
try { const r = await fetch(`/api/invoices/${id}`, { method: 'DELETE' }); if (r.ok) loadInvoices(); }
|
||||||
catch (e) { console.error(e); }
|
catch (e) { console.error(e); }
|
||||||
}
|
}
|
||||||
|
async function checkStripePayment(invoiceId) {
|
||||||
|
if (typeof showSpinner === 'function') showSpinner('Checking Stripe payment status...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/invoices/${invoiceId}/check-payment`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
if (result.paid) {
|
||||||
|
let msg = `✅ ${result.message}`;
|
||||||
|
if (result.qbo) {
|
||||||
|
if (result.qbo.error) {
|
||||||
|
msg += `\n\n⚠️ QBO booking failed: ${result.qbo.error}`;
|
||||||
|
} else {
|
||||||
|
msg += `\n\n📗 QBO Payment recorded (ID: ${result.qbo.paymentId})`;
|
||||||
|
if (result.qbo.feeBooked) msg += '\n📗 Processing fee booked';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!result.fullyPaid) {
|
||||||
|
msg += '\n\n⚠️ Partial payment — invoice is not fully paid yet.';
|
||||||
|
}
|
||||||
|
alert(msg);
|
||||||
|
loadInvoices(); // Refresh the list
|
||||||
|
} else if (result.alreadyProcessed) {
|
||||||
|
alert('ℹ️ Stripe payment was already recorded for this invoice.');
|
||||||
|
} else if (result.status === 'processing') {
|
||||||
|
alert('⏳ ACH payment is processing (typically 3-5 business days).\n\nCheck again later.');
|
||||||
|
} else {
|
||||||
|
alert('ℹ️ No payment received yet.\n\nThe customer may not have clicked the payment link, or the payment is still being processed.');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
alert(`❌ Error: ${result.error}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Check Stripe payment error:', e);
|
||||||
|
alert('Network error checking payment status.');
|
||||||
|
} finally {
|
||||||
|
if (typeof hideSpinner === 'function') hideSpinner();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editSentDates(invoiceId) {
|
||||||
|
const res = await fetch(`/api/invoices/${invoiceId}`);
|
||||||
|
const data = await res.json();
|
||||||
|
const invoice = data.invoice;
|
||||||
|
const sentDates = (invoice.sent_dates || []).map(d => d.split('T')[0]);
|
||||||
|
|
||||||
|
// Build modal
|
||||||
|
let modal = document.getElementById('sent-dates-modal');
|
||||||
|
if (!modal) {
|
||||||
|
modal = document.createElement('div');
|
||||||
|
modal.id = 'sent-dates-modal';
|
||||||
|
modal.className = 'fixed inset-0 bg-black bg-opacity-50 z-50 flex justify-center items-center';
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
}
|
||||||
|
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="bg-white rounded-lg shadow-2xl w-full max-w-sm mx-auto p-6">
|
||||||
|
<h3 class="text-lg font-bold text-gray-800 mb-4">Edit Sent Dates</h3>
|
||||||
|
<div id="sent-dates-list" class="space-y-2 mb-4"></div>
|
||||||
|
<button type="button" onclick="window.invoiceView._addSentDateRow()"
|
||||||
|
class="text-sm text-blue-600 hover:text-blue-800 mb-4">+ Add date</button>
|
||||||
|
<div class="flex justify-end space-x-3 pt-4 border-t">
|
||||||
|
<button onclick="document.getElementById('sent-dates-modal').classList.add('hidden')"
|
||||||
|
class="px-4 py-2 bg-gray-200 text-gray-700 rounded-md hover:bg-gray-300 text-sm">Cancel</button>
|
||||||
|
<button onclick="window.invoiceView._saveSentDates(${invoiceId})"
|
||||||
|
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 text-sm font-semibold">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
const list = document.getElementById('sent-dates-list');
|
||||||
|
if (sentDates.length === 0) {
|
||||||
|
_addSentDateRow();
|
||||||
|
} else {
|
||||||
|
sentDates.forEach(d => _addSentDateRow(d));
|
||||||
|
}
|
||||||
|
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _addSentDateRow(value = '') {
|
||||||
|
const list = document.getElementById('sent-dates-list');
|
||||||
|
if (!list) return;
|
||||||
|
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'flex items-center gap-2';
|
||||||
|
row.innerHTML = `
|
||||||
|
<input type="date" value="${value}" class="sent-date-input flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
<button type="button" onclick="this.parentElement.remove()" class="px-2 py-1 bg-red-100 text-red-600 rounded hover:bg-red-200 text-sm">×</button>`;
|
||||||
|
list.appendChild(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function _saveSentDates(invoiceId) {
|
||||||
|
const inputs = document.querySelectorAll('#sent-dates-list .sent-date-input');
|
||||||
|
const dates = [];
|
||||||
|
|
||||||
|
for (const input of inputs) {
|
||||||
|
if (input.value) dates.push(input.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/invoices/${invoiceId}/sent-dates`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ sent_dates: dates })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
document.getElementById('sent-dates-modal').classList.add('hidden');
|
||||||
|
loadInvoices();
|
||||||
|
} else {
|
||||||
|
const err = await response.json();
|
||||||
|
alert(`Error: ${err.error}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error updating sent dates:', e);
|
||||||
|
alert('Network error.');
|
||||||
|
}
|
||||||
|
}
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Expose
|
// Expose
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
window.invoiceView = {
|
window.invoiceView = {
|
||||||
viewPDF, viewHTML, syncFromQBO, resetQbo, markPaid, setEmailStatus, edit, remove,
|
viewPDF, viewHTML, syncFromQBO, resetQbo, markPaid, setEmailStatus, edit, remove,
|
||||||
loadInvoices, renderInvoiceView, setStatus
|
loadInvoices, renderInvoiceView, setStatus, checkStripePayment, editSentDates ,_addSentDateRow, _saveSentDates
|
||||||
};
|
};
|
||||||
101
public/js/views/quote-view.js
Normal file
101
public/js/views/quote-view.js
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* 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
|
||||||
|
};
|
||||||
182
public/js/views/settings-view.js
Normal file
182
public/js/views/settings-view.js
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
@@ -14,6 +14,7 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
let oauthClient = null;
|
let oauthClient = null;
|
||||||
|
let _lastSavedAccessToken = null;
|
||||||
const tokenFile = path.join(__dirname, 'qbo_token.json');
|
const tokenFile = path.join(__dirname, 'qbo_token.json');
|
||||||
|
|
||||||
const getOAuthClient = () => {
|
const getOAuthClient = () => {
|
||||||
@@ -72,13 +73,16 @@ function saveTokens() {
|
|||||||
try {
|
try {
|
||||||
const client = getOAuthClient();
|
const client = getOAuthClient();
|
||||||
const token = client.getToken();
|
const token = client.getToken();
|
||||||
|
|
||||||
// Debug: Was genau bekommen wir vom Client?
|
// ── NEU: Nur speichern wenn access_token sich tatsächlich geändert hat ──
|
||||||
console.log("💾 Speichere Token... refresh_token vorhanden:", !!token.refresh_token,
|
if (token.access_token === _lastSavedAccessToken) {
|
||||||
"| access_token Länge:", (token.access_token || '').length,
|
return; // Token unverändert – kein Save, kein Log
|
||||||
"| realmId:", token.realmId || 'FEHLT');
|
}
|
||||||
|
_lastSavedAccessToken = token.access_token;
|
||||||
// Sicherstellen dass alle Pflichtfelder vorhanden sind
|
|
||||||
|
const ts = new Date().toISOString().replace('T',' ').substring(0,19);
|
||||||
|
console.log(`[${ts}] 💾 Token changed – saving (realmId: ${token.realmId || 'FEHLT'})`);
|
||||||
|
|
||||||
const tokenToSave = {
|
const tokenToSave = {
|
||||||
token_type: token.token_type || 'bearer',
|
token_type: token.token_type || 'bearer',
|
||||||
access_token: token.access_token,
|
access_token: token.access_token,
|
||||||
@@ -88,16 +92,17 @@ function saveTokens() {
|
|||||||
realmId: token.realmId || process.env.QBO_REALM_ID,
|
realmId: token.realmId || process.env.QBO_REALM_ID,
|
||||||
createdAt: token.createdAt || new Date().toISOString()
|
createdAt: token.createdAt || new Date().toISOString()
|
||||||
};
|
};
|
||||||
|
|
||||||
fs.writeFileSync(tokenFile, JSON.stringify(tokenToSave, null, 2));
|
fs.writeFileSync(tokenFile, JSON.stringify(tokenToSave, null, 2));
|
||||||
console.log("💾 Tokens erfolgreich in qbo_token.json gespeichert.");
|
console.log(`[${ts}] 💾 Token saved to qbo_token.json`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("❌ Fehler beim Speichern der Tokens:", e.message);
|
console.error(`❌ Fehler beim Speichern der Tokens: ${e.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function makeQboApiCall(requestOptions) {
|
async function makeQboApiCall(requestOptions) {
|
||||||
const client = getOAuthClient();
|
const client = getOAuthClient();
|
||||||
|
const ts = () => new Date().toISOString().replace('T',' ').substring(0,19);
|
||||||
|
|
||||||
const currentToken = client.getToken();
|
const currentToken = client.getToken();
|
||||||
if (!currentToken || !currentToken.refresh_token) {
|
if (!currentToken || !currentToken.refresh_token) {
|
||||||
@@ -105,29 +110,17 @@ async function makeQboApiCall(requestOptions) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const doRefresh = async () => {
|
const doRefresh = async () => {
|
||||||
console.log("🔄 QBO Token Refresh wird ausgeführt...");
|
console.log(`[${ts()}] 🔄 QBO Token Refresh...`);
|
||||||
|
|
||||||
// Den Refresh Token als String extrahieren
|
|
||||||
const refreshTokenStr = currentToken.refresh_token;
|
const refreshTokenStr = currentToken.refresh_token;
|
||||||
console.log("🔑 Refresh Token (erste 15 Zeichen):", refreshTokenStr.substring(0, 15) + "...");
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// KRITISCHER FIX: refreshUsingToken() statt refresh() verwenden!
|
|
||||||
//
|
|
||||||
// refresh() ruft intern validateToken() auf, das bei unvollständigem
|
|
||||||
// Token-Objekt "The Refresh token is invalid" wirft — OHNE jemals
|
|
||||||
// Intuit zu kontaktieren.
|
|
||||||
//
|
|
||||||
// refreshUsingToken() akzeptiert den RT als String und umgeht das.
|
|
||||||
const authResponse = await client.refreshUsingToken(refreshTokenStr);
|
const authResponse = await client.refreshUsingToken(refreshTokenStr);
|
||||||
console.log("✅ Token erfolgreich erneuert via refreshUsingToken().");
|
console.log(`[${ts()}] ✅ Token refreshed via refreshUsingToken()`);
|
||||||
saveTokens();
|
saveTokens(); // saveTokens prüft selbst ob sich was geändert hat
|
||||||
return authResponse;
|
return authResponse;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const errMsg = e.originalMessage || e.message || String(e);
|
const errMsg = e.originalMessage || e.message || String(e);
|
||||||
console.error("❌ Refresh fehlgeschlagen:", errMsg);
|
console.error(`[${ts()}] ❌ Refresh failed: ${errMsg}`);
|
||||||
if (e.intuit_tid) console.error(" intuit_tid:", e.intuit_tid);
|
if (e.intuit_tid) console.error(` intuit_tid: ${e.intuit_tid}`);
|
||||||
|
|
||||||
if (errMsg.includes('invalid_grant')) {
|
if (errMsg.includes('invalid_grant')) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Der Refresh Token ist bei Intuit ungültig (invalid_grant). " +
|
"Der Refresh Token ist bei Intuit ungültig (invalid_grant). " +
|
||||||
@@ -140,35 +133,32 @@ async function makeQboApiCall(requestOptions) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await client.makeApiCall(requestOptions);
|
const response = await client.makeApiCall(requestOptions);
|
||||||
|
|
||||||
const data = response.getJson ? response.getJson() : response.json;
|
const data = response.getJson ? response.getJson() : response.json;
|
||||||
|
|
||||||
if (data.fault && data.fault.error) {
|
if (data.fault && data.fault.error) {
|
||||||
const errorCode = data.fault.error[0].code;
|
const errorCode = data.fault.error[0].code;
|
||||||
|
|
||||||
if (errorCode === '3200' || errorCode === '3202' || errorCode === '3100') {
|
if (errorCode === '3200' || errorCode === '3202' || errorCode === '3100') {
|
||||||
console.log(`⚠️ QBO meldet Token-Fehler (${errorCode}). Versuche Refresh und Retry...`);
|
console.log(`[${ts()}] ⚠️ QBO Token-Fehler (${errorCode}) – Refresh & Retry...`);
|
||||||
await doRefresh();
|
await doRefresh();
|
||||||
return await client.makeApiCall(requestOptions);
|
return await client.makeApiCall(requestOptions);
|
||||||
}
|
}
|
||||||
throw new Error(`QBO API Error ${errorCode}: ${data.fault.error[0].message}`);
|
throw new Error(`QBO API Error ${errorCode}: ${data.fault.error[0].message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
saveTokens();
|
// ── Kein saveTokens() hier – Token hat sich nicht geändert ──
|
||||||
return response;
|
return response;
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const isAuthError =
|
const isAuthError =
|
||||||
e.response?.status === 401 ||
|
e.response?.status === 401 ||
|
||||||
(e.authResponse && e.authResponse.response && e.authResponse.response.status === 401) ||
|
(e.authResponse?.response?.status === 401) ||
|
||||||
e.message?.includes('AuthenticationFailed');
|
e.message?.includes('AuthenticationFailed');
|
||||||
|
|
||||||
if (isAuthError) {
|
if (isAuthError) {
|
||||||
console.log("⚠️ 401 Unauthorized / AuthFailed erhalten. Versuche Refresh und Retry...");
|
console.log(`[${ts()}] ⚠️ 401 – Refresh & Retry...`);
|
||||||
await doRefresh();
|
await doRefresh();
|
||||||
return await client.makeApiCall(requestOptions);
|
return await client.makeApiCall(requestOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
|
// src/config/qbo.js
|
||||||
const OAuthClient = require('intuit-oauth');
|
const OAuthClient = require('intuit-oauth');
|
||||||
const { getOAuthClient: getClient, saveTokens, resetOAuthClient } = require('../../qbo_helper');
|
const {
|
||||||
|
getOAuthClient: getClient,
|
||||||
|
saveTokens,
|
||||||
|
resetOAuthClient,
|
||||||
|
makeQboApiCall // <-- NEU: Direkt hier mit importieren
|
||||||
|
} = require('../../qbo_helper');
|
||||||
|
|
||||||
function getOAuthClient() {
|
function getOAuthClient() {
|
||||||
return getClient();
|
return getClient();
|
||||||
@@ -16,5 +22,6 @@ module.exports = {
|
|||||||
getOAuthClient,
|
getOAuthClient,
|
||||||
getQboBaseUrl,
|
getQboBaseUrl,
|
||||||
saveTokens,
|
saveTokens,
|
||||||
resetOAuthClient
|
resetOAuthClient,
|
||||||
};
|
makeQboApiCall // <-- NEU: Und sauber weiterreichen
|
||||||
|
};
|
||||||
78
src/index.js
78
src/index.js
@@ -2,9 +2,22 @@
|
|||||||
* Quote & Invoice System - Main Entry Point
|
* Quote & Invoice System - Main Entry Point
|
||||||
* Modularized Backend
|
* Modularized Backend
|
||||||
*/
|
*/
|
||||||
|
// ── Global timestamp logger – must be first line before any require ──
|
||||||
|
const _ts = () => new Date().toISOString().replace('T', ' ').substring(0, 19);
|
||||||
|
const _origLog = console.log.bind(console);
|
||||||
|
const _origWarn = console.warn.bind(console);
|
||||||
|
const _origError = console.error.bind(console);
|
||||||
|
console.log = (...a) => _origLog(`[${_ts()}]`, ...a);
|
||||||
|
console.warn = (...a) => _origWarn(`[${_ts()}]`, ...a);
|
||||||
|
console.error = (...a) => _origError(`[${_ts()}]`, ...a);
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
const path = require('path');
|
||||||
const puppeteer = require('puppeteer');
|
const puppeteer = require('puppeteer');
|
||||||
|
|
||||||
|
// Import config
|
||||||
|
const { pool } = require('./config/database');
|
||||||
|
const { OAuthClient, getOAuthClient, saveTokens } = require('./config/qbo');
|
||||||
|
|
||||||
// Import routes
|
// Import routes
|
||||||
const customerRoutes = require('./routes/customers');
|
const customerRoutes = require('./routes/customers');
|
||||||
const quoteRoutes = require('./routes/quotes');
|
const quoteRoutes = require('./routes/quotes');
|
||||||
@@ -16,6 +29,10 @@ const settingsRoutes = require('./routes/settings');
|
|||||||
// Import PDF service for browser initialization
|
// Import PDF service for browser initialization
|
||||||
const { setBrowser } = require('./services/pdf-service');
|
const { setBrowser } = require('./services/pdf-service');
|
||||||
|
|
||||||
|
// Import recurring invoice scheduler
|
||||||
|
const { startRecurringScheduler } = require('./services/recurring-service');
|
||||||
|
const { startStripePolling } = require('./services/stripe-poll-service');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
@@ -34,8 +51,8 @@ async function initBrowser() {
|
|||||||
'--disable-dev-shm-usage',
|
'--disable-dev-shm-usage',
|
||||||
'--disable-gpu',
|
'--disable-gpu',
|
||||||
'--disable-software-rasterizer',
|
'--disable-software-rasterizer',
|
||||||
'--no-zygote',
|
'--no-zygote'
|
||||||
'--single-process'
|
// '--single-process' WURDE ENTFERNT!
|
||||||
],
|
],
|
||||||
protocolTimeout: 180000,
|
protocolTimeout: 180000,
|
||||||
timeout: 180000
|
timeout: 180000
|
||||||
@@ -44,12 +61,17 @@ async function initBrowser() {
|
|||||||
|
|
||||||
// Pass browser to PDF service
|
// Pass browser to PDF service
|
||||||
setBrowser(browser);
|
setBrowser(browser);
|
||||||
|
|
||||||
// Restart browser if it crashes
|
// Restart browser if it crashes (mit Atempause!)
|
||||||
browser.on('disconnected', () => {
|
browser.on('disconnected', () => {
|
||||||
console.log('[BROWSER] Browser disconnected, restarting...');
|
console.log('[BROWSER] Browser disconnected. Waiting 5 seconds before restarting...');
|
||||||
browser = null;
|
browser = null;
|
||||||
initBrowser();
|
setBrowser(null);
|
||||||
|
|
||||||
|
// 5 Sekunden warten, bevor ein Neustart versucht wird
|
||||||
|
setTimeout(() => {
|
||||||
|
initBrowser();
|
||||||
|
}, 5000);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return browser;
|
return browser;
|
||||||
@@ -57,9 +79,42 @@ async function initBrowser() {
|
|||||||
|
|
||||||
// Middleware
|
// Middleware
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use(express.static('public'));
|
app.use(express.static(path.join(__dirname, '..', 'public')));
|
||||||
|
|
||||||
// Mount routes
|
// =====================================================
|
||||||
|
// 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/customers', customerRoutes);
|
||||||
app.use('/api/quotes', quoteRoutes);
|
app.use('/api/quotes', quoteRoutes);
|
||||||
app.use('/api/invoices', invoiceRoutes);
|
app.use('/api/invoices', invoiceRoutes);
|
||||||
@@ -74,6 +129,10 @@ async function startServer() {
|
|||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`Quote System running on port ${PORT}`);
|
console.log(`Quote System running on port ${PORT}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Start recurring invoice scheduler (checks every 24h)
|
||||||
|
startRecurringScheduler();
|
||||||
|
startStripePolling();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Graceful shutdown
|
// Graceful shutdown
|
||||||
@@ -81,11 +140,10 @@ process.on('SIGTERM', async () => {
|
|||||||
if (browser) {
|
if (browser) {
|
||||||
await browser.close();
|
await browser.close();
|
||||||
}
|
}
|
||||||
const { pool } = require('./config/database');
|
|
||||||
await pool.end();
|
await pool.end();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
startServer();
|
startServer();
|
||||||
|
|
||||||
module.exports = app;
|
module.exports = app;
|
||||||
@@ -5,8 +5,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { pool } = require('../config/database');
|
const { pool } = require('../config/database');
|
||||||
const { getOAuthClient, getQboBaseUrl } = require('../config/qbo');
|
const { getOAuthClient, getQboBaseUrl, makeQboApiCall } = require('../config/qbo');
|
||||||
const { makeQboApiCall } = require('../../qbo_helper');
|
|
||||||
|
|
||||||
// GET all customers
|
// GET all customers
|
||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
|
|||||||
@@ -11,9 +11,41 @@ const { getNextInvoiceNumber } = require('../utils/numberGenerators');
|
|||||||
const { formatDate, formatMoney } = require('../utils/helpers');
|
const { formatDate, formatMoney } = require('../utils/helpers');
|
||||||
const { getBrowser, generatePdfFromHtml, getLogoHtml, renderInvoiceItems, formatAddressLines } = require('../services/pdf-service');
|
const { getBrowser, generatePdfFromHtml, getLogoHtml, renderInvoiceItems, formatAddressLines } = require('../services/pdf-service');
|
||||||
const { exportInvoiceToQbo, syncInvoiceToQbo } = require('../services/qbo-service');
|
const { exportInvoiceToQbo, syncInvoiceToQbo } = require('../services/qbo-service');
|
||||||
const { getOAuthClient, getQboBaseUrl } = require('../config/qbo');
|
const { getOAuthClient, getQboBaseUrl, makeQboApiCall } = require('../config/qbo');
|
||||||
const { makeQboApiCall } = require('../../qbo_helper');
|
const { sendInvoiceEmail } = require('../services/email-service');
|
||||||
|
const { createPaymentLink, checkPaymentStatus, deactivatePaymentLink } = require('../services/stripe-service');
|
||||||
|
|
||||||
|
function calculateNextRecurringDate(invoiceDate, interval) {
|
||||||
|
const d = new Date(invoiceDate);
|
||||||
|
if (interval === 'monthly') {
|
||||||
|
d.setMonth(d.getMonth() + 1);
|
||||||
|
} else if (interval === 'yearly') {
|
||||||
|
d.setFullYear(d.getFullYear() + 1);
|
||||||
|
}
|
||||||
|
return d.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Build HTML block for Stripe Payment Link on PDF invoice.
|
||||||
|
* Returns empty string if no link or invoice is already paid.
|
||||||
|
*/
|
||||||
|
function buildPaymentLinkHtml(invoice) {
|
||||||
|
if (!invoice.stripe_payment_link_url) return '';
|
||||||
|
if (invoice.paid_date || invoice.stripe_payment_status === 'paid') return '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div style="margin-top: 30px; padding: 16px 20px; border: 2px solid #635bff; border-radius: 8px; text-align: center;">
|
||||||
|
<p style="font-size: 14px; font-weight: bold; color: #635bff; margin: 0 0 8px 0;">
|
||||||
|
Pay Online — Credit Card or ACH
|
||||||
|
</p>
|
||||||
|
<a href="${invoice.stripe_payment_link_url}"
|
||||||
|
style="font-size: 13px; color: #635bff; word-break: break-all;">
|
||||||
|
${invoice.stripe_payment_link_url}
|
||||||
|
</a>
|
||||||
|
<p style="font-size: 11px; color: #888; margin: 8px 0 0 0;">
|
||||||
|
Secure payment powered by Stripe. ACH payments incur lower processing fees.
|
||||||
|
</p>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
// GET all invoices
|
// GET all invoices
|
||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -53,7 +85,7 @@ router.get('/:id', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const invoiceResult = await pool.query(`
|
const invoiceResult = await pool.query(`
|
||||||
SELECT i.*, c.name as customer_name, c.qbo_id as customer_qbo_id,
|
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,
|
c.email, 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
|
COALESCE((SELECT SUM(pi.amount) FROM payment_invoices pi WHERE pi.invoice_id = i.id), 0) as amount_paid
|
||||||
FROM invoices i
|
FROM invoices i
|
||||||
LEFT JOIN customers c ON i.customer_id = c.id
|
LEFT JOIN customers c ON i.customer_id = c.id
|
||||||
@@ -82,7 +114,7 @@ router.get('/:id', async (req, res) => {
|
|||||||
|
|
||||||
// POST create invoice
|
// POST create invoice
|
||||||
router.post('/', async (req, res) => {
|
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 } = req.body;
|
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();
|
const client = await pool.connect();
|
||||||
try {
|
try {
|
||||||
@@ -113,11 +145,11 @@ router.post('/', async (req, res) => {
|
|||||||
const tax_rate = 8.25;
|
const tax_rate = 8.25;
|
||||||
const tax_amount = tax_exempt ? 0 : (subtotal * tax_rate / 100);
|
const tax_amount = tax_exempt ? 0 : (subtotal * tax_rate / 100);
|
||||||
const total = subtotal + tax_amount;
|
const total = subtotal + tax_amount;
|
||||||
|
const next_recurring_date = is_recurring ? calculateNextRecurringDate(invoice_date, recurring_interval) : null;
|
||||||
const invoiceResult = await client.query(
|
const invoiceResult = await client.query(
|
||||||
`INSERT INTO invoices (invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, scheduled_send_date, bill_to_name, created_from_quote_id)
|
`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) RETURNING *`,
|
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]
|
[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;
|
const invoiceId = invoiceResult.rows[0].id;
|
||||||
|
|
||||||
@@ -133,7 +165,7 @@ router.post('/', async (req, res) => {
|
|||||||
// Auto QBO Export
|
// Auto QBO Export
|
||||||
let qboResult = null;
|
let qboResult = null;
|
||||||
try {
|
try {
|
||||||
qboResult = await exportInvoiceToQbo(invoiceId, pool);
|
qboResult = await exportInvoiceToQbo(invoiceId, client);
|
||||||
if (qboResult.skipped) {
|
if (qboResult.skipped) {
|
||||||
console.log(`ℹ️ Invoice ${invoiceId} not exported to QBO: ${qboResult.reason}`);
|
console.log(`ℹ️ Invoice ${invoiceId} not exported to QBO: ${qboResult.reason}`);
|
||||||
}
|
}
|
||||||
@@ -159,7 +191,7 @@ router.post('/', async (req, res) => {
|
|||||||
// PUT update invoice
|
// PUT update invoice
|
||||||
router.put('/:id', async (req, res) => {
|
router.put('/:id', async (req, res) => {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const { invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, items, scheduled_send_date, bill_to_name } = req.body;
|
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();
|
const client = await pool.connect();
|
||||||
try {
|
try {
|
||||||
@@ -205,7 +237,11 @@ router.put('/:id', async (req, res) => {
|
|||||||
[customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, scheduled_send_date || null, bill_to_name || null, id]
|
[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
|
// Delete and re-insert items
|
||||||
await client.query('DELETE FROM invoice_items WHERE invoice_id = $1', [id]);
|
await client.query('DELETE FROM invoice_items WHERE invoice_id = $1', [id]);
|
||||||
for (let i = 0; i < items.length; i++) {
|
for (let i = 0; i < items.length; i++) {
|
||||||
@@ -224,9 +260,9 @@ router.put('/:id', async (req, res) => {
|
|||||||
const hasQboId = !!checkRes.rows[0]?.qbo_id;
|
const hasQboId = !!checkRes.rows[0]?.qbo_id;
|
||||||
|
|
||||||
if (hasQboId) {
|
if (hasQboId) {
|
||||||
qboResult = await syncInvoiceToQbo(id, pool);
|
qboResult = await syncInvoiceToQbo(id, client);
|
||||||
} else {
|
} else {
|
||||||
qboResult = await exportInvoiceToQbo(id, pool);
|
qboResult = await exportInvoiceToQbo(id, client);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (qboResult.skipped) {
|
if (qboResult.skipped) {
|
||||||
@@ -730,7 +766,8 @@ router.get('/:id/pdf', async (req, res) => {
|
|||||||
.replace('{{INVOICE_DATE}}', formatDate(invoice.invoice_date))
|
.replace('{{INVOICE_DATE}}', formatDate(invoice.invoice_date))
|
||||||
.replace('{{TERMS}}', invoice.terms)
|
.replace('{{TERMS}}', invoice.terms)
|
||||||
.replace('{{AUTHORIZATION}}', authHTML)
|
.replace('{{AUTHORIZATION}}', authHTML)
|
||||||
.replace('{{ITEMS}}', itemsHTML);
|
.replace('{{ITEMS}}', itemsHTML)
|
||||||
|
.replace('{{PAYMENT_LINK}}', buildPaymentLinkHtml(invoice));
|
||||||
|
|
||||||
const pdf = await generatePdfFromHtml(html);
|
const pdf = await generatePdfFromHtml(html);
|
||||||
|
|
||||||
@@ -793,7 +830,8 @@ router.get('/:id/html', async (req, res) => {
|
|||||||
.replace('{{INVOICE_DATE}}', formatDate(invoice.invoice_date))
|
.replace('{{INVOICE_DATE}}', formatDate(invoice.invoice_date))
|
||||||
.replace('{{TERMS}}', invoice.terms)
|
.replace('{{TERMS}}', invoice.terms)
|
||||||
.replace('{{AUTHORIZATION}}', authHTML)
|
.replace('{{AUTHORIZATION}}', authHTML)
|
||||||
.replace('{{ITEMS}}', itemsHTML);
|
.replace('{{ITEMS}}', itemsHTML)
|
||||||
|
.replace('{{PAYMENT_LINK}}', buildPaymentLinkHtml(invoice));
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'text/html');
|
res.setHeader('Content-Type', 'text/html');
|
||||||
res.send(html);
|
res.send(html);
|
||||||
@@ -803,5 +841,385 @@ router.get('/:id/html', async (req, res) => {
|
|||||||
res.status(500).json({ error: 'Error generating HTML' });
|
res.status(500).json({ error: 'Error generating HTML' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
router.post('/:id/send-email', async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { recipientEmail, customText } = 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)
|
||||||
|
.replace('{{PAYMENT_LINK}}', buildPaymentLinkHtml(invoice));
|
||||||
|
|
||||||
|
const pdfBuffer = await generatePdfFromHtml(html);
|
||||||
|
|
||||||
|
// 3. E-Mail über SES versenden
|
||||||
|
const stripeLink = invoice.stripe_payment_link_url || null;
|
||||||
|
const info = await sendInvoiceEmail(invoice, recipientEmail, customText, stripeLink, pdfBuffer);
|
||||||
|
|
||||||
|
// 4. Status in der DB aktualisieren
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE invoices
|
||||||
|
SET email_status = 'sent',
|
||||||
|
sent_dates = array_append(COALESCE(sent_dates, '{}'), CURRENT_DATE),
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $1`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// POST create Stripe Payment Link
|
||||||
|
router.post('/:id/create-payment-link', async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Load invoice with balance
|
||||||
|
const invoiceResult = await pool.query(`
|
||||||
|
SELECT i.*, c.name as customer_name,
|
||||||
|
COALESCE((SELECT SUM(pi.amount) FROM payment_invoices pi WHERE pi.invoice_id = i.id), 0) as amount_paid
|
||||||
|
FROM invoices i
|
||||||
|
LEFT JOIN customers c ON i.customer_id = c.id
|
||||||
|
WHERE i.id = $1
|
||||||
|
`, [id]);
|
||||||
|
|
||||||
|
if (invoiceResult.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Invoice not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const invoice = invoiceResult.rows[0];
|
||||||
|
invoice.amount_paid = parseFloat(invoice.amount_paid) || 0;
|
||||||
|
invoice.balance = (parseFloat(invoice.total) || 0) - invoice.amount_paid;
|
||||||
|
|
||||||
|
if (invoice.balance <= 0) {
|
||||||
|
return res.status(400).json({ error: 'Invoice has no balance due.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deactivate existing payment link if present
|
||||||
|
if (invoice.stripe_payment_link_id) {
|
||||||
|
await deactivatePaymentLink(invoice.stripe_payment_link_id);
|
||||||
|
console.log(`♻️ Old payment link deactivated for Invoice #${invoice.invoice_number}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new payment link
|
||||||
|
const { paymentLinkId, paymentLinkUrl } = await createPaymentLink(invoice);
|
||||||
|
|
||||||
|
// Save to DB
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE invoices
|
||||||
|
SET stripe_payment_link_id = $1,
|
||||||
|
stripe_payment_link_url = $2,
|
||||||
|
stripe_payment_status = 'pending',
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $3`,
|
||||||
|
[paymentLinkId, paymentLinkUrl, id]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
paymentLinkId,
|
||||||
|
paymentLinkUrl,
|
||||||
|
amount: invoice.balance
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Stripe Payment Link Error:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to create payment link: ' + error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// POST check Stripe payment status
|
||||||
|
// POST check Stripe payment status (with QBO payment + fee booking)
|
||||||
|
router.post('/:id/check-payment', async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const dbClient = await pool.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const invoiceResult = await dbClient.query(`
|
||||||
|
SELECT i.*, c.name as customer_name, c.qbo_id as customer_qbo_id,
|
||||||
|
COALESCE((SELECT SUM(pi.amount) FROM payment_invoices pi WHERE pi.invoice_id = i.id), 0) as amount_paid
|
||||||
|
FROM invoices i
|
||||||
|
LEFT JOIN customers c ON i.customer_id = c.id
|
||||||
|
WHERE i.id = $1
|
||||||
|
`, [id]);
|
||||||
|
|
||||||
|
if (invoiceResult.rows.length === 0) {
|
||||||
|
return res.status(404).json({ error: 'Invoice not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const invoice = invoiceResult.rows[0];
|
||||||
|
invoice.amount_paid = parseFloat(invoice.amount_paid) || 0;
|
||||||
|
|
||||||
|
if (!invoice.stripe_payment_link_id) {
|
||||||
|
return res.status(400).json({ error: 'No Stripe payment link exists for this invoice.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already fully processed?
|
||||||
|
if (invoice.stripe_payment_status === 'paid') {
|
||||||
|
return res.json({
|
||||||
|
status: 'paid',
|
||||||
|
message: 'Stripe payment already recorded.',
|
||||||
|
alreadyProcessed: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await checkPaymentStatus(invoice.stripe_payment_link_id);
|
||||||
|
|
||||||
|
// Update stripe_payment_status in DB regardless
|
||||||
|
if (result.status !== invoice.stripe_payment_status) {
|
||||||
|
await dbClient.query(
|
||||||
|
'UPDATE invoices SET stripe_payment_status = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
|
||||||
|
[result.status, id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not paid yet, return current status
|
||||||
|
if (!result.paid) {
|
||||||
|
return res.json({
|
||||||
|
status: result.status,
|
||||||
|
paid: false,
|
||||||
|
details: result.details,
|
||||||
|
message: result.status === 'processing'
|
||||||
|
? 'ACH payment is processing (3-5 business days).'
|
||||||
|
: 'No payment received yet.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// === PAID — full processing ===
|
||||||
|
const amountReceived = result.details.amountReceived;
|
||||||
|
const paymentMethod = result.details.paymentMethod;
|
||||||
|
const stripeFee = result.details.stripeFee;
|
||||||
|
const methodLabel = paymentMethod === 'us_bank_account' ? 'ACH' : 'Credit Card';
|
||||||
|
|
||||||
|
await dbClient.query('BEGIN');
|
||||||
|
|
||||||
|
// 1. Record local payment (payment + payment_invoices)
|
||||||
|
const payResult = await dbClient.query(
|
||||||
|
`INSERT INTO payments (payment_date, payment_method, total_amount, customer_id, reference_number, notes, created_at)
|
||||||
|
VALUES (CURRENT_DATE, $1, $2, $3, $4, $5, CURRENT_TIMESTAMP)
|
||||||
|
RETURNING id`,
|
||||||
|
[
|
||||||
|
`Stripe ${methodLabel}`,
|
||||||
|
amountReceived,
|
||||||
|
invoice.customer_id,
|
||||||
|
result.details.paymentIntentId || result.details.sessionId,
|
||||||
|
`Stripe ${methodLabel} — Fee: $${stripeFee.toFixed(2)}`
|
||||||
|
]
|
||||||
|
);
|
||||||
|
const paymentId = payResult.rows[0].id;
|
||||||
|
|
||||||
|
await dbClient.query(
|
||||||
|
'INSERT INTO payment_invoices (payment_id, invoice_id, amount) VALUES ($1, $2, $3)',
|
||||||
|
[paymentId, id, amountReceived]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Check if invoice is fully paid
|
||||||
|
const newTotalPaid = invoice.amount_paid + amountReceived;
|
||||||
|
const invoiceTotal = parseFloat(invoice.total) || 0;
|
||||||
|
const fullyPaid = newTotalPaid >= (invoiceTotal - 0.01); // Cent-Toleranz
|
||||||
|
|
||||||
|
await dbClient.query(
|
||||||
|
`UPDATE invoices SET
|
||||||
|
stripe_payment_status = 'paid',
|
||||||
|
paid_date = ${fullyPaid ? 'COALESCE(paid_date, CURRENT_DATE)' : 'paid_date'},
|
||||||
|
payment_status = $1,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $2`,
|
||||||
|
[fullyPaid ? 'Stripe' : 'Partial', id]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Deactivate the payment link
|
||||||
|
await deactivatePaymentLink(invoice.stripe_payment_link_id);
|
||||||
|
|
||||||
|
// 4. QBO: Record Payment + Expense (if QBO-linked)
|
||||||
|
let qboResult = null;
|
||||||
|
if (invoice.qbo_id && invoice.customer_qbo_id) {
|
||||||
|
try {
|
||||||
|
qboResult = await recordStripePaymentInQbo(
|
||||||
|
invoice, amountReceived, methodLabel, stripeFee,
|
||||||
|
result.details.paymentIntentId || ''
|
||||||
|
);
|
||||||
|
} catch (qboErr) {
|
||||||
|
console.error(`⚠️ QBO booking failed for Invoice #${invoice.invoice_number}:`, qboErr.message);
|
||||||
|
qboResult = { error: qboErr.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await dbClient.query('COMMIT');
|
||||||
|
|
||||||
|
console.log(`✅ Invoice #${invoice.invoice_number}: Stripe ${methodLabel} $${amountReceived.toFixed(2)} recorded (Fee: $${stripeFee.toFixed(2)})`);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: 'paid',
|
||||||
|
paid: true,
|
||||||
|
fullyPaid,
|
||||||
|
details: result.details,
|
||||||
|
qbo: qboResult,
|
||||||
|
message: `Payment received: $${amountReceived.toFixed(2)} via Stripe ${methodLabel}. Fee: $${stripeFee.toFixed(2)}.`
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
await dbClient.query('ROLLBACK').catch(() => {});
|
||||||
|
console.error('Stripe Check Payment Error:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to check payment: ' + error.message });
|
||||||
|
} finally {
|
||||||
|
dbClient.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record Stripe payment in QBO: Payment on Invoice + Expense for Stripe Fee.
|
||||||
|
*/
|
||||||
|
async function recordStripePaymentInQbo(invoice, amount, methodLabel, stripeFee, reference) {
|
||||||
|
const oauthClient = getOAuthClient();
|
||||||
|
const companyId = oauthClient.getToken().realmId;
|
||||||
|
const baseUrl = getQboBaseUrl();
|
||||||
|
|
||||||
|
// --- 1. Create QBO Payment ---
|
||||||
|
const paymentPayload = {
|
||||||
|
CustomerRef: { value: invoice.customer_qbo_id },
|
||||||
|
TotalAmt: amount,
|
||||||
|
TxnDate: new Date().toISOString().split('T')[0],
|
||||||
|
PaymentRefNum: reference ? reference.substring(0, 21) : 'Stripe',
|
||||||
|
PrivateNote: `Stripe ${methodLabel} — processed via Payment Link`,
|
||||||
|
Line: [{
|
||||||
|
Amount: amount,
|
||||||
|
LinkedTxn: [{
|
||||||
|
TxnId: invoice.qbo_id,
|
||||||
|
TxnType: 'Invoice'
|
||||||
|
}]
|
||||||
|
}],
|
||||||
|
// Deposit to Undeposited Funds (Stripe will payout to bank later)
|
||||||
|
DepositToAccountRef: { value: '221' } // Undeposited Funds
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`📤 QBO: Recording Stripe payment $${amount.toFixed(2)} for Invoice #${invoice.invoice_number}...`);
|
||||||
|
|
||||||
|
const paymentRes = await makeQboApiCall({
|
||||||
|
url: `${baseUrl}/v3/company/${companyId}/payment`,
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(paymentPayload)
|
||||||
|
});
|
||||||
|
|
||||||
|
const paymentData = paymentRes.getJson ? paymentRes.getJson() : paymentRes.json;
|
||||||
|
if (paymentData.Fault) {
|
||||||
|
const errMsg = paymentData.Fault.Error?.map(e => `${e.Message}: ${e.Detail}`).join('; ');
|
||||||
|
throw new Error('QBO Payment failed: ' + errMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ QBO Payment created: ID ${paymentData.Payment?.Id}`);
|
||||||
|
|
||||||
|
// --- 2. Create QBO Expense for Stripe Fee ---
|
||||||
|
if (stripeFee > 0) {
|
||||||
|
const expensePayload = {
|
||||||
|
AccountRef: { value: '244', name: 'PlainsCapital Bank' }, // Checking
|
||||||
|
TxnDate: new Date().toISOString().split('T')[0],
|
||||||
|
PaymentType: 'Check',
|
||||||
|
PrivateNote: `Stripe processing fee for Invoice #${invoice.invoice_number} (${methodLabel})`,
|
||||||
|
Line: [{
|
||||||
|
DetailType: 'AccountBasedExpenseLineDetail',
|
||||||
|
Amount: stripeFee,
|
||||||
|
AccountBasedExpenseLineDetail: {
|
||||||
|
AccountRef: { value: '1150040001', name: 'Payment Processing Fees' }
|
||||||
|
},
|
||||||
|
Description: `Stripe ${methodLabel} fee — Invoice #${invoice.invoice_number}`
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`📤 QBO: Booking Stripe fee $${stripeFee.toFixed(2)}...`);
|
||||||
|
|
||||||
|
const expenseRes = await makeQboApiCall({
|
||||||
|
url: `${baseUrl}/v3/company/${companyId}/purchase`,
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(expensePayload)
|
||||||
|
});
|
||||||
|
|
||||||
|
const expenseData = expenseRes.getJson ? expenseRes.getJson() : expenseRes.json;
|
||||||
|
if (expenseData.Fault) {
|
||||||
|
console.error('⚠️ QBO Expense booking failed:', JSON.stringify(expenseData.Fault));
|
||||||
|
// Don't throw — payment is still valid even if fee booking fails
|
||||||
|
} else {
|
||||||
|
console.log(`✅ QBO Expense created: ID ${expenseData.Purchase?.Id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
paymentId: paymentData.Payment?.Id,
|
||||||
|
feeBooked: stripeFee > 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// PATCH update sent dates only
|
||||||
|
router.patch('/:id/sent-dates', async (req, res) => {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { sent_dates } = req.body;
|
||||||
|
|
||||||
|
if (!Array.isArray(sent_dates)) {
|
||||||
|
return res.status(400).json({ error: 'sent_dates must be an array of date strings.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate each date
|
||||||
|
for (const d of sent_dates) {
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(d)) {
|
||||||
|
return res.status(400).json({ error: `Invalid date format: ${d}. Expected YYYY-MM-DD.` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Sort chronologically
|
||||||
|
const sorted = [...sent_dates].sort();
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
'UPDATE invoices SET sent_dates = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
|
||||||
|
[sorted, id]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ success: true, sent_dates: sorted });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating sent dates:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to update sent dates.' });
|
||||||
|
}
|
||||||
|
});
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* QBO Routes
|
* QBO Routes
|
||||||
* Handles QBO OAuth, sync, and data operations
|
* Handles QBO sync and data operations
|
||||||
|
* NOTE: OAuth auth/callback routes are in index.js (root-level paths)
|
||||||
*/
|
*/
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { pool } = require('../config/database');
|
const { pool } = require('../config/database');
|
||||||
const { getOAuthClient, getQboBaseUrl, saveTokens } = require('../config/qbo');
|
const { getOAuthClient, getQboBaseUrl, makeQboApiCall } = require('../config/qbo');
|
||||||
const { makeQboApiCall } = require('../../qbo_helper');
|
|
||||||
|
|
||||||
// GET QBO status
|
// GET QBO status
|
||||||
router.get('/status', (req, res) => {
|
router.get('/status', (req, res) => {
|
||||||
@@ -23,35 +23,6 @@ router.get('/status', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET auth URL - redirects to Intuit
|
|
||||||
router.get('/auth', (req, res) => {
|
|
||||||
const client = getOAuthClient();
|
|
||||||
const authUri = client.authorizeUri({
|
|
||||||
scope: [require('../config/qbo').OAuthClient.scopes.Accounting],
|
|
||||||
state: 'intuit-qbo-auth'
|
|
||||||
});
|
|
||||||
console.log('🔗 Redirecting to QBO Authorization:', authUri);
|
|
||||||
res.redirect(authUri);
|
|
||||||
});
|
|
||||||
|
|
||||||
// OAuth callback
|
|
||||||
router.get('/auth/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>
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// GET bank accounts from QBO
|
// GET bank accounts from QBO
|
||||||
router.get('/accounts', async (req, res) => {
|
router.get('/accounts', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -438,6 +409,7 @@ router.post('/record-payment', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
// POST sync payments from QBO
|
// POST sync payments from QBO
|
||||||
router.post('/sync-payments', async (req, res) => {
|
router.post('/sync-payments', async (req, res) => {
|
||||||
const dbClient = await pool.connect();
|
const dbClient = await pool.connect();
|
||||||
@@ -459,6 +431,7 @@ router.post('/sync-payments', async (req, res) => {
|
|||||||
const companyId = oauthClient.getToken().realmId;
|
const companyId = oauthClient.getToken().realmId;
|
||||||
const baseUrl = getQboBaseUrl();
|
const baseUrl = getQboBaseUrl();
|
||||||
|
|
||||||
|
// ── Batch-fetch all invoices from QBO (max 50 per query) ──────────
|
||||||
const batchSize = 50;
|
const batchSize = 50;
|
||||||
const qboInvoices = new Map();
|
const qboInvoices = new Map();
|
||||||
|
|
||||||
@@ -466,18 +439,59 @@ router.post('/sync-payments', async (req, res) => {
|
|||||||
const batch = openInvoices.slice(i, i + batchSize);
|
const batch = openInvoices.slice(i, i + batchSize);
|
||||||
const ids = batch.map(inv => `'${inv.qbo_id}'`).join(',');
|
const ids = batch.map(inv => `'${inv.qbo_id}'`).join(',');
|
||||||
const query = `SELECT Id, DocNumber, Balance, TotalAmt, LinkedTxn FROM Invoice WHERE Id IN (${ids})`;
|
const query = `SELECT Id, DocNumber, Balance, TotalAmt, LinkedTxn FROM Invoice WHERE Id IN (${ids})`;
|
||||||
|
|
||||||
const response = await makeQboApiCall({
|
const response = await makeQboApiCall({
|
||||||
url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(query)}`,
|
url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(query)}`,
|
||||||
method: 'GET'
|
method: 'GET'
|
||||||
});
|
});
|
||||||
const data = response.getJson ? response.getJson() : response.json;
|
const data = response.getJson ? response.getJson() : response.json;
|
||||||
const invoices = data.QueryResponse?.Invoice || [];
|
(data.QueryResponse?.Invoice || []).forEach(inv => qboInvoices.set(inv.Id, inv));
|
||||||
invoices.forEach(inv => qboInvoices.set(inv.Id, inv));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`🔍 QBO Sync: ${openInvoices.length} offene Invoices, ${qboInvoices.size} aus QBO geladen`);
|
console.log(`🔍 QBO Sync: ${openInvoices.length} invoices checked, ${qboInvoices.size} loaded from QBO`);
|
||||||
|
|
||||||
|
// ── Collect all unique Payment IDs that need to be fetched ────────
|
||||||
|
// Instead of fetching each payment one-by-one, collect all IDs first
|
||||||
|
// then batch-fetch them in one query per 30 IDs
|
||||||
|
const paymentIdsToFetch = new Set();
|
||||||
|
for (const localInv of openInvoices) {
|
||||||
|
const qboInv = qboInvoices.get(localInv.qbo_id);
|
||||||
|
if (!qboInv || parseFloat(qboInv.Balance) !== 0) continue;
|
||||||
|
if (qboInv.LinkedTxn) {
|
||||||
|
for (const txn of qboInv.LinkedTxn) {
|
||||||
|
if (txn.TxnType === 'Payment') paymentIdsToFetch.add(txn.TxnId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch-fetch all payments in groups of 30
|
||||||
|
const UNDEPOSITED_FUNDS_ID = '221';
|
||||||
|
const paymentDepositMap = new Map(); // paymentId -> isDeposited (bool)
|
||||||
|
|
||||||
|
if (paymentIdsToFetch.size > 0) {
|
||||||
|
console.log(`💳 Fetching ${paymentIdsToFetch.size} unique payment(s) from QBO...`);
|
||||||
|
const pmIds = [...paymentIdsToFetch];
|
||||||
|
const pmBatchSize = 30;
|
||||||
|
for (let i = 0; i < pmIds.length; i += pmBatchSize) {
|
||||||
|
const batch = pmIds.slice(i, i + pmBatchSize);
|
||||||
|
const pmQuery = `SELECT Id, DepositToAccountRef FROM Payment WHERE Id IN (${batch.map(id => `'${id}'`).join(',')})`;
|
||||||
|
try {
|
||||||
|
const pmRes = await makeQboApiCall({
|
||||||
|
url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(pmQuery)}`,
|
||||||
|
method: 'GET'
|
||||||
|
});
|
||||||
|
const pmData = pmRes.getJson ? pmRes.getJson() : pmRes.json;
|
||||||
|
for (const pm of (pmData.QueryResponse?.Payment || [])) {
|
||||||
|
const isDeposited = pm.DepositToAccountRef?.value !== UNDEPOSITED_FUNDS_ID;
|
||||||
|
paymentDepositMap.set(pm.Id, isDeposited);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`⚠️ Payment batch fetch error (non-fatal): ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`💳 Payment deposit status loaded for ${paymentDepositMap.size} payment(s)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Process invoices ───────────────────────────────────────────────
|
||||||
let updated = 0;
|
let updated = 0;
|
||||||
let newPayments = 0;
|
let newPayments = 0;
|
||||||
|
|
||||||
@@ -488,34 +502,22 @@ router.post('/sync-payments', async (req, res) => {
|
|||||||
if (!qboInv) continue;
|
if (!qboInv) continue;
|
||||||
|
|
||||||
const qboBalance = parseFloat(qboInv.Balance) || 0;
|
const qboBalance = parseFloat(qboInv.Balance) || 0;
|
||||||
const qboTotal = parseFloat(qboInv.TotalAmt) || 0;
|
const qboTotal = parseFloat(qboInv.TotalAmt) || 0;
|
||||||
const localPaid = parseFloat(localInv.local_paid) || 0;
|
const localPaid = parseFloat(localInv.local_paid) || 0;
|
||||||
|
|
||||||
if (qboBalance === 0 && qboTotal > 0) {
|
if (qboBalance === 0 && qboTotal > 0) {
|
||||||
const UNDEPOSITED_FUNDS_ID = '221';
|
// Determine Paid vs Deposited using pre-fetched map
|
||||||
let status = 'Paid';
|
let status = 'Paid';
|
||||||
|
|
||||||
if (qboInv.LinkedTxn) {
|
if (qboInv.LinkedTxn) {
|
||||||
for (const txn of qboInv.LinkedTxn) {
|
for (const txn of qboInv.LinkedTxn) {
|
||||||
if (txn.TxnType === 'Payment') {
|
if (txn.TxnType === 'Payment' && paymentDepositMap.get(txn.TxnId) === true) {
|
||||||
try {
|
status = 'Deposited';
|
||||||
const pmRes = await makeQboApiCall({
|
break;
|
||||||
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 (!localInv.paid_date || localInv.payment_status !== status) {
|
||||||
if (needsUpdate) {
|
|
||||||
await dbClient.query(
|
await dbClient.query(
|
||||||
`UPDATE invoices SET
|
`UPDATE invoices SET
|
||||||
paid_date = COALESCE(paid_date, CURRENT_DATE),
|
paid_date = COALESCE(paid_date, CURRENT_DATE),
|
||||||
@@ -532,7 +534,9 @@ router.post('/sync-payments', async (req, res) => {
|
|||||||
if (diff > 0.01) {
|
if (diff > 0.01) {
|
||||||
const payResult = await dbClient.query(
|
const payResult = await dbClient.query(
|
||||||
`INSERT INTO payments (payment_date, payment_method, total_amount, customer_id, notes, created_at)
|
`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)
|
VALUES (CURRENT_DATE, 'Synced from QBO', $1,
|
||||||
|
(SELECT customer_id FROM invoices WHERE id = $2),
|
||||||
|
'Synced from QBO', CURRENT_TIMESTAMP)
|
||||||
RETURNING id`,
|
RETURNING id`,
|
||||||
[diff, localInv.id]
|
[diff, localInv.id]
|
||||||
);
|
);
|
||||||
@@ -541,15 +545,14 @@ router.post('/sync-payments', async (req, res) => {
|
|||||||
[payResult.rows[0].id, localInv.id, diff]
|
[payResult.rows[0].id, localInv.id, diff]
|
||||||
);
|
);
|
||||||
newPayments++;
|
newPayments++;
|
||||||
console.log(` 💰 #${localInv.invoice_number}: +$${diff.toFixed(2)} payment synced`);
|
console.log(` 💰 #${localInv.invoice_number}: +$${diff.toFixed(2)} synced`);
|
||||||
}
|
}
|
||||||
|
|
||||||
} else if (qboBalance > 0 && qboBalance < qboTotal) {
|
} else if (qboBalance > 0 && qboBalance < qboTotal) {
|
||||||
const qboPaid = qboTotal - qboBalance;
|
const qboPaid = qboTotal - qboBalance;
|
||||||
const diff = qboPaid - localPaid;
|
const diff = qboPaid - localPaid;
|
||||||
|
|
||||||
const needsUpdate = localInv.payment_status !== 'Partial';
|
if (localInv.payment_status !== 'Partial') {
|
||||||
if (needsUpdate) {
|
|
||||||
await dbClient.query(
|
await dbClient.query(
|
||||||
'UPDATE invoices SET payment_status = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
|
'UPDATE invoices SET payment_status = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
|
||||||
['Partial', localInv.id]
|
['Partial', localInv.id]
|
||||||
@@ -560,7 +563,9 @@ router.post('/sync-payments', async (req, res) => {
|
|||||||
if (diff > 0.01) {
|
if (diff > 0.01) {
|
||||||
const payResult = await dbClient.query(
|
const payResult = await dbClient.query(
|
||||||
`INSERT INTO payments (payment_date, payment_method, total_amount, customer_id, notes, created_at)
|
`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)
|
VALUES (CURRENT_DATE, 'Synced from QBO', $1,
|
||||||
|
(SELECT customer_id FROM invoices WHERE id = $2),
|
||||||
|
'Synced from QBO', CURRENT_TIMESTAMP)
|
||||||
RETURNING id`,
|
RETURNING id`,
|
||||||
[diff, localInv.id]
|
[diff, localInv.id]
|
||||||
);
|
);
|
||||||
@@ -569,7 +574,7 @@ router.post('/sync-payments', async (req, res) => {
|
|||||||
[payResult.rows[0].id, localInv.id, diff]
|
[payResult.rows[0].id, localInv.id, diff]
|
||||||
);
|
);
|
||||||
newPayments++;
|
newPayments++;
|
||||||
console.log(` 📎 #${localInv.invoice_number}: Partial +$${diff.toFixed(2)} ($${qboPaid.toFixed(2)} of $${qboTotal.toFixed(2)})`);
|
console.log(` 📎 #${localInv.invoice_number}: Partial +$${diff.toFixed(2)} ($${qboPaid.toFixed(2)} / $${qboTotal.toFixed(2)})`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -581,7 +586,7 @@ router.post('/sync-payments', async (req, res) => {
|
|||||||
|
|
||||||
await dbClient.query('COMMIT');
|
await dbClient.query('COMMIT');
|
||||||
|
|
||||||
console.log(`✅ Sync abgeschlossen: ${updated} aktualisiert, ${newPayments} neue Payments`);
|
console.log(`✅ Sync complete: ${updated} updated, ${newPayments} new payments`);
|
||||||
res.json({
|
res.json({
|
||||||
synced: updated,
|
synced: updated,
|
||||||
new_payments: newPayments,
|
new_payments: newPayments,
|
||||||
@@ -591,11 +596,11 @@ router.post('/sync-payments', async (req, res) => {
|
|||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await dbClient.query('ROLLBACK').catch(() => {});
|
await dbClient.query('ROLLBACK').catch(() => {});
|
||||||
console.error('❌ Sync Error:', error);
|
console.log(`❌ Sync Error: ${error.message}`);
|
||||||
res.status(500).json({ error: 'Sync failed: ' + error.message });
|
res.status(500).json({ error: 'Sync failed: ' + error.message });
|
||||||
} finally {
|
} finally {
|
||||||
dbClient.release();
|
dbClient.release();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
135
src/services/email-service.js
Normal file
135
src/services/email-service.js
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
// 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, stripePaymentUrl) {
|
||||||
|
const formattedText = customText || '';
|
||||||
|
|
||||||
|
// Stripe Pay Button — only if payment link exists
|
||||||
|
let paymentButtonMjml = '';
|
||||||
|
if (stripePaymentUrl) {
|
||||||
|
paymentButtonMjml = `
|
||||||
|
<mj-section background-color="#ffffff" padding="0 30px">
|
||||||
|
<mj-column>
|
||||||
|
<mj-button
|
||||||
|
background-color="#635bff"
|
||||||
|
color="white"
|
||||||
|
border-radius="6px"
|
||||||
|
href="${stripePaymentUrl}"
|
||||||
|
font-weight="600"
|
||||||
|
font-size="16px"
|
||||||
|
padding="25px 0 10px 0"
|
||||||
|
inner-padding="14px 30px"
|
||||||
|
width="100%">
|
||||||
|
Pay Online — Credit Card or ACH
|
||||||
|
</mj-button>
|
||||||
|
<mj-text font-size="12px" color="#94a3b8" align="center" padding="0 0 20px 0">
|
||||||
|
ACH payments incur lower processing fees. Secure payment powered by Stripe.
|
||||||
|
</mj-text>
|
||||||
|
</mj-column>
|
||||||
|
</mj-section>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
</mj-column>
|
||||||
|
</mj-section>
|
||||||
|
|
||||||
|
${paymentButtonMjml}
|
||||||
|
|
||||||
|
<mj-section background-color="#ffffff" padding="0 30px 30px 30px" border-radius="0 0 8px 8px">
|
||||||
|
<mj-column>
|
||||||
|
<mj-divider border-color="#e2e8f0" border-width="1px" padding-top="10px" 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>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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, stripePaymentUrl, pdfBuffer) {
|
||||||
|
const htmlContent = generateInvoiceEmailHtml(invoice, customText, stripePaymentUrl);
|
||||||
|
|
||||||
|
const mailOptions = {
|
||||||
|
from: '"Bay Area Affiliates Inc. Accounting" <accounting@bayarea-cc.com>',
|
||||||
|
to: recipientEmail,
|
||||||
|
bcc: 'accounting@bayarea-cc.com',
|
||||||
|
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 };
|
||||||
@@ -33,16 +33,26 @@ async function generatePdfFromHtml(html, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const page = await browser.newPage();
|
const page = await browser.newPage();
|
||||||
await page.setContent(html, { waitUntil: 'networkidle0', timeout: 60000 });
|
|
||||||
|
|
||||||
const pdf = await page.pdf({
|
try {
|
||||||
format,
|
// Erhöhtes Timeout: 5 Sekunden sind unter Docker manchmal zu wenig.
|
||||||
printBackground,
|
// Besser auf 15 Sekunden (15000) setzen, um den Fehler von vornherein zu vermeiden.
|
||||||
margin
|
await page.setContent(html, { waitUntil: 'load', timeout: 15000 });
|
||||||
});
|
|
||||||
|
const pdf = await page.pdf({
|
||||||
await page.close();
|
format,
|
||||||
return pdf;
|
printBackground,
|
||||||
|
margin
|
||||||
|
});
|
||||||
|
|
||||||
|
return pdf;
|
||||||
|
} finally {
|
||||||
|
// Dieser Block wird IMMER ausgeführt, selbst wenn oben ein Fehler fliegt.
|
||||||
|
// Der Tab wird also zu 100% wieder geschlossen.
|
||||||
|
if (page) {
|
||||||
|
await page.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -108,17 +118,26 @@ function renderInvoiceItems(items, invoice = null) {
|
|||||||
<td class="total-amount" style="font-size: 16px;">$${formatMoney(total)}</td>
|
<td class="total-amount" style="font-size: 16px;">$${formatMoney(total)}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
|
|
||||||
|
// Add downpayment/balance if partial
|
||||||
// Add downpayment/balance if partial
|
// Add downpayment/balance if partial
|
||||||
if (amountPaid > 0) {
|
if (amountPaid > 0) {
|
||||||
|
const isFullyPaid = balanceDue <= 0.01; // allow for rounding
|
||||||
|
const paymentLabel = isFullyPaid ? 'Payment:' : 'Downpayment:';
|
||||||
|
|
||||||
itemsHTML += `
|
itemsHTML += `
|
||||||
<tr class="footer-row">
|
<tr class="footer-row">
|
||||||
<td colspan="3" class="total-label" style="color: #059669;">Downpayment:</td>
|
<td colspan="3" class="total-label" style="color: #059669;">${paymentLabel}</td>
|
||||||
<td class="total-amount" style="color: #059669;">-$${formatMoney(amountPaid)}</td>
|
<td class="total-amount" style="color: #059669;">-$${formatMoney(amountPaid)}</td>
|
||||||
</tr>
|
</tr>`;
|
||||||
|
|
||||||
|
// Only show BALANCE DUE row if there's actually a remaining balance
|
||||||
|
if (!isFullyPaid) {
|
||||||
|
itemsHTML += `
|
||||||
<tr class="footer-row">
|
<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 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>
|
<td class="total-amount" style="font-weight: bold; font-size: 16px; border-top: 2px solid #333; padding-top: 8px;">$${formatMoney(balanceDue)}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Thank you message
|
// Thank you message
|
||||||
|
|||||||
@@ -1,17 +1,14 @@
|
|||||||
|
// src/services/qbo-service.js
|
||||||
/**
|
/**
|
||||||
* QuickBooks Online Service
|
* QuickBooks Online Service
|
||||||
* Handles QBO API interactions
|
* Handles QBO API interactions
|
||||||
*/
|
*/
|
||||||
const { getOAuthClient, getQboBaseUrl } = require('../config/qbo');
|
const { getOAuthClient, getQboBaseUrl, makeQboApiCall } = require('../config/qbo'); // Sauberer Import
|
||||||
const { makeQboApiCall } = require('../../qbo_helper');
|
|
||||||
|
|
||||||
// QBO Item IDs
|
// QBO Item IDs
|
||||||
const QBO_LABOR_ID = '5';
|
const QBO_LABOR_ID = '5';
|
||||||
const QBO_PARTS_ID = '9';
|
const QBO_PARTS_ID = '9';
|
||||||
|
|
||||||
/**
|
|
||||||
* Get OAuth client and company ID
|
|
||||||
*/
|
|
||||||
function getClientInfo() {
|
function getClientInfo() {
|
||||||
const oauthClient = getOAuthClient();
|
const oauthClient = getOAuthClient();
|
||||||
const companyId = oauthClient.getToken().realmId;
|
const companyId = oauthClient.getToken().realmId;
|
||||||
@@ -22,202 +19,198 @@ function getClientInfo() {
|
|||||||
/**
|
/**
|
||||||
* Export invoice to QBO
|
* Export invoice to QBO
|
||||||
*/
|
*/
|
||||||
async function exportInvoiceToQbo(invoiceId, pool) {
|
async function exportInvoiceToQbo(invoiceId, dbClient) { // <-- Nutzt jetzt dbClient statt pool
|
||||||
const client = await pool.connect();
|
const invoiceRes = await dbClient.query(`
|
||||||
try {
|
SELECT i.*, c.qbo_id as customer_qbo_id, c.name as customer_name, c.email
|
||||||
const invoiceRes = await client.query(`
|
FROM invoices i
|
||||||
SELECT i.*, c.qbo_id as customer_qbo_id, c.name as customer_name, c.email
|
LEFT JOIN customers c ON i.customer_id = c.id
|
||||||
FROM invoices i
|
WHERE i.id = $1
|
||||||
LEFT JOIN customers c ON i.customer_id = c.id
|
`, [invoiceId]);
|
||||||
WHERE i.id = $1
|
|
||||||
`, [invoiceId]);
|
|
||||||
|
|
||||||
const invoice = invoiceRes.rows[0];
|
const invoice = invoiceRes.rows[0];
|
||||||
if (!invoice.customer_qbo_id) return { skipped: true, reason: 'Customer not in QBO' };
|
if (!invoice.customer_qbo_id) return { skipped: true, reason: 'Customer not in QBO' };
|
||||||
|
|
||||||
const itemsRes = await client.query('SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY item_order', [invoiceId]);
|
const itemsRes = await dbClient.query('SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY item_order', [invoiceId]);
|
||||||
const items = itemsRes.rows;
|
const items = itemsRes.rows;
|
||||||
|
|
||||||
const { companyId, baseUrl } = getClientInfo();
|
const { companyId, baseUrl } = getClientInfo();
|
||||||
|
|
||||||
// Get next DocNumber
|
// Get next DocNumber
|
||||||
const maxNumResult = await client.query(`
|
const maxNumResult = await dbClient.query(`
|
||||||
SELECT GREATEST(
|
SELECT GREATEST(
|
||||||
COALESCE((SELECT MAX(CAST(qbo_doc_number AS INTEGER)) FROM invoices WHERE qbo_doc_number ~ '^[0-9]+$'), 0),
|
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)
|
COALESCE((SELECT MAX(CAST(invoice_number AS INTEGER)) FROM invoices WHERE invoice_number ~ '^[0-9]+$'), 0)
|
||||||
) as max_num
|
) as max_num
|
||||||
`);
|
`);
|
||||||
let nextDocNumber = (parseInt(maxNumResult.rows[0].max_num) + 1).toString();
|
let nextDocNumber = (parseInt(maxNumResult.rows[0].max_num) + 1).toString();
|
||||||
|
|
||||||
// Build line items
|
const lineItems = items.map(item => {
|
||||||
const lineItems = items.map(item => {
|
const parseNum = (val) => {
|
||||||
const parseNum = (val) => {
|
if (val === null || val === undefined) return 0;
|
||||||
if (val === null || val === undefined) return 0;
|
if (typeof val === 'number') return val;
|
||||||
if (typeof val === 'number') return val;
|
return parseFloat(String(val).replace(/[^0-9.\-]/g, '')) || 0;
|
||||||
return parseFloat(String(val).replace(/[^0-9.\-]/g, '')) || 0;
|
};
|
||||||
};
|
const rate = parseNum(item.rate);
|
||||||
const rate = parseNum(item.rate);
|
const qty = parseNum(item.quantity) || 1;
|
||||||
const qty = parseNum(item.quantity) || 1;
|
const amount = rate * qty;
|
||||||
const amount = rate * qty;
|
const itemRefId = item.qbo_item_id || QBO_PARTS_ID;
|
||||||
const itemRefId = item.qbo_item_id || QBO_PARTS_ID;
|
const itemRefName = itemRefId == QBO_LABOR_ID ? "Labor:Labor" : "Parts:Parts";
|
||||||
const itemRefName = itemRefId == QBO_LABOR_ID ? "Labor:Labor" : "Parts:Parts";
|
|
||||||
return {
|
return {
|
||||||
"DetailType": "SalesItemLineDetail",
|
"DetailType": "SalesItemLineDetail",
|
||||||
"Amount": amount,
|
"Amount": amount,
|
||||||
"Description": item.description,
|
"Description": item.description,
|
||||||
"SalesItemLineDetail": {
|
"SalesItemLineDetail": {
|
||||||
"ItemRef": { "value": itemRefId, "name": itemRefName },
|
"ItemRef": { "value": itemRefId, "name": itemRefName },
|
||||||
"UnitPrice": rate,
|
"UnitPrice": rate,
|
||||||
"Qty": qty
|
"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 qboPayload = {
|
const data = response.getJson ? response.getJson() : response.json;
|
||||||
"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 || "" }
|
|
||||||
};
|
|
||||||
|
|
||||||
// Retry on duplicate
|
if (data.Fault?.Error?.[0]?.code === '6140') {
|
||||||
let qboInvoice = null;
|
console.log(` ⚠️ DocNumber ${qboPayload.DocNumber} exists, retrying...`);
|
||||||
for (let attempt = 0; attempt < 5; attempt++) {
|
qboPayload.DocNumber = (parseInt(qboPayload.DocNumber) + 1).toString();
|
||||||
console.log(`📤 QBO Export Invoice (DocNumber: ${qboPayload.DocNumber})...`);
|
continue;
|
||||||
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 (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;
|
||||||
|
|
||||||
if (!qboInvoice?.Id) throw new Error('Could not find free DocNumber after 5 attempts.');
|
throw new Error("QBO returned no ID: " + JSON.stringify(data).substring(0, 500));
|
||||||
|
|
||||||
await client.query(
|
|
||||||
'UPDATE invoices SET qbo_id = $1, qbo_sync_token = $2, qbo_doc_number = $3, invoice_number = $4 WHERE id = $5',
|
|
||||||
[qboInvoice.Id, qboInvoice.SyncToken, qboInvoice.DocNumber, qboInvoice.DocNumber, invoiceId]
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log(`✅ QBO Invoice created: ID ${qboInvoice.Id}, DocNumber ${qboInvoice.DocNumber}`);
|
|
||||||
return { success: true, qbo_id: qboInvoice.Id, qbo_doc_number: qboInvoice.DocNumber };
|
|
||||||
} finally {
|
|
||||||
client.release();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
* Sync invoice to QBO (update)
|
||||||
*/
|
*/
|
||||||
async function syncInvoiceToQbo(invoiceId, pool) {
|
async function syncInvoiceToQbo(invoiceId, dbClient) { // <-- Nutzt jetzt dbClient statt pool
|
||||||
const client = await pool.connect();
|
const invoiceRes = await dbClient.query(`
|
||||||
try {
|
SELECT i.*, c.qbo_id as customer_qbo_id
|
||||||
const invoiceRes = await client.query(`
|
FROM invoices i
|
||||||
SELECT i.*, c.qbo_id as customer_qbo_id
|
LEFT JOIN customers c ON i.customer_id = c.id
|
||||||
FROM invoices i
|
WHERE i.id = $1
|
||||||
LEFT JOIN customers c ON i.customer_id = c.id
|
`, [invoiceId]);
|
||||||
WHERE i.id = $1
|
|
||||||
`, [invoiceId]);
|
|
||||||
|
|
||||||
const invoice = invoiceRes.rows[0];
|
const invoice = invoiceRes.rows[0];
|
||||||
if (!invoice.qbo_id) return { skipped: true, reason: 'Not in QBO' };
|
if (!invoice.qbo_id) return { skipped: true, reason: 'Not in QBO' };
|
||||||
|
|
||||||
const itemsRes = await client.query('SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY item_order', [invoiceId]);
|
const itemsRes = await dbClient.query('SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY item_order', [invoiceId]);
|
||||||
const { companyId, baseUrl } = getClientInfo();
|
|
||||||
|
|
||||||
// Get current sync token
|
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 qboRes = await makeQboApiCall({
|
||||||
const parseNum = (val) => {
|
url: `${baseUrl}/v3/company/${companyId}/invoice/${invoice.qbo_id}`,
|
||||||
if (val === null || val === undefined) return 0;
|
method: 'GET'
|
||||||
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 {
|
const qboData = qboRes.getJson ? qboRes.getJson() : qboRes.json;
|
||||||
"DetailType": "SalesItemLineDetail",
|
const currentSyncToken = qboData.Invoice?.SyncToken;
|
||||||
"Amount": amount,
|
|
||||||
"Description": item.description,
|
|
||||||
"SalesItemLineDetail": {
|
|
||||||
"ItemRef": { "value": itemRefId, "name": itemRefName },
|
|
||||||
"UnitPrice": rate,
|
|
||||||
"Qty": parseFloat(item.quantity) || 1
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const updatePayload = {
|
if (currentSyncToken === undefined) throw new Error('Could not get SyncToken from QBO');
|
||||||
"Id": invoice.qbo_id,
|
|
||||||
"SyncToken": currentSyncToken,
|
const lineItems = itemsRes.rows.map(item => {
|
||||||
"sparse": true,
|
const parseNum = (val) => {
|
||||||
"Line": lineItems,
|
if (val === null || val === undefined) return 0;
|
||||||
"CustomerRef": { "value": invoice.customer_qbo_id },
|
if (typeof val === 'number') return val;
|
||||||
"TxnDate": invoice.invoice_date.toISOString().split('T')[0],
|
return parseFloat(String(val).replace(/[^0-9.\-]/g, '')) || 0;
|
||||||
"CustomerMemo": { "value": invoice.auth_code ? `Auth: ${invoice.auth_code}` : "" }
|
|
||||||
};
|
};
|
||||||
|
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";
|
||||||
|
|
||||||
console.log(`📤 QBO Sync Invoice ${invoice.qbo_id}...`);
|
return {
|
||||||
const updateRes = await makeQboApiCall({
|
"DetailType": "SalesItemLineDetail",
|
||||||
url: `${baseUrl}/v3/company/${companyId}/invoice`,
|
"Amount": amount,
|
||||||
method: 'POST',
|
"Description": item.description,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
"SalesItemLineDetail": {
|
||||||
body: JSON.stringify(updatePayload)
|
"ItemRef": { "value": itemRefId, "name": itemRefName },
|
||||||
});
|
"UnitPrice": rate,
|
||||||
|
"Qty": qty
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const updateData = updateRes.getJson ? updateRes.getJson() : updateRes.json;
|
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}` : "" }
|
||||||
|
};
|
||||||
|
|
||||||
if (updateData.Fault) {
|
console.log(`📤 QBO Sync Invoice ${invoice.qbo_id}...`);
|
||||||
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;
|
const updateRes = await makeQboApiCall({
|
||||||
if (!updated.Id) {
|
url: `${baseUrl}/v3/company/${companyId}/invoice`,
|
||||||
console.error(`❌ QBO unexpected response:`, JSON.stringify(updateData).substring(0, 500));
|
method: 'POST',
|
||||||
throw new Error('QBO update returned no ID');
|
headers: { 'Content-Type': 'application/json' },
|
||||||
}
|
body: JSON.stringify(updatePayload)
|
||||||
|
});
|
||||||
|
|
||||||
await client.query(
|
const updateData = updateRes.getJson ? updateRes.getJson() : updateRes.json;
|
||||||
'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})`);
|
if (updateData.Fault) {
|
||||||
return { success: true, sync_token: updated.SyncToken };
|
const errMsg = updateData.Fault.Error?.map(e => `${e.code}: ${e.Message} - ${e.Detail}`).join('; ') || JSON.stringify(updateData.Fault);
|
||||||
} finally {
|
console.error(`❌ QBO Sync Fault:`, errMsg);
|
||||||
client.release();
|
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 = {
|
module.exports = {
|
||||||
@@ -226,4 +219,4 @@ module.exports = {
|
|||||||
getClientInfo,
|
getClientInfo,
|
||||||
exportInvoiceToQbo,
|
exportInvoiceToQbo,
|
||||||
syncInvoiceToQbo
|
syncInvoiceToQbo
|
||||||
};
|
};
|
||||||
174
src/services/recurring-service.js
Normal file
174
src/services/recurring-service.js
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
/**
|
||||||
|
* 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
|
||||||
|
};
|
||||||
238
src/services/stripe-poll-service.js
Normal file
238
src/services/stripe-poll-service.js
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
// src/services/stripe-poll-service.js
|
||||||
|
/**
|
||||||
|
* Stripe Payment Polling Service
|
||||||
|
* Periodically checks all open Stripe payment links for completed payments.
|
||||||
|
*
|
||||||
|
* Similar pattern to recurring-service.js:
|
||||||
|
* - Runs every 4 hours (and once on startup after 2 min delay)
|
||||||
|
* - Finds invoices with active Stripe links that aren't paid yet
|
||||||
|
* - Checks each via Stripe API
|
||||||
|
* - Records payment + QBO booking if paid
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { pool } = require('../config/database');
|
||||||
|
const { checkPaymentStatus, deactivatePaymentLink, calculateStripeFee } = require('./stripe-service');
|
||||||
|
const { getOAuthClient, getQboBaseUrl, makeQboApiCall } = require('../config/qbo');
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 4 * 60 * 60 * 1000; // 4 hours
|
||||||
|
const STARTUP_DELAY_MS = 2 * 60 * 1000; // 2 minutes after boot
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check all invoices with open Stripe payment links
|
||||||
|
*/
|
||||||
|
async function pollStripePayments() {
|
||||||
|
console.log('💳 [STRIPE-POLL] Checking for completed Stripe payments...');
|
||||||
|
|
||||||
|
const dbClient = await pool.connect();
|
||||||
|
try {
|
||||||
|
// Find all invoices with active (unpaid) Stripe links
|
||||||
|
const result = await dbClient.query(`
|
||||||
|
SELECT i.*, c.name as customer_name, c.qbo_id as customer_qbo_id,
|
||||||
|
COALESCE((SELECT SUM(pi.amount) FROM payment_invoices pi WHERE pi.invoice_id = i.id), 0) as amount_paid
|
||||||
|
FROM invoices i
|
||||||
|
LEFT JOIN customers c ON i.customer_id = c.id
|
||||||
|
WHERE i.stripe_payment_link_id IS NOT NULL
|
||||||
|
AND i.stripe_payment_status NOT IN ('paid')
|
||||||
|
AND i.paid_date IS NULL
|
||||||
|
`);
|
||||||
|
|
||||||
|
const openInvoices = result.rows;
|
||||||
|
|
||||||
|
if (openInvoices.length === 0) {
|
||||||
|
console.log('💳 [STRIPE-POLL] No open Stripe payment links to check.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`💳 [STRIPE-POLL] Checking ${openInvoices.length} invoice(s)...`);
|
||||||
|
|
||||||
|
let paidCount = 0;
|
||||||
|
let processingCount = 0;
|
||||||
|
let errorCount = 0;
|
||||||
|
|
||||||
|
for (const invoice of openInvoices) {
|
||||||
|
try {
|
||||||
|
const status = await checkPaymentStatus(invoice.stripe_payment_link_id);
|
||||||
|
|
||||||
|
// Update status if changed
|
||||||
|
if (status.status !== invoice.stripe_payment_status) {
|
||||||
|
await dbClient.query(
|
||||||
|
'UPDATE invoices SET stripe_payment_status = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
|
||||||
|
[status.status, invoice.id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status.status === 'processing') {
|
||||||
|
processingCount++;
|
||||||
|
console.log(` ⏳ #${invoice.invoice_number}: ACH processing`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!status.paid) continue;
|
||||||
|
|
||||||
|
// === PAID — process it ===
|
||||||
|
const amountReceived = status.details.amountReceived;
|
||||||
|
const paymentMethod = status.details.paymentMethod;
|
||||||
|
const stripeFee = status.details.stripeFee;
|
||||||
|
const methodLabel = paymentMethod === 'us_bank_account' ? 'ACH' : 'Credit Card';
|
||||||
|
|
||||||
|
invoice.amount_paid = parseFloat(invoice.amount_paid) || 0;
|
||||||
|
|
||||||
|
await dbClient.query('BEGIN');
|
||||||
|
|
||||||
|
// 1. Record local payment
|
||||||
|
const payResult = await dbClient.query(
|
||||||
|
`INSERT INTO payments (payment_date, payment_method, total_amount, customer_id, reference_number, notes, created_at)
|
||||||
|
VALUES (CURRENT_DATE, $1, $2, $3, $4, $5, CURRENT_TIMESTAMP)
|
||||||
|
RETURNING id`,
|
||||||
|
[
|
||||||
|
`Stripe ${methodLabel}`,
|
||||||
|
amountReceived,
|
||||||
|
invoice.customer_id,
|
||||||
|
status.details.paymentIntentId || status.details.sessionId,
|
||||||
|
`Stripe ${methodLabel} — Fee: $${stripeFee.toFixed(2)} (auto-polled)`
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
await dbClient.query(
|
||||||
|
'INSERT INTO payment_invoices (payment_id, invoice_id, amount) VALUES ($1, $2, $3)',
|
||||||
|
[payResult.rows[0].id, invoice.id, amountReceived]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Check if fully paid
|
||||||
|
const newTotalPaid = invoice.amount_paid + amountReceived;
|
||||||
|
const invoiceTotal = parseFloat(invoice.total) || 0;
|
||||||
|
const fullyPaid = newTotalPaid >= (invoiceTotal - 0.01);
|
||||||
|
|
||||||
|
await dbClient.query(
|
||||||
|
`UPDATE invoices SET
|
||||||
|
stripe_payment_status = 'paid',
|
||||||
|
paid_date = ${fullyPaid ? 'COALESCE(paid_date, CURRENT_DATE)' : 'paid_date'},
|
||||||
|
payment_status = $1,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $2`,
|
||||||
|
[fullyPaid ? 'Stripe' : 'Partial', invoice.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Deactivate link
|
||||||
|
await deactivatePaymentLink(invoice.stripe_payment_link_id);
|
||||||
|
|
||||||
|
// 4. QBO booking
|
||||||
|
if (invoice.qbo_id && invoice.customer_qbo_id) {
|
||||||
|
try {
|
||||||
|
await recordStripePaymentInQbo(invoice, amountReceived, methodLabel, stripeFee,
|
||||||
|
status.details.paymentIntentId || '');
|
||||||
|
} catch (qboErr) {
|
||||||
|
console.error(` ⚠️ QBO booking failed for #${invoice.invoice_number}:`, qboErr.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await dbClient.query('COMMIT');
|
||||||
|
paidCount++;
|
||||||
|
console.log(` ✅ #${invoice.invoice_number}: $${amountReceived.toFixed(2)} via Stripe ${methodLabel} (Fee: $${stripeFee.toFixed(2)})`);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
await dbClient.query('ROLLBACK').catch(() => {});
|
||||||
|
errorCount++;
|
||||||
|
console.error(` ❌ #${invoice.invoice_number}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`💳 [STRIPE-POLL] Done: ${paidCount} paid, ${processingCount} processing, ${errorCount} errors (of ${openInvoices.length} checked)`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('💳 [STRIPE-POLL] Fatal error:', error.message);
|
||||||
|
} finally {
|
||||||
|
dbClient.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record Stripe payment in QBO (same logic as in invoices.js check-payment route)
|
||||||
|
*/
|
||||||
|
async function recordStripePaymentInQbo(invoice, amount, methodLabel, stripeFee, reference) {
|
||||||
|
const oauthClient = getOAuthClient();
|
||||||
|
const companyId = oauthClient.getToken().realmId;
|
||||||
|
const baseUrl = getQboBaseUrl();
|
||||||
|
|
||||||
|
// 1. QBO Payment
|
||||||
|
const paymentPayload = {
|
||||||
|
CustomerRef: { value: invoice.customer_qbo_id },
|
||||||
|
TotalAmt: amount,
|
||||||
|
TxnDate: new Date().toISOString().split('T')[0],
|
||||||
|
PaymentRefNum: reference ? reference.substring(0, 21) : 'Stripe',
|
||||||
|
PrivateNote: `Stripe ${methodLabel} — auto-polled`,
|
||||||
|
Line: [{
|
||||||
|
Amount: amount,
|
||||||
|
LinkedTxn: [{
|
||||||
|
TxnId: invoice.qbo_id,
|
||||||
|
TxnType: 'Invoice'
|
||||||
|
}]
|
||||||
|
}],
|
||||||
|
DepositToAccountRef: { value: '221' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const paymentRes = await makeQboApiCall({
|
||||||
|
url: `${baseUrl}/v3/company/${companyId}/payment`,
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(paymentPayload)
|
||||||
|
});
|
||||||
|
|
||||||
|
const paymentData = paymentRes.getJson ? paymentRes.getJson() : paymentRes.json;
|
||||||
|
if (paymentData.Fault) {
|
||||||
|
const errMsg = paymentData.Fault.Error?.map(e => `${e.Message}: ${e.Detail}`).join('; ');
|
||||||
|
throw new Error('QBO Payment failed: ' + errMsg);
|
||||||
|
}
|
||||||
|
console.log(` 📗 QBO Payment: ID ${paymentData.Payment?.Id}`);
|
||||||
|
|
||||||
|
// 2. QBO Expense for fee
|
||||||
|
if (stripeFee > 0) {
|
||||||
|
const expensePayload = {
|
||||||
|
AccountRef: { value: '244', name: 'PlainsCapital Bank' },
|
||||||
|
TxnDate: new Date().toISOString().split('T')[0],
|
||||||
|
PaymentType: 'Check',
|
||||||
|
PrivateNote: `Stripe fee for Invoice #${invoice.invoice_number} (${methodLabel}) — auto-polled`,
|
||||||
|
Line: [{
|
||||||
|
DetailType: 'AccountBasedExpenseLineDetail',
|
||||||
|
Amount: stripeFee,
|
||||||
|
AccountBasedExpenseLineDetail: {
|
||||||
|
AccountRef: { value: '1150040001', name: 'Payment Processing Fees' }
|
||||||
|
},
|
||||||
|
Description: `Stripe ${methodLabel} fee — Invoice #${invoice.invoice_number}`
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
|
||||||
|
const expenseRes = await makeQboApiCall({
|
||||||
|
url: `${baseUrl}/v3/company/${companyId}/purchase`,
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(expensePayload)
|
||||||
|
});
|
||||||
|
|
||||||
|
const expenseData = expenseRes.getJson ? expenseRes.getJson() : expenseRes.json;
|
||||||
|
if (expenseData.Fault) {
|
||||||
|
console.error(` ⚠️ QBO Fee booking failed:`, JSON.stringify(expenseData.Fault));
|
||||||
|
} else {
|
||||||
|
console.log(` 📗 QBO Fee: ID ${expenseData.Purchase?.Id} ($${stripeFee.toFixed(2)})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start the polling scheduler
|
||||||
|
*/
|
||||||
|
function startStripePolling() {
|
||||||
|
// First check after startup delay
|
||||||
|
setTimeout(() => {
|
||||||
|
pollStripePayments();
|
||||||
|
}, STARTUP_DELAY_MS);
|
||||||
|
|
||||||
|
// Then every 4 hours
|
||||||
|
setInterval(() => {
|
||||||
|
pollStripePayments();
|
||||||
|
}, POLL_INTERVAL_MS);
|
||||||
|
|
||||||
|
console.log(`💳 [STRIPE-POLL] Scheduler started (every ${POLL_INTERVAL_MS / 3600000}h, first check in ${STARTUP_DELAY_MS / 1000}s)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { startStripePolling, pollStripePayments };
|
||||||
173
src/services/stripe-service.js
Normal file
173
src/services/stripe-service.js
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
// src/services/stripe-service.js
|
||||||
|
/**
|
||||||
|
* Stripe Payment Links Service
|
||||||
|
* Creates payment links for invoices, checks payment status via API polling.
|
||||||
|
*
|
||||||
|
* No webhooks needed — the app is not internet-facing.
|
||||||
|
* Status is checked on-demand via checkPaymentStatus().
|
||||||
|
*/
|
||||||
|
|
||||||
|
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Stripe Payment Link for an invoice.
|
||||||
|
*
|
||||||
|
* @param {object} invoice - Invoice record from DB
|
||||||
|
* @param {number} invoice.id
|
||||||
|
* @param {string} invoice.invoice_number
|
||||||
|
* @param {number} invoice.total - Total in dollars (e.g. 194.85)
|
||||||
|
* @param {number} invoice.balance - Remaining balance (total - amount_paid)
|
||||||
|
* @param {string} [invoice.customer_name]
|
||||||
|
* @returns {object} { paymentLinkId, paymentLinkUrl }
|
||||||
|
*/
|
||||||
|
async function createPaymentLink(invoice) {
|
||||||
|
const amountDue = parseFloat(invoice.balance ?? invoice.total);
|
||||||
|
if (!amountDue || amountDue <= 0) {
|
||||||
|
throw new Error('Invoice has no balance due.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const unitAmount = Math.round(amountDue * 100); // Convert dollars to cents
|
||||||
|
const invoiceLabel = `Invoice #${invoice.invoice_number || invoice.id}`;
|
||||||
|
|
||||||
|
console.log(`💳 Creating Stripe Payment Link for ${invoiceLabel} — $${amountDue.toFixed(2)}...`);
|
||||||
|
|
||||||
|
const paymentLink = await stripe.paymentLinks.create({
|
||||||
|
line_items: [{
|
||||||
|
price_data: {
|
||||||
|
currency: 'usd',
|
||||||
|
product_data: {
|
||||||
|
name: invoiceLabel,
|
||||||
|
description: invoice.customer_name
|
||||||
|
? `Bay Area Affiliates, Inc. — ${invoice.customer_name}`
|
||||||
|
: 'Bay Area Affiliates, Inc.'
|
||||||
|
},
|
||||||
|
unit_amount: unitAmount,
|
||||||
|
},
|
||||||
|
quantity: 1,
|
||||||
|
}],
|
||||||
|
metadata: {
|
||||||
|
invoice_id: String(invoice.id),
|
||||||
|
invoice_number: String(invoice.invoice_number || ''),
|
||||||
|
source: 'quote-invoice-system'
|
||||||
|
},
|
||||||
|
payment_method_types: ['card', 'us_bank_account'],
|
||||||
|
// After payment, show a simple confirmation
|
||||||
|
after_completion: {
|
||||||
|
type: 'hosted_confirmation',
|
||||||
|
hosted_confirmation: {
|
||||||
|
custom_message: `Thank you! Your payment for ${invoiceLabel} has been received. Bay Area Affiliates, Inc. will send a confirmation.`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`✅ Stripe Payment Link created: ${paymentLink.url}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
paymentLinkId: paymentLink.id,
|
||||||
|
paymentLinkUrl: paymentLink.url
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check payment status for a Stripe Payment Link.
|
||||||
|
* Polls completed Checkout Sessions associated with the payment link.
|
||||||
|
*
|
||||||
|
* @param {string} paymentLinkId - Stripe Payment Link ID (plink_xxx)
|
||||||
|
* @returns {object} { paid, status, details }
|
||||||
|
*/
|
||||||
|
async function checkPaymentStatus(paymentLinkId) {
|
||||||
|
if (!paymentLinkId) {
|
||||||
|
return { paid: false, status: 'no_link', details: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`🔍 Checking Stripe payment status for ${paymentLinkId}...`);
|
||||||
|
|
||||||
|
// List checkout sessions created via this payment link
|
||||||
|
const sessions = await stripe.checkout.sessions.list({
|
||||||
|
payment_link: paymentLinkId,
|
||||||
|
limit: 10,
|
||||||
|
expand: ['data.payment_intent']
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find a completed/paid session
|
||||||
|
const paidSession = sessions.data.find(s => s.payment_status === 'paid');
|
||||||
|
|
||||||
|
if (paidSession) {
|
||||||
|
const pi = paidSession.payment_intent;
|
||||||
|
const paymentMethod = pi?.payment_method_types?.[0] || 'unknown';
|
||||||
|
const amountReceived = (pi?.amount_received || 0) / 100;
|
||||||
|
const stripeFee = calculateStripeFee(amountReceived, paymentMethod);
|
||||||
|
|
||||||
|
console.log(`✅ Payment found! $${amountReceived.toFixed(2)} via ${paymentMethod}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
paid: true,
|
||||||
|
status: 'paid',
|
||||||
|
details: {
|
||||||
|
sessionId: paidSession.id,
|
||||||
|
paymentIntentId: pi?.id,
|
||||||
|
amountReceived,
|
||||||
|
paymentMethod, // 'card' or 'us_bank_account'
|
||||||
|
customerEmail: paidSession.customer_details?.email,
|
||||||
|
paidAt: new Date(paidSession.created * 1000).toISOString(),
|
||||||
|
stripeFee
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for pending ACH payments (processing state)
|
||||||
|
const pendingSession = sessions.data.find(s => s.payment_status === 'unpaid' && s.status === 'complete');
|
||||||
|
if (pendingSession) {
|
||||||
|
return {
|
||||||
|
paid: false,
|
||||||
|
status: 'processing',
|
||||||
|
details: { note: 'ACH payment is processing (may take 3-5 business days).' }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
paid: false,
|
||||||
|
status: sessions.data.length > 0 ? 'attempted' : 'pending',
|
||||||
|
details: null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deactivate a payment link (e.g. when invoice is voided or amount changes).
|
||||||
|
*
|
||||||
|
* @param {string} paymentLinkId
|
||||||
|
*/
|
||||||
|
async function deactivatePaymentLink(paymentLinkId) {
|
||||||
|
if (!paymentLinkId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await stripe.paymentLinks.update(paymentLinkId, { active: false });
|
||||||
|
console.log(`🚫 Stripe Payment Link ${paymentLinkId} deactivated.`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`⚠️ Could not deactivate payment link ${paymentLinkId}:`, e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate estimated Stripe fee for reference/QBO booking.
|
||||||
|
* Card: 2.9% + $0.30
|
||||||
|
* ACH: 0.8%, capped at $5.00
|
||||||
|
*
|
||||||
|
* @param {number} amount - Amount in dollars
|
||||||
|
* @param {string} method - 'card' or 'us_bank_account'
|
||||||
|
* @returns {number} Estimated fee in dollars
|
||||||
|
*/
|
||||||
|
function calculateStripeFee(amount, method) {
|
||||||
|
if (method === 'us_bank_account') {
|
||||||
|
return Math.min(amount * 0.008, 5.00);
|
||||||
|
}
|
||||||
|
// Default: card
|
||||||
|
return (amount * 0.029) + 0.30;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createPaymentLink,
|
||||||
|
checkPaymentStatus,
|
||||||
|
deactivatePaymentLink,
|
||||||
|
calculateStripeFee
|
||||||
|
};
|
||||||
@@ -282,6 +282,7 @@
|
|||||||
{{ITEMS}}
|
{{ITEMS}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
{{PAYMENT_LINK}}
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
Reference in New Issue
Block a user