service = new DatevExportService; } /* |-------------------------------------------------------------------------- | Revenue Account Mapping Tests |-------------------------------------------------------------------------- */ /** @test */ public function it_maps_domestic_19_percent_to_account_8400_bu_9() { $method = new \ReflectionMethod(DatevExportService::class, 'determineRevenueAccount'); $method->setAccessible(true); $result = $method->invoke($this->service, 19, true, false, false); $this->assertEquals(8400, $result['konto']); $this->assertEquals(9, $result['bu']); } /** @test */ public function it_maps_domestic_7_percent_to_account_8300_bu_8() { $method = new \ReflectionMethod(DatevExportService::class, 'determineRevenueAccount'); $method->setAccessible(true); $result = $method->invoke($this->service, 7, true, false, false); $this->assertEquals(8300, $result['konto']); $this->assertEquals(8, $result['bu']); } /** @test */ public function it_maps_domestic_5_percent_to_account_8300_bu_8() { $method = new \ReflectionMethod(DatevExportService::class, 'determineRevenueAccount'); $method->setAccessible(true); $result = $method->invoke($this->service, 5, true, false, false); $this->assertEquals(8300, $result['konto']); $this->assertEquals(8, $result['bu']); } /** @test */ public function it_maps_eu_with_ustid_to_account_8125_bu_1() { $method = new \ReflectionMethod(DatevExportService::class, 'determineRevenueAccount'); $method->setAccessible(true); $result = $method->invoke($this->service, 19, false, true, true); $this->assertEquals(8125, $result['konto']); $this->assertEquals(1, $result['bu']); } /** @test */ public function it_maps_eu_without_ustid_to_domestic_19_percent() { $method = new \ReflectionMethod(DatevExportService::class, 'determineRevenueAccount'); $method->setAccessible(true); $result = $method->invoke($this->service, 19, false, true, false); $this->assertEquals(8400, $result['konto']); $this->assertEquals(9, $result['bu']); } /** @test */ public function it_maps_eu_without_ustid_7_percent_to_domestic_7() { $method = new \ReflectionMethod(DatevExportService::class, 'determineRevenueAccount'); $method->setAccessible(true); $result = $method->invoke($this->service, 7, false, true, false); $this->assertEquals(8300, $result['konto']); $this->assertEquals(8, $result['bu']); } /** @test */ public function it_maps_third_country_exempt_to_account_8120_bu_11() { $method = new \ReflectionMethod(DatevExportService::class, 'determineRevenueAccount'); $method->setAccessible(true); $result = $method->invoke($this->service, 19, false, false, false); $this->assertEquals(8120, $result['konto']); $this->assertEquals(11, $result['bu']); } /* |-------------------------------------------------------------------------- | Commission Tax Status Tests |-------------------------------------------------------------------------- */ /** @test */ public function it_determines_normal_tax_status_for_vat_registered() { $method = new \ReflectionMethod(DatevExportService::class, 'determineCommissionTaxStatus'); $method->setAccessible(true); $account = new \stdClass; $account->reverse_charge = false; $account->taxable_sales = 1; $result = $method->invoke($this->service, $account); $this->assertEquals('normal', $result); } /** @test */ public function it_determines_kleinunternehmer_for_taxable_sales_2() { $method = new \ReflectionMethod(DatevExportService::class, 'determineCommissionTaxStatus'); $method->setAccessible(true); $account = new \stdClass; $account->reverse_charge = false; $account->taxable_sales = 2; $result = $method->invoke($this->service, $account); $this->assertEquals('kleinunternehmer', $result); } /** @test */ public function it_determines_reverse_charge_when_flag_is_set() { $method = new \ReflectionMethod(DatevExportService::class, 'determineCommissionTaxStatus'); $method->setAccessible(true); $account = new \stdClass; $account->reverse_charge = true; $account->taxable_sales = 1; $result = $method->invoke($this->service, $account); $this->assertEquals('reverse_charge', $result); } /** @test */ public function reverse_charge_takes_priority_over_other_statuses() { $method = new \ReflectionMethod(DatevExportService::class, 'determineCommissionTaxStatus'); $method->setAccessible(true); $account = new \stdClass; $account->reverse_charge = true; $account->taxable_sales = 2; // Even if Kleinunternehmer, RC takes priority $result = $method->invoke($this->service, $account); $this->assertEquals('reverse_charge', $result); } /** @test */ public function null_account_defaults_to_normal() { $method = new \ReflectionMethod(DatevExportService::class, 'determineCommissionTaxStatus'); $method->setAccessible(true); $result = $method->invoke($this->service, null); $this->assertEquals('normal', $result); } /* |-------------------------------------------------------------------------- | CSV Rendering Tests |-------------------------------------------------------------------------- */ /** @test */ public function it_renders_csv_row_with_correct_column_count() { $method = new \ReflectionMethod(DatevExportService::class, 'renderCsvRow'); $method->setAccessible(true); $data = [ 'amount_gross' => 119.00, 'soll_haben' => 'H', 'konto' => 8400, 'gegenkonto' => 10000, 'bu_schluessel' => 9, 'belegdatum' => '2025-07-01', 'belegfeld1' => '202500123', 'buchungstext' => 'Mustermann Max', 'eu_ustid' => null, ]; $row = $method->invoke($this->service, $data); $columns = explode(';', $row); $this->assertCount(116, $columns, 'DATEV CSV row must have exactly 116 columns'); } /** @test */ public function it_formats_amount_with_comma_as_decimal_separator() { $method = new \ReflectionMethod(DatevExportService::class, 'renderCsvRow'); $method->setAccessible(true); $data = [ 'amount_gross' => 1234.56, 'soll_haben' => 'H', 'konto' => 8400, 'gegenkonto' => 10000, 'bu_schluessel' => 9, 'belegdatum' => '2025-07-01', 'belegfeld1' => 'TEST-001', 'buchungstext' => 'Test', ]; $row = $method->invoke($this->service, $data); $columns = explode(';', $row); $this->assertEquals('1234,56', $columns[0], 'Amount must use comma as decimal separator'); } /** @test */ public function it_formats_belegdatum_as_ddmm() { $method = new \ReflectionMethod(DatevExportService::class, 'renderCsvRow'); $method->setAccessible(true); $data = [ 'amount_gross' => 100.00, 'soll_haben' => 'H', 'konto' => 8400, 'gegenkonto' => 10000, 'bu_schluessel' => 9, 'belegdatum' => '2025-07-15', 'belegfeld1' => 'TEST', 'buchungstext' => 'Test', ]; $row = $method->invoke($this->service, $data); $columns = explode(';', $row); $this->assertEquals('1507', $columns[9], 'Belegdatum must be in TTMM format'); } /** @test */ public function it_places_fields_in_correct_datev_columns() { $method = new \ReflectionMethod(DatevExportService::class, 'renderCsvRow'); $method->setAccessible(true); $data = [ 'amount_gross' => 119.00, 'soll_haben' => 'H', 'konto' => 8400, 'gegenkonto' => 10000, 'bu_schluessel' => 9, 'belegdatum' => '2025-01-07', 'belegfeld1' => '202500001', 'buchungstext' => 'Nachname Vorname', 'eu_ustid' => 'DE123456789', ]; $row = $method->invoke($this->service, $data); $columns = explode(';', $row); // Spalte A (Index 0): Umsatz $this->assertEquals('119,00', $columns[0]); // Spalte B (Index 1): Soll/Haben $this->assertEquals('H', $columns[1]); // Spalte G (Index 6): Konto $this->assertEquals('8400', $columns[6]); // Spalte H (Index 7): Gegenkonto $this->assertEquals('10000', $columns[7]); // Spalte I (Index 8): BU-Schluessel $this->assertEquals('9', $columns[8]); // Spalte J (Index 9): Belegdatum (TTMM) $this->assertEquals('0701', $columns[9]); // Spalte K (Index 10): Belegfeld 1 $this->assertEquals('202500001', $columns[10]); // Spalte N (Index 13): Buchungstext $this->assertEquals('Nachname Vorname', $columns[13]); // Spalte AN (Index 39): EU-Land u. UStID $this->assertEquals('DE123456789', $columns[39]); } /* |-------------------------------------------------------------------------- | Validation Tests |-------------------------------------------------------------------------- */ /** @test */ public function validation_detects_missing_belegdatum() { $lines = collect([ [ 'source_type' => 'invoice', 'source_id' => 1, 'amount_gross' => 100, 'belegdatum' => null, 'belegfeld1' => 'TEST-001', 'buchungstext' => 'Test', 'bu_schluessel' => 9, ], ]); $result = $this->service->validate($lines); $this->assertFalse($result['valid']); $this->assertNotEmpty($result['errors']); $this->assertStringContainsString('Belegdatum', $result['errors'][0]['message']); } /** @test */ public function validation_detects_missing_belegnummer() { $lines = collect([ [ 'source_type' => 'invoice', 'source_id' => 1, 'amount_gross' => 100, 'belegdatum' => '2025-01-01', 'belegfeld1' => '', 'buchungstext' => 'Test', 'bu_schluessel' => 9, ], ]); $result = $this->service->validate($lines); $this->assertFalse($result['valid']); $this->assertStringContainsString('Belegnummer', $result['errors'][0]['message']); } /** @test */ public function validation_warns_on_eu_delivery_without_ustid() { $lines = collect([ [ 'source_type' => 'invoice', 'source_id' => 1, 'amount_gross' => 100, 'belegdatum' => '2025-01-01', 'belegfeld1' => 'TEST-001', 'buchungstext' => 'Test', 'bu_schluessel' => 1, 'eu_ustid' => null, ], ]); $result = $this->service->validate($lines); $this->assertTrue($result['valid']); $this->assertNotEmpty($result['warnings']); $this->assertStringContainsString('EU-Lieferung', $result['warnings'][0]['message']); } /** @test */ public function validation_warns_on_reverse_charge_without_ustid() { $lines = collect([ [ 'source_type' => 'credit', 'source_id' => 1, 'amount_gross' => 50, 'belegdatum' => '2025-01-01', 'belegfeld1' => 'GS-001', 'buchungstext' => 'Test', 'bu_schluessel' => 94, 'eu_ustid' => null, ], ]); $result = $this->service->validate($lines); $this->assertTrue($result['valid']); $this->assertNotEmpty($result['warnings']); $this->assertStringContainsString('Reverse Charge', $result['warnings'][0]['message']); } /** @test */ public function validation_passes_for_complete_data() { $lines = collect([ [ 'source_type' => 'invoice', 'source_id' => 1, 'amount_gross' => 119.00, 'belegdatum' => '2025-07-01', 'belegfeld1' => '202500001', 'buchungstext' => 'Mustermann Max', 'bu_schluessel' => 9, 'eu_ustid' => null, ], ]); $result = $this->service->validate($lines); $this->assertTrue($result['valid']); $this->assertEmpty($result['errors']); } /* |-------------------------------------------------------------------------- | Number Parsing Tests |-------------------------------------------------------------------------- */ /** @test */ public function it_parses_formatted_numbers_correctly() { $method = new \ReflectionMethod(DatevExportService::class, 'parseNumber'); $method->setAccessible(true); // Standard format from number_format $this->assertEquals(5.00, $method->invoke($this->service, '5.00')); $this->assertEquals(1234.56, $method->invoke($this->service, '1234.56')); // Comma format $this->assertEquals(5.00, $method->invoke($this->service, '5,00')); } /** @test */ public function it_parses_homeparty_tax_split_format() { $method = new \ReflectionMethod(DatevExportService::class, 'parseNumber'); $method->setAccessible(true); // Homeparty format: ['vk_tax' => '5.00', 'ek_tax' => '2.00'] $result = $method->invoke($this->service, ['vk_tax' => '15.50', 'ek_tax' => '7.50']); $this->assertEquals(15.50, $result); } /** @test */ public function it_parses_homeparty_ek_tax_split_format_with_preferred_key() { $method = new \ReflectionMethod(DatevExportService::class, 'parseNumber'); $method->setAccessible(true); $result = $method->invoke($this->service, ['vk_tax' => '15.50', 'ek_tax' => '7.50'], 'ek_tax'); $this->assertEquals(7.50, $result); } /** @test */ public function it_parses_homeparty_ek_net_split_format_with_preferred_key() { $method = new \ReflectionMethod(DatevExportService::class, 'parseNumber'); $method->setAccessible(true); $result = $method->invoke($this->service, ['vk_net' => '123.45', 'ek_net' => '67.89'], 'ek_net'); $this->assertEquals(67.89, $result); } /** @test */ public function it_resolves_net_split_from_collective_order_when_order_has_none() { $method = new \ReflectionMethod(DatevExportService::class, 'resolveNetSplit'); $method->setAccessible(true); $collectOrder = new \stdClass; $collectOrder->net_split = ['19' => '533.61']; $order = new \stdClass; $order->net_split = null; $order->shopping_collect_order = $collectOrder; $result = $method->invoke($this->service, $order); $this->assertEquals(['19' => '533.61'], $result); } /** @test */ public function it_prefers_order_net_split_over_collective_order_net_split() { $method = new \ReflectionMethod(DatevExportService::class, 'resolveNetSplit'); $method->setAccessible(true); $collectOrder = new \stdClass; $collectOrder->net_split = ['19' => '533.61']; $order = new \stdClass; $order->net_split = ['19' => '677.51']; $order->shopping_collect_order = $collectOrder; $result = $method->invoke($this->service, $order); $this->assertEquals(['19' => '677.51'], $result); } /* |-------------------------------------------------------------------------- | Model Tests |-------------------------------------------------------------------------- */ /** @test */ public function datev_export_status_labels_are_defined() { $this->assertEquals('Entwurf', DatevExport::STATUS_LABELS[DatevExport::STATUS_DRAFT]); $this->assertEquals('Generiert', DatevExport::STATUS_LABELS[DatevExport::STATUS_GENERATED]); $this->assertEquals('Heruntergeladen', DatevExport::STATUS_LABELS[DatevExport::STATUS_DOWNLOADED]); $this->assertEquals('Gesperrt', DatevExport::STATUS_LABELS[DatevExport::STATUS_LOCKED]); } /** @test */ public function datev_export_line_source_types_are_defined() { $this->assertEquals('invoice', DatevExportLine::SOURCE_INVOICE); $this->assertEquals('credit', DatevExportLine::SOURCE_CREDIT); $this->assertEquals('cancellation', DatevExportLine::SOURCE_CANCELLATION); } /* |-------------------------------------------------------------------------- | EU USt-ID Resolution Tests |-------------------------------------------------------------------------- */ /** @test */ public function it_resolves_eu_ustid_from_order_auth_user_account() { $method = new \ReflectionMethod(DatevExportService::class, 'resolveEuUstid'); $method->setAccessible(true); $account = new \stdClass; $account->tax_identification_number = 'ATU12345678'; $user = new \stdClass; $user->account = $account; $order = new \stdClass; $order->auth_user = $user; $result = $method->invoke($this->service, $order, false); $this->assertEquals('ATU12345678', $result); } /** @test */ public function it_returns_null_ustid_for_domestic_orders() { $method = new \ReflectionMethod(DatevExportService::class, 'resolveEuUstid'); $method->setAccessible(true); $account = new \stdClass; $account->tax_identification_number = 'DE123456789'; $user = new \stdClass; $user->account = $account; $order = new \stdClass; $order->auth_user = $user; $result = $method->invoke($this->service, $order, true); $this->assertNull($result); } /** @test */ public function it_returns_null_ustid_for_guest_orders() { $method = new \ReflectionMethod(DatevExportService::class, 'resolveEuUstid'); $method->setAccessible(true); $order = new \stdClass; $order->auth_user = null; $result = $method->invoke($this->service, $order, false); $this->assertNull($result); } /** @test */ public function it_returns_null_ustid_when_account_has_no_tax_id() { $method = new \ReflectionMethod(DatevExportService::class, 'resolveEuUstid'); $method->setAccessible(true); $account = new \stdClass; $account->tax_identification_number = null; $user = new \stdClass; $user->account = $account; $order = new \stdClass; $order->auth_user = $user; $result = $method->invoke($this->service, $order, false); $this->assertNull($result); } /** @test */ public function validation_no_warning_for_eu_delivery_with_ustid() { $lines = collect([ [ 'source_type' => 'invoice', 'source_id' => 1, 'amount_gross' => 100, 'belegdatum' => '2025-01-01', 'belegfeld1' => 'TEST-001', 'buchungstext' => 'Test', 'bu_schluessel' => 1, 'eu_ustid' => 'ATU12345678', ], ]); $result = $this->service->validate($lines); $this->assertTrue($result['valid']); $euWarnings = array_filter($result['warnings'], fn ($w) => str_contains($w['message'], 'EU-Lieferung')); $this->assertEmpty($euWarnings); } /** @test */ public function validation_returns_structured_entries_with_metadata() { $lines = collect([ [ 'source_type' => 'invoice', 'source_id' => 42, 'order_id' => 100, 'user_id' => 5, 'amount_gross' => 100, 'belegdatum' => '2025-01-01', 'belegfeld1' => 'TEST-001', 'buchungstext' => 'Test', 'bu_schluessel' => 1, 'eu_ustid' => null, ], ]); $result = $this->service->validate($lines); $warning = $result['warnings'][0]; $this->assertIsArray($warning); $this->assertArrayHasKey('message', $warning); $this->assertArrayHasKey('source_id', $warning); $this->assertArrayHasKey('order_id', $warning); $this->assertArrayHasKey('user_id', $warning); $this->assertEquals(42, $warning['source_id']); $this->assertEquals(100, $warning['order_id']); $this->assertEquals(5, $warning['user_id']); } /* |-------------------------------------------------------------------------- | Config Tests |-------------------------------------------------------------------------- */ /** @test */ public function datev_config_has_required_keys() { $this->assertNotNull(config('datev.revenue_accounts')); $this->assertNotNull(config('datev.commission_accounts')); $this->assertNotNull(config('datev.commission_tax_keys')); $this->assertNotNull(config('datev.counteraccount_map')); $this->assertNotNull(config('datev.sammeldebitor')); $this->assertNotNull(config('datev.sammelkreditor')); } /** @test */ public function datev_config_revenue_accounts_are_complete() { $accounts = config('datev.revenue_accounts'); $this->assertArrayHasKey('domestic_19', $accounts); $this->assertArrayHasKey('domestic_7', $accounts); $this->assertArrayHasKey('eu_exempt', $accounts); $this->assertArrayHasKey('third_country_exempt', $accounts); // Verify correct account numbers $this->assertEquals(8400, $accounts['domestic_19']['konto']); $this->assertEquals(8300, $accounts['domestic_7']['konto']); $this->assertEquals(8125, $accounts['eu_exempt']['konto']); $this->assertEquals(8120, $accounts['third_country_exempt']['konto']); } /** @test */ public function datev_config_commission_accounts_map_correctly() { $accounts = config('datev.commission_accounts'); $this->assertEquals(4760, $accounts['shop']); $this->assertEquals(4760, $accounts['growth_bonus']); $this->assertEquals(4764, $accounts['payline']); } /** @test */ public function datev_column_headers_have_correct_count() { $reflection = new \ReflectionClass(DatevExportService::class); $prop = $reflection->getConstant('COLUMN_COUNT'); $this->assertEquals(116, $prop); } }