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

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

View File

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

View File

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

View File

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