Compare commits
5
Commits
dev-4.6.1
...
09f9767eb7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
09f9767eb7 | ||
|
|
b3bbeb5b0b | ||
|
|
c17facf063 | ||
|
|
6b33c1b4dd | ||
|
|
3b47979265 |
@@ -56,6 +56,8 @@ MAIL_PASSWORD=null
|
|||||||
MAIL_FROM_ADDRESS="hello@example.com"
|
MAIL_FROM_ADDRESS="hello@example.com"
|
||||||
MAIL_FROM_NAME="${APP_NAME}"
|
MAIL_FROM_NAME="${APP_NAME}"
|
||||||
|
|
||||||
|
APP_ADMIN_MAIL=
|
||||||
|
|
||||||
AWS_ACCESS_KEY_ID=
|
AWS_ACCESS_KEY_ID=
|
||||||
AWS_SECRET_ACCESS_KEY=
|
AWS_SECRET_ACCESS_KEY=
|
||||||
AWS_DEFAULT_REGION=us-east-1
|
AWS_DEFAULT_REGION=us-east-1
|
||||||
|
|||||||
@@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Mail\AdminMails;
|
||||||
|
|
||||||
|
use Illuminate\Mail\Mailable;
|
||||||
|
use Illuminate\Mail\Mailables\Attachment;
|
||||||
|
use Illuminate\Mail\Mailables\Content;
|
||||||
|
use Illuminate\Mail\Mailables\Envelope;
|
||||||
|
|
||||||
|
class CostUnitInvalidBillingDeadlineMail extends Mailable
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private int|string $costUnitId,
|
||||||
|
private ?string $costUnitName,
|
||||||
|
private ?string $invalidBillingDeadline,
|
||||||
|
)
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the message envelope.
|
||||||
|
*/
|
||||||
|
public function envelope(): Envelope
|
||||||
|
{
|
||||||
|
return new Envelope(
|
||||||
|
subject: sprintf('Ungültiges Abrechnungsende für Kostenstelle #%s', $this->costUnitId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the message content definition.
|
||||||
|
*/
|
||||||
|
public function content(): Content
|
||||||
|
{
|
||||||
|
return new Content(
|
||||||
|
view: 'emails.admin.cost_unit_invalid_billing_deadline',
|
||||||
|
with: [
|
||||||
|
'costUnitId' => $this->costUnitId,
|
||||||
|
'costUnitName' => $this->costUnitName,
|
||||||
|
'invalidBillingDeadline' => $this->invalidBillingDeadline,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the attachments for the message.
|
||||||
|
*
|
||||||
|
* @return array<int, Attachment>
|
||||||
|
*/
|
||||||
|
public function attachments(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ use App\Domains\CostUnit\Actions\ChangeCostUnitDetails\ChangeCostUnitDetailsComm
|
|||||||
use App\Domains\CostUnit\Actions\ChangeCostUnitDetails\ChangeCostUnitDetailsRequest;
|
use App\Domains\CostUnit\Actions\ChangeCostUnitDetails\ChangeCostUnitDetailsRequest;
|
||||||
use App\Domains\CostUnit\Actions\ChangeCostUnitState\ChangeCostUnitStateCommand;
|
use App\Domains\CostUnit\Actions\ChangeCostUnitState\ChangeCostUnitStateCommand;
|
||||||
use App\Domains\CostUnit\Actions\ChangeCostUnitState\ChangeCostUnitStateRequest;
|
use App\Domains\CostUnit\Actions\ChangeCostUnitState\ChangeCostUnitStateRequest;
|
||||||
|
use App\Mail\AdminMails\CostUnitInvalidBillingDeadlineMail;
|
||||||
use App\Models\CostUnit;
|
use App\Models\CostUnit;
|
||||||
use App\Repositories\CostUnitRepository;
|
use App\Repositories\CostUnitRepository;
|
||||||
use App\ValueObjects\Amount;
|
use App\ValueObjects\Amount;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
|
||||||
class CloseCostUnit implements CronTask {
|
class CloseCostUnit implements CronTask {
|
||||||
public function handle(): void
|
public function handle(): void
|
||||||
@@ -33,6 +35,27 @@ class CloseCostUnit implements CronTask {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$billingEndTime = \DateTime::createFromFormat('Y-m-d H:i:s', $billingEnd);
|
$billingEndTime = \DateTime::createFromFormat('Y-m-d H:i:s', $billingEnd);
|
||||||
|
if (false === $billingEndTime) {
|
||||||
|
$billingEndTime = \DateTime::createFromFormat('Y-m-d', $billingEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (false === $billingEndTime) {
|
||||||
|
$recipients = config('app.admin_mail');
|
||||||
|
if (null === $recipients) {
|
||||||
|
$recipients = CostUnit::where('id', $costUnit['id'])->first()
|
||||||
|
?->treasurers()->pluck('email')->all() ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!empty($recipients)) {
|
||||||
|
Mail::to($recipients)->send(new CostUnitInvalidBillingDeadlineMail(
|
||||||
|
costUnitId: $costUnit['id'],
|
||||||
|
costUnitName: $costUnit['name'] ?? null,
|
||||||
|
invalidBillingDeadline: $billingEnd,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
$billingEndTime->setTime(0,0,0);
|
$billingEndTime->setTime(0,0,0);
|
||||||
$billingEndTime->add(new \DateInterval('P1D'));
|
$billingEndTime->add(new \DateInterval('P1D'));
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ return [
|
|||||||
|
|
||||||
'name' => env('APP_NAME', 'Laravel'),
|
'name' => env('APP_NAME', 'Laravel'),
|
||||||
|
|
||||||
|
'admin_mail' => env('APP_ADMIN_MAIL'),
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
| Application Environment
|
| Application Environment
|
||||||
|
|||||||
@@ -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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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,16 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<body>
|
||||||
|
<p>
|
||||||
|
Für die Kostenstelle "{{ $costUnitName }}" (ID {{ $costUnitId }}) konnte das Abrechnungsende
|
||||||
|
nicht verarbeitet werden.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Der hinterlegte Wert "{{ $invalidBillingDeadline }}" ist weder ein gültiges Datum noch ein
|
||||||
|
Datum mit Uhrzeit. Die automatische Verarbeitung dieser Kostenstelle wurde deshalb übersprungen.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Bitte prüft und korrigiert das Abrechnungsende dieser Kostenstelle.
|
||||||
|
</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user