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:
@@ -429,37 +429,25 @@ router.get('/crm-stats', adminAuth, async (req, res) => {
|
|||||||
if (r.status in invoiceCounts) invoiceCounts[r.status] = Number(r.count) || 0;
|
if (r.status in invoiceCounts) invoiceCounts[r.status] = Number(r.count) || 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Revenue windows: sum of `paid_amount_minor` for invoices
|
// Revenue windows: sum of `paid_amount_minor` for invoices marked
|
||||||
// marked PAID inside the window. Using paid_amount (not total)
|
// PAID whose payment date (`paid_at`) falls inside the window —
|
||||||
// so partial payments are tracked accurately. Stornos excluded
|
// cash-basis recognition (revenue counts when the money arrives).
|
||||||
// — they're never status='paid' in normal flow but the guard
|
// Using paid_amount (not total) so partial payments are tracked
|
||||||
// is defensive.
|
// accurately. Stornos excluded — never status='paid' in normal flow,
|
||||||
|
// the guard is defensive.
|
||||||
//
|
//
|
||||||
// Recognition date differs by origin:
|
// `paid_at` is admin-controllable, so an old/imported invoice lands
|
||||||
// • imported (historical) invoices → recognise on issue_date,
|
// in the right window without any special-casing here: the mark-paid
|
||||||
// their true economic date. A year-old imported invoice must
|
// dialog takes an optional payment date (backdate to when the money
|
||||||
// never land in the rolling "last 30/90 days" window. This is
|
// actually arrived) and the historical-import route anchors paid_at
|
||||||
// robust even for rows imported BEFORE the import route began
|
// to the invoice's issue_date.
|
||||||
// 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).
|
|
||||||
const winSum = async (cutoff) => {
|
const winSum = async (cutoff) => {
|
||||||
const cutoffDateStr = cutoff.toISOString().slice(0, 10);
|
|
||||||
const row = await db('invoices')
|
const row = await db('invoices')
|
||||||
.where('status', 'paid')
|
.where('status', 'paid')
|
||||||
|
.where('paid_at', '>=', cutoff)
|
||||||
.andWhere(function() {
|
.andWhere(function() {
|
||||||
this.whereNot('kind', 'storno').orWhereNull('kind');
|
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')
|
.sum('paid_amount_minor as total')
|
||||||
.first();
|
.first();
|
||||||
return Number(row?.total || 0);
|
return Number(row?.total || 0);
|
||||||
|
|||||||
@@ -3818,6 +3818,7 @@
|
|||||||
"payment": {
|
"payment": {
|
||||||
"paidAt": "Bezahlt am",
|
"paidAt": "Bezahlt am",
|
||||||
"amount": "Betrag",
|
"amount": "Betrag",
|
||||||
|
"date": "Zahlungsdatum",
|
||||||
"method": "Methode",
|
"method": "Methode",
|
||||||
"reference": "Referenz",
|
"reference": "Referenz",
|
||||||
"notes": "Notizen",
|
"notes": "Notizen",
|
||||||
|
|||||||
@@ -3807,6 +3807,7 @@
|
|||||||
"payment": {
|
"payment": {
|
||||||
"paidAt": "Date",
|
"paidAt": "Date",
|
||||||
"amount": "Amount",
|
"amount": "Amount",
|
||||||
|
"date": "Payment date",
|
||||||
"method": "Method",
|
"method": "Method",
|
||||||
"reference": "Reference",
|
"reference": "Reference",
|
||||||
"notes": "Notes",
|
"notes": "Notes",
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { ArrowLeft, Eye, Send, CheckCircle, BellRing, XCircle, Truck, Edit2, RefreshCw } from 'lucide-react';
|
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 { DocumentLineageCard } from '../../../components/admin/DocumentLineageCard';
|
||||||
import { billsService } from '../../../services/bills.service';
|
import { billsService } from '../../../services/bills.service';
|
||||||
import { formatMoney } from '../../../components/admin/LineItemsTable';
|
import { formatMoney } from '../../../components/admin/LineItemsTable';
|
||||||
@@ -29,6 +29,9 @@ export const BillDetailPage: React.FC = () => {
|
|||||||
|
|
||||||
const [payDialogOpen, setPayDialogOpen] = useState(false);
|
const [payDialogOpen, setPayDialogOpen] = useState(false);
|
||||||
const [payAmount, setPayAmount] = useState('');
|
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 [payMethod, setPayMethod] = useState('');
|
||||||
const [payReference, setPayReference] = useState('');
|
const [payReference, setPayReference] = useState('');
|
||||||
const [payNotes, setPayNotes] = useState('');
|
const [payNotes, setPayNotes] = useState('');
|
||||||
@@ -210,6 +213,7 @@ export const BillDetailPage: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
await billsService.markPaid(inv.id, {
|
await billsService.markPaid(inv.id, {
|
||||||
amountMinor: Math.round(Number(payAmount) * 100),
|
amountMinor: Math.round(Number(payAmount) * 100),
|
||||||
|
paidAt: payDate || undefined,
|
||||||
paymentMethod: payMethod || undefined,
|
paymentMethod: payMethod || undefined,
|
||||||
reference: payReference || undefined,
|
reference: payReference || undefined,
|
||||||
notes: payNotes || undefined,
|
notes: payNotes || undefined,
|
||||||
@@ -218,6 +222,7 @@ export const BillDetailPage: React.FC = () => {
|
|||||||
setPayDialogOpen(false);
|
setPayDialogOpen(false);
|
||||||
setPayAmount(''); setPayMethod(''); setPayReference(''); setPayNotes('');
|
setPayAmount(''); setPayMethod(''); setPayReference(''); setPayNotes('');
|
||||||
setPayWithSkonto(false);
|
setPayWithSkonto(false);
|
||||||
|
setPayDate(new Date().toISOString().slice(0, 10));
|
||||||
qc.invalidateQueries({ queryKey: ['invoice', id] });
|
qc.invalidateQueries({ queryKey: ['invoice', id] });
|
||||||
toast.success(t('bills.paymentRecordedToast', 'Payment recorded.'));
|
toast.success(t('bills.paymentRecordedToast', 'Payment recorded.'));
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -466,6 +471,14 @@ export const BillDetailPage: React.FC = () => {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Input type="number" step="0.01" label={t('bills.payment.amount', 'Amount') as string} value={payAmount}
|
<Input type="number" step="0.01" label={t('bills.payment.amount', 'Amount') as string} value={payAmount}
|
||||||
onChange={(e) => setPayAmount(e.target.value)} placeholder={String(outstanding.toFixed(2))} />
|
onChange={(e) => 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. */}
|
||||||
|
<LocalizedDateInput
|
||||||
|
label={t('bills.payment.date', 'Payment date') as string}
|
||||||
|
value={payDate}
|
||||||
|
onChange={setPayDate}
|
||||||
|
/>
|
||||||
{/* Skonto checkbox (migration 126). Only surfaced when
|
{/* Skonto checkbox (migration 126). Only surfaced when
|
||||||
the invoice's payment terms actually offer Skonto —
|
the invoice's payment terms actually offer Skonto —
|
||||||
the backend resolves skontoPercent from the snapshot
|
the backend resolves skontoPercent from the snapshot
|
||||||
|
|||||||
Reference in New Issue
Block a user