Added tax support

This commit is contained in:
2026-08-06 11:49:58 +02:00
parent 6b33c1b4dd
commit c17facf063
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,