Finanzen: Zuordnungsvorschlaege und Erkennung wiederkehrender Ausgaben
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3cedb5feef
commit
f14a6b7b57
4 files changed
+658
No files matched your search
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Zuordnungsvorschlaege fuer Bankeingaenge (rein, ohne I/O): Zu einer Gutschrift auf dem Konto werden
|
||||
* offene Stripe-Rechnungen oder Stripe-Auszahlungen vorgeschlagen. Die Buchung wird nie automatisch
|
||||
* zugeordnet; die Vorschlaege sind nur Hilfe fuer die Auswahl in der Oberflaeche.
|
||||
*
|
||||
* Regeln in dieser Reihenfolge, je Rechnung/Auszahlung gilt der staerkste Treffer:
|
||||
* 1. Stripe-Auszahlung (hoch): "stripe" in Gegenpartei oder Verwendungszweck, gleicher Betrag, Buchung
|
||||
* hoechstens 3 Tage von der Ankunft entfernt.
|
||||
* 2. Rechnungsnummer (hoch / mittel): Nummer im Verwendungszweck; Betrag = offener Betrag -> hoch, sonst mittel.
|
||||
* 3. Betrag und Name (mittel): Betrag = offener Betrag und ein Namenswort der Rechnung steht im Zahlernamen.
|
||||
* 4. Nur Betrag (niedrig): Betrag = offener Betrag genau einer offenen Rechnung.
|
||||
*/
|
||||
import { daysBetween } from './dates.js'
|
||||
|
||||
const MAX_SUGGESTIONS = 3
|
||||
const MAX_PAYOUT_DAYS = 3
|
||||
// Kuerzere Rechnungsnummern ("R1") stecken zufaellig in fast jedem Verwendungszweck
|
||||
const MIN_NUMBER_LENGTH = 4
|
||||
const MIN_TOKEN_LENGTH = 4
|
||||
const OPEN_STATUS = new Set(['offen', 'ueberfaellig'])
|
||||
const NAME_STOPWORDS = new Set(['gmbh', 'gbr', 'ohg', 'mbh', 'und', 'der', 'die', 'das', 'kg', 'ag', 'ug', 'haftungsbeschränkt'])
|
||||
const RANK = { hoch: 0, mittel: 1, niedrig: 2 }
|
||||
|
||||
/** Grossbuchstaben, ohne Leerraum, Bindestrich, Unterstrich, Punkt und Schraegstrich: "wk-0007" -> "WK0007". */
|
||||
export function normalizeRef(text) {
|
||||
return String(text ?? '')
|
||||
.toUpperCase()
|
||||
.replace(/[\s\-_./]+/g, '')
|
||||
}
|
||||
|
||||
/** Namenswoerter zum Vergleich: klein, an Nicht-Buchstaben getrennt (Umlaute bleiben), mindestens 4 Zeichen, ohne Rechtsformen. */
|
||||
export function nameTokens(name) {
|
||||
return String(name ?? '')
|
||||
.toLowerCase()
|
||||
.split(/[^\p{L}]+/u)
|
||||
.filter((token) => token.length >= MIN_TOKEN_LENGTH && !NAME_STOPWORDS.has(token))
|
||||
}
|
||||
|
||||
/** Abstand in Tagen; bei einem kaputten Datum Infinity, damit nichts zugeordnet wird (statt einen Fehler zu werfen). */
|
||||
function dayDistance(fromYmd, toYmd) {
|
||||
try {
|
||||
return Math.abs(daysBetween(fromYmd, toYmd))
|
||||
} catch {
|
||||
return Infinity
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bis zu 3 Vorschlaege fuer eine noch nicht zugeordnete Gutschrift, hoch -> niedrig:
|
||||
* [{ kind: 'invoice' | 'payout', id, number, confidence, reason, amountDiffers }]
|
||||
*/
|
||||
export function suggestMatches(tx, { invoices = [], payouts = [] } = {}) {
|
||||
if (!(tx.amountCents > 0) || tx.category) return []
|
||||
const suggestions = []
|
||||
const amount = tx.amountCents
|
||||
const payerName = String(tx.counterpartyName ?? '').toLowerCase()
|
||||
const remittance = normalizeRef(tx.remittance)
|
||||
|
||||
// Regel 1: Stripe-Auszahlung
|
||||
if (`${tx.counterpartyName ?? ''} ${tx.remittance ?? ''}`.toLowerCase().includes('stripe')) {
|
||||
for (const payout of payouts || []) {
|
||||
if (payout.amountCents === amount && dayDistance(payout.arrivalDate, tx.bookingDate) <= MAX_PAYOUT_DAYS) {
|
||||
suggestions.push({
|
||||
kind: 'payout',
|
||||
id: payout.id,
|
||||
number: '',
|
||||
confidence: 'hoch',
|
||||
reason: 'Stripe-Auszahlung gleicher Betrag',
|
||||
amountDiffers: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const open = (invoices || []).filter((invoice) => OPEN_STATUS.has(invoice.status))
|
||||
const taken = new Set()
|
||||
const suggestInvoice = (invoice, confidence, reason, amountDiffers) => {
|
||||
taken.add(invoice.id)
|
||||
suggestions.push({ kind: 'invoice', id: invoice.id, number: invoice.number || '', confidence, reason, amountDiffers })
|
||||
}
|
||||
|
||||
// Regel 2: Rechnungsnummer im Verwendungszweck
|
||||
for (const invoice of open) {
|
||||
const number = normalizeRef(invoice.number)
|
||||
if (number.length < MIN_NUMBER_LENGTH || !remittance.includes(number)) continue
|
||||
if (invoice.remainingCents === amount) suggestInvoice(invoice, 'hoch', 'Rechnungsnummer im Verwendungszweck', false)
|
||||
else suggestInvoice(invoice, 'mittel', 'Rechnungsnummer im Verwendungszweck, Betrag weicht ab', true)
|
||||
}
|
||||
|
||||
// Regel 3: Betrag und Name
|
||||
for (const invoice of open) {
|
||||
if (taken.has(invoice.id) || invoice.remainingCents !== amount) continue
|
||||
if (nameTokens(invoice.customerName).some((token) => payerName.includes(token))) {
|
||||
suggestInvoice(invoice, 'mittel', 'Betrag und Name passen', false)
|
||||
}
|
||||
}
|
||||
|
||||
// Regel 4: nur der Betrag, aber nur wenn er zu genau einer offenen Rechnung passt
|
||||
const sameAmount = open.filter((invoice) => invoice.remainingCents === amount)
|
||||
if (sameAmount.length === 1 && !taken.has(sameAmount[0].id)) {
|
||||
suggestInvoice(sameAmount[0], 'niedrig', 'Nur der Betrag passt', false)
|
||||
}
|
||||
|
||||
// Stabil sortiert: innerhalb einer Stufe bleibt die Reihenfolge der Regeln und der Eingabe erhalten
|
||||
return suggestions.sort((a, b) => RANK[a.confidence] - RANK[b.confidence]).slice(0, MAX_SUGGESTIONS)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Erkennung wiederkehrender Ausgaben aus den Buchungen (rein, ohne I/O): Abbuchungen derselben Gegenpartei
|
||||
* in regelmaessigem Abstand und mit nahezu gleichem Betrag, die noch nicht als feste Kosten erfasst sind.
|
||||
* Die Oberflaeche schlaegt sie dann als neue Fixkostenposition vor.
|
||||
*/
|
||||
import { daysBetween } from './dates.js'
|
||||
|
||||
const MIN_COUNT = 3
|
||||
// Abstand (Tage, Median) -> Rhythmus
|
||||
const INTERVALS = [
|
||||
{ interval: 'monat', min: 25, max: 35 },
|
||||
{ interval: 'quartal', min: 80, max: 100 },
|
||||
{ interval: 'jahr', min: 350, max: 380 },
|
||||
]
|
||||
// Variationskoeffizient (Standardabweichung / Mittelwert) der Betraege
|
||||
const MAX_AMOUNT_VARIATION = 0.15
|
||||
|
||||
/** Gegenpartei als Gruppenschluessel: klein, ohne Ziffern und Satzzeichen, Leerraum zusammengefasst. */
|
||||
function counterpartyKey(name) {
|
||||
return String(name ?? '')
|
||||
.toLowerCase()
|
||||
.replace(/[^\p{L}\s]/gu, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function median(numbers) {
|
||||
const sorted = [...numbers].sort((a, b) => a - b)
|
||||
const mid = sorted.length >> 1
|
||||
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2
|
||||
}
|
||||
|
||||
/** Variationskoeffizient (Grundgesamtheit); Infinity, wenn der Mittelwert 0 ist. */
|
||||
function variation(numbers) {
|
||||
const mean = numbers.reduce((sum, n) => sum + n, 0) / numbers.length
|
||||
if (!mean) return Infinity
|
||||
const variance = numbers.reduce((sum, n) => sum + (n - mean) ** 2, 0) / numbers.length
|
||||
return Math.sqrt(variance) / Math.abs(mean)
|
||||
}
|
||||
|
||||
/** true, wenn das Datum ein echter Kalendertag YYYY-MM-DD ist (daysBetween wirft sonst). */
|
||||
function validDay(ymd) {
|
||||
try {
|
||||
daysBetween(ymd, ymd)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wiederkehrende Abbuchungen: [{ key, name, amountCents, interval: 'monat'|'quartal'|'jahr', lastDate, count }],
|
||||
* groesster Betrag zuerst. name ist die Schreibweise der juengsten Buchung, amountCents der Median als positiver Wert.
|
||||
*/
|
||||
export function detectRecurring(transactions, { fixedCosts = [] } = {}) {
|
||||
const matches = (fixedCosts || [])
|
||||
.filter((fixedCost) => fixedCost.active)
|
||||
.map((fixedCost) => String(fixedCost.counterpartyMatch ?? '').trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
|
||||
const groups = new Map()
|
||||
for (const tx of transactions || []) {
|
||||
if (!(tx.amountCents < 0) || tx.status !== 'BOOK' || !validDay(tx.bookingDate)) continue
|
||||
const key = counterpartyKey(tx.counterpartyName)
|
||||
if (!key) continue
|
||||
if (!groups.has(key)) groups.set(key, [])
|
||||
groups.get(key).push(tx)
|
||||
}
|
||||
|
||||
const result = []
|
||||
for (const [key, group] of groups) {
|
||||
if (group.length < MIN_COUNT) continue
|
||||
group.sort((a, b) => (a.bookingDate < b.bookingDate ? -1 : a.bookingDate > b.bookingDate ? 1 : 0))
|
||||
const last = group[group.length - 1]
|
||||
const name = String(last.counterpartyName).trim()
|
||||
// Schon als feste Kosten erfasst (Treffer gegen den bereinigten Schluessel und den Originalnamen, denn
|
||||
// ein counterpartyMatch darf Ziffern enthalten)
|
||||
const lowerName = name.toLowerCase()
|
||||
if (matches.some((match) => key.includes(match) || lowerName.includes(match))) continue
|
||||
|
||||
const gaps = []
|
||||
for (let i = 1; i < group.length; i++) gaps.push(daysBetween(group[i - 1].bookingDate, group[i].bookingDate))
|
||||
const gap = median(gaps)
|
||||
const rhythm = INTERVALS.find(({ min, max }) => gap >= min && gap <= max)
|
||||
if (!rhythm) continue
|
||||
|
||||
const amounts = group.map((tx) => -tx.amountCents)
|
||||
if (variation(amounts) > MAX_AMOUNT_VARIATION) continue
|
||||
|
||||
result.push({
|
||||
key,
|
||||
name,
|
||||
amountCents: Math.round(median(amounts)),
|
||||
interval: rhythm.interval,
|
||||
lastDate: last.bookingDate,
|
||||
count: group.length,
|
||||
})
|
||||
}
|
||||
return result.sort((a, b) => b.amountCents - a.amountCents || (a.key < b.key ? -1 : 1))
|
||||
}
|
||||
Reference in new issue
Block a user