Compare commits
16 Commits
refactorin
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e45ce6cf9 | |||
| 0b738ba530 | |||
| 96ed8c7141 | |||
| 5e792ab96f | |||
| fe7a9f6dd4 | |||
| a9173acc8d | |||
| 453f8654b7 | |||
| acd5b7d605 | |||
| cc154141bd | |||
| 81ab5df13f | |||
| 01ee278e03 | |||
| 041103be04 | |||
| a18c47112b | |||
| a4a79f3eb2 | |||
| b4a442954f | |||
| fb09a0b7e1 |
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."
|
||||
@@ -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">
|
||||
|
||||
@@ -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,9 +137,36 @@ 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>
|
||||
// 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>
|
||||
@@ -146,6 +174,8 @@ function renderModalContent() {
|
||||
<p>Bay Area Affiliates, Inc.</p>
|
||||
<p>accounting@bayarea-cc.com</p>
|
||||
`;
|
||||
}
|
||||
|
||||
quillInstance.root.innerHTML = defaultHtml;
|
||||
|
||||
// Bind Submit Handler
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(' ');
|
||||
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 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
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
22
src/index.js
22
src/index.js
@@ -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);
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -463,30 +506,18 @@ router.post('/sync-payments', async (req, res) => {
|
||||
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) {
|
||||
if (txn.TxnType === 'Payment' && paymentDepositMap.get(txn.TxnId) === true) {
|
||||
status = 'Deposited';
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
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();
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -33,8 +33,11 @@ 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 });
|
||||
|
||||
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 });
|
||||
|
||||
const pdf = await page.pdf({
|
||||
format,
|
||||
@@ -42,8 +45,14 @@ async function generatePdfFromHtml(html, options = {}) {
|
||||
margin
|
||||
});
|
||||
|
||||
await page.close();
|
||||
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,18 +118,27 @@ 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
|
||||
itemsHTML += `
|
||||
|
||||
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 };
|
||||
@@ -282,6 +282,7 @@
|
||||
{{ITEMS}}
|
||||
</tbody>
|
||||
</table>
|
||||
{{PAYMENT_LINK}}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user