better states
This commit is contained in:
@@ -25,6 +25,98 @@ function getEmailModalTitle(invoice, isOverdue) {
|
|||||||
if (invoice.email_status === 'sent') return `🔁 Resend Invoice #${invoice.invoice_number || invoice.id}`;
|
if (invoice.email_status === 'sent') return `🔁 Resend Invoice #${invoice.invoice_number || invoice.id}`;
|
||||||
return `📤 Send Invoice #${invoice.invoice_number || invoice.id}`;
|
return `📤 Send Invoice #${invoice.invoice_number || invoice.id}`;
|
||||||
}
|
}
|
||||||
|
function parseLocalDate(dateValue) {
|
||||||
|
if (!dateValue) return null;
|
||||||
|
|
||||||
|
if (dateValue instanceof Date) {
|
||||||
|
return new Date(dateValue.getFullYear(), dateValue.getMonth(), dateValue.getDate());
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateStr = String(dateValue).split('T')[0];
|
||||||
|
const parts = dateStr.split('-').map(Number);
|
||||||
|
|
||||||
|
if (parts.length === 3 && parts.every(Number.isFinite)) {
|
||||||
|
return new Date(parts[0], parts[1] - 1, parts[2]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const d = new Date(dateValue);
|
||||||
|
return Number.isNaN(d.getTime()) ? null : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLastSentDate(invoice) {
|
||||||
|
const sentDates = Array.isArray(invoice.sent_dates)
|
||||||
|
? invoice.sent_dates.filter(Boolean)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (sentDates.length > 0) {
|
||||||
|
return sentDates[sentDates.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTermDays(invoice) {
|
||||||
|
const terms = String(invoice.terms || '').toLowerCase();
|
||||||
|
|
||||||
|
if (terms.includes('due on receipt') || terms.includes('upon receipt')) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = terms.match(/net\s*(\d+)/i);
|
||||||
|
if (match) {
|
||||||
|
return parseInt(match[1], 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
return 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDays(dateValue, days) {
|
||||||
|
const d = parseLocalDate(dateValue);
|
||||||
|
if (!d) return null;
|
||||||
|
|
||||||
|
d.setDate(d.getDate() + days);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEffectiveDueDate(invoice) {
|
||||||
|
const lastSentDate = getLastSentDate(invoice);
|
||||||
|
|
||||||
|
// Nie versendet = kein Reminder/Overdue Text
|
||||||
|
if (!lastSentDate) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invoice.due_date) {
|
||||||
|
return parseLocalDate(invoice.due_date);
|
||||||
|
}
|
||||||
|
|
||||||
|
return addDays(lastSentDate, getTermDays(invoice));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOverdueDays(invoice) {
|
||||||
|
const dueDate = getEffectiveDueDate(invoice);
|
||||||
|
if (!dueDate) return 0;
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
dueDate.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
return Math.max(0, Math.floor((today - dueDate) / 86400000));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInvoiceOverdue(invoice) {
|
||||||
|
const dueDate = getEffectiveDueDate(invoice);
|
||||||
|
if (!dueDate) return false;
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
dueDate.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
return !invoice.paid_date && dueDate < today;
|
||||||
|
}
|
||||||
|
|
||||||
function renderModalContent() {
|
function renderModalContent() {
|
||||||
const modal = document.getElementById('email-modal');
|
const modal = document.getElementById('email-modal');
|
||||||
if (!modal) return;
|
if (!modal) return;
|
||||||
@@ -34,13 +126,8 @@ function renderModalContent() {
|
|||||||
const stripeStatus = currentInvoice.stripe_payment_status || '';
|
const stripeStatus = currentInvoice.stripe_payment_status || '';
|
||||||
|
|
||||||
// Detect overdue: unpaid + older than 30 days
|
// Detect overdue: unpaid + older than 30 days
|
||||||
const invoiceDateParsed = currentInvoice.invoice_date
|
const isOverdue = isInvoiceOverdue(currentInvoice);
|
||||||
? new Date(currentInvoice.invoice_date.split('T')[0])
|
const daysOverdue = getOverdueDays(currentInvoice);
|
||||||
: null;
|
|
||||||
const daysSinceInvoice = invoiceDateParsed
|
|
||||||
? Math.floor((new Date() - invoiceDateParsed) / 86400000)
|
|
||||||
: 0;
|
|
||||||
const isOverdue = !currentInvoice.paid_date && daysSinceInvoice > 30;
|
|
||||||
|
|
||||||
const modalTitle = getEmailModalTitle(currentInvoice, isOverdue);
|
const modalTitle = getEmailModalTitle(currentInvoice, isOverdue);
|
||||||
|
|
||||||
@@ -159,7 +246,7 @@ function renderModalContent() {
|
|||||||
defaultHtml = `
|
defaultHtml = `
|
||||||
<p>Dear ${customerName},</p>
|
<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>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>This invoice is now <strong>${daysOverdue} days overdue</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>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>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>Thank you for your attention to this matter. We value your business and look forward to continuing our partnership.</p>
|
||||||
|
|||||||
@@ -69,19 +69,121 @@ function getMonthName(i) {
|
|||||||
return ['January','February','March','April','May','June','July','August','September','October','November','December'][i];
|
return ['January','February','March','April','May','June','July','August','September','October','November','December'][i];
|
||||||
}
|
}
|
||||||
|
|
||||||
function isPaid(inv) { return !!inv.paid_date; }
|
// ============================================================
|
||||||
function isDraft(inv) { return !inv.qbo_id; }
|
// Status Helpers
|
||||||
function isOverdue(inv) { return !isPaid(inv) && !isPartiallyPaid(inv) && daysSince(inv.invoice_date) > OVERDUE_DAYS; }
|
// ============================================================
|
||||||
|
|
||||||
|
function isPaid(inv) {
|
||||||
|
return !!inv.paid_date;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDraft(inv) {
|
||||||
|
return !inv.qbo_id;
|
||||||
|
}
|
||||||
|
|
||||||
function isPartiallyPaid(inv) {
|
function isPartiallyPaid(inv) {
|
||||||
const amountPaid = parseFloat(inv.amount_paid) || 0;
|
const amountPaid = parseFloat(inv.amount_paid) || 0;
|
||||||
const balance = parseFloat(inv.balance) ?? ((parseFloat(inv.total) || 0) - amountPaid);
|
const balance = parseFloat(inv.balance) ?? ((parseFloat(inv.total) || 0) - amountPaid);
|
||||||
return !inv.paid_date && amountPaid > 0 && balance > 0;
|
return !inv.paid_date && amountPaid > 0 && balance > 0;
|
||||||
}
|
}
|
||||||
function isSent(inv) {
|
|
||||||
return !!inv.qbo_id && !isPaid(inv) && !isPartiallyPaid(inv) && !isOverdue(inv) && inv.email_status === 'sent';
|
function getLastSentDate(inv) {
|
||||||
|
const sentDates = Array.isArray(inv.sent_dates) ? inv.sent_dates.filter(Boolean) : [];
|
||||||
|
|
||||||
|
if (sentDates.length > 0) {
|
||||||
|
return sentDates[sentDates.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getTermDays(inv) {
|
||||||
|
const terms = String(inv.terms || '').toLowerCase();
|
||||||
|
|
||||||
|
if (terms.includes('due on receipt') || terms.includes('upon receipt')) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = terms.match(/net\s*(\d+)/i);
|
||||||
|
if (match) {
|
||||||
|
return parseInt(match[1], 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default in deiner App ist Net 30
|
||||||
|
return 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDays(dateValue, days) {
|
||||||
|
const d = parseLocalDate(dateValue);
|
||||||
|
if (!d) return null;
|
||||||
|
|
||||||
|
d.setDate(d.getDate() + days);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBeforeToday(dateObj) {
|
||||||
|
if (!dateObj) return false;
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
const compare = new Date(dateObj);
|
||||||
|
compare.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
return compare < today;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEffectiveDueDate(inv) {
|
||||||
|
const lastSentDate = getLastSentDate(inv);
|
||||||
|
|
||||||
|
// Wichtig: Nie versendete Rechnungen können nicht overdue sein.
|
||||||
|
if (!lastSentDate) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wenn due_date vorhanden ist, darf es genutzt werden,
|
||||||
|
// aber nur nachdem die Rechnung tatsächlich gesendet wurde.
|
||||||
|
if (inv.due_date) {
|
||||||
|
return parseLocalDate(inv.due_date);
|
||||||
|
}
|
||||||
|
|
||||||
|
return addDays(lastSentDate, getTermDays(inv));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOverdueDays(inv) {
|
||||||
|
const dueDate = getEffectiveDueDate(inv);
|
||||||
|
if (!dueDate) return 0;
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
dueDate.setHours(0, 0, 0, 0);
|
||||||
|
|
||||||
|
return Math.max(0, Math.floor((today - dueDate) / 86400000));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOverdue(inv) {
|
||||||
|
return !!inv.qbo_id
|
||||||
|
&& !isPaid(inv)
|
||||||
|
&& !isPartiallyPaid(inv)
|
||||||
|
&& !!getLastSentDate(inv)
|
||||||
|
&& isBeforeToday(getEffectiveDueDate(inv));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSent(inv) {
|
||||||
|
return !!inv.qbo_id
|
||||||
|
&& !isPaid(inv)
|
||||||
|
&& !isPartiallyPaid(inv)
|
||||||
|
&& !isOverdue(inv)
|
||||||
|
&& inv.email_status === 'sent';
|
||||||
|
}
|
||||||
|
|
||||||
function isOpen(inv) {
|
function isOpen(inv) {
|
||||||
return !!inv.qbo_id && !isPaid(inv) && !isPartiallyPaid(inv) && !isOverdue(inv) && inv.email_status !== 'sent';
|
return !!inv.qbo_id
|
||||||
|
&& !isPaid(inv)
|
||||||
|
&& !isPartiallyPaid(inv)
|
||||||
|
&& !isOverdue(inv)
|
||||||
|
&& inv.email_status !== 'sent';
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveSettings() {
|
function saveSettings() {
|
||||||
@@ -235,7 +337,7 @@ function renderInvoiceRow(invoice) {
|
|||||||
}
|
}
|
||||||
statusBadge += `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800" title="Paid: $${amountPaid.toFixed(2)} / Balance: $${balance.toFixed(2)}">Partial</span>`;
|
statusBadge += `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800" title="Paid: $${amountPaid.toFixed(2)} / Balance: $${balance.toFixed(2)}">Partial</span>`;
|
||||||
} else if (overdue) {
|
} else if (overdue) {
|
||||||
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-red-100 text-red-800" title="${daysSince(invoice.invoice_date)} days">Overdue</span>`;
|
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-red-100 text-red-800" title="${getOverdueDays(invoice)} days overdue">Overdue</span>`;
|
||||||
} else if (hasQbo && invoice.email_status === 'sent') {
|
} else if (hasQbo && invoice.email_status === 'sent') {
|
||||||
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-cyan-200 text-cyan-800">Sent</span>`;
|
statusBadge = `<span class="inline-block px-2 py-0.5 text-xs font-semibold rounded-full bg-cyan-200 text-cyan-800">Sent</span>`;
|
||||||
} else if (hasQbo) {
|
} else if (hasQbo) {
|
||||||
|
|||||||
@@ -135,7 +135,23 @@ router.post('/', async (req, res) => {
|
|||||||
return res.status(400).json({ error: `Invoice number ${invoice_number} already exists.` });
|
return res.status(400).json({ error: `Invoice number ${invoice_number} already exists.` });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Validate items
|
||||||
|
if (!Array.isArray(items) || items.length === 0) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
return res.status(400).json({ error: 'Please add at least one item.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recurring invoices must contain at least one Subscription item
|
||||||
|
if (is_recurring) {
|
||||||
|
const subscriptionItems = items.filter(item => String(item.qbo_item_id) === '115');
|
||||||
|
|
||||||
|
if (subscriptionItems.length === 0) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Recurring invoices must contain at least one Subscription item.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
let subtotal = 0;
|
let subtotal = 0;
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const amount = parseFloat(item.amount.replace(/[$,]/g, ''));
|
const amount = parseFloat(item.amount.replace(/[$,]/g, ''));
|
||||||
@@ -210,7 +226,23 @@ router.put('/:id', async (req, res) => {
|
|||||||
return res.status(400).json({ error: `Invoice number ${invoice_number} already exists.` });
|
return res.status(400).json({ error: `Invoice number ${invoice_number} already exists.` });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Validate items
|
||||||
|
if (!Array.isArray(items) || items.length === 0) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
return res.status(400).json({ error: 'Please add at least one item.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recurring invoices must contain at least one Subscription item
|
||||||
|
if (is_recurring) {
|
||||||
|
const subscriptionItems = items.filter(item => String(item.qbo_item_id) === '115');
|
||||||
|
|
||||||
|
if (subscriptionItems.length === 0) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
return res.status(400).json({
|
||||||
|
error: 'Recurring invoices must contain at least one Subscription item.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
let subtotal = 0;
|
let subtotal = 0;
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const amount = parseFloat(item.amount.replace(/[$,]/g, ''));
|
const amount = parseFloat(item.amount.replace(/[$,]/g, ''));
|
||||||
|
|||||||
@@ -36,13 +36,13 @@ async function processRecurringInvoices() {
|
|||||||
console.log(`🔄 [RECURRING] Checking for due recurring invoices (today: ${today})...`);
|
console.log(`🔄 [RECURRING] Checking for due recurring invoices (today: ${today})...`);
|
||||||
|
|
||||||
const client = await pool.connect();
|
const client = await pool.connect();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Find all recurring invoices that are due
|
|
||||||
const dueResult = await client.query(`
|
const dueResult = await client.query(`
|
||||||
SELECT i.*, c.qbo_id as customer_qbo_id, c.name as customer_name
|
SELECT i.*, c.qbo_id as customer_qbo_id, c.name as customer_name
|
||||||
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
|
||||||
WHERE i.is_recurring = true
|
WHERE i.is_recurring = true
|
||||||
AND i.next_recurring_date IS NOT NULL
|
AND i.next_recurring_date IS NOT NULL
|
||||||
AND i.next_recurring_date <= $1
|
AND i.next_recurring_date <= $1
|
||||||
`, [today]);
|
`, [today]);
|
||||||
@@ -55,38 +55,86 @@ async function processRecurringInvoices() {
|
|||||||
console.log(`🔄 [RECURRING] Found ${dueResult.rows.length} recurring invoice(s) due.`);
|
console.log(`🔄 [RECURRING] Found ${dueResult.rows.length} recurring invoice(s) due.`);
|
||||||
|
|
||||||
let created = 0;
|
let created = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
for (const source of dueResult.rows) {
|
for (const source of dueResult.rows) {
|
||||||
await client.query('BEGIN');
|
await client.query('BEGIN');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Load items from the source invoice
|
|
||||||
const itemsResult = await client.query(
|
const itemsResult = await client.query(
|
||||||
'SELECT * FROM invoice_items WHERE invoice_id = $1 ORDER BY item_order',
|
`
|
||||||
|
SELECT *
|
||||||
|
FROM invoice_items
|
||||||
|
WHERE invoice_id = $1
|
||||||
|
AND qbo_item_id::text = '115'
|
||||||
|
ORDER BY item_order
|
||||||
|
`,
|
||||||
[source.id]
|
[source.id]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const subscriptionItems = itemsResult.rows;
|
||||||
|
|
||||||
|
const nextDate = advanceDate(source.next_recurring_date, source.recurring_interval);
|
||||||
|
|
||||||
|
if (subscriptionItems.length === 0) {
|
||||||
|
console.warn(
|
||||||
|
` ⚠️ Recurring invoice #${source.invoice_number || source.id} has no Subscription items. No new invoice created.`
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
'UPDATE invoices SET next_recurring_date = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
|
||||||
|
[nextDate, source.id]
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const newInvoiceDate = source.next_recurring_date.toISOString().split('T')[0];
|
const newInvoiceDate = source.next_recurring_date.toISOString().split('T')[0];
|
||||||
|
|
||||||
// Create the new invoice (no invoice_number — QBO will assign one)
|
let subtotal = 0;
|
||||||
|
for (const item of subscriptionItems) {
|
||||||
|
const amount = parseFloat(String(item.amount || '0').replace(/[$,]/g, ''));
|
||||||
|
if (!Number.isNaN(amount)) {
|
||||||
|
subtotal += amount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const taxRate = parseFloat(source.tax_rate) || 8.25;
|
||||||
|
const taxAmount = source.tax_exempt ? 0 : (subtotal * taxRate / 100);
|
||||||
|
const total = subtotal + taxAmount;
|
||||||
|
|
||||||
const newInvoice = await client.query(
|
const newInvoice = await client.query(
|
||||||
`INSERT INTO invoices (
|
`
|
||||||
invoice_number, customer_id, invoice_date, terms, auth_code,
|
INSERT INTO invoices (
|
||||||
tax_exempt, tax_rate, subtotal, tax_amount, total,
|
invoice_number,
|
||||||
bill_to_name, recurring_source_id
|
customer_id,
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
invoice_date,
|
||||||
RETURNING *`,
|
terms,
|
||||||
|
auth_code,
|
||||||
|
tax_exempt,
|
||||||
|
tax_rate,
|
||||||
|
subtotal,
|
||||||
|
tax_amount,
|
||||||
|
total,
|
||||||
|
bill_to_name,
|
||||||
|
recurring_source_id
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||||
|
RETURNING *
|
||||||
|
`,
|
||||||
[
|
[
|
||||||
`DRAFT-${Date.now()}`, // Temporary, QBO export will assign real number
|
`DRAFT-${Date.now()}`,
|
||||||
source.customer_id,
|
source.customer_id,
|
||||||
newInvoiceDate,
|
newInvoiceDate,
|
||||||
source.terms,
|
source.terms,
|
||||||
source.auth_code,
|
source.auth_code,
|
||||||
source.tax_exempt,
|
source.tax_exempt,
|
||||||
source.tax_rate,
|
taxRate,
|
||||||
source.subtotal,
|
subtotal,
|
||||||
source.tax_amount,
|
taxAmount,
|
||||||
source.total,
|
total,
|
||||||
source.bill_to_name,
|
source.bill_to_name,
|
||||||
source.id
|
source.id
|
||||||
]
|
]
|
||||||
@@ -94,18 +142,34 @@ async function processRecurringInvoices() {
|
|||||||
|
|
||||||
const newInvoiceId = newInvoice.rows[0].id;
|
const newInvoiceId = newInvoice.rows[0].id;
|
||||||
|
|
||||||
// Copy items
|
for (let i = 0; i < subscriptionItems.length; i++) {
|
||||||
for (let i = 0; i < itemsResult.rows.length; i++) {
|
const item = subscriptionItems[i];
|
||||||
const item = itemsResult.rows[i];
|
|
||||||
await client.query(
|
await client.query(
|
||||||
`INSERT INTO invoice_items (invoice_id, quantity, description, rate, amount, item_order, qbo_item_id)
|
`
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
INSERT INTO invoice_items (
|
||||||
[newInvoiceId, item.quantity, item.description, item.rate, item.amount, i, item.qbo_item_id || '9']
|
invoice_id,
|
||||||
|
quantity,
|
||||||
|
description,
|
||||||
|
rate,
|
||||||
|
amount,
|
||||||
|
item_order,
|
||||||
|
qbo_item_id
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
newInvoiceId,
|
||||||
|
item.quantity,
|
||||||
|
item.description,
|
||||||
|
item.rate,
|
||||||
|
item.amount,
|
||||||
|
i,
|
||||||
|
item.qbo_item_id || '115'
|
||||||
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Advance the source invoice's next_recurring_date
|
|
||||||
const nextDate = advanceDate(source.next_recurring_date, source.recurring_interval);
|
|
||||||
await client.query(
|
await client.query(
|
||||||
'UPDATE invoices SET next_recurring_date = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
|
'UPDATE invoices SET next_recurring_date = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
|
||||||
[nextDate, source.id]
|
[nextDate, source.id]
|
||||||
@@ -113,13 +177,16 @@ async function processRecurringInvoices() {
|
|||||||
|
|
||||||
await client.query('COMMIT');
|
await client.query('COMMIT');
|
||||||
|
|
||||||
console.log(` ✅ Created recurring invoice from #${source.invoice_number || source.id} → new ID ${newInvoiceId} (date: ${newInvoiceDate}), next due: ${nextDate}`);
|
console.log(
|
||||||
|
` ✅ Created recurring invoice from #${source.invoice_number || source.id} → new ID ${newInvoiceId} (date: ${newInvoiceDate}), next due: ${nextDate}`
|
||||||
|
);
|
||||||
|
|
||||||
// Auto-export to QBO (outside transaction, non-blocking)
|
|
||||||
try {
|
try {
|
||||||
const dbClient = await pool.connect();
|
const dbClient = await pool.connect();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const qboResult = await exportInvoiceToQbo(newInvoiceId, dbClient);
|
const qboResult = await exportInvoiceToQbo(newInvoiceId, dbClient);
|
||||||
|
|
||||||
if (qboResult.success) {
|
if (qboResult.success) {
|
||||||
console.log(` 📤 Auto-exported to QBO: #${qboResult.qbo_doc_number}`);
|
console.log(` 📤 Auto-exported to QBO: #${qboResult.qbo_doc_number}`);
|
||||||
} else if (qboResult.skipped) {
|
} else if (qboResult.skipped) {
|
||||||
@@ -129,18 +196,24 @@ async function processRecurringInvoices() {
|
|||||||
dbClient.release();
|
dbClient.release();
|
||||||
}
|
}
|
||||||
} catch (qboErr) {
|
} catch (qboErr) {
|
||||||
console.error(` ⚠️ QBO auto-export failed for recurring invoice ${newInvoiceId}:`, qboErr.message);
|
console.error(
|
||||||
|
` ⚠️ QBO auto-export failed for recurring invoice ${newInvoiceId}:`,
|
||||||
|
qboErr.message
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
created++;
|
created++;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await client.query('ROLLBACK');
|
await client.query('ROLLBACK');
|
||||||
console.error(` ❌ Failed to create recurring invoice from #${source.invoice_number || source.id}:`, err.message);
|
console.error(
|
||||||
|
` ❌ Failed to create recurring invoice from #${source.invoice_number || source.id}:`,
|
||||||
|
err.message
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`🔄 [RECURRING] Done. Created ${created} invoice(s).`);
|
console.log(`🔄 [RECURRING] Done. Created ${created} invoice(s), skipped ${skipped}.`);
|
||||||
return { created };
|
return { created, skipped };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ [RECURRING] Error:', error.message);
|
console.error('❌ [RECURRING] Error:', error.message);
|
||||||
return { created: 0, error: error.message };
|
return { created: 0, error: error.message };
|
||||||
|
|||||||
Reference in New Issue
Block a user