mivita/app/Services/DatevReconciliationService.php
Kevin 1a3af17f9a DATEV: stornierte Rechnungen im Rechnungsmonat, Pruefliste fuer den Steuerberater
- Eine spaeter stornierte Rechnung fehlte im Export ihres Rechnungsmonats,
  weil cancellation = true auch an der Originalrechnung steht. Sie bleibt
  jetzt als Erloes im Rechnungsmonat; die Stornierung wird im Monat des
  Stornobelegs gegengebucht (nur Belege ohne cancellation_id).
- Pruefliste je Buchungsstapel als CSV (Button im Steuerberater-Bereich):
  Belege, die sich ohne Zusatzinformation nicht abstimmen lassen (doppelte
  Rechnungsnummern, Stornos ausserhalb des Systems).
- Korrespondenz und offene Differenzen mit dem Steuerberater unter
  dev/2026-07-17-steuern/.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 15:42:54 +00:00

363 lines
15 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Services;
use App\Models\ShoppingPayment;
use App\Models\UserInvoice;
use Illuminate\Support\Collection;
/**
* Erzeugt eine Prüfliste zum DATEV-Buchungsstapel.
*
* Der Buchungsstapel selbst bleibt unverändert (EXTF-Format lässt keine Zusatzspalten
* zu). Die Prüfliste markiert stattdessen die Belege, die der Steuerberater nicht ohne
* Zusatzinformation abstimmen kann — Altfälle wie doppelt vergebene Rechnungsnummern
* oder außerhalb des Systems abgewickelte Stornos lassen sich nachträglich nicht mehr
* korrigieren, wohl aber vollständig benennen.
*/
class DatevReconciliationService
{
public const CATEGORY_DUPLICATE_NUMBER = 'doppelte_rechnungsnummer';
public const CATEGORY_EXTERNAL_CANCELLATION = 'storno_ausserhalb_system';
public const CATEGORY_CANCELLED_LATER = 'spaeter_storniert';
public const CATEGORY_CROSS_MONTH_CANCELLATION = 'storno_monatsuebergreifend';
public const CATEGORY_INTRA_COMMUNITY_WITHOUT_VATID = 'igl_ohne_ustid';
public const CATEGORY_REVERSE_CHARGE_WITHOUT_VATID = 'reverse_charge_ohne_ustid';
/**
* @var array<string, string>
*/
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<int, array{kategorie: string, belegnummer: string, belegdatum: ?string, konto: ?int, bu_schluessel: ?int, betrag_brutto: float, name: string, hinweis: string, empfehlung: string}>
*/
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<string, int>
*/
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<int, array<string, mixed>>
*/
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<int, array<string, mixed>>
*/
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<int, array<string, mixed>>
*/
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<int, array<string, mixed>>
*/
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<int, string> $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');
}
}