2 Commits
Author SHA1 Message Date
th.guenther c17facf063 Added tax support 2026-08-06 11:49:58 +02:00
th.guenther 6b33c1b4dd Added tax support 2026-08-06 11:49:39 +02:00
15 changed files with 679 additions and 7 deletions
@@ -0,0 +1,64 @@
<?php
namespace App\Domains\Admin\Actions\UpdateTenantTax;
use App\Enumerations\TaxExemptionReason;
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),
'tax_exemption_reason' => null,
'tax_exemption_note' => null,
]);
$response->success = true;
$response->message = 'Steuerinformationen wurden gespeichert.';
return $response;
}
$reason = TaxExemptionReason::tryFrom((string)$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->requiresNote() && $note === '') {
$response->message = 'Bitte einen Text für den Befreiungsgrund angeben.';
return $response;
}
$this->request->tenant->update([
'tax_liable' => false,
'vat_rate' => 0,
'tax_exemption_reason' => $reason->value,
'tax_exemption_note' => $reason->requiresNote() ? $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,18 @@
<?php
namespace App\Domains\Admin\Actions\UpdateTenantTax;
use App\Models\Tenant;
class UpdateTenantTaxRequest
{
public function __construct(
public Tenant $tenant,
public bool $taxLiable,
public int $vatRate,
public ?string $exemptionReason,
public ?string $exemptionNote,
)
{
}
}
@@ -0,0 +1,9 @@
<?php
namespace App\Domains\Admin\Actions\UpdateTenantTax;
class UpdateTenantTaxResponse
{
public bool $success = false;
public string $message = '';
}
@@ -0,0 +1,25 @@
<?php
namespace App\Domains\Admin\Controllers;
use App\Enumerations\TaxExemptionReason;
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,
'exemption_reasons' => TaxExemptionReason::options(),
'saveEndpoint' => '/api/v1/admin/tenants/' . $slug . '/tax',
]);
}
}
@@ -0,0 +1,32 @@
<?php
namespace App\Domains\Admin\Controllers;
use App\Domains\Admin\Actions\UpdateTenantTax\UpdateTenantTaxAction;
use App\Domains\Admin\Actions\UpdateTenantTax\UpdateTenantTaxRequest;
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: $request->input('tax_exemption_reason'),
exemptionNote: $request->input('tax_exemption_note'),
));
$response = $action->execute();
return response()->json([
'status' => $response->success ? 'success' : 'error',
'message' => $response->message,
]);
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Domains\Admin\Controllers;
use App\Enumerations\TaxExemptionReason;
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,
'exemption_reasons' => TaxExemptionReason::options(),
'saveEndpoint' => '/api/v1/admin/tenant/tax',
]);
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Domains\Admin\Controllers;
use App\Domains\Admin\Actions\UpdateTenantTax\UpdateTenantTaxAction;
use App\Domains\Admin\Actions\UpdateTenantTax\UpdateTenantTaxRequest;
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: $request->input('tax_exemption_reason'),
exemptionNote: $request->input('tax_exemption_note'),
));
$response = $action->execute();
return response()->json([
'status' => $response->success ? 'success' : 'error',
'message' => $response->message,
]);
}
}
+14 -6
View File
@@ -1,10 +1,5 @@
<?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\ManagedTenantContactUpdateController;
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\ManagedTenantPaymentGetController;
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\TenantContactUpdateController;
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\TenantListApiController;
use App\Domains\Admin\Controllers\TenantPaymentGetController;
use App\Domains\Admin\Controllers\TenantPaymentUpdateController;
use App\Domains\Admin\Controllers\TenantPaymentMethodsGetController;
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\IdentifyTenant;
use App\Middleware\LvOnlyMiddleware;
@@ -44,6 +48,8 @@ Route::middleware([IdentifyTenant::class, 'auth', AdminRoleMiddleware::class])->
Route::post('/impress', TenantImpressUpdateController::class);
Route::get('/gdpr', TenantGdprGetController::class);
Route::post('/gdpr', TenantGdprUpdateController::class);
Route::get('/tax', TenantTaxGetController::class);
Route::post('/tax', TenantTaxUpdateController::class);
});
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::get('/gdpr', ManagedTenantGdprGetController::class);
Route::post('/gdpr', ManagedTenantGdprUpdateController::class);
Route::get('/tax', ManagedTenantTaxGetController::class);
Route::post('/tax', ManagedTenantTaxUpdateController::class);
});
});
});
@@ -0,0 +1,223 @@
<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 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 ?? '',
})
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
)
// 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,
},
})
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-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>
<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 TenantGdpr from "./Partials/TenantGdpr.vue";
import TenantPaymentMethods from "./Partials/TenantPaymentMethods.vue";
import TenantTaxInfo from "./Partials/TenantTaxInfo.vue";
const props = defineProps({
tenant: Object,
@@ -28,6 +29,11 @@ const tabs = [
component: TenantPaymentMethods,
endpoint: '/api/v1/admin/tenant/payment-methods',
},
{
title: 'Steuerinformationen',
component: TenantTaxInfo,
endpoint: '/api/v1/admin/tenant/tax',
},
{
title: 'Impressum',
component: TenantImpress,
+6
View File
@@ -7,6 +7,7 @@ import TenantContact from "./Partials/TenantContact.vue";
import TenantPayment from "./Partials/TenantPayment.vue";
import TenantImpress from "./Partials/TenantImpress.vue";
import TenantGdpr from "./Partials/TenantGdpr.vue";
import TenantTaxInfo from "./Partials/TenantTaxInfo.vue";
const props = defineProps({
tenant: Object,
@@ -30,6 +31,11 @@ const tabs = [
component: TenantPayment,
endpoint: '/api/v1/admin/tenants/' + slug + '/payment',
},
{
title: 'Steuerinformationen',
component: TenantTaxInfo,
endpoint: '/api/v1/admin/tenants/' + slug + '/tax',
},
{
title: 'Impressum',
component: TenantImpress,
+67
View File
@@ -0,0 +1,67 @@
<?php
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';
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)',
};
}
/**
* Pflichthinweis für die Rechnung. Bei einem individuellen Grund (Custom) 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),
};
}
/**
* 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.
*
* @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(),
);
}
}
+14 -1
View File
@@ -21,6 +21,10 @@ use App\Scopes\CommonModel;
* @property string $url_participation_rules
* @property boolean $events_allowed
* @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
*/
class Tenant extends CommonModel
{
@@ -46,6 +50,15 @@ class Tenant extends CommonModel
'impress_text',
'url_participation_rules',
'is_active_local_group',
'has_active_instance'
'has_active_instance',
'tax_liable',
'vat_rate',
'tax_exemption_reason',
'tax_exemption_note',
];
protected $casts = [
'tax_liable' => 'boolean',
'vat_rate' => 'integer',
];
}
@@ -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')->default('youth_welfare');
$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',
]);
});
}
};
+119
View File
@@ -0,0 +1,119 @@
<?php
namespace Tests\Feature;
use App\Domains\Admin\Actions\UpdateTenantTax\UpdateTenantTaxAction;
use App\Domains\Admin\Actions\UpdateTenantTax\UpdateTenantTaxRequest;
use App\Enumerations\TaxExemptionReason;
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): object
{
return new UpdateTenantTaxAction(new UpdateTenantTaxRequest(
tenant: $this->tenant,
taxLiable: $taxLiable,
vatRate: $vatRate,
exemptionReason: $reason,
exemptionNote: $note,
))->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);
$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_enum_invoice_text_per_case(): 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(),
);
$this->assertSame(
'Die Leistung ist gemäß § 4 Nr. 25 UStG von der Umsatzsteuer befreit.',
TaxExemptionReason::YouthWelfare->invoiceText(),
);
$this->assertSame('Mein Text', TaxExemptionReason::Custom->invoiceText('Mein Text'));
}
}