*/ public const CATEGORY_LABELS = [ self::CATEGORY_DUPLICATE_NUMBER => 'Doppelt vergebene Rechnungsnummer', self::CATEGORY_EXTERNAL_CANCELLATION => 'Storno/Rückzahlung außerhalb des Systems', self::CATEGORY_CANCELLED_LATER => 'Später storniert (Storno in Folgemonat)', self::CATEGORY_CROSS_MONTH_CANCELLATION => 'Storno zu Rechnung aus Vormonat', self::CATEGORY_INTRA_COMMUNITY_WITHOUT_VATID => 'Steuerfreie igL ohne USt-ID', self::CATEGORY_REVERSE_CHARGE_WITHOUT_VATID => 'Reverse Charge ohne USt-ID', ]; public function __construct(private DatevExportService $exportService) {} /** * Baut die vollständige Prüfliste für einen Zeitraum. * * @return Collection */ public function build(int $month, int $year): Collection { return collect() ->concat($this->findDuplicateInvoiceNumbers($month, $year)) ->concat($this->findExternalCancellations($month, $year)) ->concat($this->findCancellationTimingHints($month, $year)) ->concat($this->findMissingVatIds($month, $year)) ->sortBy([['kategorie', 'asc'], ['belegnummer', 'asc']]) ->values(); } /** * Zählt die Prüfhinweise je Kategorie. * * @return array */ public function summarize(Collection $rows): array { return $rows->groupBy('kategorie')->map->count()->all(); } /** * Rechnungsnummern, die im Zeitraum für mehrere Rechnungen vergeben wurden. * * Nicht rückwirkend korrigierbar; die Prüfliste nennt daher zu jeder Nummer alle * dahinterliegenden Einzelrechnungen mit Kunde und Betrag. * * @return Collection> */ private function findDuplicateInvoiceNumbers(int $month, int $year): Collection { $invoices = UserInvoice::with(['shopping_order.shopping_user']) ->where('month', $month) ->where('year', $year) ->whereNotIn('status', [11, 12]) ->get() ->filter(fn (UserInvoice $invoice) => ! empty($invoice->full_number)); $rows = collect(); foreach ($invoices->groupBy('full_number') as $number => $group) { if ($group->count() < 2) { continue; } $parts = $group->map(fn (UserInvoice $invoice) => [ 'name' => $this->customerName($invoice), 'amount' => $this->invoiceGross($invoice), ]); $total = round($parts->sum('amount'), 2); foreach ($group as $invoice) { $others = $parts ->reject(fn (array $part) => $part['name'] === $this->customerName($invoice) && abs($part['amount'] - $this->invoiceGross($invoice)) < 0.005) ->map(fn (array $part) => $part['name'].' ('.$this->money($part['amount']).')') ->implode(', '); $rows->push([ 'kategorie' => self::CATEGORY_DUPLICATE_NUMBER, 'belegnummer' => (string) $number, 'belegdatum' => $this->date($invoice->getRawOriginal('date')), 'konto' => null, 'bu_schluessel' => null, 'betrag_brutto' => $this->invoiceGross($invoice), 'name' => $this->customerName($invoice), 'hinweis' => 'Nummer im Zeitraum '.$group->count().'× vergeben, Summe unter dieser Nummer ' .$this->money($total).'. Weitere Rechnung(en): '.($others !== '' ? $others : '—'), 'empfehlung' => 'Position anhand des Kundennamens der jeweiligen Einzelrechnung zuordnen.', ]); } } return $rows; } /** * Rechnungen, die über den Zahlungsanbieter (teil-)erstattet wurden, ohne dass im * System ein Stornobeleg existiert. Der Buchungsstapel enthält solche Rechnungen * weiterhin mit dem vollen Betrag. * * @return Collection> */ private function findExternalCancellations(int $month, int $year): Collection { $invoices = UserInvoice::with(['shopping_order.shopping_user']) ->where('month', $month) ->where('year', $year) ->whereNotIn('status', [11, 12]) ->whereNull('cancellation_id') ->get(); if ($invoices->isEmpty()) { return collect(); } $refunds = ShoppingPayment::query() ->whereIn('shopping_order_id', $invoices->pluck('shopping_order_id')->filter()->unique()) ->where('txaction', 'refund') ->get() ->keyBy('shopping_order_id'); return $invoices ->filter(fn (UserInvoice $invoice) => $refunds->has($invoice->shopping_order_id)) ->map(function (UserInvoice $invoice) use ($refunds) { $refund = $refunds->get($invoice->shopping_order_id); return [ 'kategorie' => self::CATEGORY_EXTERNAL_CANCELLATION, 'belegnummer' => (string) $invoice->full_number, 'belegdatum' => $this->date($invoice->getRawOriginal('date')), 'konto' => null, 'bu_schluessel' => null, 'betrag_brutto' => $this->invoiceGross($invoice), 'name' => $this->customerName($invoice), 'hinweis' => 'Rückzahlung über den Zahlungsanbieter am ' .$this->date($refund->updated_at).', aber kein Stornobeleg im System. ' .'Der Buchungsstapel enthält den vollen Rechnungsbetrag.', 'empfehlung' => 'Erstatteten Betrag manuell gegenbuchen; Betrag laut Zahlungsanbieter prüfen.', ]; }) ->values(); } /** * Weist auf Stornos hin, deren Original in einem anderen Monat liegt — in beide * Richtungen, damit der Steuerberater die Gegenbuchung im richtigen Zeitraum sucht. * * @return Collection> */ private function findCancellationTimingHints(int $month, int $year): Collection { $rows = collect(); $cancelledOriginals = UserInvoice::with(['shopping_order.shopping_user']) ->where('month', $month) ->where('year', $year) ->where('cancellation', true) ->whereNotNull('cancellation_id') ->get(); foreach ($cancelledOriginals as $invoice) { $storno = UserInvoice::find($invoice->cancellation_id); if (! $storno || ($storno->month === $month && $storno->year === $year)) { continue; } $rows->push([ 'kategorie' => self::CATEGORY_CANCELLED_LATER, 'belegnummer' => (string) $invoice->full_number, 'belegdatum' => $this->date($invoice->getRawOriginal('date')), 'konto' => null, 'bu_schluessel' => null, 'betrag_brutto' => $this->invoiceGross($invoice), 'name' => $this->customerName($invoice), 'hinweis' => 'Rechnung wurde am '.$this->date($storno->getRawOriginal('date')) .' storniert (Stornobeleg '.$storno->full_number.'). Der Erlös steht bewusst in diesem ' .'Zeitraum, die Gegenbuchung folgt im Stornomonat.', 'empfehlung' => 'Keine Korrektur in diesem Monat — Gegenbuchung im Stornomonat abwarten.', ]); } $stornos = UserInvoice::with(['shopping_order.shopping_user']) ->where('month', $month) ->where('year', $year) ->where('cancellation', true) ->whereNull('cancellation_id') ->get(); foreach ($stornos as $storno) { $original = UserInvoice::where('cancellation_id', $storno->id)->first(); if (! $original || ($original->month === $month && $original->year === $year)) { continue; } $rows->push([ 'kategorie' => self::CATEGORY_CROSS_MONTH_CANCELLATION, 'belegnummer' => (string) $storno->full_number, 'belegdatum' => $this->date($storno->getRawOriginal('date')), 'konto' => null, 'bu_schluessel' => null, 'betrag_brutto' => $this->invoiceGross($storno), 'name' => $this->customerName($storno), 'hinweis' => 'Stornobeleg zur Rechnung '.$original->full_number.' vom ' .$this->date($original->getRawOriginal('date')).' (Zeitraum ' .sprintf('%02d/%d', $original->month, $original->year).').', 'empfehlung' => 'Gegenbuchung zum genannten Beleg aus dem Vormonat.', ]); } return $rows; } /** * Belege, deren Steuerschlüssel eine USt-ID des Geschäftspartners voraussetzt, * für die im System aber keine hinterlegt ist. * * @return Collection> */ private function findMissingVatIds(int $month, int $year): Collection { $lines = $this->exportService->collectInvoiceLines($month, $year) ->concat($this->exportService->collectCreditLines($month, $year)) ->concat($this->exportService->collectCancellationLines($month, $year)); return $lines ->filter(fn (array $line) => in_array($line['bu_schluessel'] ?? 0, [1, 94], true) && empty($line['eu_ustid'])) ->map(function (array $line) { $isReverseCharge = ($line['bu_schluessel'] ?? 0) === 94; return [ 'kategorie' => $isReverseCharge ? self::CATEGORY_REVERSE_CHARGE_WITHOUT_VATID : self::CATEGORY_INTRA_COMMUNITY_WITHOUT_VATID, 'belegnummer' => (string) ($line['belegfeld1'] ?? ''), 'belegdatum' => $this->date($line['belegdatum'] ?? null), 'konto' => $line['konto'] ?? null, 'bu_schluessel' => $line['bu_schluessel'] ?? null, 'betrag_brutto' => round((float) ($line['amount_gross'] ?? 0), 2), 'name' => (string) ($line['buchungstext'] ?? '-'), 'hinweis' => $isReverseCharge ? 'Provisionsgutschrift ins EU-Ausland mit Reverse Charge (BU 94), ' .'zum Berater ist jedoch keine USt-ID hinterlegt.' : 'Steuerfreie innergemeinschaftliche Lieferung (BU 1) ohne hinterlegte USt-ID ' .'des Empfängers.', 'empfehlung' => 'Steuerschlüssel prüfen: ohne USt-ID des Empfängers ist der ' .'Nachweis für die Steuerbefreiung bzw. das Reverse-Charge-Verfahren unvollständig.', ]; }) ->unique(fn (array $row) => $row['kategorie'].'|'.$row['belegnummer'].'|'.$row['konto']) ->values(); } /** * Baut die Prüfliste als CSV im gleichen Format wie der Buchungsstapel. */ public function buildCsv(int $month, int $year): string { $delimiter = config('datev.delimiter', ';'); $lineEnding = config('datev.line_ending', "\r\n"); $header = [ 'Kategorie', 'Belegnummer', 'Belegdatum', 'Konto', 'BU-Schlüssel', 'Betrag brutto', 'Name', 'Hinweis', 'Empfehlung', ]; $csv = "\xEF\xBB\xBF".$this->csvRow($header, $delimiter).$lineEnding; foreach ($this->build($month, $year) as $row) { $csv .= $this->csvRow([ self::CATEGORY_LABELS[$row['kategorie']] ?? $row['kategorie'], $row['belegnummer'], $row['belegdatum'] ?? '', $row['konto'] !== null ? (string) $row['konto'] : '', $row['bu_schluessel'] !== null ? (string) $row['bu_schluessel'] : '', number_format($row['betrag_brutto'], 2, ',', ''), $row['name'], $row['hinweis'], $row['empfehlung'], ], $delimiter).$lineEnding; } return $csv; } /** * @param array $values */ private function csvRow(array $values, string $delimiter): string { return collect($values) ->map(fn (string $value) => '"'.str_replace('"', '""', str_replace(["\r", "\n"], ' ', $value)).'"') ->implode($delimiter); } private function invoiceGross(UserInvoice $invoice): float { $order = $invoice->shopping_order; return round((float) ($order->total_shipping ?? $order->total ?? 0), 2); } private function customerName(UserInvoice $invoice): string { $user = $invoice->shopping_order?->shopping_user; if (! $user) { return '-'; } $name = trim(($user->billing_lastname ?? '').' '.($user->billing_firstname ?? '')); return $name !== '' ? mb_substr($name, 0, 60) : '-'; } private function money(float $amount): string { return number_format($amount, 2, ',', '.').' €'; } private function date($value): ?string { if (empty($value)) { return null; } return \Carbon::parse($value)->format('d.m.Y'); } }