Files
Webklar-Kundenbereich/server/services/finance/matching.js
T

107 lines
4.6 KiB
JavaScript

/**
* 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)
}