Compare commits

16 Commits

Author SHA1 Message Date
6e45ce6cf9 better logging 2026-04-02 12:07:39 -05:00
0b738ba530 fix for qbo changes 2026-04-01 17:13:04 -05:00
96ed8c7141 fix for Downpayment label + fix for invoice modal 2026-04-01 17:06:31 -05:00
5e792ab96f click if sent 2026-03-25 18:01:45 -05:00
fe7a9f6dd4 edit date renewed 2026-03-25 17:40:57 -05:00
a9173acc8d sent date 2026-03-25 17:35:30 -05:00
453f8654b7 set status to sent if sent 2026-03-25 16:34:23 -05:00
acd5b7d605 new message for overdue 2026-03-25 15:42:51 -05:00
cc154141bd puppeteer handling change 2026-03-21 18:08:17 -05:00
81ab5df13f get the email 2026-03-20 15:13:03 -05:00
01ee278e03 backup db 2026-03-20 14:32:17 -05:00
041103be04 bcc 2026-03-20 14:18:04 -05:00
a18c47112b Polling stripe payments 2026-03-20 14:13:10 -05:00
a4a79f3eb2 pdf includes payment link 2026-03-20 13:58:52 -05:00
b4a442954f show Pay Link only in certain situations 2026-03-20 13:47:39 -05:00
fb09a0b7e1 new text 2026-03-19 17:13:33 -05:00
13 changed files with 647 additions and 122 deletions

38
backup-invoice-db.sh Executable file
View 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."

View File

@@ -437,7 +437,10 @@
+ Add Item
</button>
</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 class="bg-gray-50 p-4 rounded-lg">

View File

@@ -2,7 +2,7 @@
// Modal to review and send invoice emails via AWS SES
// With Stripe Payment Link integration
import { showSpinner, hideSpinner } from '../utils/helpers.js';
import { showSpinner, hideSpinner, formatDate } from '../utils/helpers.js';
let currentInvoice = null;
let quillInstance = null;
@@ -118,6 +118,7 @@ function renderModalContent() {
// 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';
@@ -136,16 +137,45 @@ function renderModalContent() {
paymentText = 'Our terms are Net 30.';
}
const defaultHtml = `
<p>Good afternoon,</p>
<p>Attached is invoice <strong>#${invoiceNum}</strong> for service performed at your location. The total amount due is <strong>$${totalDue}</strong>. ${paymentText}</p>
<p>Please pay at your earliest convenience. We appreciate your continued business.</p>
<p>If you have any questions about the invoice, feel free to reply to this email.</p>
<p>Best regards,</p>
<p><strong>Claudia Knuth</strong></p>
<p>Bay Area Affiliates, Inc.</p>
<p>accounting@bayarea-cc.com</p>
`;
// 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

View File

@@ -73,7 +73,12 @@ const API = {
body: JSON.stringify({ status })
}).then(r => r.json()),
getPdf: (id) => window.open(`/api/invoices/${id}/pdf`, '_blank'),
getHtml: (id) => window.open(`/api/invoices/${id}/html`, '_blank')
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

View File

