From b644253e0b863704720d391292354887e6108fb7 Mon Sep 17 00:00:00 2001 From: Andreas Knuth Date: Wed, 19 Aug 2026 19:18:45 +0200 Subject: [PATCH] guthaben --- migrations/add-invoice-credit.sql | 14 +++ public/index.html | 37 ++++++++ public/js/modals/invoice-modal.js | 137 +++++++++++++++++++++++++++++ public/js/utils/api.js | 3 + public/js/views/accounting-view.js | 59 +++++++++++++ src/routes/accounting.js | 7 ++ src/routes/invoices.js | 51 ++++++++--- src/services/accounting-service.js | 49 +++++++++++ src/services/pdf-service.js | 42 +++++++-- 9 files changed, 379 insertions(+), 20 deletions(-) create mode 100644 migrations/add-invoice-credit.sql diff --git a/migrations/add-invoice-credit.sql b/migrations/add-invoice-credit.sql new file mode 100644 index 0000000..d8f6048 --- /dev/null +++ b/migrations/add-invoice-credit.sql @@ -0,0 +1,14 @@ +-- Guthaben (Customer Credit), das auf einer Rechnung ausgewiesen wird. +-- +-- Bewusst nur zwei Spalten auf der Rechnung statt einer eigenen Guthabentabelle: +-- FΓΌhrend dafΓΌr, wieviel Guthaben ein Kunde noch hat, bleibt QBO. Die App merkt +-- sich ausschliesslich, welcher Betrag auf DIESER Rechnung ausgewiesen wurde -- +-- genau das, was der Ausdruck braucht. Damit kann kein zweiter Guthabenbestand +-- entstehen, der von QBO abweicht. +-- +-- credit_applied mindert NICHT subtotal/tax/total: Das Guthaben ist eine +-- Zahlungsverrechnung, kein Rabatt. Die Steuer auf die neue Leistung faellt voll +-- an, und die Umsatzreports bleiben unberuehrt. +ALTER TABLE public.invoices + ADD COLUMN IF NOT EXISTS credit_applied numeric(12,2) NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS credit_memo character varying(255); diff --git a/public/index.html b/public/index.html index d6c0cd1..4500da2 100644 --- a/public/index.html +++ b/public/index.html @@ -129,6 +129,11 @@
+
+

Customer Credits

+
+
+

Register

