Compare commits
16 Commits
229e658831
...
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
|
+ 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">
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// Modal to review and send invoice emails via AWS SES
|
// Modal to review and send invoice emails via AWS SES
|
||||||
// With Stripe Payment Link integration
|
// With Stripe Payment Link integration
|
||||||
|
|
||||||
import { showSpinner, hideSpinner } from '../utils/helpers.js';
|
import { showSpinner, hideSpinner, formatDate } from '../utils/helpers.js';
|
||||||
|
|
||||||
let currentInvoice = null;
|
let currentInvoice = null;
|
||||||
let quillInstance = null;
|
let quillInstance = null;
|
||||||
@@ -118,6 +118,7 @@ function renderModalContent() {
|
|||||||
// Variablen für den Text aufbereiten
|
// Variablen für den Text aufbereiten
|
||||||
const invoiceNum = currentInvoice.invoice_number || currentInvoice.id;
|
const invoiceNum = currentInvoice.invoice_number || currentInvoice.id;
|
||||||
const totalDue = parseFloat(currentInvoice.balance ?? currentInvoice.total).toFixed(2);
|
const totalDue = parseFloat(currentInvoice.balance ?? currentInvoice.total).toFixed(2);
|
||||||
|
const customerName = currentInvoice.customer_name || 'Valued Customer';
|
||||||
|
|
||||||
// Datum formatieren
|
// Datum formatieren
|
||||||
let dueDateStr = 'Upon Receipt';
|
let dueDateStr = 'Upon Receipt';
|
||||||
@@ -136,9 +137,36 @@ function renderModalContent() {
|
|||||||
paymentText = 'Our terms are Net 30.';
|
paymentText = 'Our terms are Net 30.';
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultHtml = `
|
// Detect overdue: unpaid + older than 30 days
|
||||||
<p>Good afternoon,</p>
|
const invoiceDateParsed = currentInvoice.invoice_date
|
||||||
<p>Attached is invoice <strong>#${invoiceNum}</strong> for service performed at your location. The total amount due is <strong>$${totalDue}</strong>. ${paymentText}</p>
|
? 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>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>If you have any questions about the invoice, feel free to reply to this email.</p>
|
||||||
<p>Best regards,</p>
|
<p>Best regards,</p>
|
||||||
@@ -146,6 +174,8 @@ function renderModalContent() {
|
|||||||
<p>Bay Area Affiliates, Inc.</p>
|
<p>Bay Area Affiliates, Inc.</p>
|
||||||
<p>accounting@bayarea-cc.com</p>
|
<p>accounting@bayarea-cc.com</p>
|
||||||
`;
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
quillInstance.root.innerHTML = defaultHtml;
|
quillInstance.root.innerHTML = defaultHtml;
|
||||||
|
|
||||||
// Bind Submit Handler
|
// Bind Submit Handler
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>`;
|
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;
|
||||||
if (partial) {
|
if (partial) {
|
||||||
@@ -269,19 +289,10 @@ function renderInvoiceRow(invoice) {
|
|||||||
if (hasQbo && !paid && !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>`;
|
||||||
}
|
}
|
||||||
// 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 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>`
|
? `<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 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} ${stripeEmailBtn} ${stripeCheckBtn} ${paidBtn} ${delBtn}
|
${editBtn} ${qboBtn} ${pdfBtn} ${htmlBtn} ${sendBtn} ${stripeEmailBtn} ${stripeCheckBtn} ${paidBtn} ${delBtn}
|
||||||
@@ -579,11 +591,90 @@ async function checkStripePayment(invoiceId) {
|
|||||||
if (typeof hideSpinner === 'function') hideSpinner();
|
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, checkStripePayment
|
loadInvoices, renderInvoiceView, setStatus, checkStripePayment, editSentDates ,_addSentDateRow, _saveSentDates
|
||||||
};
|
};
|
||||||
@@ -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 = () => {
|
||||||
@@ -73,12 +74,15 @@ function saveTokens() {
|
|||||||
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;
|
||||||
|
|
||||||
|
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 = {
|
const tokenToSave = {
|
||||||
token_type: token.token_type || 'bearer',
|
token_type: token.token_type || 'bearer',
|
||||||
access_token: token.access_token,
|
access_token: token.access_token,
|
||||||
@@ -90,14 +94,15 @@ function saveTokens() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
22
src/index.js
22
src/index.js
@@ -2,6 +2,14 @@
|
|||||||
* 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 path = require('path');
|
||||||
const puppeteer = require('puppeteer');
|
const puppeteer = require('puppeteer');
|
||||||
@@ -23,6 +31,7 @@ const { setBrowser } = require('./services/pdf-service');
|
|||||||
|
|
||||||
// Import recurring invoice scheduler
|
// Import recurring invoice scheduler
|
||||||
const { startRecurringScheduler } = require('./services/recurring-service');
|
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;
|
||||||
@@ -42,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
|
||||||
@@ -53,12 +62,16 @@ 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;
|
||||||
setBrowser(null);
|
setBrowser(null);
|
||||||
|
|
||||||
|
// 5 Sekunden warten, bevor ein Neustart versucht wird
|
||||||
|
setTimeout(() => {
|
||||||
initBrowser();
|
initBrowser();
|
||||||
|
}, 5000);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return browser;
|
return browser;
|
||||||
@@ -119,6 +132,7 @@ async function startServer() {
|
|||||||
|
|
||||||
// Start recurring invoice scheduler (checks every 24h)
|
// Start recurring invoice scheduler (checks every 24h)
|
||||||
startRecurringScheduler();
|
startRecurringScheduler();
|
||||||
|
startStripePolling();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Graceful shutdown
|
// Graceful shutdown
|
||||||
|
|||||||
@@ -24,7 +24,28 @@ function calculateNextRecurringDate(invoiceDate, interval) {
|
|||||||
}
|
}
|
||||||
return d.toISOString().split('T')[0];
|
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 {
|
||||||
@@ -64,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
|
||||||
@@ -745,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);
|
||||||
|
|
||||||
@@ -808,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);
|
||||||
@@ -862,7 +885,8 @@ router.post('/:id/send-email', 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 pdfBuffer = await generatePdfFromHtml(html);
|
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 stripeLink = invoice.stripe_payment_link_url || null;
|
||||||
const info = await sendInvoiceEmail(invoice, recipientEmail, customText, stripeLink, pdfBuffer);
|
const info = await sendInvoiceEmail(invoice, recipientEmail, customText, stripeLink, pdfBuffer);
|
||||||
|
|
||||||
// 4. (Optional) Status in der DB aktualisieren
|
// 4. Status in der DB aktualisieren
|
||||||
//await pool.query('UPDATE invoices SET email_status = $1 WHERE id = $2', ['sent', id]);
|
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 });
|
res.json({ success: true, messageId: info.messageId });
|
||||||
|
|
||||||
@@ -1160,4 +1191,35 @@ async function recordStripePaymentInQbo(invoice, amount, methodLabel, stripeFee,
|
|||||||
feeBooked: stripeFee > 0
|
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;
|
||||||
|
|||||||
@@ -409,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();
|
||||||
@@ -430,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();
|
||||||
|
|
||||||
@@ -437,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;
|
||||||
|
|
||||||
@@ -463,30 +506,18 @@ router.post('/sync-payments', async (req, res) => {
|
|||||||
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 {
|
|
||||||
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';
|
status = 'Deposited';
|
||||||
}
|
break;
|
||||||
} 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),
|
||||||
@@ -503,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]
|
||||||
);
|
);
|
||||||
@@ -512,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]
|
||||||
@@ -531,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]
|
||||||
);
|
);
|
||||||
@@ -540,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)})`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -552,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,
|
||||||
@@ -562,7 +596,7 @@ 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();
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ async function sendInvoiceEmail(invoice, recipientEmail, customText, stripePayme
|
|||||||
const mailOptions = {
|
const mailOptions = {
|
||||||
from: '"Bay Area Affiliates Inc. Accounting" <accounting@bayarea-cc.com>',
|
from: '"Bay Area Affiliates Inc. Accounting" <accounting@bayarea-cc.com>',
|
||||||
to: recipientEmail,
|
to: recipientEmail,
|
||||||
|
bcc: 'accounting@bayarea-cc.com',
|
||||||
subject: `Invoice #${invoice.invoice_number || invoice.id} from Bay Area Affiliates, Inc.`,
|
subject: `Invoice #${invoice.invoice_number || invoice.id} from Bay Area Affiliates, Inc.`,
|
||||||
html: htmlContent,
|
html: htmlContent,
|
||||||
attachments: [
|
attachments: [
|
||||||
|
|||||||
@@ -33,8 +33,11 @@ async function generatePdfFromHtml(html, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const page = await browser.newPage();
|
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({
|
const pdf = await page.pdf({
|
||||||
format,
|
format,
|
||||||
@@ -42,8 +45,14 @@ async function generatePdfFromHtml(html, options = {}) {
|
|||||||
margin
|
margin
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.close();
|
|
||||||
return pdf;
|
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>
|
<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
|
||||||
itemsHTML += `
|
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}}
|
{{ITEMS}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
{{PAYMENT_LINK}}
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
Reference in New Issue
Block a user