Fehler in Abrechnungsende behoben

This commit is contained in:
2026-09-11 11:26:35 +02:00
parent 328825293a
commit ef6284d5cc
11 changed files with 284 additions and 19 deletions
@@ -14,7 +14,7 @@ class ChangeCostUnitDetailsCommand {
$this->request->costUnit->distance_allowance = $this->request->distanceAllowance->getAmount(); $this->request->costUnit->distance_allowance = $this->request->distanceAllowance->getAmount();
$this->request->costUnit->mail_on_new = $this->request->mailOnNew; $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(); $response->success = $this->request->costUnit->save();
return $response; return $response;
@@ -17,7 +17,7 @@ class CreateCostUnitCommand {
'name' => $this->request->name, 'name' => $this->request->name,
'tenant' => currentTenant()->slug, 'tenant' => currentTenant()->slug,
'type' => $this->request->type, 'type' => $this->request->type,
'billing_deadline' => $this->request->billingDeadline, 'billing_deadline' => $this->request->billingDeadline?->format('Y-m-d'),
'distance_allowance' => $this->request->distanceAllowance->getAmount(), 'distance_allowance' => $this->request->distanceAllowance->getAmount(),
'mail_on_new' => $this->request->mailOnNew, 'mail_on_new' => $this->request->mailOnNew,
'allow_new' => true, 'allow_new' => true,
@@ -37,7 +37,10 @@ class EditController extends CommonController{
$saveParams = $request->get('formData'); $saveParams = $request->get('formData');
$distanceAllowance = Amount::fromString($saveParams['distanceAllowance']); $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); $request = new ChangeCostUnitDetailsRequest($costUnit, $distanceAllowance, $saveParams['mailOnNew'], $billingDeadline);
$command = new ChangeCostUnitDetailsCommand($request); $command = new ChangeCostUnitDetailsCommand($request);
@@ -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; $uploadedFile = null;
if (null !== $request->file('receipt')) { if (null !== $request->file('receipt')) {
$maxFileSize = env('MAX_INVOICE_FILE_SIZE', 16); $maxFileSize = env('MAX_INVOICE_FILE_SIZE', 16);
@@ -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 * 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 * 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 * bleibt das Feld leer; weiter geht es erst, wenn jemand eingetragen ist -- wie beim Reisegrund.
* Bedingung.
*/ */
const travellers = ref(data.userName || ''); const travellers = ref(data.userName || '');
const travellersComplete = computed(() => travellers.value.trim() !== '');
const have_receipt = ref('') const have_receipt = ref('')
const amount = ref(0.00); const amount = ref(0.00);
const invoiceType = ref(null); const invoiceType = ref(null);
@@ -121,7 +122,7 @@ function handleFileChange(event) {
/> />
</fieldset><br /><br /> </fieldset><br /><br />
<fieldset v-if="travelReasonComplete"> <fieldset v-if="travelReasonComplete && travellersComplete">
<legend><span style="font-weight: bolder;">Bist du mit dem ÖPNV gefahren oder besitzt du einen Beleg</span></legend> <legend><span style="font-weight: bolder;">Bist du mit dem ÖPNV gefahren oder besitzt du einen Beleg</span></legend>
<input type="button" style="border-radius: 0; width: 100px;" @click="have_receipt='yes'" value="Ja" /> <input type="button" style="border-radius: 0; width: 100px;" @click="have_receipt='yes'" value="Ja" />
<input type="button" style="border-radius: 0; width: 100px;" @click="getDistanceAllowance" value="Nein" /> <input type="button" style="border-radius: 0; width: 100px;" @click="getDistanceAllowance" value="Nein" />
@@ -129,7 +130,8 @@ function handleFileChange(event) {
<br /><br /> <br /><br />
<fieldset v-if="have_receipt === 'yes'"> <!-- Auch nach "Ja"/"Nein" gilt die Pflicht: Wer die Personen wieder löscht, kann nicht einreichen. -->
<fieldset v-if="travellersComplete && have_receipt === 'yes'">
<legend><span style="font-weight: bolder;">Wie hoch ist der Betrag?</span></legend> <legend><span style="font-weight: bolder;">Wie hoch ist der Betrag?</span></legend>
<AmountInput v-model="amount" class="width-small" id="amount" name="amount" /> Euro <AmountInput v-model="amount" class="width-small" id="amount" name="amount" /> Euro
@@ -161,7 +163,7 @@ function handleFileChange(event) {
/> />
</fieldset> </fieldset>
<fieldset v-else-if="distanceAllowance != null"> <fieldset v-else-if="travellersComplete && distanceAllowance != null">
<legend><span style="font-weight: bolder;">Reiseinformationen</span></legend> <legend><span style="font-weight: bolder;">Reiseinformationen</span></legend>
Gesamtlänge des Reisewegs: Gesamtlänge des Reisewegs:
<NumericInput <NumericInput
+1 -1
View File
@@ -11,7 +11,7 @@ use Illuminate\Database\Eloquent\Relations\HasOne;
/** /**
* @property string $name * @property string $name
* @property string $type * @property string $type
* @property string $billing_deadline * @property string|null $billing_deadline Y-m-d, Abrechnungsschluss einschließlich des ganzen Tages
* @property string $distance_allowance * @property string $distance_allowance
* @property boolean $mail_on_new * @property boolean $mail_on_new
* @property boolean $allow_new * @property boolean $allow_new
+1
View File
@@ -49,6 +49,7 @@ class CostUnitResource {
$data = array_merge( $data = array_merge(
$this->costUnit->toArray(), $this->costUnit->toArray(),
[ [
'billingDeadline' => $this->costUnit->billing_deadline,
'distanceAllowanceSmall' => new Amount($this->costUnit->distance_allowance, '')->toString(), 'distanceAllowanceSmall' => new Amount($this->costUnit->distance_allowance, '')->toString(),
'distanceAllowanceFull' => new Amount($this->costUnit->distance_allowance, ' Euro')->toString(), 'distanceAllowanceFull' => new Amount($this->costUnit->distance_allowance, ' Euro')->toString(),
'totalAmount' => new Amount($totalAmount, ' Euro')->toString(), 'totalAmount' => new Amount($totalAmount, ' Euro')->toString(),
+6 -9
View File
@@ -15,7 +15,6 @@ use Illuminate\Support\Facades\Mail;
class CloseCostUnit implements CronTask { class CloseCostUnit implements CronTask {
public function handle(): void public function handle(): void
{ {
$now = now();
$costUnitRepository = new CostUnitRepository(); $costUnitRepository = new CostUnitRepository();
$now = now(); $now = now();
$billingResetEnd = clone $now; $billingResetEnd = clone $now;
@@ -25,7 +24,7 @@ class CloseCostUnit implements CronTask {
$billingEnd = $costUnit['billing_deadline']; $billingEnd = $costUnit['billing_deadline'];
if ($billingEnd === null) { 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( new ChangeCostUnitDetailsCommand(new ChangeCostUnitDetailsRequest(
CostUnit::where('id', $costUnit['id'])->first(), CostUnit::where('id', $costUnit['id'])->first(),
Amount::fromString($costUnit['distance_allowance']), Amount::fromString($costUnit['distance_allowance']),
@@ -34,9 +33,9 @@ class CloseCostUnit implements CronTask {
))->execute(); ))->execute();
} }
$billingEndTime = \DateTime::createFromFormat('Y-m-d H:i:s', $billingEnd); $billingEndTime = \DateTime::createFromFormat('!Y-m-d', $billingEnd);
if (false === $billingEndTime) { if (false === $billingEndTime) {
$billingEndTime = \DateTime::createFromFormat('Y-m-d', $billingEnd); $billingEndTime = \DateTime::createFromFormat('Y-m-d H:i:s', $billingEnd);
} }
if (false === $billingEndTime) { if (false === $billingEndTime) {
@@ -56,12 +55,10 @@ class CloseCostUnit implements CronTask {
continue; continue;
} }
$billingEndTime->setTime(0,0,0); // Der Abrechnungsschluss gilt einschließlich des ganzen Tages: bis 23:59:59 darf eingereicht werden.
$billingEndTime->add(new \DateInterval('P1D')); $billingEndTime->setTime(23, 59, 59);
if ($now > $billingEndTime) {
if ($billingEndTime < $now) {
new ChangeCostUnitStateCommand( new ChangeCostUnitStateCommand(
new ChangeCostUnitStateRequest( new ChangeCostUnitStateRequest(
CostUnit::where('id', $costUnit['id'])->first(),false, false CostUnit::where('id', $costUnit['id'])->first(),false, false
@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Der Abrechnungsschluss ist ein Tag, keine Uhrzeit: eingereicht werden darf bis einschließlich 23:59 des Stichtags.
* Die Uhrzeit, die bisher mitgespeichert wurde, hat der Cron ohnehin verworfen -- das Abschneiden ändert kein
* Verhalten. Erst normalisieren, dann den Typ ändern, damit das ALTER nichts abschneiden muss.
*/
return new class extends Migration {
public function up(): void
{
DB::table('cost_units')
->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();
});
}
};
@@ -0,0 +1,184 @@
<?php
namespace Tests\Feature;
use App\Domains\CostUnit\Actions\ChangeCostUnitDetails\ChangeCostUnitDetailsCommand;
use App\Domains\CostUnit\Actions\ChangeCostUnitDetails\ChangeCostUnitDetailsRequest;
use App\Enumerations\CostUnitType;
use App\Enumerations\UserRole;
use App\Models\CostUnit;
use App\Models\Tenant;
use App\Models\User;
use App\Tasks\CloseCostUnit;
use App\ValueObjects\Amount;
use DateTime;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
/**
* Der Abrechnungsschluss einer Kostenstelle.
*
* Er ist ein Tag, keine Uhrzeit: Abrechnungen dürfen bis einschließlich 23:59 des Stichtags eingereicht werden. Das
* Modal „Details anpassen" muss ihn anzeigen -- sonst löscht jedes Speichern den Wert unbemerkt.
*/
class CostUnitBillingDeadlineTest extends TestCase
{
use RefreshDatabase;
private Tenant $tenant;
protected function setUp(): void
{
parent::setUp();
$this->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,
]);
}
}
+36 -1
View File
@@ -183,12 +183,47 @@ class InvoicePurposeTest extends TestCase
public function test_travel_costs_without_travellers_record_only_the_reason(): void 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'); $invoice = $this->submit(InvoiceType::INVOICE_TYPE_TRAVELLING, travelReason: 'Landeslager');
$this->assertSame('Landeslager', $invoice->purpose); $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 public function test_a_refund_records_no_purpose(): void
{ {
// Beitragserstattungen entstehen ohne Freitext. `null` statt Leerstring: So greift für sie // Beitragserstattungen entstehen ohne Freitext. `null` statt Leerstring: So greift für sie