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:
parent
d01b9bca3a
commit
1a3af17f9a
12 changed files with 1058 additions and 7 deletions
130
tests/Feature/DatevExportCancellationTest.php
Normal file
130
tests/Feature/DatevExportCancellationTest.php
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Country;
|
||||
use App\Models\ShoppingOrder;
|
||||
use App\Models\UserInvoice;
|
||||
use App\Services\DatevExportService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
/**
|
||||
* Legt eine Bestellung mit Original- und Stornorechnung an.
|
||||
*
|
||||
* `cancellation = true` steht an beiden Belegen; nur der Stornobeleg hat kein
|
||||
* `cancellation_id` (siehe InvoiceRepository::cancel).
|
||||
*
|
||||
* @return array{original: UserInvoice, storno: UserInvoice}
|
||||
*/
|
||||
function makeCancelledInvoicePair(string $invoiceDate, string $cancellationDate): array
|
||||
{
|
||||
$country = Country::firstOrCreate(
|
||||
['code' => 'DE'],
|
||||
[
|
||||
'phone' => '49', 'en' => 'Germany', 'de' => 'Deutschland', 'es' => 'Alemania',
|
||||
'fr' => 'Allemagne', 'it' => 'Germania', 'ru' => 'Германия',
|
||||
]
|
||||
);
|
||||
|
||||
$shoppingUserId = DB::table('shopping_users')->insertGetId([
|
||||
'auth_user_id' => 0,
|
||||
'member_id' => 0,
|
||||
'billing_firstname' => 'Erika',
|
||||
'billing_lastname' => 'Mustermann',
|
||||
'billing_email' => 'erika-'.uniqid('', true).'@example.com',
|
||||
'billing_country_id' => $country->id,
|
||||
'shipping_country_id' => $country->id,
|
||||
'wp_order_number' => 0,
|
||||
'created_at' => now(), 'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$order = ShoppingOrder::forceCreate([
|
||||
'shopping_user_id' => $shoppingUserId,
|
||||
'auth_user_id' => 0,
|
||||
'member_id' => 0,
|
||||
'country_id' => $country->id,
|
||||
'user_shop_id' => 1,
|
||||
'total' => 119.00,
|
||||
'total_shipping' => 119.00,
|
||||
'tax_split' => ['19' => '19.00'],
|
||||
'net_split' => ['19' => '100.00'],
|
||||
]);
|
||||
|
||||
$original = UserInvoice::forceCreate([
|
||||
'shopping_order_id' => $order->id,
|
||||
'date' => $invoiceDate,
|
||||
'month' => (int) date('n', strtotime($invoiceDate)),
|
||||
'year' => (int) date('Y', strtotime($invoiceDate)),
|
||||
'full_number' => '202699001',
|
||||
'number' => 99001,
|
||||
'status' => 2,
|
||||
]);
|
||||
|
||||
$storno = UserInvoice::forceCreate([
|
||||
'shopping_order_id' => $order->id,
|
||||
'date' => $cancellationDate,
|
||||
'month' => (int) date('n', strtotime($cancellationDate)),
|
||||
'year' => (int) date('Y', strtotime($cancellationDate)),
|
||||
'full_number' => '202699002',
|
||||
'number' => 99002,
|
||||
'status' => 12,
|
||||
'cancellation' => true,
|
||||
]);
|
||||
|
||||
$original->cancellation = true;
|
||||
$original->cancellation_id = $storno->id;
|
||||
$original->cancellation_date = $cancellationDate;
|
||||
$original->save();
|
||||
|
||||
return ['original' => $original, 'storno' => $storno];
|
||||
}
|
||||
|
||||
it('buys the revenue in the invoice month and reverses it in the cancellation month', function () {
|
||||
makeCancelledInvoicePair('2026-08-15', '2026-09-03');
|
||||
|
||||
$service = new DatevExportService;
|
||||
|
||||
$august = $service->collectInvoiceLines(8, 2026)
|
||||
->concat($service->collectCancellationLines(8, 2026));
|
||||
|
||||
expect($august)->toHaveCount(1);
|
||||
expect($august->first()['soll_haben'])->toBe('H');
|
||||
expect($august->first()['belegfeld1'])->toBe('202699001');
|
||||
expect(round($august->first()['amount_gross'], 2))->toBe(119.00);
|
||||
|
||||
$september = $service->collectInvoiceLines(9, 2026)
|
||||
->concat($service->collectCancellationLines(9, 2026));
|
||||
|
||||
expect($september)->toHaveCount(1);
|
||||
expect($september->first()['soll_haben'])->toBe('S');
|
||||
expect($september->first()['belegfeld1'])->toBe('202699002');
|
||||
expect(round($september->first()['amount_gross'], 2))->toBe(119.00);
|
||||
});
|
||||
|
||||
it('nets a same-month cancellation to zero instead of booking it twice', function () {
|
||||
makeCancelledInvoicePair('2026-08-15', '2026-08-20');
|
||||
|
||||
$service = new DatevExportService;
|
||||
|
||||
$lines = $service->collectInvoiceLines(8, 2026)
|
||||
->concat($service->collectCancellationLines(8, 2026));
|
||||
|
||||
expect($lines)->toHaveCount(2);
|
||||
|
||||
$signed = $lines->sum(fn (array $line) => $line['soll_haben'] === 'S'
|
||||
? -$line['amount_gross']
|
||||
: $line['amount_gross']);
|
||||
|
||||
expect(round($signed, 2))->toBe(0.00);
|
||||
});
|
||||
|
||||
it('does not treat the cancelled original invoice as a cancellation document', function () {
|
||||
makeCancelledInvoicePair('2026-08-15', '2026-08-20');
|
||||
|
||||
$cancellationLines = (new DatevExportService)->collectCancellationLines(8, 2026);
|
||||
|
||||
expect($cancellationLines)->toHaveCount(1);
|
||||
expect($cancellationLines->first()['belegfeld1'])->toBe('202699002');
|
||||
});
|
||||
155
tests/Feature/DatevReconciliationChecklistTest.php
Normal file
155
tests/Feature/DatevReconciliationChecklistTest.php
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
<?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);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue