2 Commits
Author SHA1 Message Date
th.guentherandClaude Opus 4.8 09f9767eb7 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>
2026-08-10 14:34:50 +02:00
th.guenther b3bbeb5b0b Added tax support 2026-08-10 13:45:55 +02:00
20 changed files with 459 additions and 72 deletions
@@ -2,7 +2,7 @@
namespace App\Domains\Admin\Actions\UpdateTenantTax;
use App\Enumerations\TaxExemptionReason;
use App\Enumerations\VatPricingMode;
class UpdateTenantTaxAction
{
@@ -18,6 +18,7 @@ class UpdateTenantTaxAction
$this->request->tenant->update([
'tax_liable' => true,
'vat_rate' => $this->clampVatRate($this->request->vatRate),
'vat_pricing_mode' => $this->request->vatPricingMode->value,
'tax_exemption_reason' => null,
'tax_exemption_note' => null,
]);
@@ -28,7 +29,7 @@ class UpdateTenantTaxAction
return $response;
}
$reason = TaxExemptionReason::tryFrom((string)$this->request->exemptionReason);
$reason = $this->request->exemptionReason;
if ($reason === null) {
$response->message = 'Bitte einen gültigen Befreiungsgrund auswählen.';
@@ -38,7 +39,7 @@ class UpdateTenantTaxAction
$note = trim((string)$this->request->exemptionNote);
if ($reason->requiresNote() && $note === '') {
if ($reason->requires_note && $note === '') {
$response->message = 'Bitte einen Text für den Befreiungsgrund angeben.';
return $response;
@@ -47,8 +48,9 @@ class UpdateTenantTaxAction
$this->request->tenant->update([
'tax_liable' => false,
'vat_rate' => 0,
'tax_exemption_reason' => $reason->value,
'tax_exemption_note' => $reason->requiresNote() ? $note : null,
'vat_pricing_mode' => VatPricingMode::Inclusive->value,
'tax_exemption_reason' => $reason->slug,
'tax_exemption_note' => $reason->requires_note ? $note : null,
]);
$response->success = true;
@@ -2,6 +2,8 @@
namespace App\Domains\Admin\Actions\UpdateTenantTax;
use App\Enumerations\TaxExemptionReason;
use App\Enumerations\VatPricingMode;
use App\Models\Tenant;
class UpdateTenantTaxRequest
@@ -10,8 +12,9 @@ class UpdateTenantTaxRequest
public Tenant $tenant,
public bool $taxLiable,
public int $vatRate,
public ?string $exemptionReason,
public ?TaxExemptionReason $exemptionReason,
public ?string $exemptionNote,
public VatPricingMode $vatPricingMode,
)
{
}
@@ -3,6 +3,7 @@
namespace App\Domains\Admin\Controllers;
use App\Enumerations\TaxExemptionReason;
use App\Enumerations\VatPricingMode;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -18,7 +19,9 @@ class ManagedTenantTaxGetController extends CommonController
'vat_rate' => $tenant->vat_rate,
'tax_exemption_reason' => $tenant->tax_exemption_reason,
'tax_exemption_note' => $tenant->tax_exemption_note,
'vat_pricing_mode' => $tenant->vat_pricing_mode,
'exemption_reasons' => TaxExemptionReason::options(),
'pricing_modes' => VatPricingMode::options(),
'saveEndpoint' => '/api/v1/admin/tenants/' . $slug . '/tax',
]);
}
@@ -4,6 +4,8 @@ namespace App\Domains\Admin\Controllers;
use App\Domains\Admin\Actions\UpdateTenantTax\UpdateTenantTaxAction;
use App\Domains\Admin\Actions\UpdateTenantTax\UpdateTenantTaxRequest;
use App\Enumerations\TaxExemptionReason;
use App\Enumerations\VatPricingMode;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -18,8 +20,9 @@ class ManagedTenantTaxUpdateController extends CommonController
tenant: $tenant,
taxLiable: $request->boolean('tax_liable'),
vatRate: (int)$request->input('vat_rate', 0),
exemptionReason: $request->input('tax_exemption_reason'),
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,
));
$response = $action->execute();
@@ -3,6 +3,7 @@
namespace App\Domains\Admin\Controllers;
use App\Enumerations\TaxExemptionReason;
use App\Enumerations\VatPricingMode;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -16,7 +17,9 @@ class TenantTaxGetController extends CommonController
'vat_rate' => $this->tenant->vat_rate,
'tax_exemption_reason' => $this->tenant->tax_exemption_reason,
'tax_exemption_note' => $this->tenant->tax_exemption_note,
'vat_pricing_mode' => $this->tenant->vat_pricing_mode,
'exemption_reasons' => TaxExemptionReason::options(),
'pricing_modes' => VatPricingMode::options(),
'saveEndpoint' => '/api/v1/admin/tenant/tax',
]);
}
@@ -4,6 +4,8 @@ namespace App\Domains\Admin\Controllers;
use App\Domains\Admin\Actions\UpdateTenantTax\UpdateTenantTaxAction;
use App\Domains\Admin\Actions\UpdateTenantTax\UpdateTenantTaxRequest;
use App\Enumerations\TaxExemptionReason;
use App\Enumerations\VatPricingMode;
use App\Scopes\CommonController;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@@ -16,8 +18,9 @@ class TenantTaxUpdateController extends CommonController
tenant: $this->tenant,
taxLiable: $request->boolean('tax_liable'),
vatRate: (int)$request->input('vat_rate', 0),
exemptionReason: $request->input('tax_exemption_reason'),
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,
));
$response = $action->execute();
@@ -16,6 +16,7 @@ const {request} = useAjax()
const editing = ref(false)
const reasons = computed(() => props.data.exemption_reasons ?? [])
const pricingModes = computed(() => props.data.pricing_modes ?? [])
const liableOptions = [
{value: '1', label: 'Ja Umsatzsteuer wird erhoben'},
@@ -27,6 +28,7 @@ const form = ref({
vat_rate: props.data.vat_rate ?? 0,
tax_exemption_reason: props.data.tax_exemption_reason ?? '',
tax_exemption_note: props.data.tax_exemption_note ?? '',
vat_pricing_mode: props.data.vat_pricing_mode ?? 'inclusive',
})
const isLiable = computed(() => form.value.tax_liable === '1')
@@ -36,6 +38,10 @@ const selectedReason = computed(
() => reasons.value.find(r => r.value === form.value.tax_exemption_reason) ?? null
)
const pricingModeLabel = computed(
() => pricingModes.value.find(m => m.value === form.value.vat_pricing_mode)?.label ?? '—'
)
// Vorschau des Pflichthinweises nach § 14 Abs. 4 Nr. 8 UStG, der später auf der Rechnung erscheint.
const invoiceHint = computed(() => {
if (isLiable.value) {
@@ -58,6 +64,7 @@ async function save() {
vat_rate: Number(form.value.vat_rate) || 0,
tax_exemption_reason: isLiable.value ? null : form.value.tax_exemption_reason,
tax_exemption_note: form.value.tax_exemption_note,
vat_pricing_mode: form.value.vat_pricing_mode,
},
})
@@ -81,6 +88,10 @@ async function save() {
<th>USt-Satz</th>
<td>{{ form.vat_rate || 0 }} %</td>
</tr>
<tr v-if="isLiable">
<th>Preismodus</th>
<td>{{ pricingModeLabel }}</td>
</tr>
<tr v-else>
<th>Befreiungsgrund</th>
<td>{{ reasonLabel }}</td>
@@ -106,6 +117,12 @@ async function save() {
<th>USt-Satz (%)</th>
<td><input type="number" min="0" max="100" v-model.number="form.vat_rate" class="form-input"/></td>
</tr>
<tr v-if="isLiable">
<th>Preismodus</th>
<td>
<RichSelectBox v-model="form.vat_pricing_mode" :options="pricingModes"/>
</td>
</tr>
<template v-else>
<tr>
@@ -47,6 +47,13 @@ class CreateEventCommand {
'total_max_amount' => 0,
'support_per_person' => 0,
'support_flat' => 0,
// Umsatzsteuerliche Grundlage bei Anlage einfrieren (unveränderlich), damit Preisermittlung und
// spätere Rechnungen von späteren Tenant-Änderungen unberührt bleiben.
'tax_liable' => app('tenant')->tax_liable,
'vat_rate' => app('tenant')->vat_rate,
'vat_pricing_mode' => app('tenant')->vat_pricing_mode,
'tax_exemption_reason' => app('tenant')->tax_exemption_reason,
'tax_exemption_note' => app('tenant')->tax_exemption_note,
]);
if ($event !== null) {
+45 -46
View File
@@ -2,66 +2,65 @@
namespace App\Enumerations;
/**
* Gründe für eine Umsatzsteuerbefreiung. Jeder Fall kapselt sein UI-Label und den auf der Rechnung
* verpflichtenden Hinweis nach § 14 Abs. 4 Nr. 8 UStG, damit Label und Text nur an einer Stelle
* gepflegt werden. Gemeinnützigkeit allein befreit nicht die Befreiung braucht immer einen konkreten
* Rechtsgrund; die Vereins-Fälle sind über § 4 Nr. 22a / Nr. 25 abgedeckt.
*/
enum TaxExemptionReason: string
{
case SmallBusiness = 'small_business';
case Education = 'education';
case YouthWelfare = 'youth_welfare';
case Custom = 'custom';
use App\Scopes\CommonModel;
public function label(): string
{
return match ($this) {
self::SmallBusiness => 'Kleinunternehmer (§ 19 UStG)',
self::Education => 'Bildungs-/Kursleistung (§ 4 Nr. 22a UStG)',
self::YouthWelfare => 'Kinder- und Jugendhilfe (§ 4 Nr. 25 UStG)',
self::Custom => 'Sonstiger Grund (Freitext)',
};
}
/**
* Gründe für eine Umsatzsteuerbefreiung -- DB-gestützt (analog {@see PaymentStatus}), damit Label und
* der auf der Rechnung verpflichtende Hinweis nach § 14 Abs. 4 Nr. 8 UStG zentral pflegbar sind.
* Gemeinnützigkeit allein befreit nicht; die Befreiung braucht immer einen konkreten Rechtsgrund
* (Vereins-Fälle über § 4 Nr. 22a / Nr. 25).
*
* @property string $slug
* @property string $name
* @property string $invoice_text
* @property bool $requires_note
*/
class TaxExemptionReason extends CommonModel
{
public const string SMALL_BUSINESS = 'small_business';
public const string EDUCATION = 'education';
public const string YOUTH_WELFARE = 'youth_welfare';
public const string CUSTOM = 'custom';
protected $table = 'tax_exemption_reasons';
protected $primaryKey = 'slug';
public $incrementing = false;
protected $keyType = 'string';
protected $fillable = [
'slug',
'name',
'invoice_text',
'requires_note',
];
protected $casts = [
'requires_note' => 'boolean',
];
/**
* Pflichthinweis für die Rechnung. Bei einem individuellen Grund (Custom) wird der hinterlegte
* Pflichthinweis für die Rechnung. Bei einem individuellen Grund (requires_note) wird der hinterlegte
* Freitext des Tenants verwendet.
*/
public function invoiceText(?string $customNote = null): string
{
return match ($this) {
self::SmallBusiness => 'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.',
self::Education => 'Die Leistung ist gemäß § 4 Nr. 22 Buchst. a UStG von der Umsatzsteuer befreit.',
self::YouthWelfare => 'Die Leistung ist gemäß § 4 Nr. 25 UStG von der Umsatzsteuer befreit.',
self::Custom => trim((string)$customNote),
};
return $this->requires_note ? trim((string) $customNote) : $this->invoice_text;
}
/**
* Ob dieser Grund einen zusätzlichen Freitext des Tenants erfordert.
*/
public function requiresNote(): bool
{
return $this === self::Custom;
}
/**
* Optionen für das Frontend inkl. Vorschau-Text. Bei Custom ist `text` leer, da der Text erst aus
* dem Freitext des Tenants entsteht.
* Optionen für das Frontend inkl. Vorschau-Text. Bei einem Freitext-Grund ist `text` leer, da der Text
* erst aus dem Freitext des Tenants entsteht.
*
* @return array<int, array{value: string, label: string, text: string}>
*/
public static function options(): array
{
return array_map(
static fn(self $reason): array => [
'value' => $reason->value,
'label' => $reason->label(),
'text' => $reason->requiresNote() ? '' : $reason->invoiceText(),
],
self::cases(),
);
return self::all()
->map(static fn (self $reason): array => [
'value' => $reason->slug,
'label' => $reason->name,
'text' => $reason->requires_note ? '' : $reason->invoice_text,
])
->all();
}
}
+38
View File
@@ -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(),
);
}
}
+8 -3
View File
@@ -8,14 +8,11 @@ use App\Enumerations\EatingHabit;
use App\Enumerations\ParticipationFeeType;
use App\RelationModels\EventParticipationFee;
use App\RelationModels\EventPaymentMethods;
use App\Resources\EventResource;
use App\Scopes\CommonModel;
use App\Scopes\InstancedModel;
use DateTime;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @property string $tenant
@@ -85,6 +82,11 @@ class Event extends InstancedModel
'support_flat',
'alcoholics_age',
'archived',
'tax_liable',
'vat_rate',
'vat_pricing_mode',
'tax_exemption_reason',
'tax_exemption_note',
];
@@ -109,6 +111,9 @@ class Event extends InstancedModel
'total_max_amount' => AmountCast::class,
'sibling_reduction' => 'boolean',
'tax_liable' => 'boolean',
'vat_rate' => 'integer',
];
public function tenant(): BelongsTo
+2
View File
@@ -25,6 +25,7 @@ use App\Scopes\CommonModel;
* @property int $vat_rate
* @property string|null $tax_exemption_reason
* @property string|null $tax_exemption_note
* @property string $vat_pricing_mode
*/
class Tenant extends CommonModel
{
@@ -55,6 +56,7 @@ class Tenant extends CommonModel
'vat_rate',
'tax_exemption_reason',
'tax_exemption_note',
'vat_pricing_mode',
];
protected $casts = [
+8 -1
View File
@@ -4,6 +4,7 @@ namespace App\Resources;
use App\Enumerations\ParticipationFeeType;
use App\Enumerations\ParticipationType;
use App\Enumerations\VatPricingMode;
use App\Models\Event;
use App\ValueObjects\Amount;
use DateTime;
@@ -342,7 +343,13 @@ class EventResource extends JsonResource{
$basicFee = $basicFee->multiply(0.5);
}
return $basicFee;
// Bei bestehender USt-Pflicht und Netto-Preismodus die USt aufschlagen; gespeichert wird immer der
// finale Brutto-Betrag. Bei "inklusive" oder ohne Steuerpflicht bleibt der Beitrag unverändert.
if ($this->event->tax_liable && $this->event->vat_pricing_mode === VatPricingMode::AddOn->value) {
$basicFee = $basicFee->multiply(1 + $this->event->vat_rate / 100);
}
return new Amount(round($basicFee->getAmount(), 2), 'Euro');
}
}
@@ -10,7 +10,7 @@ return new class extends Migration {
Schema::table('tenants', function (Blueprint $table) {
$table->boolean('tax_liable')->default(false);
$table->unsignedTinyInteger('vat_rate')->default(0);
$table->string('tax_exemption_reason')->default('youth_welfare');
$table->string('tax_exemption_reason')->nullable();
$table->text('tax_exemption_note')->nullable();
});
}
@@ -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']);
});
}
};
+155
View File
@@ -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);
}
}
+22 -10
View File
@@ -5,6 +5,7 @@ namespace Tests\Feature;
use App\Domains\Admin\Actions\UpdateTenantTax\UpdateTenantTaxAction;
use App\Domains\Admin\Actions\UpdateTenantTax\UpdateTenantTaxRequest;
use App\Enumerations\TaxExemptionReason;
use App\Enumerations\VatPricingMode;
use App\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
@@ -37,14 +38,15 @@ class TenantTaxInformationTest extends TestCase
app()->instance('tenant', $this->tenant);
}
private function runAction(bool $taxLiable, int $vatRate, ?string $reason, ?string $note = null): object
private function runAction(bool $taxLiable, int $vatRate, ?string $reason, ?string $note = null, string $mode = 'inclusive'): object
{
return new UpdateTenantTaxAction(new UpdateTenantTaxRequest(
tenant: $this->tenant,
taxLiable: $taxLiable,
vatRate: $vatRate,
exemptionReason: $reason,
exemptionReason: $reason ? TaxExemptionReason::where('slug', $reason)->first() : null,
exemptionNote: $note,
vatPricingMode: VatPricingMode::from($mode),
))->execute();
}
@@ -81,6 +83,7 @@ class TenantTaxInformationTest extends TestCase
$response = $this->runAction(taxLiable: false, vatRate: 0, reason: 'not_a_reason');
$this->assertFalse($response->success);
// Unbekannter Slug wird nicht aufgelöst (null) → kein Speichern, Wert bleibt unverändert.
$this->assertNull($this->tenant->fresh()->tax_exemption_reason);
}
@@ -100,20 +103,29 @@ class TenantTaxInformationTest extends TestCase
$this->assertSame(100, $this->tenant->fresh()->vat_rate);
}
public function test_enum_invoice_text_per_case(): void
public function test_pricing_mode_persists_when_liable(): void
{
$this->runAction(taxLiable: true, vatRate: 19, reason: null, mode: 'add_on');
$this->assertSame('add_on', $this->tenant->fresh()->vat_pricing_mode);
}
public function test_pricing_mode_resets_to_inclusive_when_exempt(): void
{
$this->runAction(taxLiable: false, vatRate: 0, reason: 'small_business', mode: 'add_on');
$this->assertSame('inclusive', $this->tenant->fresh()->vat_pricing_mode);
}
public function test_invoice_text_per_reason(): void
{
$this->assertSame(
'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.',
TaxExemptionReason::SmallBusiness->invoiceText(),
);
$this->assertSame(
'Die Leistung ist gemäß § 4 Nr. 22 Buchst. a UStG von der Umsatzsteuer befreit.',
TaxExemptionReason::Education->invoiceText(),
TaxExemptionReason::where('slug', 'small_business')->first()->invoiceText(),
);
$this->assertSame(
'Die Leistung ist gemäß § 4 Nr. 25 UStG von der Umsatzsteuer befreit.',
TaxExemptionReason::YouthWelfare->invoiceText(),
TaxExemptionReason::where('slug', 'youth_welfare')->first()->invoiceText(),
);
$this->assertSame('Mein Text', TaxExemptionReason::Custom->invoiceText('Mein Text'));
// Freitext-Grund liefert den mitgegebenen Text statt eines festen Rechnungshinweises.
$this->assertSame('Mein Text', TaxExemptionReason::where('slug', 'custom')->first()->invoiceText('Mein Text'));
}
}