diff --git a/backend/src/routes/adminDashboard.js b/backend/src/routes/adminDashboard.js index 7140223d..ec4ec4b3 100644 --- a/backend/src/routes/adminDashboard.js +++ b/backend/src/routes/adminDashboard.js @@ -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); diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index f2763516..a33d2dbd 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -3818,6 +3818,7 @@ "payment": { "paidAt": "Bezahlt am", "amount": "Betrag", + "date": "Zahlungsdatum", "method": "Methode", "reference": "Referenz", "notes": "Notizen", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 16fdbe80..757c2d55 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -3807,6 +3807,7 @@ "payment": { "paidAt": "Date", "amount": "Amount", + "date": "Payment date", "method": "Method", "reference": "Reference", "notes": "Notes", diff --git a/frontend/src/pages/admin/bills/BillDetailPage.tsx b/frontend/src/pages/admin/bills/BillDetailPage.tsx index 37e960cf..6440eb38 100644 --- a/frontend/src/pages/admin/bills/BillDetailPage.tsx +++ b/frontend/src/pages/admin/bills/BillDetailPage.tsx @@ -8,7 +8,7 @@ import { useTranslation } from 'react-i18next'; import { Link, useNavigate, useParams } from 'react-router-dom'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { ArrowLeft, Eye, Send, CheckCircle, BellRing, XCircle, Truck, Edit2, RefreshCw } from 'lucide-react'; -import { Button, Card, Loading, Input } from '../../../components/common'; +import { Button, Card, Loading, Input, LocalizedDateInput } from '../../../components/common'; import { DocumentLineageCard } from '../../../components/admin/DocumentLineageCard'; import { billsService } from '../../../services/bills.service'; import { formatMoney } from '../../../components/admin/LineItemsTable'; @@ -29,6 +29,9 @@ export const BillDetailPage: React.FC = () => { const [payDialogOpen, setPayDialogOpen] = useState(false); const [payAmount, setPayAmount] = useState(''); + // Optional payment date — defaults to today, backdate it to when the + // payment actually arrived. Drives `paid_at` (cash-basis revenue windows). + const [payDate, setPayDate] = useState(new Date().toISOString().slice(0, 10)); const [payMethod, setPayMethod] = useState(''); const [payReference, setPayReference] = useState(''); const [payNotes, setPayNotes] = useState(''); @@ -210,6 +213,7 @@ export const BillDetailPage: React.FC = () => { try { await billsService.markPaid(inv.id, { amountMinor: Math.round(Number(payAmount) * 100), + paidAt: payDate || undefined, paymentMethod: payMethod || undefined, reference: payReference || undefined, notes: payNotes || undefined, @@ -218,6 +222,7 @@ export const BillDetailPage: React.FC = () => { setPayDialogOpen(false); setPayAmount(''); setPayMethod(''); setPayReference(''); setPayNotes(''); setPayWithSkonto(false); + setPayDate(new Date().toISOString().slice(0, 10)); qc.invalidateQueries({ queryKey: ['invoice', id] }); toast.success(t('bills.paymentRecordedToast', 'Payment recorded.')); } catch (e: any) { @@ -466,6 +471,14 @@ export const BillDetailPage: React.FC = () => {
setPayAmount(e.target.value)} placeholder={String(outstanding.toFixed(2))} /> + {/* Optional payment date — drives `paid_at`, which the + dashboard's cash-basis revenue windows key on. Defaults + to today; backdate it to when the payment actually arrived. */} + {/* Skonto checkbox (migration 126). Only surfaced when the invoice's payment terms actually offer Skonto — the backend resolves skontoPercent from the snapshot