Added tax support

This commit is contained in:
2026-08-10 13:45:55 +02:00
parent 6b33c1b4dd
commit b3bbeb5b0b
18 changed files with 749 additions and 11 deletions
@@ -0,0 +1,66 @@
<?php
namespace App\Domains\Admin\Actions\UpdateTenantTax;
use App\Enumerations\VatPricingMode;
class UpdateTenantTaxAction
{
public function __construct(private UpdateTenantTaxRequest $request)
{
}
public function execute(): UpdateTenantTaxResponse
{
$response = new UpdateTenantTaxResponse();
if ($this->request->taxLiable) {
$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,
]);
$response->success = true;
$response->message = 'Steuerinformationen wurden gespeichert.';
return $response;
}
$reason = $this->request->exemptionReason;
if ($reason === null) {
$response->message = 'Bitte einen gültigen Befreiungsgrund auswählen.';
return $response;
}
$note = trim((string)$this->request->exemptionNote);
if ($reason->requires_note && $note === '') {
$response->message = 'Bitte einen Text für den Befreiungsgrund angeben.';
return $response;
}
$this->request->tenant->update([
'tax_liable' => false,
'vat_rate' => 0,
'vat_pricing_mode' => VatPricingMode::Inclusive->value,
'tax_exemption_reason' => $reason->slug,
'tax_exemption_note' => $reason->requires_note ? $note : null,
]);
$response->success = true;
$response->message = 'Steuerinformationen wurden gespeichert.';
return $response;
}
private function clampVatRate(int $vatRate): int
{
return max(0, min(100, $vatRate));
}
}
@@ -0,0 +1,21 @@
<?php
namespace App\Domains\Admin\Actions\UpdateTenantTax;
use App\Enumerations\TaxExemptionReason;
use App\Enumerations\VatPricingMode;
use App\Models\Tenant;
class UpdateTenantTaxRequest
{
public function __construct(
public Tenant $tenant,
public bool $taxLiable,
public int $vatRate,
public ?TaxExemptionReason $exemptionReason,
public ?string $exemptionNote,
public VatPricingMode $vatPricingMode,
)
{
}
}
@@ -0,0 +1,9 @@
<?php
namespace App\Domains\Admin\Actions\UpdateTenantTax;
class UpdateTenantTaxResponse
{
public bool $success = false;
public string $message = '';
}
@@ -0,0 +1,28 @@
<?php
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;
class ManagedTenantTaxGetController extends CommonController
{
public function __invoke(string $slug, Request $request): JsonResponse
{
$tenant = $this->adminTenants->findBySlug($slug);
return response()->json([
'tax_liable' => $tenant->tax_liable,
'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',
]);
}
}
@@ -0,0 +1,35 @@
<?php
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;
class ManagedTenantTaxUpdateController extends CommonController
{
public function __invoke(string $slug, Request $request): JsonResponse
{
$tenant = $this->adminTenants->findBySlug($slug);
$action = new UpdateTenantTaxAction(new UpdateTenantTaxRequest(
tenant: $tenant,
taxLiable: $request->boolean('tax_liable'),
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,
));
$response = $action->execute();
return response()->json([
'status' => $response->success ? 'success' : 'error',
'message' => $response->message,
]);
}
}
@@ -0,0 +1,26 @@
<?php
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;
class TenantTaxGetController extends CommonController
{
public function __invoke(Request $request): JsonResponse
{
return response()->json([
'tax_liable' => $this->tenant->tax_liable,
'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',
]);
}
}
@@ -0,0 +1,33 @@
<?php
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;
class TenantTaxUpdateController extends CommonController
{
public function __invoke(Request $request): JsonResponse
{
$action = new UpdateTenantTaxAction(new UpdateTenantTaxRequest(
tenant: $this->tenant,
taxLiable: $request->boolean('tax_liable'),
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,
));
$response = $action->execute();
return response()->json([
'status' => $response->success ? 'success' : 'error',
'message' => $response->message,
]);
}
}
+14 -6
View File
@@ -1,10 +1,5 @@
<?php <?php
use App\Domains\Admin\Controllers\UserDetailGetController;
use App\Domains\Admin\Controllers\UserListApiController;
use App\Domains\Admin\Controllers\UserResetPasswordController;
use App\Domains\Admin\Controllers\UserToggleActiveController;
use App\Domains\Admin\Controllers\UserUpdateController;
use App\Domains\Admin\Controllers\ManagedTenantContactGetController; use App\Domains\Admin\Controllers\ManagedTenantContactGetController;
use App\Domains\Admin\Controllers\ManagedTenantContactUpdateController; use App\Domains\Admin\Controllers\ManagedTenantContactUpdateController;
use App\Domains\Admin\Controllers\ManagedTenantGdprGetController; use App\Domains\Admin\Controllers\ManagedTenantGdprGetController;
@@ -13,6 +8,8 @@ use App\Domains\Admin\Controllers\ManagedTenantImpressGetController;
use App\Domains\Admin\Controllers\ManagedTenantImpressUpdateController; use App\Domains\Admin\Controllers\ManagedTenantImpressUpdateController;
use App\Domains\Admin\Controllers\ManagedTenantPaymentGetController; use App\Domains\Admin\Controllers\ManagedTenantPaymentGetController;
use App\Domains\Admin\Controllers\ManagedTenantPaymentUpdateController; use App\Domains\Admin\Controllers\ManagedTenantPaymentUpdateController;
use App\Domains\Admin\Controllers\ManagedTenantTaxGetController;
use App\Domains\Admin\Controllers\ManagedTenantTaxUpdateController;
use App\Domains\Admin\Controllers\TenantContactGetController; use App\Domains\Admin\Controllers\TenantContactGetController;
use App\Domains\Admin\Controllers\TenantContactUpdateController; use App\Domains\Admin\Controllers\TenantContactUpdateController;
use App\Domains\Admin\Controllers\TenantCreateController; use App\Domains\Admin\Controllers\TenantCreateController;
@@ -24,9 +21,16 @@ use App\Domains\Admin\Controllers\TenantImpressGetController;
use App\Domains\Admin\Controllers\TenantImpressUpdateController; use App\Domains\Admin\Controllers\TenantImpressUpdateController;
use App\Domains\Admin\Controllers\TenantListApiController; use App\Domains\Admin\Controllers\TenantListApiController;
use App\Domains\Admin\Controllers\TenantPaymentGetController; use App\Domains\Admin\Controllers\TenantPaymentGetController;
use App\Domains\Admin\Controllers\TenantPaymentUpdateController;
use App\Domains\Admin\Controllers\TenantPaymentMethodsGetController; use App\Domains\Admin\Controllers\TenantPaymentMethodsGetController;
use App\Domains\Admin\Controllers\TenantPaymentMethodsUpdateController; use App\Domains\Admin\Controllers\TenantPaymentMethodsUpdateController;
use App\Domains\Admin\Controllers\TenantPaymentUpdateController;
use App\Domains\Admin\Controllers\TenantTaxGetController;
use App\Domains\Admin\Controllers\TenantTaxUpdateController;
use App\Domains\Admin\Controllers\UserDetailGetController;
use App\Domains\Admin\Controllers\UserListApiController;
use App\Domains\Admin\Controllers\UserResetPasswordController;
use App\Domains\Admin\Controllers\UserToggleActiveController;
use App\Domains\Admin\Controllers\UserUpdateController;
use App\Middleware\AdminRoleMiddleware; use App\Middleware\AdminRoleMiddleware;
use App\Middleware\IdentifyTenant; use App\Middleware\IdentifyTenant;
use App\Middleware\LvOnlyMiddleware; use App\Middleware\LvOnlyMiddleware;
@@ -44,6 +48,8 @@ Route::middleware([IdentifyTenant::class, 'auth', AdminRoleMiddleware::class])->
Route::post('/impress', TenantImpressUpdateController::class); Route::post('/impress', TenantImpressUpdateController::class);
Route::get('/gdpr', TenantGdprGetController::class); Route::get('/gdpr', TenantGdprGetController::class);
Route::post('/gdpr', TenantGdprUpdateController::class); Route::post('/gdpr', TenantGdprUpdateController::class);
Route::get('/tax', TenantTaxGetController::class);
Route::post('/tax', TenantTaxUpdateController::class);
}); });
Route::prefix('api/v1/admin/users')->group(function () { Route::prefix('api/v1/admin/users')->group(function () {
@@ -69,6 +75,8 @@ Route::middleware([IdentifyTenant::class, 'auth', AdminRoleMiddleware::class])->
Route::post('/impress', ManagedTenantImpressUpdateController::class); Route::post('/impress', ManagedTenantImpressUpdateController::class);
Route::get('/gdpr', ManagedTenantGdprGetController::class); Route::get('/gdpr', ManagedTenantGdprGetController::class);
Route::post('/gdpr', ManagedTenantGdprUpdateController::class); Route::post('/gdpr', ManagedTenantGdprUpdateController::class);
Route::get('/tax', ManagedTenantTaxGetController::class);
Route::post('/tax', ManagedTenantTaxUpdateController::class);
}); });
}); });
}); });
@@ -0,0 +1,240 @@
<script setup>
import {computed, ref} from 'vue';
import {useAjax} from "../../../../../resources/js/components/ajaxHandler.js";
import RichSelectBox from "../../../../Views/Components/RichSelectBox.vue";
import {toast} from "vue3-toastify";
const props = defineProps({
data: {
type: Object,
default: () => ({})
},
})
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'},
{value: '0', label: 'Nein umsatzsteuerbefreit'},
]
const form = ref({
tax_liable: props.data.tax_liable ? '1' : '0',
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')
const isCustomReason = computed(() => form.value.tax_exemption_reason === 'custom')
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) {
return `Umsatzsteuerpflichtig USt-Satz: ${form.value.vat_rate || 0} %`
}
if (isCustomReason.value) {
return form.value.tax_exemption_note?.trim() || '—'
}
return selectedReason.value?.text || '—'
})
const reasonLabel = computed(() => selectedReason.value?.label ?? '—')
async function save() {
const saveUrl = props.data.saveEndpoint ?? '/api/v1/admin/tenant/tax'
const response = await request(saveUrl, {
method: 'POST',
body: {
tax_liable: isLiable.value,
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,
},
})
if (response && response.status === 'success') {
toast.success(response.message)
editing.value = false
} else {
toast.error(response?.message ?? 'Fehler beim Speichern')
}
}
</script>
<template>
<div v-if="!editing">
<table class="data-table">
<tr>
<th>Steuerpflicht</th>
<td>{{ isLiable ? 'Ja Umsatzsteuer wird erhoben' : 'Nein umsatzsteuerbefreit' }}</td>
</tr>
<tr v-if="isLiable">
<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>
</tr>
<tr>
<th>Rechnungshinweis</th>
<td>{{ invoiceHint }}</td>
</tr>
</table>
<button class="btn-edit" @click="editing = true">Bearbeiten</button>
</div>
<div v-else>
<table class="data-table">
<tr>
<th>Steuerpflicht</th>
<td>
<RichSelectBox v-model="form.tax_liable" :options="liableOptions"/>
</td>
</tr>
<tr v-if="isLiable">
<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>
<th>Befreiungsgrund</th>
<td>
<RichSelectBox
v-model="form.tax_exemption_reason"
:options="reasons"
placeholder="Grund auswählen…"
/>
</td>
</tr>
<tr v-if="isCustomReason">
<th>Freitext</th>
<td>
<textarea
v-model="form.tax_exemption_note"
class="form-input"
rows="3"
placeholder="Hinweistext für die Rechnung…"
></textarea>
</td>
</tr>
</template>
<tr>
<th>Rechnungshinweis</th>
<td class="hint-preview">{{ invoiceHint }}</td>
</tr>
</table>
<div class="btn-group">
<button class="btn-save" @click="save">Speichern</button>
<button class="btn-cancel" @click="editing = false">Abbrechen</button>
</div>
</div>
</template>
<style scoped>
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th {
text-align: left;
padding: 8px 12px;
width: 200px;
color: #374151;
border-bottom: 1px solid #e5e7eb;
vertical-align: top;
}
.data-table td {
padding: 8px 12px;
border-bottom: 1px solid #e5e7eb;
}
.form-input {
width: 100%;
padding: 6px 10px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 0.95rem;
box-sizing: border-box;
font-family: inherit;
}
.hint-preview {
color: #6b7280;
font-style: italic;
}
.btn-edit, .btn-save, .btn-cancel {
margin-top: 15px;
padding: 8px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: bold;
font-size: 0.9rem;
}
.btn-edit {
background-color: #1d4899;
color: #ffffff;
}
.btn-edit:hover {
background-color: #163a7a;
}
.btn-save {
background-color: #16a34a;
color: #ffffff;
}
.btn-save:hover {
background-color: #15803d;
}
.btn-cancel {
background-color: #e5e7eb;
color: #374151;
margin-left: 10px;
}
.btn-cancel:hover {
background-color: #d1d5db;
}
.btn-group {
display: flex;
gap: 10px;
}
</style>
+6
View File
@@ -7,6 +7,7 @@ import TenantPayment from "./Partials/TenantPayment.vue";
import TenantImpress from "./Partials/TenantImpress.vue"; import TenantImpress from "./Partials/TenantImpress.vue";
import TenantGdpr from "./Partials/TenantGdpr.vue"; import TenantGdpr from "./Partials/TenantGdpr.vue";
import TenantPaymentMethods from "./Partials/TenantPaymentMethods.vue"; import TenantPaymentMethods from "./Partials/TenantPaymentMethods.vue";
import TenantTaxInfo from "./Partials/TenantTaxInfo.vue";
const props = defineProps({ const props = defineProps({
tenant: Object, tenant: Object,
@@ -28,6 +29,11 @@ const tabs = [
component: TenantPaymentMethods, component: TenantPaymentMethods,
endpoint: '/api/v1/admin/tenant/payment-methods', endpoint: '/api/v1/admin/tenant/payment-methods',
}, },
{
title: 'Steuerinformationen',
component: TenantTaxInfo,
endpoint: '/api/v1/admin/tenant/tax',
},
{ {
title: 'Impressum', title: 'Impressum',
component: TenantImpress, component: TenantImpress,
+6
View File
@@ -7,6 +7,7 @@ import TenantContact from "./Partials/TenantContact.vue";
import TenantPayment from "./Partials/TenantPayment.vue"; import TenantPayment from "./Partials/TenantPayment.vue";
import TenantImpress from "./Partials/TenantImpress.vue"; import TenantImpress from "./Partials/TenantImpress.vue";
import TenantGdpr from "./Partials/TenantGdpr.vue"; import TenantGdpr from "./Partials/TenantGdpr.vue";
import TenantTaxInfo from "./Partials/TenantTaxInfo.vue";
const props = defineProps({ const props = defineProps({
tenant: Object, tenant: Object,
@@ -30,6 +31,11 @@ const tabs = [
component: TenantPayment, component: TenantPayment,
endpoint: '/api/v1/admin/tenants/' + slug + '/payment', endpoint: '/api/v1/admin/tenants/' + slug + '/payment',
}, },
{
title: 'Steuerinformationen',
component: TenantTaxInfo,
endpoint: '/api/v1/admin/tenants/' + slug + '/tax',
},
{ {
title: 'Impressum', title: 'Impressum',
component: TenantImpress, component: TenantImpress,
@@ -47,6 +47,13 @@ class CreateEventCommand {
'total_max_amount' => 0, 'total_max_amount' => 0,
'support_per_person' => 0, 'support_per_person' => 0,
'support_flat' => 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) { if ($event !== null) {
+66
View File
@@ -0,0 +1,66 @@
<?php
namespace App\Enumerations;
use App\Scopes\CommonModel;
/**
* 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 (requires_note) wird der hinterlegte
* Freitext des Tenants verwendet.
*/
public function invoiceText(?string $customNote = null): string
{
return $this->requires_note ? trim((string)$customNote) : $this->invoice_text;
}
/**
* 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 self::all()
->map(static fn(self $reason): array => [
'value' => $reason->slug,
'label' => $reason->name,
'text' => $reason->requires_note ? '' : $reason->invoice_text,
])
->all();
}
}
+8 -3
View File
@@ -8,14 +8,11 @@ use App\Enumerations\EatingHabit;
use App\Enumerations\ParticipationFeeType; use App\Enumerations\ParticipationFeeType;
use App\RelationModels\EventParticipationFee; use App\RelationModels\EventParticipationFee;
use App\RelationModels\EventPaymentMethods; use App\RelationModels\EventPaymentMethods;
use App\Resources\EventResource;
use App\Scopes\CommonModel;
use App\Scopes\InstancedModel; use App\Scopes\InstancedModel;
use DateTime; use DateTime;
use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Http\Resources\Json\JsonResource;
/** /**
* @property string $tenant * @property string $tenant
@@ -85,6 +82,11 @@ class Event extends InstancedModel
'support_flat', 'support_flat',
'alcoholics_age', 'alcoholics_age',
'archived', '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, 'total_max_amount' => AmountCast::class,
'sibling_reduction' => 'boolean', 'sibling_reduction' => 'boolean',
'tax_liable' => 'boolean',
'vat_rate' => 'integer',
]; ];
public function tenant(): BelongsTo public function tenant(): BelongsTo
+16 -1
View File
@@ -21,6 +21,11 @@ use App\Scopes\CommonModel;
* @property string $url_participation_rules * @property string $url_participation_rules
* @property boolean $events_allowed * @property boolean $events_allowed
* @property boolean $has_active_instance * @property boolean $has_active_instance
* @property boolean $tax_liable
* @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 class Tenant extends CommonModel
{ {
@@ -46,6 +51,16 @@ class Tenant extends CommonModel
'impress_text', 'impress_text',
'url_participation_rules', 'url_participation_rules',
'is_active_local_group', 'is_active_local_group',
'has_active_instance' 'has_active_instance',
'tax_liable',
'vat_rate',
'tax_exemption_reason',
'tax_exemption_note',
'vat_pricing_mode',
];
protected $casts = [
'tax_liable' => 'boolean',
'vat_rate' => 'integer',
]; ];
} }
+8 -1
View File
@@ -4,6 +4,7 @@ namespace App\Resources;
use App\Enumerations\ParticipationFeeType; use App\Enumerations\ParticipationFeeType;
use App\Enumerations\ParticipationType; use App\Enumerations\ParticipationType;
use App\Enumerations\VatPricingMode;
use App\Models\Event; use App\Models\Event;
use App\ValueObjects\Amount; use App\ValueObjects\Amount;
use DateTime; use DateTime;
@@ -342,7 +343,13 @@ class EventResource extends JsonResource{
$basicFee = $basicFee->multiply(0.5); $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');
} }
} }
@@ -0,0 +1,29 @@
<?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->boolean('tax_liable')->default(false);
$table->unsignedTinyInteger('vat_rate')->default(0);
$table->string('tax_exemption_reason')->nullable();
$table->text('tax_exemption_note')->nullable();
});
}
public function down(): void
{
Schema::table('tenants', function (Blueprint $table) {
$table->dropColumn([
'tax_liable',
'vat_rate',
'tax_exemption_reason',
'tax_exemption_note',
]);
});
}
};
+131
View File
@@ -0,0 +1,131 @@
<?php
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;
class TenantTaxInformationTest 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);
}
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 ? TaxExemptionReason::where('slug', $reason)->first() : null,
exemptionNote: $note,
vatPricingMode: VatPricingMode::from($mode),
))->execute();
}
public function test_taxable_sets_rate_and_clears_exemption(): void
{
$this->tenant->update(['tax_exemption_reason' => 'small_business', 'tax_exemption_note' => 'alt']);
$response = $this->runAction(taxLiable: true, vatRate: 7, reason: null);
$this->assertTrue($response->success);
$fresh = $this->tenant->fresh();
$this->assertTrue($fresh->tax_liable);
$this->assertSame(7, $fresh->vat_rate);
$this->assertNull($fresh->tax_exemption_reason);
$this->assertNull($fresh->tax_exemption_note);
}
public function test_exemption_reason_persists_and_clears_rate(): void
{
$response = $this->runAction(taxLiable: false, vatRate: 19, reason: 'youth_welfare');
$this->assertTrue($response->success);
$fresh = $this->tenant->fresh();
$this->assertFalse($fresh->tax_liable);
$this->assertSame(0, $fresh->vat_rate);
$this->assertSame('youth_welfare', $fresh->tax_exemption_reason);
$this->assertNull($fresh->tax_exemption_note);
}
public function test_invalid_exemption_reason_fails(): void
{
$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);
}
public function test_custom_reason_requires_note(): void
{
$missing = $this->runAction(taxLiable: false, vatRate: 0, reason: 'custom', note: ' ');
$this->assertFalse($missing->success);
$ok = $this->runAction(taxLiable: false, vatRate: 0, reason: 'custom', note: 'Individueller Grund');
$this->assertTrue($ok->success);
$this->assertSame('Individueller Grund', $this->tenant->fresh()->tax_exemption_note);
}
public function test_vat_rate_is_clamped(): void
{
$this->runAction(taxLiable: true, vatRate: 250, reason: null);
$this->assertSame(100, $this->tenant->fresh()->vat_rate);
}
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::where('slug', 'small_business')->first()->invoiceText(),
);
$this->assertSame(
'Die Leistung ist gemäß § 4 Nr. 25 UStG von der Umsatzsteuer befreit.',
TaxExemptionReason::where('slug', 'youth_welfare')->first()->invoiceText(),
);
// Freitext-Grund liefert den mitgegebenen Text statt eines festen Rechnungshinweises.
$this->assertSame('Mein Text', TaxExemptionReason::where('slug', 'custom')->first()->invoiceText('Mein Text'));
}
}