mivita/tests/Feature/DatevReconciliationChecklistTest.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

155 lines
5.7 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
use App\Models\Country;
use App\Models\ShoppingOrder;
use App\Models\UserInvoice;
use App\Services\DatevReconciliationService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function makeChecklistCountry(): Country
{
return Country::firstOrCreate(
['code' => 'DE'],
[
'phone' => '49', 'en' => 'Germany', 'de' => 'Deutschland', 'es' => 'Alemania',
'fr' => 'Allemagne', 'it' => 'Germania', 'ru' => 'Германия',
]
);
}
function makeChecklistOrder(string $lastName, float $gross): ShoppingOrder
{
$country = makeChecklistCountry();
$shoppingUserId = DB::table('shopping_users')->insertGetId([
'auth_user_id' => 0,
'member_id' => 0,
'billing_firstname' => 'Test',
'billing_lastname' => $lastName,
'billing_email' => strtolower($lastName).'-'.uniqid('', true).'@example.com',
'billing_country_id' => $country->id,
'shipping_country_id' => $country->id,
'wp_order_number' => 0,
'created_at' => now(), 'updated_at' => now(),
]);
$net = round($gross / 1.19, 2);
return ShoppingOrder::forceCreate([
'shopping_user_id' => $shoppingUserId,
'auth_user_id' => 0,
'member_id' => 0,
'country_id' => $country->id,
'user_shop_id' => 1,
'total' => $gross,
'total_shipping' => $gross,
'tax_split' => ['19' => (string) round($gross - $net, 2)],
'net_split' => ['19' => (string) $net],
]);
}
function makeChecklistInvoice(ShoppingOrder $order, string $number, string $date, array $overrides = []): UserInvoice
{
return UserInvoice::forceCreate(array_merge([
'shopping_order_id' => $order->id,
'date' => $date,
'month' => (int) date('n', strtotime($date)),
'year' => (int) date('Y', strtotime($date)),
'full_number' => $number,
'number' => (int) substr($number, -5),
'status' => 2,
], $overrides));
}
it('lists both invoices behind a duplicated invoice number with the counterpart named', function () {
makeChecklistInvoice(makeChecklistOrder('Hoppe', 24.80), '202699100', '2026-08-11');
makeChecklistInvoice(makeChecklistOrder('Kuhn', 144.90), '202699100', '2026-08-11');
$rows = app(DatevReconciliationService::class)->build(8, 2026)
->where('kategorie', DatevReconciliationService::CATEGORY_DUPLICATE_NUMBER);
expect($rows)->toHaveCount(2);
$hoppe = $rows->firstWhere('name', 'Hoppe Test');
expect($hoppe['betrag_brutto'])->toBe(24.80);
expect($hoppe['hinweis'])->toContain('2× vergeben');
expect($hoppe['hinweis'])->toContain('169,70');
expect($hoppe['hinweis'])->toContain('Kuhn Test (144,90 €)');
});
it('flags an invoice refunded through the payment provider without a cancellation document', function () {
$order = makeChecklistOrder('Matyjaszek', 113.40);
makeChecklistInvoice($order, '202699200', '2026-08-05');
DB::table('shopping_payments')->insert([
'shopping_order_id' => $order->id,
'clearingtype' => 'wlt',
'reference' => 'ref-'.uniqid('', true),
'identifier' => 'id-'.uniqid('', true),
'txaction' => 'refund',
'status' => 'success',
'amount' => 11340,
'currency' => 'EUR',
'created_at' => '2026-08-05 10:00:00',
'updated_at' => '2026-08-28 10:00:00',
]);
$rows = app(DatevReconciliationService::class)->build(8, 2026)
->where('kategorie', DatevReconciliationService::CATEGORY_EXTERNAL_CANCELLATION);
expect($rows)->toHaveCount(1);
expect($rows->first()['belegnummer'])->toBe('202699200');
expect($rows->first()['hinweis'])->toContain('28.08.2026');
expect($rows->first()['hinweis'])->toContain('kein Stornobeleg');
});
it('points a cross-month cancellation at its original invoice and back', function () {
$order = makeChecklistOrder('Ebner', 123.10);
$original = makeChecklistInvoice($order, '202699300', '2026-08-24');
$storno = makeChecklistInvoice($order, '202699301', '2026-09-25', [
'status' => 12,
'cancellation' => true,
]);
$original->cancellation = true;
$original->cancellation_id = $storno->id;
$original->save();
$service = app(DatevReconciliationService::class);
$august = $service->build(8, 2026)
->where('kategorie', DatevReconciliationService::CATEGORY_CANCELLED_LATER);
expect($august)->toHaveCount(1);
expect($august->first()['hinweis'])->toContain('202699301');
expect($august->first()['empfehlung'])->toContain('Stornomonat');
$september = $service->build(9, 2026)
->where('kategorie', DatevReconciliationService::CATEGORY_CROSS_MONTH_CANCELLATION);
expect($september)->toHaveCount(1);
expect($september->first()['hinweis'])->toContain('202699300');
expect($september->first()['hinweis'])->toContain('08/2026');
});
it('stays empty for a clean period', function () {
makeChecklistInvoice(makeChecklistOrder('Sauber', 119.00), '202699400', '2026-08-15');
expect(app(DatevReconciliationService::class)->build(8, 2026))->toBeEmpty();
});
it('renders the checklist as a quoted csv with a header row', function () {
makeChecklistInvoice(makeChecklistOrder('Hoppe', 24.80), '202699100', '2026-08-11');
makeChecklistInvoice(makeChecklistOrder('Kuhn', 144.90), '202699100', '2026-08-11');
$csv = app(DatevReconciliationService::class)->buildCsv(8, 2026);
expect($csv)->toStartWith("\xEF\xBB\xBF\"Kategorie\";\"Belegnummer\"");
expect($csv)->toContain('"Doppelt vergebene Rechnungsnummer"');
expect($csv)->toContain('"24,80"');
expect(substr_count($csv, "\r\n"))->toBe(3);
});