diff --git a/app/Domains/CostUnit/Actions/ChangeCostUnitDetails/ChangeCostUnitDetailsCommand.php b/app/Domains/CostUnit/Actions/ChangeCostUnitDetails/ChangeCostUnitDetailsCommand.php index 341b025..b687759 100644 --- a/app/Domains/CostUnit/Actions/ChangeCostUnitDetails/ChangeCostUnitDetailsCommand.php +++ b/app/Domains/CostUnit/Actions/ChangeCostUnitDetails/ChangeCostUnitDetailsCommand.php @@ -14,7 +14,7 @@ class ChangeCostUnitDetailsCommand { $this->request->costUnit->distance_allowance = $this->request->distanceAllowance->getAmount(); $this->request->costUnit->mail_on_new = $this->request->mailOnNew; - $this->request->costUnit->billing_deadline = $this->request->billingDeadline; + $this->request->costUnit->billing_deadline = $this->request->billingDeadline?->format('Y-m-d'); $response->success = $this->request->costUnit->save(); return $response; diff --git a/app/Domains/CostUnit/Actions/CreateCostUnit/CreateCostUnitCommand.php b/app/Domains/CostUnit/Actions/CreateCostUnit/CreateCostUnitCommand.php index 93129f5..ea7617a 100644 --- a/app/Domains/CostUnit/Actions/CreateCostUnit/CreateCostUnitCommand.php +++ b/app/Domains/CostUnit/Actions/CreateCostUnit/CreateCostUnitCommand.php @@ -17,7 +17,7 @@ class CreateCostUnitCommand { 'name' => $this->request->name, 'tenant' => currentTenant()->slug, 'type' => $this->request->type, - 'billing_deadline' => $this->request->billingDeadline, + 'billing_deadline' => $this->request->billingDeadline?->format('Y-m-d'), 'distance_allowance' => $this->request->distanceAllowance->getAmount(), 'mail_on_new' => $this->request->mailOnNew, 'allow_new' => true, diff --git a/app/Domains/CostUnit/Controllers/EditController.php b/app/Domains/CostUnit/Controllers/EditController.php index 9bc5192..e3605ce 100644 --- a/app/Domains/CostUnit/Controllers/EditController.php +++ b/app/Domains/CostUnit/Controllers/EditController.php @@ -37,7 +37,10 @@ class EditController extends CommonController{ $saveParams = $request->get('formData'); $distanceAllowance = Amount::fromString($saveParams['distanceAllowance']); - $billingDeadline = isset($saveParams['billingDeadline']) ? \DateTime::createFromFormat('Y-m-d', $saveParams['billingDeadline']) : null; + $rawBillingDeadline = $saveParams['billingDeadline'] ?? null; + $billingDeadline = !empty($rawBillingDeadline) + ? (\DateTime::createFromFormat('!Y-m-d', $rawBillingDeadline) ?: null) + : null; $request = new ChangeCostUnitDetailsRequest($costUnit, $distanceAllowance, $saveParams['mailOnNew'], $billingDeadline); $command = new ChangeCostUnitDetailsCommand($request); diff --git a/app/Domains/Invoice/Controllers/SaveInvoiceController.php b/app/Domains/Invoice/Controllers/SaveInvoiceController.php index 649958d..df1c8e8 100644 --- a/app/Domains/Invoice/Controllers/SaveInvoiceController.php +++ b/app/Domains/Invoice/Controllers/SaveInvoiceController.php @@ -26,6 +26,18 @@ class SaveInvoiceController extends CommonController ]); } + // Sicherheitsnetz hinter der Oberfläche: Wer gereist ist, steht im Zahlungsgrund auf Beleg und EüR und + // ist bei Fahrtkosten Pflicht. Geprüft wird hier und nicht im Command, weil auch Abrechnungskorrekturen + // durch den Command laufen -- sie bringen ihren Zahlungsgrund mit, aber keine Personen. Und vor dem + // Upload, damit eine abgelehnte Einreichung keinen verwaisten Beleg hinterlässt. + if ($invoiceType === InvoiceType::INVOICE_TYPE_TRAVELLING + && Validator::make($request->all(), ['travellers' => 'required|string'])->fails()) { + return response()->json([ + 'status' => 'error', + 'message' => 'Bitte gib an, welche Personen gereist sind.' + ]); + } + $uploadedFile = null; if (null !== $request->file('receipt')) { $maxFileSize = env('MAX_INVOICE_FILE_SIZE', 16); diff --git a/app/Domains/Invoice/Views/Partials/newInvoice/travel-expense-accounting.vue b/app/Domains/Invoice/Views/Partials/newInvoice/travel-expense-accounting.vue index 1438c33..78b3029 100644 --- a/app/Domains/Invoice/Views/Partials/newInvoice/travel-expense-accounting.vue +++ b/app/Domains/Invoice/Views/Partials/newInvoice/travel-expense-accounting.vue @@ -41,10 +41,11 @@ const travelReasonComplete = computed(() => /** * Wer gereist ist, steht später als Zahlungsgrund in der Belegliste, auf der EüR und auf dem Beleg. Wer * den Beleg einreicht, war meist selbst dabei -- deshalb der eigene Name als Vorschlag. Ohne Login - * bleibt das Feld leer, und es hält den Ablauf auch dann nicht auf: Ein Name ist eine Hilfe, keine - * Bedingung. + * bleibt das Feld leer; weiter geht es erst, wenn jemand eingetragen ist -- wie beim Reisegrund. */ const travellers = ref(data.userName || ''); + +const travellersComplete = computed(() => travellers.value.trim() !== ''); const have_receipt = ref('') const amount = ref(0.00); const invoiceType = ref(null); @@ -121,7 +122,7 @@ function handleFileChange(event) { />

