feat(crm): cash-basis revenue + backdatable payment date on mark-paid

Per decision: keep dashboard revenue windows on pure cash basis (recognise
by paid_at for ALL invoices) and give the admin control over paid_at.

- adminDashboard: revert the imported-vs-native split; winSum is paid_at >=
  cutoff for every paid invoice again (clean cash basis).
- BillDetailPage mark-paid dialog: add an optional 'Payment date' field
  (LocalizedDateInput, defaults to today) so a payment can be backdated to
  when it actually arrived. Backend already accepted paidAt end-to-end
  (route validator + markPaid service + payment-log) — only the UI was
  missing. EN/DE 'bills.payment.date' added.

This fixes the collapsed 30=90=365 windows (they were collapsing because
many invoices were marked paid in one session, all stamped 'now').
This commit is contained in:
Luca
2026-06-03 18:20:55 +02:00
parent 0b4690afab
commit 82ec23824a
4 changed files with 28 additions and 25 deletions
+12 -24
View File
@@ -429,37 +429,25 @@ router.get('/crm-stats', adminAuth, async (req, res) => {
if (r.status in invoiceCounts) invoiceCounts[r.status] = Number(r.count) || 0;
}
// Revenue windows: sum of `paid_amount_minor` for invoices
// marked PAID inside the window. Using paid_amount (not total)
// so partial payments are tracked accurately. Stornos excluded
// — they're never status='paid' in normal flow but the guard
// is defensive.
// Revenue windows: sum of `paid_amount_minor` for invoices marked
// PAID whose payment date (`paid_at`) falls inside the window —
// cash-basis recognition (revenue counts when the money arrives).
// Using paid_amount (not total) so partial payments are tracked
// accurately. Stornos excluded — never status='paid' in normal flow,
// the guard is defensive.
//
// Recognition date differs by origin:
// • imported (historical) invoices → recognise on issue_date,
// their true economic date. A year-old imported invoice must
// never land in the rolling "last 30/90 days" window. This is
// robust even for rows imported BEFORE the import route began
// anchoring paid_at to issue_date (commit c6b8cc9) — those
// legacy rows still carry an import-time paid_at, so keying on
// issue_date is what actually fixes them.
// • native invoices → recognise on paid_at (cash-basis).
// `paid_at` is admin-controllable, so an old/imported invoice lands
// in the right window without any special-casing here: the mark-paid
// dialog takes an optional payment date (backdate to when the money
// actually arrived) and the historical-import route anchors paid_at
// to the invoice's issue_date.
const winSum = async (cutoff) => {
const cutoffDateStr = cutoff.toISOString().slice(0, 10);
const row = await db('invoices')
.where('status', 'paid')
.where('paid_at', '>=', cutoff)
.andWhere(function() {
this.whereNot('kind', 'storno').orWhereNull('kind');
})
.andWhere(function() {
this.where(function() {
this.whereNotNull('imported_pdf_path')
.andWhere('issue_date', '>=', cutoffDateStr);
}).orWhere(function() {
this.whereNull('imported_pdf_path')
.andWhere('paid_at', '>=', cutoff);
});
})
.sum('paid_amount_minor as total')
.first();
return Number(row?.total || 0);