Dauerzahlungen zu einem Vertrag gelten als belegt

Bei einer monatlichen Praemie gibt es keinen Beleg - es gibt einen
Vertrag, und der wird einmal abgeschlossen, nicht monatlich quittiert.
Bisher blieb nur ein Freitextfeld pro Buchung: zwoelfmal im Jahr
derselbe Satz, mal Anzahl der Vertraege.

Buchung.vertragId mit onDelete: SetNull. Der Belegzustand kennt jetzt
drei Faelle statt zwei, getrennt ausgewiesen - ein Vertrag belegt, eine
Begruendung erklaert nur; zusammengeworfen verliert die Belegquote ihre
Aussage gegenueber dem Rechtspfleger.

Dazu: automatische Zuordnung beim Import bei eindeutiger Gegenstelle,
Sammelzuordnung ueber die Gruppen aus Issue #3, und die Rechnungslegung
nennt den Vertrag samt Nummer statt "ohne Beleg".

Die Sicherung dagegen: Weicht der Betrag um mehr als ein Fuenftel vom
hinterlegten Beitrag ab, faellt die Anerkennung weg und die Buchung wird
als "Betrag pruefen" markiert. Sonst waere die Zuordnung eine Hintertuer.

Zur Toleranz - ein Entwurf und seine Korrektur: Zuerst verglich ich den
Betrag gegen alle ueblichen Zahlweisen, damit es auch passt, wenn die
Bank anders abbucht als der Vertrag es fuehrt. Der eigene Test hat das
widerlegt: Bei 42,50 monatlich liegen 420 innerhalb von 20 Prozent des
Jahresbetrags von 510 - ausgerechnet der Fall aus der Aufgabenstellung
waere durchgerutscht. Jetzt wird stur gegen den hinterlegten Beitrag
verglichen. Bucht die Bank anders ab, stimmen die Vertragsdaten nicht,
und das soll auffallen.

Behoben: Die Buchungsliste hatte eine eigene, fast gleiche Datenabfrage
neben der aus berichte/gemeinsam.ts. Nur eine von beiden wurde
nachgezogen, in der Liste stand weiter "Beleg fehlt". Beide benutzen
jetzt BUCHUNG_EINBEZUG und zuBuchungEingabe - das kann nicht halb
vergessen werden. Gefunden hat es der Browsertest.

Nebenbei: Die Roadmap trug noch "Aktuelle Fassung 1.1.2" - Nachwirkung
der parallelen Bearbeitung von #3 und #10. Nachgezogen wie zugesagt.

Geprueft: 23 neue Einheitstests, die Haelfte davon darauf gerichtet, dass
die Anerkennung auch wirklich wegfaellt. Fuenf End-to-End-Tests auf
beiden Geraeteprofilen. 560 Unit-Tests, 63 e2e, null Typfehler.

Refs #11
This commit is contained in:
Florian Wilde
2026-08-23 09:00:46 +02:00
parent 8779cc6a25
commit fa994350d6
28 changed files with 1105 additions and 96 deletions
+40 -1
View File
@@ -1,6 +1,6 @@
import * as React from 'react'
import Link from 'next/link'
import { AlertTriangle, FileWarning, Paperclip } from 'lucide-react'
import { AlertTriangle, FileCheck, FileWarning, Paperclip } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent } from '@/components/ui/card'
import { cn, formatCent } from '@/lib/utils'
@@ -104,10 +104,19 @@ export function Belegzeichen({
belegTitel,
begruendung,
belegId,
vertragBezeichnung = null,
vertragAutomatisch = false,
betragZuPruefen = false,
}: {
belegId: string | null
belegTitel: string | null
begruendung: string | null
/** Dauerzahlung: gesetzt, wenn der Vertrag als Beleg gilt. */
vertragBezeichnung?: string | null
/** Beim Import automatisch zugeordnet - eine Vermutung, kein Urteil. */
vertragAutomatisch?: boolean
/** Zugeordnet, aber der Betrag weicht ab. */
betragZuPruefen?: boolean
}) {
if (belegId) {
return (
@@ -118,6 +127,36 @@ export function Belegzeichen({
)
}
// Zugeordnet, aber der Betrag passt nicht. Steht vor allem anderen: Das
// ist der Fall, den jemand ansehen soll.
if (betragZuPruefen) {
return (
<Badge
variant="rot"
title="Der Betrag weicht deutlich vom hinterlegten Beitrag ab. Meist eine Beitragsanpassung, die am Vertrag noch fehlt."
>
<AlertTriangle className="size-3.5" aria-hidden="true" />
Betrag prüfen
</Badge>
)
}
if (vertragBezeichnung) {
return (
<Badge
variant="gruen"
title={
vertragAutomatisch
? `Beim Import automatisch dem Vertrag „${vertragBezeichnung}“ zugeordnet. Bitte einmal durchsehen.`
: `Dauerzahlung zu Vertrag „${vertragBezeichnung}“.`
}
>
<FileCheck className="size-3.5" aria-hidden="true" />
{vertragAutomatisch ? 'Vertrag (automatisch)' : 'durch Vertrag belegt'}
</Badge>
)
}
if (begruendung && begruendung.trim() !== '') {
return (
<Badge variant="gelb" title={begruendung}>
+34 -1
View File
@@ -33,6 +33,12 @@ export type BelegAuswahl = {
titel: string
}
export type VertragAuswahl = {
id: string
bezeichnung: string
gesellschaft: string | null
}
export type BuchungWerte = {
id: string
kontoId: string | null
@@ -45,6 +51,7 @@ export type BuchungWerte = {
verwendungszweck: string | null
belegId: string | null
belegFehltBegruendung: string | null
vertragId: string | null
betrifftBetreute: boolean
vermoegenswirksam: boolean
}
@@ -65,6 +72,7 @@ export function Buchungsformular({
betreuteId,
konten,
belege,
vertraege,
buchung,
standardDatum,
schnellerfassung = false,
@@ -72,6 +80,7 @@ export function Buchungsformular({
betreuteId: string
konten: KontoAuswahl[]
belege: BelegAuswahl[]
vertraege: VertragAuswahl[]
buchung?: BuchungWerte
/** Vorbelegung des Datumsfeldes bei einer neuen Buchung. */
standardDatum?: string
@@ -87,6 +96,7 @@ export function Buchungsformular({
)
const [betrifftBetreute, setBetrifftBetreute] = React.useState(buchung?.betrifftBetreute ?? true)
const [belegId, setBelegId] = React.useState(buchung?.belegId ?? '')
const [vertragId, setVertragId] = React.useState(buchung?.vertragId ?? '')
const konto = konten.find((k) => k.id === kontoId)
const vermischt = Boolean(konto?.istBetreuerkonto) && betrifftBetreute
@@ -275,7 +285,30 @@ export function Buchungsformular({
</Select>
</Feld>
{belegId === '' && (
{belegId === '' && vertraege.length > 0 && (
<Feld
id="vertragId"
beschriftung="Oder: Dauerzahlung zu einem Vertrag"
erklaerung="Bei einem Dauerschuldverhältnis ist der Vertrag der Beleg — für zwölf gleiche Lastschriften gibt es keine zwölf Quittungen, sondern eine Police. Weicht der Betrag deutlich vom hinterlegten Beitrag ab, wird die Buchung trotzdem zur Prüfung markiert."
fehler={zustand.felder?.vertragId}
>
<Select
name="vertragId"
value={vertragId}
onChange={(ereignis) => setVertragId(ereignis.target.value)}
>
<option value="">– keine Dauerzahlung –</option>
{vertraege.map((vertrag) => (
<option key={vertrag.id} value={vertrag.id}>
{vertrag.bezeichnung}
{vertrag.gesellschaft ? ` (${vertrag.gesellschaft})` : ''}
</option>
))}
</Select>
</Feld>
)}
{belegId === '' && vertragId === '' && (
<Feld
id="belegFehltBegruendung"
beschriftung="Warum fehlt der Beleg?"
@@ -2,12 +2,13 @@
import * as React from 'react'
import { useActionState } from 'react'
import { AlertCircle, CheckCircle2, Undo2, XCircle } from 'lucide-react'
import { AlertCircle, CheckCircle2, Link2, Undo2, XCircle } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Absendeknopf } from '@/components/finanzen/eingaben'
import {
zahlungenVertragZuordnen,
zahlungsgruppeAbhaken,
zahlungsgruppeZurueckholen,
type Formularzustand,
@@ -126,3 +127,51 @@ export function Rueckmeldung({ text }: { text: string | null }) {
</Alert>
)
}
/**
* Ordnet alle noch offenen Buchungen einer Gruppe dem Vertrag zu.
*
* Der Sinn dieses Knopfes ist die Menge: Eine monatliche Prämie über drei
* Jahre sind 36 Buchungen. Sie einzeln anzuklicken wäre genau die stumpfe
* Arbeit, die diese Zuordnung abschaffen soll.
*/
export function ZahlungenZuordnen({
betreuteId,
vertragId,
vertragBezeichnung,
buchungIds,
}: {
betreuteId: string
vertragId: string
vertragBezeichnung: string
buchungIds: string[]
}) {
const [zustand, absenden] = useActionState<Formularzustand, FormData>(
zahlungenVertragZuordnen,
{},
)
if (buchungIds.length === 0) return null
return (
<form action={absenden} className="flex flex-wrap items-center gap-2">
<input type="hidden" name="betreuteId" value={betreuteId} />
<input type="hidden" name="vertragId" value={vertragId} />
<input type="hidden" name="buchungIds" value={buchungIds.join(',')} />
{zustand.fehler && (
<span className="text-xs text-destructive">{zustand.fehler}</span>
)}
{zustand.erfolg && (
<span className="text-xs text-ampel-gruen">{zustand.erfolg}</span>
)}
<Absendeknopf variant="outline" size="sm" wartetext="Wird zugeordnet …">
<Link2 aria-hidden="true" />
{buchungIds.length} Zahlung{buchungIds.length === 1 ? '' : 'en'} als belegt zuordnen
</Absendeknopf>
<span className="text-xs text-muted-foreground">zu „{vertragBezeichnung}“</span>
</form>
)
}