@@ -218,20 +218,40 @@ 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>`;
}
// Send Date
// Send Date — show actual sent dates if available, otherwise scheduled
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('&#10;');
sendDateDisplay = `<span title="All send dates:&#10;${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 today = new Date(); today.setHours(0, 0, 0, 0);
const daysUntil = Math.floor((sendDate - today) / 86400000);
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>`;
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>`;
}
}
// 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
let amountDisplay;
if (partial) {
@@ -269,19 +289,10 @@ function renderInvoiceRow(invoice) {
if (hasQbo && !paid && !overdue && invoice.email_status !== 'sent') {
sendBtn = `<button onclick="window.invoiceView.setEmailStatus(${invoice.id}, 'sent')" class="text-indigo-600 hover:text-indigo-800 text-xs font-medium" title="Mark as sent to customer">📤 Mark Sent</button>`;
}
// if (hasQbo && !paid && !overdue) {
// sendBtn = `
// <button onclick="window.invoiceView.setEmailStatus(${invoice.id}, 'sent')" class="text-gray-600 hover:text-gray-900 text-xs font-medium mr-4" title="Nur Status ändern">
// ✔️ Mark Sent
// </button>
// <button onclick="window.emailModal.open(${invoice.id})" class="text-indigo-600 hover:text-indigo-800 text-xs font-medium" title="E-Mail via SES versenden">
// 📧 Send Email
// </button>
// `; }
const delBtn = `<button onclick="window.invoiceView.remove(${invoice.id})" class="text-red-600 hover:text-red-900">Del</button>`;
const stripeEmailBtn = (!paid && hasQbo)
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>`
: '';
@@ -296,8 +307,9 @@ 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 text-sm text-gray-500">${invoice.customer_name || 'N/A'}</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500">${formatDate(invoice.invoice_date)}</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500">${sendDateDisplay}</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500">${invoice.terms}</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-600">
${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 font-medium space-x-1">
${editBtn} ${qboBtn} ${pdfBtn} ${htmlBtn} ${sendBtn} ${stripeEmailBtn} ${stripeCheckBtn} ${paidBtn} ${delBtn}
@@ -579,11 +591,90 @@ async function checkStripePayment(invoiceId) {
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
// ============================================================
window.invoiceView = {
viewPDF, viewHTML, syncFromQBO, resetQbo, markPaid, setEmailStatus, edit, remove,
loadInvoices, renderInvoiceView, setStatus, checkStripePayment
loadInvoices, renderInvoiceView, setStatus, checkStripePayment, editSentDates ,_addSentDateRow, _saveSentDates
};

View File

@@ -14,6 +14,7 @@ const fs = require('fs');
const path = require('path');
let oauthClient = null;
let _lastSavedAccessToken = null;
const tokenFile = path.join(__dirname, 'qbo_token.json');
const getOAuthClient = () => {
@@ -73,12 +74,15 @@ function saveTokens() {
const client = getOAuthClient();
const token = client.getToken();
// Debug: Was genau bekommen wir vom Client?
console.log("💾 Speichere Token... refresh_token vorhanden:", !!token.refresh_token,
"| access_token Länge:", (token.access_token || '').length,
"| realmId:", token.realmId || 'FEHLT');
// ── NEU: Nur speichern wenn access_token sich tatsächlich geändert hat ──
if (token.access_token === _lastSavedAccessToken) {
return; // Token unverändert kein Save, kein Log
}
_lastSavedAccessToken = token.access_token;
const ts = new Date().toISOString().replace('T',' ').substring(0,19);
console.log(`[${ts}] 💾 Token changed saving (realmId: ${token.realmId || 'FEHLT'})`);
// Sicherstellen dass alle Pflichtfelder vorhanden sind
const tokenToSave = {
token_type: token.token_type || 'bearer',
access_token: token.access_token,
@@ -90,14 +94,15 @@ function saveTokens() {
};
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) {
console.error("❌ Fehler beim Speichern der Tokens:", e.message);
console.error(`❌ Fehler beim Speichern der Tokens: ${e.message}`);
}
}
async function makeQboApiCall(requestOptions) {
const client = getOAuthClient();
const ts = () => new Date().toISOString().replace('T',' ').substring(0,19);
const currentToken = client.getToken();
if (!currentToken || !currentToken.refresh_token) {
@@ -105,29 +110,17 @@ async function makeQboApiCall(requestOptions) {
}
const doRefresh = async () => {
console.log("🔄 QBO Token Refresh wird ausgeführt...");
// Den Refresh Token als String extrahieren
console.log(`[${ts()}] 🔄 QBO Token Refresh...`);
const refreshTokenStr = currentToken.refresh_token;
console.log("🔑 Refresh Token (erste 15 Zeichen):", refreshTokenStr.substring(0, 15) + "...");
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);
console.log("✅ Token erfolgreich erneuert via refreshUsingToken().");
saveTokens();
console.log(`[${ts()}] ✅ Token refreshed via refreshUsingToken()`);
saveTokens(); // saveTokens prüft selbst ob sich was geändert hat
return authResponse;
} catch (e) {
const errMsg = e.originalMessage || e.message || String(e);
console.error("❌ Refresh fehlgeschlagen:", errMsg);
if (e.intuit_tid) console.error(" intuit_tid:", e.intuit_tid);
console.error(`[${ts()}] ❌ Refresh failed: ${errMsg}`);
if (e.intuit_tid) console.error(` intuit_tid: ${e.intuit_tid}`);
if (errMsg.includes('invalid_grant')) {
throw new Error(
"Der Refresh Token ist bei Intuit ungültig (invalid_grant). " +
@@ -140,35 +133,32 @@ async function makeQboApiCall(requestOptions) {
try {
const response = await client.makeApiCall(requestOptions);
const data = response.getJson ? response.getJson() : response.json;
if (data.fault && data.fault.error) {
const errorCode = data.fault.error[0].code;
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();
return await client.makeApiCall(requestOptions);
}
throw new Error(`QBO API Error ${errorCode}: ${data.fault.error[0].message}`);
}
saveTokens();
// ── Kein saveTokens() hier Token hat sich nicht geändert ──
return response;
} catch (e) {
const isAuthError =
e.response?.status === 401 ||
(e.authResponse && e.authResponse.response && e.authResponse.response.status === 401) ||
(e.authResponse?.response?.status === 401) ||
e.message?.includes('AuthenticationFailed');
if (isAuthError) {
console.log("⚠️ 401 Unauthorized / AuthFailed erhalten. Versuche Refresh und Retry...");
console.log(`[${ts()}] ⚠️ 401 Refresh & Retry...`);
await doRefresh();
return await client.makeApiCall(requestOptions);
}
throw e;
}
}

View File

@@ -2,6 +2,14 @@
* Quote & Invoice System - Main Entry Point
* 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 path = require('path');
const puppeteer = require('puppeteer');
@@ -23,6 +31,7 @@ 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 PORT = process.env.PORT || 3000;
@@ -42,8 +51,8 @@ async function initBrowser() {
'--disable-dev-shm-usage',
'--disable-gpu',
'--disable-software-rasterizer',
'--no-zygote',
'--single-process'
'--no-zygote'
// '--single-process' WURDE ENTFERNT!
],
protocolTimeout: 180000,
timeout: 180000
@@ -53,12 +62,16 @@ async function initBrowser() {
// Pass browser to PDF service
setBrowser(browser);
// Restart browser if it crashes
// Restart browser if it crashes (mit Atempause!)
browser.on('disconnected', () => {
console.log('[BROWSER] Browser disconnected, restarting...');
console.log('[BROWSER] Browser disconnected. Waiting 5 seconds before restarting...');
browser = null;
setBrowser(null);
initBrowser();
// 5 Sekunden warten, bevor ein Neustart versucht wird
setTimeout(() => {
initBrowser();
}, 5000);
});
}
return browser;
@@ -119,6 +132,7 @@ async function startServer() {
// Start recurring invoice scheduler (checks every 24h)
startRecurringScheduler();
startStripePolling();
}
// Graceful shutdown

View File

@@ -24,7 +24,28 @@ function calculateNextRecurringDate(invoiceDate, interval) {
}
return d.toISOString().split('T')[0];
}
/**
* Build HTML block for Stripe Payment Link on PDF invoice.
* Returns empty string if no link or invoice is already paid.
*/
function buildPaymentLinkHtml(invoice) {
if (!invoice.stripe_payment_link_url) return '';
if (invoice.paid_date || invoice.stripe_payment_status === 'paid') return '';
return `
<div style="margin-top: 30px; padding: 16px 20px; border: 2px solid #635bff; border-radius: 8px; text-align: center;">
<p style="font-size: 14px; font-weight: bold; color: #635bff; margin: 0 0 8px 0;">
Pay Online — Credit Card or ACH
</p>
<a href="${invoice.stripe_payment_link_url}"
style="font-size: 13px; color: #635bff; word-break: break-all;">
${invoice.stripe_payment_link_url}
</a>
<p style="font-size: 11px; color: #888; margin: 8px 0 0 0;">
Secure payment powered by Stripe. ACH payments incur lower processing fees.
</p>
</div>`;
}
// GET all invoices
router.get('/', async (req, res) => {
try {
@@ -64,7 +85,7 @@ router.get('/:id', async (req, res) => {
try {
const invoiceResult = await pool.query(`
SELECT i.*, c.name as customer_name, c.qbo_id as customer_qbo_id,
c.line1, c.line2, c.line3, c.line4, c.city, c.state, c.zip_code, c.account_number,
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
FROM invoices i
LEFT JOIN customers c ON i.customer_id = c.id
@@ -745,7 +766,8 @@ router.get('/:id/pdf', async (req, res) => {
.replace('{{INVOICE_DATE}}', formatDate(invoice.invoice_date))
.replace('{{TERMS}}', invoice.terms)
.replace('{{AUTHORIZATION}}', authHTML)
.replace('{{ITEMS}}', itemsHTML);
.replace('{{ITEMS}}', itemsHTML)
.replace('{{PAYMENT_LINK}}', buildPaymentLinkHtml(invoice));
const pdf = await generatePdfFromHtml(html);
@@ -808,7 +830,8 @@ router.get('/:id/html', async (req, res) => {
.replace('{{INVOICE_DATE}}', formatDate(invoice.invoice_date))
.replace('{{TERMS}}', invoice.terms)
.replace('{{AUTHORIZATION}}', authHTML)
.replace('{{ITEMS}}', itemsHTML);
.replace('{{ITEMS}}', itemsHTML)
.replace('{{PAYMENT_LINK}}', buildPaymentLinkHtml(invoice));
res.setHeader('Content-Type', 'text/html');
res.send(html);
@@ -862,7 +885,8 @@ router.post('/:id/send-email', async (req, res) => {
.replace('{{INVOICE_DATE}}', formatDate(invoice.invoice_date))
.replace('{{TERMS}}', invoice.terms)
.replace('{{AUTHORIZATION}}', authHTML)
.replace('{{ITEMS}}', itemsHTML);
.replace('{{ITEMS}}', itemsHTML)
.replace('{{PAYMENT_LINK}}', buildPaymentLinkHtml(invoice));
const pdfBuffer = await generatePdfFromHtml(html);
@@ -870,8 +894,15 @@ router.post('/:id/send-email', async (req, res) => {
const stripeLink = invoice.stripe_payment_link_url || null;
const info = await sendInvoiceEmail(invoice, recipientEmail, customText, stripeLink, pdfBuffer);
// 4. (Optional) Status in der DB aktualisieren
//await pool.query('UPDATE invoices SET email_status = $1 WHERE id = $2', ['sent', id]);
// 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 });
@@ -1160,4 +1191,35 @@ async function recordStripePaymentInQbo(invoice, amount, methodLabel, stripeFee,
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;

View File

@@ -409,6 +409,7 @@ router.post('/record-payment', async (req, res) => {
}
});
// POST sync payments from QBO
router.post('/sync-payments', async (req, res) => {
const dbClient = await pool.connect();
@@ -430,6 +431,7 @@ router.post('/sync-payments', async (req, res) => {
const companyId = oauthClient.getToken().realmId;
const baseUrl = getQboBaseUrl();
// ── Batch-fetch all invoices from QBO (max 50 per query) ──────────
const batchSize = 50;
const qboInvoices = new Map();
@@ -437,18 +439,59 @@ router.post('/sync-payments', async (req, res) => {
const batch = openInvoices.slice(i, i + batchSize);
const ids = batch.map(inv => `'${inv.qbo_id}'`).join(',');
const query = `SELECT Id, DocNumber, Balance, TotalAmt, LinkedTxn FROM Invoice WHERE Id IN (${ids})`;
const response = await makeQboApiCall({
url: `${baseUrl}/v3/company/${companyId}/query?query=${encodeURI(query)}`,
method: 'GET'
});
const data = response.getJson ? response.getJson() : response.json;
const invoices = data.QueryResponse?.Invoice || [];
invoices.forEach(inv => qboInvoices.set(inv.Id, inv));
(data.QueryResponse?.Invoice || []).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 newPayments = 0;
@@ -459,34 +502,22 @@ router.post('/sync-payments', async (req, res) => {
if (!qboInv) continue;
const qboBalance = parseFloat(qboInv.Balance) || 0;
const qboTotal = parseFloat(qboInv.TotalAmt) || 0;
const localPaid = parseFloat(localInv.local_paid) || 0;
const qboTotal = parseFloat(qboInv.TotalAmt) || 0;
const localPaid = parseFloat(localInv.local_paid) || 0;
if (qboBalance === 0 && qboTotal > 0) {
const UNDEPOSITED_FUNDS_ID = '221';
// Determine Paid vs Deposited using pre-fetched map
let status = 'Paid';
if (qboInv.LinkedTxn) {
for (const txn of qboInv.LinkedTxn) {
if (txn.TxnType === 'Payment') {
try {
const pmRes = await makeQboApiCall({
url: `${baseUrl}/v3/company/${companyId}/payment/${txn.TxnId}`,
method: 'GET'
});
const pmData = pmRes.getJson ? pmRes.getJson() : pmRes.json;
const payment = pmData.Payment;
if (payment && payment.DepositToAccountRef &&
payment.DepositToAccountRef.value !== UNDEPOSITED_FUNDS_ID) {
status = 'Deposited';
}
} catch (e) { /* ignore */ }
if (txn.TxnType === 'Payment' && paymentDepositMap.get(txn.TxnId) === true) {
status = 'Deposited';
break;
}
}
}
const needsUpdate = !localInv.paid_date || localInv.payment_status !== status;
if (needsUpdate) {
if (!localInv.paid_date || localInv.payment_status !== status) {
await dbClient.query(
`UPDATE invoices SET
paid_date = COALESCE(paid_date, CURRENT_DATE),
@@ -503,7 +534,9 @@ router.post('/sync-payments', async (req, res) => {
if (diff > 0.01) {
const payResult = await dbClient.query(
`INSERT INTO payments (payment_date, payment_method, total_amount, customer_id, notes, created_at)
VALUES (CURRENT_DATE, 'Synced from QBO', $1, (SELECT customer_id FROM invoices WHERE id = $2), 'Synced from QBO', CURRENT_TIMESTAMP)
VALUES (CURRENT_DATE, 'Synced from QBO', $1,
(SELECT customer_id FROM invoices WHERE id = $2),
'Synced from QBO', CURRENT_TIMESTAMP)
RETURNING id`,
[diff, localInv.id]
);
@@ -512,15 +545,14 @@ router.post('/sync-payments', async (req, res) => {
[payResult.rows[0].id, localInv.id, diff]
);
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) {
const qboPaid = qboTotal - qboBalance;
const diff = qboPaid - localPaid;
const needsUpdate = localInv.payment_status !== 'Partial';
if (needsUpdate) {
if (localInv.payment_status !== 'Partial') {
await dbClient.query(
'UPDATE invoices SET payment_status = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
['Partial', localInv.id]
@@ -531,7 +563,9 @@ router.post('/sync-payments', async (req, res) => {
if (diff > 0.01) {
const payResult = await dbClient.query(
`INSERT INTO payments (payment_date, payment_method, total_amount, customer_id, notes, created_at)
VALUES (CURRENT_DATE, 'Synced from QBO', $1, (SELECT customer_id FROM invoices WHERE id = $2), 'Synced from QBO', CURRENT_TIMESTAMP)
VALUES (CURRENT_DATE, 'Synced from QBO', $1,
(SELECT customer_id FROM invoices WHERE id = $2),
'Synced from QBO', CURRENT_TIMESTAMP)
RETURNING id`,
[diff, localInv.id]
);
@@ -540,7 +574,7 @@ router.post('/sync-payments', async (req, res) => {
[payResult.rows[0].id, localInv.id, diff]
);
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)})`);
}
}
}
@@ -552,7 +586,7 @@ router.post('/sync-payments', async (req, res) => {
await dbClient.query('COMMIT');
console.log(`✅ Sync abgeschlossen: ${updated} aktualisiert, ${newPayments} neue Payments`);
console.log(`✅ Sync complete: ${updated} updated, ${newPayments} new payments`);
res.json({
synced: updated,
new_payments: newPayments,
@@ -562,7 +596,7 @@ router.post('/sync-payments', async (req, res) => {
} catch (error) {
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 });
} finally {
dbClient.release();

View File

@@ -117,6 +117,7 @@ async function sendInvoiceEmail(invoice, recipientEmail, customText, stripePayme
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: [

View File

@@ -33,17 +33,26 @@ async function generatePdfFromHtml(html, options = {}) {
}
const page = await browser.newPage();
//await page.setContent(html, { waitUntil: 'networkidle0', timeout: 60000 });
await page.setContent(html, { waitUntil: 'load', timeout: 5000 });
const pdf = await page.pdf({
format,
printBackground,
margin
});
try {
// Erhöhtes Timeout: 5 Sekunden sind unter Docker manchmal zu wenig.
// Besser auf 15 Sekunden (15000) setzen, um den Fehler von vornherein zu vermeiden.
await page.setContent(html, { waitUntil: 'load', timeout: 15000 });
await page.close();
return pdf;
const pdf = await page.pdf({
format,
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();
}
}
}
/**
@@ -109,17 +118,26 @@ function renderInvoiceItems(items, invoice = null) {
<td class="total-amount" style="font-size: 16px;">$${formatMoney(total)}</td>
</tr>`;
// Add downpayment/balance if partial
// Add downpayment/balance if partial
if (amountPaid > 0) {
const isFullyPaid = balanceDue <= 0.01; // allow for rounding
const paymentLabel = isFullyPaid ? 'Payment:' : 'Downpayment:';
itemsHTML += `
<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>
</tr>
</tr>`;
// Only show BALANCE DUE row if there's actually a remaining balance
if (!isFullyPaid) {
itemsHTML += `
<tr class="footer-row">
<td colspan="3" class="total-label" style="font-weight: bold; font-size: 16px; border-top: 2px solid #333; padding-top: 8px;">BALANCE DUE:</td>
<td class="total-amount" style="font-weight: bold; font-size: 16px; border-top: 2px solid #333; padding-top: 8px;">$${formatMoney(balanceDue)}</td>
</tr>`;
}
}
// Thank you message

View 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 };

View File

@@ -282,6 +282,7 @@
{{ITEMS}}
</tbody>
</table>
{{PAYMENT_LINK}}
</div>
</body>
</html>