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>
This commit is contained in:
Kevin 2026-09-23 15:42:54 +00:00
parent d01b9bca3a
commit 1a3af17f9a
12 changed files with 1058 additions and 7 deletions

View file

@ -5,6 +5,7 @@ namespace App\Http\Controllers;
use App\Models\DatevExport;
use App\Models\DatevExportLine;
use App\Services\DatevExportService;
use App\Services\DatevReconciliationService;
use App\Services\HTMLHelper;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
@ -132,6 +133,26 @@ class PaymentTaxAdvisorController extends Controller
]);
}
/**
* Prüfliste zum Buchungsstapel als CSV herunterladen.
*
* Enthält die Belege, die der Steuerberater nicht ohne Zusatzinformation abstimmen
* kann (Altfälle wie doppelte Rechnungsnummern oder Stornos außerhalb des Systems).
*/
public function checklist($id)
{
$export = DatevExport::findOrFail($id);
$csv = app(DatevReconciliationService::class)->buildCsv($export->month, $export->year);
$filename = sprintf('DATEV_Pruefliste_%04d_%02d.csv', $export->year, $export->month);
return response($csv, 200, [
'Content-Type' => 'text/csv; charset=utf-8',
'Content-Disposition' => 'attachment; filename="'.$filename.'"',
]);
}
/**
* DataTable: Export-Lines eines bestimmten Exports.
*/

View file

@ -244,6 +244,9 @@ class DatevExportService
/**
* Sammelt alle Rechnungszeilen für die Periode.
* Pro Steuersatz in tax_split wird eine eigene Zeile erzeugt.
*
* Später stornierte Rechnungen bleiben hier als Erlös enthalten: Die Stornierung
* wird im Monat des Stornobelegs gebucht, nicht rückwirkend im Rechnungsmonat.
*/
public function collectInvoiceLines(int $month, int $year): Collection
{
@ -258,11 +261,7 @@ class DatevExportService
])
->where('month', $month)
->where('year', $year)
->where(function ($q) {
$q->where('cancellation', false)
->orWhereNull('cancellation');
})
->whereNotIn('status', [11, 12]) // Nicht stornierte
->whereNotIn('status', [11, 12]) // Stornobelege selbst (siehe collectCancellationLines)
->get();
foreach ($invoices as $invoice) {
@ -481,8 +480,10 @@ class DatevExportService
*/
/**
* Sammelt alle Stornorechnungen für die Periode.
* Stornos werden als negative Beträge exportiert.
* Sammelt alle Stornobelege für die Periode und exportiert sie als Gegenbuchung (S).
*
* `cancellation = true` steht sowohl am Stornobeleg als auch an der stornierten
* Originalrechnung; nur der Stornobeleg hat kein `cancellation_id`.
*/
public function collectCancellationLines(int $month, int $year): Collection
{
@ -498,6 +499,7 @@ class DatevExportService
->where('month', $month)
->where('year', $year)
->where('cancellation', true)
->whereNull('cancellation_id') // nur der Stornobeleg, nicht die stornierte Originalrechnung
->get();
foreach ($cancellations as $invoice) {

View file

@ -0,0 +1,363 @@
<?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');
}
}