This commit is contained in:
2026-08-19 19:18:45 +02:00
parent f8b0d52ff7
commit b644253e0b
9 changed files with 379 additions and 20 deletions

View File

@@ -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' });

View File

@@ -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.' });

View File

@@ -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,

View File

@@ -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 += `
<tr class="footer-row">
@@ -120,17 +132,31 @@ function renderInvoiceItems(items, invoice = null) {
<td class="total-amount" style="font-size: 16px;">$${formatMoney(total)}</td>
</tr>`;
// Add downpayment/balance if partial
// Add downpayment/balance if partial
if (amountPaid > 0) {
// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')})`
: '';
itemsHTML += `
<tr class="footer-row">
<td colspan="3" class="total-label" style="color: #059669;">Less: credit on account${memo}:</td>
<td class="total-amount" style="color: #059669;">-$${formatMoney(creditApplied)}</td>
</tr>`;
}
if (paymentsShown > 0) {
const paymentLabel = isFullyPaid ? 'Payment:' : 'Downpayment:';
itemsHTML += `
<tr class="footer-row">
<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(paymentsShown)}</td>
</tr>`;
}
// Only show BALANCE DUE row if there's actually a remaining balance
if (!isFullyPaid) {