Merge origin/dev-4.7.0: Steuer-Feature (Preismodus + DB-gestützte Befreiungsgründe)
Konflikte in den Steuer-Dateien zugunsten der weiterentwickelten lokalen Version aufgelöst: vat_pricing_mode (Preismodus), DB-gestützte tax_exemption_reasons mit FK, typisierte Request-DTOs, Event-Steuer-Snapshot. origin/dev-4.7.0 enthielt eine frühere Teilmenge desselben Features. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -22,7 +22,7 @@ class ManagedTenantTaxUpdateController extends CommonController
|
||||
vatRate: (int)$request->input('vat_rate', 0),
|
||||
exemptionReason: TaxExemptionReason::where('slug', $request->input('tax_exemption_reason'))->first(),
|
||||
exemptionNote: $request->input('tax_exemption_note'),
|
||||
vatPricingMode: VatPricingMode::tryFrom((string)$request->input('vat_pricing_mode', 'inclusive')) ?? VatPricingMode::Inclusive,
|
||||
vatPricingMode: VatPricingMode::tryFrom((string) $request->input('vat_pricing_mode', 'inclusive')) ?? VatPricingMode::Inclusive,
|
||||
));
|
||||
|
||||
$response = $action->execute();
|
||||
|
||||
@@ -20,7 +20,7 @@ class TenantTaxUpdateController extends CommonController
|
||||
vatRate: (int)$request->input('vat_rate', 0),
|
||||
exemptionReason: TaxExemptionReason::where('slug', $request->input('tax_exemption_reason'))->first(),
|
||||
exemptionNote: $request->input('tax_exemption_note'),
|
||||
vatPricingMode: VatPricingMode::tryFrom((string)$request->input('vat_pricing_mode', 'inclusive')) ?? VatPricingMode::Inclusive,
|
||||
vatPricingMode: VatPricingMode::tryFrom((string) $request->input('vat_pricing_mode', 'inclusive')) ?? VatPricingMode::Inclusive,
|
||||
));
|
||||
|
||||
$response = $action->execute();
|
||||
|
||||
@@ -44,7 +44,7 @@ class TaxExemptionReason extends CommonModel
|
||||
*/
|
||||
public function invoiceText(?string $customNote = null): string
|
||||
{
|
||||
return $this->requires_note ? trim((string)$customNote) : $this->invoice_text;
|
||||
return $this->requires_note ? trim((string) $customNote) : $this->invoice_text;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -56,7 +56,7 @@ class TaxExemptionReason extends CommonModel
|
||||
public static function options(): array
|
||||
{
|
||||
return self::all()
|
||||
->map(static fn(self $reason): array => [
|
||||
->map(static fn (self $reason): array => [
|
||||
'value' => $reason->slug,
|
||||
'label' => $reason->name,
|
||||
'text' => $reason->requires_note ? '' : $reason->invoice_text,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enumerations;
|
||||
|
||||
/**
|
||||
* Preismodus bei bestehender Umsatzsteuerpflicht: entweder ist die USt bereits im konfigurierten Beitrag
|
||||
* enthalten (Brutto/„Supermarkt") oder sie wird bei der Preisermittlung aufgeschlagen (Netto). Gespeichert
|
||||
* wird immer der finale Brutto-Beitrag; der Modus beeinflusst nur die Preisermittlung im Anmeldeprozess.
|
||||
*/
|
||||
enum VatPricingMode: string
|
||||
{
|
||||
case Inclusive = 'inclusive';
|
||||
case AddOn = 'add_on';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Inclusive => 'Inklusive – Beiträge sind Endpreise (USt enthalten)',
|
||||
self::AddOn => 'Aufschlagen – USt wird auf die Beiträge aufgeschlagen',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Optionen für das Frontend: [['value' => ..., 'label' => ...], ...].
|
||||
*
|
||||
* @return array<int, array{value: string, label: string}>
|
||||
*/
|
||||
public static function options(): array
|
||||
{
|
||||
return array_map(
|
||||
static fn (self $mode): array => [
|
||||
'value' => $mode->value,
|
||||
'label' => $mode->label(),
|
||||
],
|
||||
self::cases(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('tenants', function (Blueprint $table) {
|
||||
$table->string('vat_pricing_mode')->default('inclusive');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('tenants', function (Blueprint $table) {
|
||||
$table->dropColumn('vat_pricing_mode');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Friert die umsatzsteuerliche Grundlage bei Event-Anlage aus den Tenant-Einstellungen ein. Die Werte sind
|
||||
* danach unveränderlich, damit Preisermittlung und spätere Rechnungen von späteren Tenant-Änderungen
|
||||
* unberührt bleiben. Bestandsevents erhalten die Defaults (keine USt).
|
||||
*/
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('events', function (Blueprint $table) {
|
||||
$table->boolean('tax_liable')->default(false);
|
||||
$table->unsignedTinyInteger('vat_rate')->default(0);
|
||||
$table->string('vat_pricing_mode')->default('inclusive');
|
||||
$table->string('tax_exemption_reason')->nullable();
|
||||
$table->text('tax_exemption_note')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('events', function (Blueprint $table) {
|
||||
$table->dropColumn([
|
||||
'tax_liable',
|
||||
'vat_rate',
|
||||
'vat_pricing_mode',
|
||||
'tax_exemption_reason',
|
||||
'tax_exemption_note',
|
||||
]);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('tax_exemption_reasons', function (Blueprint $table) {
|
||||
$table->string('slug')->primary();
|
||||
$table->string('name');
|
||||
$table->text('invoice_text')->nullable();
|
||||
$table->boolean('requires_note')->default(false);
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('tax_exemption_reasons');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Befüllt die Referenztabelle mit den Befreiungsgründen (§ 14 Abs. 4 Nr. 8 UStG) und verknüpft danach den
|
||||
* nullbaren Befreiungsgrund von Tenant und Event per Foreign Key. Das Seeding erfolgt hier (nicht im
|
||||
* ProductionDataSeeder), damit die FK-Ziele auch auf bestehenden Datenbanken beim Migrieren existieren.
|
||||
* Ein noch nicht konfigurierter Tenant hat keinen Grund (NULL).
|
||||
*/
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
$now = now();
|
||||
DB::table('tax_exemption_reasons')->insertOrIgnore([
|
||||
['slug' => 'small_business', 'name' => 'Kleinunternehmer (§ 19 UStG)', 'invoice_text' => 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.', 'requires_note' => false, 'created_at' => $now, 'updated_at' => $now],
|
||||
['slug' => 'education', 'name' => 'Bildungs-/Kursleistung (§ 4 Nr. 22a UStG)', 'invoice_text' => 'Die Leistung ist gemäß § 4 Nr. 22 Buchst. a UStG von der Umsatzsteuer befreit.', 'requires_note' => false, 'created_at' => $now, 'updated_at' => $now],
|
||||
['slug' => 'youth_welfare', 'name' => 'Kinder- und Jugendhilfe (§ 4 Nr. 25 UStG)', 'invoice_text' => 'Die Leistung ist gemäß § 4 Nr. 25 UStG von der Umsatzsteuer befreit.', 'requires_note' => false, 'created_at' => $now, 'updated_at' => $now],
|
||||
['slug' => 'custom', 'name' => 'Sonstiger Grund (Freitext)', 'invoice_text' => null, 'requires_note' => true, 'created_at' => $now, 'updated_at' => $now],
|
||||
]);
|
||||
|
||||
Schema::table('tenants', function (Blueprint $table) {
|
||||
$table->foreign('tax_exemption_reason')
|
||||
->references('slug')->on('tax_exemption_reasons')
|
||||
->restrictOnDelete()->cascadeOnUpdate();
|
||||
});
|
||||
|
||||
Schema::table('events', function (Blueprint $table) {
|
||||
$table->foreign('tax_exemption_reason')
|
||||
->references('slug')->on('tax_exemption_reasons')
|
||||
->restrictOnDelete()->cascadeOnUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('tenants', function (Blueprint $table) {
|
||||
$table->dropForeign(['tax_exemption_reason']);
|
||||
});
|
||||
|
||||
Schema::table('events', function (Blueprint $table) {
|
||||
$table->dropForeign(['tax_exemption_reason']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Domains\Event\Actions\CreateEvent\CreateEventCommand;
|
||||
use App\Domains\Event\Actions\CreateEvent\CreateEventRequest;
|
||||
use App\Enumerations\EatingHabit;
|
||||
use App\Enumerations\ParticipationFeeType;
|
||||
use App\Models\Event;
|
||||
use App\Models\Tenant;
|
||||
use App\RelationModels\EventParticipationFee;
|
||||
use App\Resources\EventResource;
|
||||
use DateTime;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
class EventVatPricingTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private Tenant $tenant;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->tenant = Tenant::create([
|
||||
'slug' => 'tv',
|
||||
'name' => 'Test',
|
||||
'email' => 't@example.com',
|
||||
'email_finance' => 'finance@example.com',
|
||||
'url' => 'test.local',
|
||||
'account_name' => 'Test e.V.',
|
||||
'account_iban' => 'DE00',
|
||||
'account_bic' => 'XY',
|
||||
'city' => 'Stadt',
|
||||
'postcode' => '00000',
|
||||
'is_active_local_group' => true,
|
||||
'has_active_instance' => true,
|
||||
]);
|
||||
|
||||
app()->instance('tenant', $this->tenant);
|
||||
|
||||
DB::table('participation_types')->insert(['slug' => 'participant', 'name' => 'Teilnehmer']);
|
||||
DB::table('participation_fee_types')->insert(['slug' => 'fixed', 'name' => 'Fix']);
|
||||
EatingHabit::create(['slug' => EatingHabit::EATING_HABIT_VEGAN, 'name' => 'Vegan']);
|
||||
EatingHabit::create(['slug' => EatingHabit::EATING_HABIT_VEGETARIAN, 'name' => 'Vegetarisch']);
|
||||
}
|
||||
|
||||
private function makeFee(float $amountStandard): EventParticipationFee
|
||||
{
|
||||
return EventParticipationFee::create([
|
||||
'tenant' => $this->tenant->slug,
|
||||
'type' => 'participant',
|
||||
'name' => 'Standard',
|
||||
'description' => null,
|
||||
'amount_standard' => $amountStandard,
|
||||
'amount_reduced' => null,
|
||||
'amount_solidarity' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
private function makeEvent(bool $taxLiable, int $vatRate, string $mode, int $feeId): Event
|
||||
{
|
||||
return Event::create([
|
||||
'tenant' => $this->tenant->slug,
|
||||
'name' => 'Event',
|
||||
'identifier' => 'evt-' . uniqid(),
|
||||
'location' => 'Ort',
|
||||
'postal_code' => '00000',
|
||||
'email' => 'e@example.com',
|
||||
'start_date' => '2026-09-01',
|
||||
'end_date' => '2026-09-05',
|
||||
'early_bird_end' => '2026-08-20',
|
||||
'registration_final_end' => '2026-08-25',
|
||||
'early_bird_end_amount_increase' => 0,
|
||||
'account_owner' => 'Owner',
|
||||
'account_iban' => 'DE00',
|
||||
'participation_fee_type' => 'fixed',
|
||||
'participation_fee_1' => $feeId,
|
||||
'pay_per_day' => false,
|
||||
'pay_direct' => false,
|
||||
'tax_liable' => $taxLiable,
|
||||
'vat_rate' => $vatRate,
|
||||
'vat_pricing_mode' => $mode,
|
||||
]);
|
||||
}
|
||||
|
||||
private function calculate(Event $event): float
|
||||
{
|
||||
return new EventResource($event)->calculateAmount(
|
||||
'participant',
|
||||
'standard',
|
||||
new DateTime('2026-09-01'),
|
||||
new DateTime('2026-09-01'),
|
||||
false,
|
||||
)->getAmount();
|
||||
}
|
||||
|
||||
public function test_add_on_mode_adds_vat_on_top(): void
|
||||
{
|
||||
$event = $this->makeEvent(taxLiable: true, vatRate: 19, mode: 'add_on', feeId: $this->makeFee(100.0)->id);
|
||||
|
||||
$this->assertEqualsWithDelta(119.0, $this->calculate($event), 0.001);
|
||||
}
|
||||
|
||||
public function test_inclusive_mode_keeps_amount(): void
|
||||
{
|
||||
$event = $this->makeEvent(taxLiable: true, vatRate: 19, mode: 'inclusive', feeId: $this->makeFee(100.0)->id);
|
||||
|
||||
$this->assertEqualsWithDelta(100.0, $this->calculate($event), 0.001);
|
||||
}
|
||||
|
||||
public function test_not_liable_ignores_add_on_mode(): void
|
||||
{
|
||||
$event = $this->makeEvent(taxLiable: false, vatRate: 19, mode: 'add_on', feeId: $this->makeFee(100.0)->id);
|
||||
|
||||
$this->assertEqualsWithDelta(100.0, $this->calculate($event), 0.001);
|
||||
}
|
||||
|
||||
public function test_create_event_snapshots_tenant_tax_config(): void
|
||||
{
|
||||
$this->tenant->update([
|
||||
'tax_liable' => true,
|
||||
'vat_rate' => 7,
|
||||
'vat_pricing_mode' => 'add_on',
|
||||
'tax_exemption_reason' => null,
|
||||
'tax_exemption_note' => null,
|
||||
]);
|
||||
|
||||
$response = new CreateEventCommand(new CreateEventRequest(
|
||||
name: 'Sommerlager',
|
||||
location: 'Ort',
|
||||
postalCode: '00000',
|
||||
email: 'e@example.com',
|
||||
begin: new DateTime('2026-09-01'),
|
||||
end: new DateTime('2026-09-05'),
|
||||
earlyBirdEnd: new DateTime('2026-08-20'),
|
||||
registrationFinalEnd: new DateTime('2026-08-25'),
|
||||
earlyBirdEndAmountIncrease: 0,
|
||||
participationFeeType: ParticipationFeeType::where('slug', 'fixed')->first(),
|
||||
accountOwner: 'Owner',
|
||||
accountIban: 'DE00',
|
||||
payPerDay: false,
|
||||
))->execute();
|
||||
|
||||
$this->assertTrue($response->success);
|
||||
|
||||
$event = $response->event->fresh();
|
||||
$this->assertTrue($event->tax_liable);
|
||||
$this->assertSame(7, $event->vat_rate);
|
||||
$this->assertSame('add_on', $event->vat_pricing_mode);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user