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