@@ -516,6 +521,19 @@ + + +
@@ -530,6 +548,25 @@ TOTAL: $0.00
+ + +
diff --git a/public/js/modals/invoice-modal.js b/public/js/modals/invoice-modal.js index 6400665..c1265db 100644 --- a/public/js/modals/invoice-modal.js +++ b/public/js/modals/invoice-modal.js @@ -68,6 +68,109 @@ function applyCustomerTaxStatus(customerId) { } } +// ──────────────────────────────────────────────────────────────────── +// Customer Credit +// +// Das Guthaben selbst wird in QBO gefuehrt (negativer A/R-Saldo). Die App +// merkt sich nur, welcher Betrag auf DIESER Rechnung ausgewiesen wird, damit +// der Kunde den Endbetrag nachvollziehen kann. Subtotal, Tax und Total bleiben +// unberuehrt β€” ein Guthaben ist eine Zahlungsverrechnung, kein Rabatt. +// ──────────────────────────────────────────────────────────────────── + +/** Guthaben des aktuell gewaehlten Kunden laut QBO (null = noch nicht geprueft). */ +let availableCredit = null; + +function creditEls() { + return { + banner: document.getElementById('invoice-credit-banner'), + text: document.getElementById('invoice-credit-available-text'), + fields: document.getElementById('invoice-credit-fields'), + amount: document.getElementById('invoice-credit-applied'), + memo: document.getElementById('invoice-credit-memo'), + balance: document.getElementById('invoice-balance-due') + }; +} + +/** Betrag im Guthabenfeld β€” 0, wenn leer oder ungueltig. */ +function appliedCredit() { + const el = document.getElementById('invoice-credit-applied'); + const n = parseFloat(el?.value); + return isFinite(n) && n > 0 ? n : 0; +} + +function resetCreditUi() { + availableCredit = null; + const e = creditEls(); + if (e.banner) e.banner.classList.add('hidden'); + if (e.fields) e.fields.style.display = 'none'; + if (e.amount) e.amount.value = '0'; + if (e.memo) e.memo.value = ''; +} + +/** + * Fragt QBO nach Guthaben des gewaehlten Kunden. Fehler bleiben stumm: ein + * nicht erreichbares QBO darf das Anlegen einer Rechnung nicht blockieren. + */ +async function checkCustomerCredit(customerId) { + availableCredit = null; + const e = creditEls(); + if (e.banner) e.banner.classList.add('hidden'); + if (!customerId) return; + + try { + const credits = await window.API.accounting.getCustomerCredits(); + if (!Array.isArray(credits)) return; + const match = credits.find(c => c.customerId === parseInt(customerId)); + if (!match || !(match.credit > 0)) return; + + availableCredit = match.credit; + if (e.text) e.text.textContent = + `${match.name} has a credit of $${match.credit.toFixed(2)} on account.`; + if (e.banner) e.banner.classList.remove('hidden'); + } catch (err) { + console.log('Customer credit check skipped:', err.message); + } +} + +/** Uebernimmt das Guthaben in die Rechnung β€” gedeckelt auf das Rechnungstotal. */ +export function applyCustomerCredit() { + if (!(availableCredit > 0)) return; + const e = creditEls(); + const total = currentInvoiceTotal(); + const value = Math.min(availableCredit, total); + + if (value <= 0) { + alert('Please add items first β€” a credit can only be applied to an invoice amount.'); + return; + } + if (e.fields) e.fields.style.display = 'block'; + if (e.amount) e.amount.value = value.toFixed(2); + if (e.memo && !e.memo.value) e.memo.value = 'Credit on account'; + if (value < availableCredit) { + alert(`Only $${value.toFixed(2)} of the $${availableCredit.toFixed(2)} credit fits on this invoice. ` + + 'The remainder stays on account in QBO.'); + } + updateInvoiceTotals(); +} + +export function clearCustomerCredit() { + const e = creditEls(); + if (e.amount) e.amount.value = '0'; + if (e.memo) e.memo.value = ''; + if (e.fields) e.fields.style.display = 'none'; + updateInvoiceTotals(); +} + +function currentInvoiceTotal() { + const items = getItems('invoice-items'); + const taxExempt = document.getElementById('invoice-tax-exempt')?.checked; + let subtotal = 0; + items.forEach(item => { + subtotal += parseFloat(String(item.amount).replace(/[$,]/g, '')) || 0; + }); + return subtotal + (taxExempt ? 0 : subtotal * 8.25 / 100); +} + function updateRecurringChildUi(invoice = null) { const recurringCb = document.getElementById('invoice-recurring'); if (!recurringCb) return; @@ -205,6 +308,17 @@ async function loadInvoiceForEdit(invoiceId) { document.getElementById('invoice-authorization').value = data.invoice.auth_code || ''; document.getElementById('invoice-tax-exempt').checked = data.invoice.tax_exempt; document.getElementById('invoice-bill-to-name').value = data.invoice.bill_to_name || ''; + + // Guthaben einer bestehenden Rechnung wiederherstellen + resetCreditUi(); + const savedCredit = parseFloat(data.invoice.credit_applied) || 0; + if (savedCredit > 0) { + const ce = creditEls(); + if (ce.fields) ce.fields.style.display = 'block'; + if (ce.amount) ce.amount.value = savedCredit.toFixed(2); + if (ce.memo) ce.memo.value = data.invoice.credit_memo || ''; + } + checkCustomerCredit(data.invoice.customer_id); // Worker populateWorkerDropdown(); @@ -254,6 +368,7 @@ function prepareNewInvoice() { document.getElementById('invoice-modal-title').textContent = 'New Invoice'; document.getElementById('invoice-form').reset(); document.getElementById('invoice-items').innerHTML = ''; + resetCreditUi(); document.getElementById('invoice-terms').value = 'Net 14'; document.getElementById('invoice-number').value = ''; document.getElementById('invoice-send-date').value = ''; @@ -297,6 +412,18 @@ export function updateInvoiceTotals() { document.getElementById('invoice-tax').textContent = taxExempt ? '$0.00' : `$${taxAmount.toFixed(2)}`; document.getElementById('invoice-total').textContent = `$${total.toFixed(2)}`; document.getElementById('invoice-tax-row').style.display = taxExempt ? 'none' : 'block'; + + // Guthabenzeile: Betrag nie ueber dem Total, sonst waere die Balance Due negativ. + const credit = appliedCredit(); + const e = creditEls(); + if (credit > 0) { + if (credit > total && e.amount) e.amount.value = total.toFixed(2); + const applied = Math.min(credit, total); + if (e.fields) e.fields.style.display = 'block'; + if (e.balance) e.balance.textContent = `$${(total - applied).toFixed(2)}`; + } else if (e.fields) { + e.fields.style.display = 'none'; + } } export async function handleInvoiceSubmit(e) { @@ -317,6 +444,8 @@ export async function handleInvoiceSubmit(e) { worker: document.getElementById('invoice-worker')?.value || null, is_recurring: isRecurring, recurring_interval: recurringInterval, + credit_applied: appliedCredit(), + credit_memo: document.getElementById('invoice-credit-memo')?.value?.trim() || null, items: getItems('invoice-items') }; @@ -357,6 +486,9 @@ export function initInvoiceModal() { const taxExempt = document.getElementById('invoice-tax-exempt'); if (taxExempt) taxExempt.addEventListener('change', updateInvoiceTotals); + const creditAmount = document.getElementById('invoice-credit-applied'); + if (creditAmount) creditAmount.addEventListener('input', updateInvoiceTotals); + // Recurring toggle const recurringCb = document.getElementById('invoice-recurring'); const recurringGroup = document.getElementById('invoice-recurring-group'); @@ -373,12 +505,17 @@ export function initInvoiceModal() { // Only auto-apply when creating new (not editing existing) if (!currentInvoiceId && customerHidden.value) { applyCustomerTaxStatus(customerHidden.value); + checkCustomerCredit(customerHidden.value); } }); observer.observe(customerHidden, { attributes: true, attributeFilter: ['value'] }); } } +window.invoiceModal = { + applyCustomerCredit, + clearCustomerCredit +}; window.openInvoiceModal = openInvoiceModal; window.closeInvoiceModal = closeInvoiceModal; window.addInvoiceItem = addInvoiceItem; diff --git a/public/js/utils/api.js b/public/js/utils/api.js index f588e64..d9f8f95 100644 --- a/public/js/utils/api.js +++ b/public/js/utils/api.js @@ -155,6 +155,9 @@ const API = { body: JSON.stringify({ paidDate }) }).then(r => r.json()), + // Kunden mit Guthaben (live aus QBO) + getCustomerCredits: () => fetch('/api/accounting/customer-credits').then(r => r.json()), + // Customer Revenue Report getCustomerRevenue: (startDate, endDate) => { const params = new URLSearchParams({ startDate, endDate }); diff --git a/public/js/views/accounting-view.js b/public/js/views/accounting-view.js index e575882..34f4ce0 100644 --- a/public/js/views/accounting-view.js +++ b/public/js/views/accounting-view.js @@ -188,6 +188,62 @@ function renderAccountCard(a) { `; } +// ──────────────────────────────────────────────────────────────────── +// Customer Credits +// +// Kunden mit negativem A/R-Saldo in QBO β€” typischerweise Doppelzahlungen, die +// im Banking-Screen als Einzahlung auf Accounts Receivable aufgelΓΆst wurden. +// FΓΌhrend ist QBO; die App hΓ€lt dazu keinen eigenen Bestand. +// ──────────────────────────────────────────────────────────────────── + +export async function loadCustomerCredits() { + const slot = 'accounting-credits'; + showLoading(slot, 'Loading customer credits from QBO…'); + + try { + const credits = await window.API.accounting.getCustomerCredits(); + if (credits.error) return showError(slot, credits.error); + + const el = document.getElementById(slot); + if (!credits.length) { + el.innerHTML = `
No customer has a credit balance.
`; + return; + } + + const total = credits.reduce((s, c) => s + (Number(c.credit) || 0), 0); + el.innerHTML = ` +
+ + + + + + + + + ${credits.map(c => ` + + + + `).join('')} + + + + + +
CustomerCredit
${escapeHtml(c.name)}${fmtMoney(c.credit)}
TOTAL (${credits.length} customer${credits.length === 1 ? '' : 's'})${fmtMoney(total)}
+
+

+ Net A/R balance from QBO. A customer with a credit and open invoices at the same time + nets out and is not listed here. Apply a credit in QBO when receiving payment, then run + Sync payments. +

`; + } catch (err) { + console.error('Customer credits load failed:', err); + showError(slot, err.message || 'Failed to load customer credits'); + } +} + // ──────────────────────────────────────────────────────────────────── // Register // ──────────────────────────────────────────────────────────────────── @@ -500,11 +556,13 @@ export function renderAccountingView() { maybeAutoSyncCaches().then(() => { loadAccountsOverview(); + loadCustomerCredits(); }); } export function refreshAll() { loadAccountsOverview(); + loadCustomerCredits(); if (registerAccountId) loadRegister(); } export async function editExpense(expenseJson) { @@ -533,6 +591,7 @@ window.accountingView = { refreshAll, manualSync, loadAccountsOverview, + loadCustomerCredits, loadRegister, loadExpenses, openNewExpense, diff --git a/src/routes/accounting.js b/src/routes/accounting.js index 3a5b1e5..469bb1a 100644 --- a/src/routes/accounting.js +++ b/src/routes/accounting.js @@ -63,6 +63,13 @@ router.get('/accounts', async (req, res) => { } catch (err) { handleQboError(err, res, 'accounts'); } }); +// Kunden mit Guthaben (negativer A/R-Saldo in QBO) β€” siehe listCustomerCredits(). +router.get('/customer-credits', async (req, res) => { + try { + res.json(await accountingService.listCustomerCredits()); + } catch (err) { handleQboError(err, res, 'customer-credits'); } +}); + router.get('/register', async (req, res) => { const { accountId, startDate, endDate } = req.query; if (!accountId) return res.status(400).json({ error: 'accountId is required' }); diff --git a/src/routes/invoices.js b/src/routes/invoices.js index 6eea57b..bf1fb98 100644 --- a/src/routes/invoices.js +++ b/src/routes/invoices.js @@ -16,6 +16,20 @@ const { sendInvoiceEmail } = require('../services/email-service'); const { createPaymentLink, checkPaymentStatus, deactivatePaymentLink } = require('../services/stripe-service'); const { recordStripePaymentInQbo } = require('../services/qbo-service'); +/** + * Guthaben, das auf der Rechnung ausgewiesen wird (Customer Credit aus QBO). + * + * Nie negativ und nie hoeher als das Rechnungstotal: Mehr Guthaben als + * Rechnungsbetrag laesst sich auf diesem Dokument nicht ausweisen, der Rest + * bleibt in QBO stehen. Der Wert mindert bewusst weder subtotal noch tax_amount + * noch total -- ein Guthaben ist eine Zahlungsverrechnung, kein Rabatt. + */ +function sanitizeCredit(value, total) { + const n = parseFloat(value); + if (!isFinite(n) || n <= 0) return 0; + return Math.round(Math.min(n, parseFloat(total) || 0) * 100) / 100; +} + function calculateNextRecurringDate(invoiceDate, interval) { const d = new Date(invoiceDate); if (interval === 'monthly') { @@ -160,7 +174,7 @@ router.get('/:id', async (req, res) => { // POST create invoice router.post('/', async (req, res) => { - const { invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, items, scheduled_send_date, bill_to_name, created_from_quote_id, is_recurring, recurring_interval, worker } = req.body; + const { invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, items, scheduled_send_date, bill_to_name, created_from_quote_id, is_recurring, recurring_interval, worker, credit_applied, credit_memo } = req.body; const client = await pool.connect(); try { @@ -208,10 +222,11 @@ router.post('/', async (req, res) => { const tax_amount = tax_exempt ? 0 : (subtotal * tax_rate / 100); const total = subtotal + tax_amount; const next_recurring_date = is_recurring ? calculateNextRecurringDate(invoice_date, recurring_interval) : null; + const creditApplied = sanitizeCredit(credit_applied, total); const invoiceResult = await client.query( - `INSERT INTO invoices (invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, scheduled_send_date, bill_to_name, created_from_quote_id, is_recurring, recurring_interval, next_recurring_date, worker) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17) RETURNING *`, - [tempNumber, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, scheduled_send_date || null, bill_to_name || null, created_from_quote_id, is_recurring || false, recurring_interval || null, next_recurring_date, worker || null] + `INSERT INTO invoices (invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, scheduled_send_date, bill_to_name, created_from_quote_id, is_recurring, recurring_interval, next_recurring_date, worker, credit_applied, credit_memo) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) RETURNING *`, + [tempNumber, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, scheduled_send_date || null, bill_to_name || null, created_from_quote_id, is_recurring || false, recurring_interval || null, next_recurring_date, worker || null, creditApplied, creditApplied > 0 ? (credit_memo || null) : null] ); const invoiceId = invoiceResult.rows[0].id; @@ -253,7 +268,7 @@ router.post('/', async (req, res) => { // PUT update invoice router.put('/:id', async (req, res) => { const { id } = req.params; - const { invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, items, scheduled_send_date, bill_to_name, is_recurring, recurring_interval, worker } = req.body; + const { invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, items, scheduled_send_date, bill_to_name, is_recurring, recurring_interval, worker, credit_applied, credit_memo } = req.body; const client = await pool.connect(); try { await client.query('BEGIN'); @@ -299,19 +314,23 @@ router.put('/:id', async (req, res) => { const total = subtotal + tax_amount; // Update local + const creditApplied = sanitizeCredit(credit_applied, total); + const creditMemo = creditApplied > 0 ? (credit_memo || null) : null; if (invoice_number) { await client.query( `UPDATE invoices SET invoice_number = $1, customer_id = $2, invoice_date = $3, terms = $4, auth_code = $5, tax_exempt = $6, - tax_rate = $7, subtotal = $8, tax_amount = $9, total = $10, scheduled_send_date = $11, bill_to_name = $12, worker = $13, updated_at = CURRENT_TIMESTAMP - WHERE id = $14`, - [invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, scheduled_send_date || null, bill_to_name || null, worker || null, id] + tax_rate = $7, subtotal = $8, tax_amount = $9, total = $10, scheduled_send_date = $11, bill_to_name = $12, worker = $13, + credit_applied = $14, credit_memo = $15, updated_at = CURRENT_TIMESTAMP + WHERE id = $16`, + [invoice_number, customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, scheduled_send_date || null, bill_to_name || null, worker || null, creditApplied, creditMemo, id] ); } else { await client.query( `UPDATE invoices SET customer_id = $1, invoice_date = $2, terms = $3, auth_code = $4, tax_exempt = $5, - tax_rate = $6, subtotal = $7, tax_amount = $8, total = $9, scheduled_send_date = $10, bill_to_name = $11, worker = $12, updated_at = CURRENT_TIMESTAMP - WHERE id = $13`, - [customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, scheduled_send_date || null, bill_to_name || null, worker || null, id] + tax_rate = $6, subtotal = $7, tax_amount = $8, total = $9, scheduled_send_date = $10, bill_to_name = $11, worker = $12, + credit_applied = $13, credit_memo = $14, updated_at = CURRENT_TIMESTAMP + WHERE id = $15`, + [customer_id, invoice_date, terms, auth_code, tax_exempt, tax_rate, subtotal, tax_amount, total, scheduled_send_date || null, bill_to_name || null, worker || null, creditApplied, creditMemo, id] ); } @@ -1086,7 +1105,15 @@ router.post('/:id/create-payment-link', async (req, res) => { const invoice = invoiceResult.rows[0]; invoice.amount_paid = parseFloat(invoice.amount_paid) || 0; - invoice.balance = (parseFloat(invoice.total) || 0) - invoice.amount_paid; + + // Der Zahlungslink muss exakt die BALANCE DUE des Ausdrucks verlangen. + // Ein ausgewiesenes Guthaben zaehlt also mit -- dieselbe Rechnung wie in + // renderInvoiceItems(): solange das Guthaben in QBO noch nicht angewendet + // und zurueckgesynct wurde, steht es nur in credit_applied, danach + // zusaetzlich in amount_paid, und der Abzug bleibt derselbe. + const creditApplied = parseFloat(invoice.credit_applied) || 0; + const paymentsShown = Math.max(0, invoice.amount_paid - creditApplied); + invoice.balance = (parseFloat(invoice.total) || 0) - creditApplied - paymentsShown; if (invoice.balance <= 0) { return res.status(400).json({ error: 'Invoice has no balance due.' }); diff --git a/src/services/accounting-service.js b/src/services/accounting-service.js index 8b8f0df..3e66a8c 100644 --- a/src/services/accounting-service.js +++ b/src/services/accounting-service.js @@ -173,6 +173,54 @@ async function listAccounts({ type = null, activeOnly = true } = {}) { })); } +/** + * Kunden mit Guthaben (negativer A/R-Saldo) -- live aus QBO. + * + * Bewusst ohne lokale Guthabentabelle: Fuehrend dafuer, wieviel Guthaben ein + * Kunde hat, ist QBO. Eine lokale Kopie muesste bei jeder Anwendung, Gutschrift + * und Stornierung nachgezogen werden und waere die erste Zahl, die auseinander + * laeuft. + * + * Warum Customer.Balance und nicht die "unangewendeten" Felder einer Payment: + * Ein Guthaben entsteht hier typischerweise als Einzahlung auf Accounts + * Receivable (so wird eine Doppelzahlung im Banking-Screen aufgeloest). Solche + * Einzahlungen sind keine Payment-Objekte und tauchen in Payment.UnappliedAmt + * nie auf -- Customer.Balance erfasst dagegen Einzahlungen, Credit Memos und + * unangewendete Zahlungen gleichermassen. + * + * Grenze der Methode: Balance ist der NETTO-Saldo. Ein Kunde mit Guthaben und + * gleichzeitig offener Rechnung saldiert sich heraus und erscheint hier nicht. + * Fuer den Regelfall -- Guthaben ohne offene Rechnung -- ist es exakt. + * + * @returns [{ qboId, name, credit, customerId }] -- credit als positive Zahl, + * customerId ist die lokale Kunden-ID (null, wenn nicht zuordenbar). + */ +async function listCustomerCredits() { + // Gefiltert wird bewusst in JS, nicht in der QBO-Query: Balance gehoert bei + // Customer nicht zu den filterbaren Attributen der QBO-Query-Sprache, ein + // "WHERE Balance < 0" quittiert QBO mit einem Fault. Bei dieser Kundenzahl + // ist das genau ein Seitenabruf ueber queryAll(). + const customers = (await queryAll('Customer', 'Active = true', 'DisplayName ASC')) + .filter(c => (Number(c.Balance) || 0) < 0); + if (!customers.length) return []; + + // Zuordnung zum lokalen Kundenstamm ueber customers.qbo_id, damit der + // Rechnungsdialog das Guthaben ohne zweite QBO-Abfrage anzeigen kann. + const localResult = await pool.query( + 'SELECT id, qbo_id FROM customers WHERE qbo_id = ANY($1::text[])', + [customers.map(c => String(c.Id))] + ); + const localByQboId = new Map(localResult.rows.map(r => [String(r.qbo_id), r.id])); + + return customers.map(c => ({ + qboId: String(c.Id), + name: c.DisplayName, + credit: Math.abs(Number(c.Balance) || 0), + customerId: localByQboId.get(String(c.Id)) ?? null + })).filter(c => c.credit > 0) + .sort((a, b) => b.credit - a.credit); +} + async function getRegister({ accountId, startDate, endDate, includeSplits = true }) { if (!accountId) throw new Error('accountId is required'); const { companyId, baseUrl } = getClientInfo(); @@ -1634,6 +1682,7 @@ async function attachFileToEntity({ entityType, entityId, fileBuffer, fileName, module.exports = { // Phase 1 listAccounts, + listCustomerCredits, getRegister, getProfitAndLoss, getBalanceSheet, diff --git a/src/services/pdf-service.js b/src/services/pdf-service.js index 29a9cb9..9e261d1 100644 --- a/src/services/pdf-service.js +++ b/src/services/pdf-service.js @@ -112,7 +112,19 @@ function renderInvoiceItems(items, invoice = null) { // Add total const amountPaid = invoice ? (parseFloat(invoice.amount_paid) || 0) : 0; const total = invoice ? parseFloat(invoice.total) : 0; - const balanceDue = total - amountPaid; + + // Guthaben (Customer Credit) steht als eigene Zeile UNTER dem Total, nicht als + // negative Position: Es ist eine Zahlungsverrechnung, kein Rabatt -- Subtotal + // und Sales Tax bleiben davon unberuehrt. + // + // Sobald das Guthaben in QBO angewendet und per Sync-Payments zurueckgeholt + // wurde, steckt derselbe Betrag zusaetzlich in amount_paid. Ohne die Differenz + // stuende er zweimal auf der Rechnung; so zeigt der Ausdruck vor und nach dem + // Sync dieselbe Balance Due. + const creditApplied = invoice ? (parseFloat(invoice.credit_applied) || 0) : 0; + const paymentsShown = Math.max(0, amountPaid - creditApplied); + const deductions = creditApplied + paymentsShown; + const balanceDue = total - deductions; itemsHTML += ` @@ -120,17 +132,31 @@ function renderInvoiceItems(items, invoice = null) { $${formatMoney(total)} `; - // Add downpayment/balance if partial - // Add downpayment/balance if partial - if (amountPaid > 0) { + // Add credit / downpayment / balance + if (deductions > 0) { const isFullyPaid = balanceDue <= 0.01; // allow for rounding - const paymentLabel = isFullyPaid ? 'Payment:' : 'Downpayment:'; - - itemsHTML += ` + + // Das Memo nennt dem Kunden den Grund (z.B. die Check-Nummer der + // Doppelzahlung) -- ohne diesen Hinweis ist der Endbetrag nicht nachvollziehbar. + if (creditApplied > 0) { + const memo = invoice.credit_memo + ? ` (${String(invoice.credit_memo).replace(/&/g, '&').replace(//g, '>')})` + : ''; + itemsHTML += ` + + Less: credit on account${memo}: + -$${formatMoney(creditApplied)} + `; + } + + if (paymentsShown > 0) { + const paymentLabel = isFullyPaid ? 'Payment:' : 'Downpayment:'; + itemsHTML += ` ${paymentLabel} - -$${formatMoney(amountPaid)} + -$${formatMoney(paymentsShown)} `; + } // Only show BALANCE DUE row if there's actually a remaining balance if (!isFullyPaid) {