- 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>
363 lines
15 KiB
PHP
363 lines
15 KiB
PHP
<?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');
|
||
}
|
||
}
|