-
+
Bist du mit dem ÖPNV gefahren oder besitzt du einen Beleg @@ -129,7 +130,8 @@ function handleFileChange(event) {

-
+ +
Wie hoch ist der Betrag? Euro @@ -161,7 +163,7 @@ function handleFileChange(event) { />
-
+
Reiseinformationen Gesamtlänge des Reisewegs: costUnit->toArray(), [ + 'billingDeadline' => $this->costUnit->billing_deadline, 'distanceAllowanceSmall' => new Amount($this->costUnit->distance_allowance, '')->toString(), 'distanceAllowanceFull' => new Amount($this->costUnit->distance_allowance, ' Euro')->toString(), 'totalAmount' => new Amount($totalAmount, ' Euro')->toString(), diff --git a/app/Tasks/CloseCostUnit.php b/app/Tasks/CloseCostUnit.php index 2e80dc3..2e55028 100644 --- a/app/Tasks/CloseCostUnit.php +++ b/app/Tasks/CloseCostUnit.php @@ -15,7 +15,6 @@ use Illuminate\Support\Facades\Mail; class CloseCostUnit implements CronTask { public function handle(): void { - $now = now(); $costUnitRepository = new CostUnitRepository(); $now = now(); $billingResetEnd = clone $now; @@ -25,7 +24,7 @@ class CloseCostUnit implements CronTask { $billingEnd = $costUnit['billing_deadline']; if ($billingEnd === null) { - $billingEnd = \DateTime::createFromFormat('Y-m-d H:i:s', $billingResetEnd)->format('Y-m-d H:i:s'); + $billingEnd = $billingResetEnd->format('Y-m-d'); new ChangeCostUnitDetailsCommand(new ChangeCostUnitDetailsRequest( CostUnit::where('id', $costUnit['id'])->first(), Amount::fromString($costUnit['distance_allowance']), @@ -34,9 +33,9 @@ class CloseCostUnit implements CronTask { ))->execute(); } - $billingEndTime = \DateTime::createFromFormat('Y-m-d H:i:s', $billingEnd); + $billingEndTime = \DateTime::createFromFormat('!Y-m-d', $billingEnd); if (false === $billingEndTime) { - $billingEndTime = \DateTime::createFromFormat('Y-m-d', $billingEnd); + $billingEndTime = \DateTime::createFromFormat('Y-m-d H:i:s', $billingEnd); } if (false === $billingEndTime) { @@ -56,12 +55,10 @@ class CloseCostUnit implements CronTask { continue; } - $billingEndTime->setTime(0,0,0); - $billingEndTime->add(new \DateInterval('P1D')); + // Der Abrechnungsschluss gilt einschließlich des ganzen Tages: bis 23:59:59 darf eingereicht werden. + $billingEndTime->setTime(23, 59, 59); - - - if ($billingEndTime < $now) { + if ($now > $billingEndTime) { new ChangeCostUnitStateCommand( new ChangeCostUnitStateRequest( CostUnit::where('id', $costUnit['id'])->first(),false, false diff --git a/database/migrations/2026_09_17_140010_change_billing_deadline_to_date_on_cost_units.php b/database/migrations/2026_09_17_140010_change_billing_deadline_to_date_on_cost_units.php new file mode 100644 index 0000000..78a1f87 --- /dev/null +++ b/database/migrations/2026_09_17_140010_change_billing_deadline_to_date_on_cost_units.php @@ -0,0 +1,31 @@ +whereNotNull('billing_deadline') + ->update(['billing_deadline' => DB::raw('DATE(billing_deadline)')]); + + Schema::table('cost_units', function (Blueprint $table) { + $table->date('billing_deadline')->nullable()->change(); + }); + } + + public function down(): void + { + Schema::table('cost_units', function (Blueprint $table) { + $table->dateTime('billing_deadline')->nullable()->change(); + }); + } +}; diff --git a/tests/Feature/CostUnitBillingDeadlineTest.php b/tests/Feature/CostUnitBillingDeadlineTest.php new file mode 100644 index 0000000..b4aa87b --- /dev/null +++ b/tests/Feature/CostUnitBillingDeadlineTest.php @@ -0,0 +1,184 @@ +tenant = Tenant::create([ + 'slug' => 'wm', + 'name' => 'Wilde Möhre', + 'address_1' => 'Musterweg 1', + 'email' => 't@example.com', + 'email_finance' => 'finance@example.com', + 'url' => parse_url(config('app.url'), PHP_URL_HOST), + 'account_name' => 'Test e.V.', + 'account_iban' => 'DE00', + 'account_bic' => 'XY', + 'city' => 'Stadt', + 'postcode' => '00000', + 'invoice_prefix' => 'WM', + 'is_active_local_group' => true, + 'has_active_instance' => true, + ]); + + app()->instance('tenant', $this->tenant); + + DB::table('cost_unit_types')->insert(['slug' => CostUnitType::COST_UNIT_TYPE_EVENT, 'name' => 'Veranstaltung']); + + foreach ([UserRole::USER_ROLE_ADMIN, UserRole::USER_ROLE_GROUP_LEADER, UserRole::USER_ROLE_USER] as $role) { + UserRole::create(['slug' => $role, 'name' => $role]); + } + } + + public function test_details_liefern_den_abrechnungsschluss_im_format_des_datumsfelds(): void + { + $costUnit = $this->makeCostUnit('2026-09-11'); + $this->actingAs($this->makeAdmin()); + + $response = $this->getJson('/api/v1/cost-unit/' . $costUnit->id . '/details'); + + $response->assertOk(); + $this->assertSame('2026-09-11', $response->json('costUnit.billingDeadline')); + } + + public function test_details_ohne_abrechnungsschluss_liefern_null(): void + { + $costUnit = $this->makeCostUnit(null); + $this->actingAs($this->makeAdmin()); + + $response = $this->getJson('/api/v1/cost-unit/' . $costUnit->id . '/details'); + + $this->assertNull($response->json('costUnit.billingDeadline')); + } + + public function test_speichern_uebernimmt_das_datum_aus_dem_formular(): void + { + $costUnit = $this->makeCostUnit('2026-09-11'); + $this->actingAs($this->makeAdmin()); + + $this->postJson('/api/v1/cost-unit/' . $costUnit->id . '/details', ['formData' => [ + 'billingDeadline' => '2026-09-30', + 'mailOnNew' => true, + 'distanceAllowance' => '0,30', + ]])->assertOk()->assertJson(['status' => 'success']); + + $this->assertSame('2026-09-30', $costUnit->fresh()->billing_deadline); + } + + public function test_ein_geleertes_datumsfeld_entfernt_den_abrechnungsschluss_ohne_fehler(): void + { + $costUnit = $this->makeCostUnit('2026-09-11'); + $this->actingAs($this->makeAdmin()); + + $this->postJson('/api/v1/cost-unit/' . $costUnit->id . '/details', ['formData' => [ + 'billingDeadline' => '', + 'mailOnNew' => false, + 'distanceAllowance' => '0,25', + ]])->assertOk()->assertJson(['status' => 'success']); + + $this->assertNull($costUnit->fresh()->billing_deadline); + } + + public function test_eine_uhrzeit_im_request_wird_nicht_gespeichert(): void + { + $costUnit = $this->makeCostUnit(null); + + new ChangeCostUnitDetailsCommand(new ChangeCostUnitDetailsRequest( + $costUnit, + Amount::fromString('0,25'), + false, + new DateTime('2026-09-11 14:23:45'), + ))->execute(); + + $this->assertSame('2026-09-11', $costUnit->fresh()->billing_deadline); + } + + public function test_am_stichtag_um_2359_ist_die_kostenstelle_noch_offen(): void + { + $costUnit = $this->makeCostUnit('2026-09-11'); + + $this->travelTo(new DateTime('2026-09-11 23:59:00')); + new CloseCostUnit()->handle(); + + $this->assertTrue((bool) $costUnit->fresh()->allow_new); + } + + public function test_am_folgetag_schliesst_der_cron_die_kostenstelle(): void + { + $costUnit = $this->makeCostUnit('2026-09-11'); + + $this->travelTo(new DateTime('2026-09-12 00:05:00')); + new CloseCostUnit()->handle(); + + $this->assertFalse((bool) $costUnit->fresh()->allow_new); + } + + public function test_ohne_abrechnungsschluss_setzt_der_cron_sechs_wochen_und_laesst_offen(): void + { + $costUnit = $this->makeCostUnit(null); + + $this->travelTo(new DateTime('2026-09-12 00:05:00')); + new CloseCostUnit()->handle(); + + $costUnit = $costUnit->fresh(); + $this->assertSame('2026-10-24', $costUnit->billing_deadline); + $this->assertTrue((bool) $costUnit->allow_new); + } + + private function makeCostUnit(?string $billingDeadline): CostUnit + { + return CostUnit::create([ + 'tenant' => $this->tenant->slug, + 'name' => 'Sommerlager', + 'type' => CostUnitType::COST_UNIT_TYPE_EVENT, + 'billing_deadline' => $billingDeadline, + 'distance_allowance' => 0.25, + 'mail_on_new' => false, + 'allow_new' => true, + 'archived' => false, + ]); + } + + private function makeAdmin(): User + { + return User::create([ + 'username' => 'admin-' . uniqid() . '@example.com', + 'email' => 'admin-' . uniqid() . '@example.com', + 'firstname' => 'Test', + 'lastname' => 'Person', + 'password' => bcrypt('secret'), + 'local_group' => $this->tenant->slug, + 'user_role_main' => UserRole::USER_ROLE_USER, + 'user_role_local_group' => UserRole::USER_ROLE_ADMIN, + 'active' => true, + ]); + } +} diff --git a/tests/Feature/InvoicePurposeTest.php b/tests/Feature/InvoicePurposeTest.php index 402b4ae..6c0b0b9 100644 --- a/tests/Feature/InvoicePurposeTest.php +++ b/tests/Feature/InvoicePurposeTest.php @@ -183,12 +183,47 @@ class InvoicePurposeTest extends TestCase public function test_travel_costs_without_travellers_record_only_the_reason(): void { - // Ohne Login steht im Formular niemand drin -- das darf den Beleg nicht aufhalten. + // Der Command selbst verlangt keine Personen: Abrechnungskorrekturen laufen ohne sie durch. Die + // Pflicht beim Einreichen prüft der SaveInvoiceController -- siehe die Tests unten. $invoice = $this->submit(InvoiceType::INVOICE_TYPE_TRAVELLING, travelReason: 'Landeslager'); $this->assertSame('Landeslager', $invoice->purpose); } + /** + * Eine Fahrtkostenabrechnung über das Formular, als Kilometerpauschale ohne Beleg. + */ + private function postTravelExpense(CostUnit $costUnit, ?string $travellers): \Illuminate\Testing\TestResponse + { + return $this->postJson('/api/v1/invoice/new/' . $costUnit->id . '/' . InvoiceType::INVOICE_TYPE_TRAVELLING, [ + 'name' => 'Max Mustermann', + 'amount' => '100', + 'otherText' => 'Halle – Leipzig', + 'decision' => 'payout', + 'travelReason' => 'Landeslager', + 'travellers' => $travellers, + ]); + } + + public function test_submitting_travel_costs_requires_who_travelled(): void + { + $costUnit = $this->makeCostUnit(); + + foreach ([null, '', ' '] as $travellers) { + $this->postTravelExpense($costUnit, $travellers) + ->assertJson(['status' => 'error', 'message' => 'Bitte gib an, welche Personen gereist sind.']); + } + + $this->assertSame(0, Invoice::count()); + } + + public function test_submitting_travel_costs_with_travellers_is_accepted(): void + { + $this->postTravelExpense($this->makeCostUnit(), 'Mika und Kim')->assertJson(['status' => 'success']); + + $this->assertSame('Landeslager — Mika und Kim', Invoice::sole()->purpose); + } + public function test_a_refund_records_no_purpose(): void { // Beitragserstattungen entstehen ohne Freitext. `null` statt Leerstring: So greift für sie