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 | +Credit | +
|---|---|
| ${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 += `