guthaben
This commit is contained in:
14
migrations/add-invoice-credit.sql
Normal file
14
migrations/add-invoice-credit.sql
Normal file
@@ -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);
|
||||
@@ -129,6 +129,11 @@
|
||||
<div id="accounting-accounts"></div>
|
||||
</section>
|
||||
|
||||
<section class="mb-6">
|
||||
<h3 class="text-md font-semibold text-gray-700 mb-3">Customer Credits</h3>
|
||||
<div id="accounting-credits"></div>
|
||||
</section>
|
||||
|
||||
<section class="mb-6">
|
||||
<h3 class="text-md font-semibold text-gray-700 mb-3">Register</h3>
|
||||
<div id="accounting-register-controls"></div>
|
||||
@@ -516,6 +521,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Guthaben aus QBO: erscheint nur, wenn der gewaehlte Kunde einen
|
||||
negativen A/R-Saldo hat. Fuehrend bleibt QBO. -->
|
||||
<div id="invoice-credit-banner"
|
||||
class="hidden px-4 py-3 bg-green-50 border border-green-200 rounded-lg flex flex-wrap items-center gap-3">
|
||||
<span class="text-sm text-green-800">💰 <span id="invoice-credit-available-text"></span></span>
|
||||
<button type="button" id="invoice-credit-apply-btn"
|
||||
onclick="window.invoiceModal.applyCustomerCredit()"
|
||||
class="px-3 py-1.5 bg-green-600 hover:bg-green-700 text-white rounded-md text-sm font-medium">
|
||||
Apply to this invoice
|
||||
</button>
|
||||
<span class="text-xs text-green-700">Apply it in QBO as well when receiving payment, then run Sync payments.</span>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 p-4 rounded-lg">
|
||||
<div class="space-y-2 text-right">
|
||||
<div class="flex justify-end items-center">
|
||||
@@ -530,6 +548,25 @@
|
||||
<span class="text-lg font-bold text-gray-900 mr-4">TOTAL:</span>
|
||||
<span id="invoice-total" class="text-2xl font-bold text-blue-600">$0.00</span>
|
||||
</div>
|
||||
|
||||
<!-- Guthabenabzug: mindert bewusst weder Subtotal noch Tax -->
|
||||
<div id="invoice-credit-fields" class="pt-2 border-t border-gray-200" style="display:none">
|
||||
<div class="flex flex-wrap justify-end items-center gap-2">
|
||||
<span class="text-sm font-medium text-green-700">Less: credit on account</span>
|
||||
<input type="text" id="invoice-credit-memo" maxlength="255"
|
||||
placeholder="e.g. Double payment Check #3612"
|
||||
class="px-3 py-1.5 border border-gray-300 rounded-md text-sm w-72">
|
||||
<span class="text-sm text-green-700">−$</span>
|
||||
<input type="number" id="invoice-credit-applied" step="0.01" min="0" value="0"
|
||||
class="w-28 px-2 py-1.5 border border-gray-300 rounded-md text-sm text-right font-semibold">
|
||||
<button type="button" onclick="window.invoiceModal.clearCustomerCredit()"
|
||||
class="text-red-400 hover:text-red-600 text-sm px-1" title="Remove credit">✕</button>
|
||||
</div>
|
||||
<div class="flex justify-end items-center pt-2">
|
||||
<span class="text-lg font-bold text-gray-900 mr-4">BALANCE DUE:</span>
|
||||
<span id="invoice-balance-due" class="text-2xl font-bold text-green-700">$0.00</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -188,6 +188,62 @@ function renderAccountCard(a) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────
|
||||
// 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 = `<div class="p-4 bg-gray-50 border border-gray-200 rounded-lg text-gray-600 text-sm">No customer has a credit balance.</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const total = credits.reduce((s, c) => s + (Number(c.credit) || 0), 0);
|
||||
el.innerHTML = `
|
||||
<div class="overflow-x-auto border border-gray-200 rounded-lg bg-white">
|
||||
<table class="min-w-full text-sm">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left font-medium text-gray-700">Customer</th>
|
||||
<th class="px-3 py-2 text-right font-medium text-gray-700">Credit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${credits.map(c => `
|
||||
<tr class="border-t">
|
||||
<td class="px-3 py-2">${escapeHtml(c.name)}</td>
|
||||
<td class="px-3 py-2 text-right font-semibold text-green-700">${fmtMoney(c.credit)}</td>
|
||||
</tr>`).join('')}
|
||||
<tr class="border-t-2 border-gray-300 bg-gray-50 font-semibold">
|
||||
<td class="px-3 py-2">TOTAL (${credits.length} customer${credits.length === 1 ? '' : 's'})</td>
|
||||
<td class="px-3 py-2 text-right">${fmtMoney(total)}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500">
|
||||
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.
|
||||
</p>`;
|
||||
} 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,
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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.' });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>')})`
|
||||
: '';
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user