Tax excpetion texts
This commit is contained in:
@@ -14,12 +14,14 @@ use App\Scopes\CommonModel;
|
|||||||
* @property string $name
|
* @property string $name
|
||||||
* @property string $invoice_text
|
* @property string $invoice_text
|
||||||
* @property bool $requires_note
|
* @property bool $requires_note
|
||||||
|
* @property int $sort_order
|
||||||
*/
|
*/
|
||||||
class TaxExemptionReason extends CommonModel
|
class TaxExemptionReason extends CommonModel
|
||||||
{
|
{
|
||||||
public const string SMALL_BUSINESS = 'small_business';
|
public const string USTG_19 = 'ustg_19';
|
||||||
public const string EDUCATION = 'education';
|
public const string USTG_4_24 = 'ustg_4_24';
|
||||||
public const string YOUTH_WELFARE = 'youth_welfare';
|
public const string USTG_4_25A = 'ustg_4_25a';
|
||||||
|
public const string USTG_4_22A = 'ustg_4_22a';
|
||||||
public const string CUSTOM = 'custom';
|
public const string CUSTOM = 'custom';
|
||||||
|
|
||||||
protected $table = 'tax_exemption_reasons';
|
protected $table = 'tax_exemption_reasons';
|
||||||
@@ -32,10 +34,12 @@ class TaxExemptionReason extends CommonModel
|
|||||||
'name',
|
'name',
|
||||||
'invoice_text',
|
'invoice_text',
|
||||||
'requires_note',
|
'requires_note',
|
||||||
|
'sort_order',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'requires_note' => 'boolean',
|
'requires_note' => 'boolean',
|
||||||
|
'sort_order' => 'integer',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -55,7 +59,7 @@ class TaxExemptionReason extends CommonModel
|
|||||||
*/
|
*/
|
||||||
public static function options(): array
|
public static function options(): array
|
||||||
{
|
{
|
||||||
return self::all()
|
return self::orderBy('sort_order')->get()
|
||||||
->map(static fn (self $reason): array => [
|
->map(static fn (self $reason): array => [
|
||||||
'value' => $reason->slug,
|
'value' => $reason->slug,
|
||||||
'label' => $reason->name,
|
'label' => $reason->name,
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import {computed, onBeforeUnmount, onMounted, ref} from 'vue'
|
import {computed, nextTick, onBeforeUnmount, onMounted, ref} from 'vue'
|
||||||
import Icon from './Icon.vue'
|
import Icon from './Icon.vue'
|
||||||
|
|
||||||
// Generisches Custom-Dropdown, das je Option ein Icon + Label anzeigt (ein natives <select> kann
|
// Generisches Custom-Dropdown, das je Option ein Icon + Label anzeigt (ein natives <select> kann
|
||||||
// keine SVG-Icons rendern). Wiederverwendbar für Icon-/Rich-Selects.
|
// keine SVG-Icons rendern). Die Options-Liste wird per <Teleport> an <body> gehängt und fixed
|
||||||
|
// positioniert, damit sie nicht von einem Eltern-Container mit overflow (z. B. .tab-content in
|
||||||
|
// TabbedPage) abgeschnitten wird. Wiederverwendbar für Icon-/Rich-Selects.
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
// Ausgewählter Wert (value einer Option).
|
// Ausgewählter Wert (value einer Option).
|
||||||
modelValue: {type: String, default: ''},
|
modelValue: {type: String, default: ''},
|
||||||
@@ -15,11 +17,23 @@ const emit = defineEmits(['update:modelValue'])
|
|||||||
|
|
||||||
const open = ref(false)
|
const open = ref(false)
|
||||||
const root = ref(null)
|
const root = ref(null)
|
||||||
|
const listRef = ref(null)
|
||||||
|
const pos = ref({top: 0, left: 0, width: 0})
|
||||||
|
|
||||||
const selected = computed(() => props.options.find(o => o.value === props.modelValue) ?? null)
|
const selected = computed(() => props.options.find(o => o.value === props.modelValue) ?? null)
|
||||||
|
|
||||||
function toggle() {
|
function updatePosition() {
|
||||||
|
if (!root.value) return
|
||||||
|
const rect = root.value.getBoundingClientRect()
|
||||||
|
pos.value = {top: rect.bottom + 4, left: rect.left, width: rect.width}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggle() {
|
||||||
open.value = !open.value
|
open.value = !open.value
|
||||||
|
if (open.value) {
|
||||||
|
await nextTick()
|
||||||
|
updatePosition()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function select(option) {
|
function select(option) {
|
||||||
@@ -28,7 +42,9 @@ function select(option) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onClickOutside(event) {
|
function onClickOutside(event) {
|
||||||
if (root.value && !root.value.contains(event.target)) {
|
const inRoot = root.value && root.value.contains(event.target)
|
||||||
|
const inList = listRef.value && listRef.value.contains(event.target)
|
||||||
|
if (!inRoot && !inList) {
|
||||||
open.value = false
|
open.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -39,13 +55,22 @@ function onKeydown(event) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onReposition() {
|
||||||
|
if (open.value) updatePosition()
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
document.addEventListener('click', onClickOutside)
|
document.addEventListener('click', onClickOutside)
|
||||||
document.addEventListener('keydown', onKeydown)
|
document.addEventListener('keydown', onKeydown)
|
||||||
|
window.addEventListener('resize', onReposition)
|
||||||
|
// capture=true, damit auch Scrollen in inneren Containern die Position aktualisiert
|
||||||
|
window.addEventListener('scroll', onReposition, true)
|
||||||
})
|
})
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
document.removeEventListener('click', onClickOutside)
|
document.removeEventListener('click', onClickOutside)
|
||||||
document.removeEventListener('keydown', onKeydown)
|
document.removeEventListener('keydown', onKeydown)
|
||||||
|
window.removeEventListener('resize', onReposition)
|
||||||
|
window.removeEventListener('scroll', onReposition, true)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -67,20 +92,28 @@ onBeforeUnmount(() => {
|
|||||||
<span class="rich-select__caret" aria-hidden="true">▾</span>
|
<span class="rich-select__caret" aria-hidden="true">▾</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<ul v-if="open" class="rich-select__list" role="listbox">
|
<Teleport to="body">
|
||||||
<li
|
<ul
|
||||||
v-for="option in options"
|
v-if="open"
|
||||||
:key="option.value"
|
ref="listRef"
|
||||||
class="rich-select__option"
|
class="rich-select__list"
|
||||||
:class="{ 'rich-select__option--active': option.value === modelValue }"
|
role="listbox"
|
||||||
role="option"
|
:style="{ top: pos.top + 'px', left: pos.left + 'px', width: pos.width + 'px' }"
|
||||||
:aria-selected="option.value === modelValue"
|
|
||||||
@click="select(option)"
|
|
||||||
>
|
>
|
||||||
<Icon v-if="option.icon" :name="option.icon"/>
|
<li
|
||||||
<span>{{ option.label }}</span>
|
v-for="option in options"
|
||||||
</li>
|
:key="option.value"
|
||||||
</ul>
|
class="rich-select__option"
|
||||||
|
:class="{ 'rich-select__option--active': option.value === modelValue }"
|
||||||
|
role="option"
|
||||||
|
:aria-selected="option.value === modelValue"
|
||||||
|
@click="select(option)"
|
||||||
|
>
|
||||||
|
<Icon v-if="option.icon" :name="option.icon"/>
|
||||||
|
<span>{{ option.label }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</Teleport>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -126,11 +159,8 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.rich-select__list {
|
.rich-select__list {
|
||||||
position: absolute;
|
position: fixed;
|
||||||
z-index: 50;
|
z-index: 1000;
|
||||||
top: calc(100% + 4px);
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
@@ -140,6 +170,7 @@ onBeforeUnmount(() => {
|
|||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||||
max-height: 260px;
|
max-height: 260px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rich-select__option {
|
.rich-select__option {
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schärft die Befreiungsgründe: paragraphenbasierte Slugs, präzise Zitate (§ 4 Nr. 25 Buchst. a bzw.
|
||||||
|
* Nr. 22 Buchst. a), neuer Grund § 4 Nr. 24 (Jugendherbergen) und eine feste Anzeige-Reihenfolge.
|
||||||
|
* Slug-Renames kaskadieren über die FKs (ON UPDATE CASCADE) auf tenants/events.
|
||||||
|
*/
|
||||||
|
return new class extends Migration {
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('tax_exemption_reasons', function (Blueprint $table) {
|
||||||
|
$table->unsignedSmallInteger('sort_order')->default(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// § 19 – nur Slug + Sortierung
|
||||||
|
DB::table('tax_exemption_reasons')->where('slug', 'small_business')->update([
|
||||||
|
'slug' => 'ustg_19',
|
||||||
|
'sort_order' => 10,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// § 4 Nr. 25 Buchst. a – präzises Zitat (Durchführung kultureller/sportlicher Veranstaltungen)
|
||||||
|
DB::table('tax_exemption_reasons')->where('slug', 'youth_welfare')->update([
|
||||||
|
'slug' => 'ustg_4_25a',
|
||||||
|
'name' => 'Jugendhilfe – Veranstaltungen (§ 4 Nr. 25 Buchst. a UStG)',
|
||||||
|
'invoice_text' => 'Die Leistung ist gemäß § 4 Nr. 25 Buchst. a UStG von der Umsatzsteuer befreit.',
|
||||||
|
'sort_order' => 30,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// § 4 Nr. 22 Buchst. a – korrekt benannt, ans Ende (vor Freitext)
|
||||||
|
DB::table('tax_exemption_reasons')->where('slug', 'education')->update([
|
||||||
|
'slug' => 'ustg_4_22a',
|
||||||
|
'name' => 'Vorträge/Kurse (§ 4 Nr. 22 Buchst. a UStG)',
|
||||||
|
'invoice_text' => 'Die Leistung ist gemäß § 4 Nr. 22 Buchst. a UStG von der Umsatzsteuer befreit.',
|
||||||
|
'sort_order' => 40,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Freitext ans Ende
|
||||||
|
DB::table('tax_exemption_reasons')->where('slug', 'custom')->update([
|
||||||
|
'sort_order' => 50,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// § 4 Nr. 24 (Jugendherbergen) – neu
|
||||||
|
$now = now();
|
||||||
|
DB::table('tax_exemption_reasons')->insertOrIgnore([
|
||||||
|
[
|
||||||
|
'slug' => 'ustg_4_24',
|
||||||
|
'name' => 'Jugendherbergen (§ 4 Nr. 24 UStG)',
|
||||||
|
'invoice_text' => 'Die Leistung ist gemäß § 4 Nr. 24 UStG von der Umsatzsteuer befreit.',
|
||||||
|
'requires_note' => false,
|
||||||
|
'sort_order' => 20,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
DB::table('tax_exemption_reasons')->where('slug', 'ustg_4_24')->delete();
|
||||||
|
DB::table('tax_exemption_reasons')->where('slug', 'ustg_19')->update(['slug' => 'small_business']);
|
||||||
|
DB::table('tax_exemption_reasons')->where('slug', 'ustg_4_25a')->update([
|
||||||
|
'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.',
|
||||||
|
]);
|
||||||
|
DB::table('tax_exemption_reasons')->where('slug', 'ustg_4_22a')->update([
|
||||||
|
'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.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Schema::table('tax_exemption_reasons', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('sort_order');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setzt den Standard-Befreiungsgrund neuer Tenants auf Jugendhilfe (§ 4 Nr. 25 Buchst. a). Der Wert
|
||||||
|
* existiert in tax_exemption_reasons, der FK bleibt gültig. Bestehende Zeilen bleiben unverändert.
|
||||||
|
*/
|
||||||
|
return new class extends Migration {
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
DB::statement("ALTER TABLE tenants ALTER COLUMN tax_exemption_reason SET DEFAULT 'ustg_4_25a'");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
DB::statement('ALTER TABLE tenants ALTER COLUMN tax_exemption_reason DROP DEFAULT');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -52,7 +52,7 @@ class TenantTaxInformationTest extends TestCase
|
|||||||
|
|
||||||
public function test_taxable_sets_rate_and_clears_exemption(): void
|
public function test_taxable_sets_rate_and_clears_exemption(): void
|
||||||
{
|
{
|
||||||
$this->tenant->update(['tax_exemption_reason' => 'small_business', 'tax_exemption_note' => 'alt']);
|
$this->tenant->update(['tax_exemption_reason' => 'ustg_19', 'tax_exemption_note' => 'alt']);
|
||||||
|
|
||||||
$response = $this->runAction(taxLiable: true, vatRate: 7, reason: null);
|
$response = $this->runAction(taxLiable: true, vatRate: 7, reason: null);
|
||||||
|
|
||||||
@@ -67,14 +67,14 @@ class TenantTaxInformationTest extends TestCase
|
|||||||
|
|
||||||
public function test_exemption_reason_persists_and_clears_rate(): void
|
public function test_exemption_reason_persists_and_clears_rate(): void
|
||||||
{
|
{
|
||||||
$response = $this->runAction(taxLiable: false, vatRate: 19, reason: 'youth_welfare');
|
$response = $this->runAction(taxLiable: false, vatRate: 19, reason: 'ustg_4_25a');
|
||||||
|
|
||||||
$this->assertTrue($response->success);
|
$this->assertTrue($response->success);
|
||||||
|
|
||||||
$fresh = $this->tenant->fresh();
|
$fresh = $this->tenant->fresh();
|
||||||
$this->assertFalse($fresh->tax_liable);
|
$this->assertFalse($fresh->tax_liable);
|
||||||
$this->assertSame(0, $fresh->vat_rate);
|
$this->assertSame(0, $fresh->vat_rate);
|
||||||
$this->assertSame('youth_welfare', $fresh->tax_exemption_reason);
|
$this->assertSame('ustg_4_25a', $fresh->tax_exemption_reason);
|
||||||
$this->assertNull($fresh->tax_exemption_note);
|
$this->assertNull($fresh->tax_exemption_note);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,7 +111,7 @@ class TenantTaxInformationTest extends TestCase
|
|||||||
|
|
||||||
public function test_pricing_mode_resets_to_inclusive_when_exempt(): void
|
public function test_pricing_mode_resets_to_inclusive_when_exempt(): void
|
||||||
{
|
{
|
||||||
$this->runAction(taxLiable: false, vatRate: 0, reason: 'small_business', mode: 'add_on');
|
$this->runAction(taxLiable: false, vatRate: 0, reason: 'ustg_19', mode: 'add_on');
|
||||||
$this->assertSame('inclusive', $this->tenant->fresh()->vat_pricing_mode);
|
$this->assertSame('inclusive', $this->tenant->fresh()->vat_pricing_mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,13 +119,23 @@ class TenantTaxInformationTest extends TestCase
|
|||||||
{
|
{
|
||||||
$this->assertSame(
|
$this->assertSame(
|
||||||
'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.',
|
'Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.',
|
||||||
TaxExemptionReason::where('slug', 'small_business')->first()->invoiceText(),
|
TaxExemptionReason::where('slug', 'ustg_19')->first()->invoiceText(),
|
||||||
);
|
);
|
||||||
$this->assertSame(
|
$this->assertSame(
|
||||||
'Die Leistung ist gemäß § 4 Nr. 25 UStG von der Umsatzsteuer befreit.',
|
'Die Leistung ist gemäß § 4 Nr. 25 Buchst. a UStG von der Umsatzsteuer befreit.',
|
||||||
TaxExemptionReason::where('slug', 'youth_welfare')->first()->invoiceText(),
|
TaxExemptionReason::where('slug', 'ustg_4_25a')->first()->invoiceText(),
|
||||||
|
);
|
||||||
|
$this->assertSame(
|
||||||
|
'Die Leistung ist gemäß § 4 Nr. 24 UStG von der Umsatzsteuer befreit.',
|
||||||
|
TaxExemptionReason::where('slug', 'ustg_4_24')->first()->invoiceText(),
|
||||||
);
|
);
|
||||||
// Freitext-Grund liefert den mitgegebenen Text statt eines festen Rechnungshinweises.
|
// Freitext-Grund liefert den mitgegebenen Text statt eines festen Rechnungshinweises.
|
||||||
$this->assertSame('Mein Text', TaxExemptionReason::where('slug', 'custom')->first()->invoiceText('Mein Text'));
|
$this->assertSame('Mein Text', TaxExemptionReason::where('slug', 'custom')->first()->invoiceText('Mein Text'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_options_are_ordered(): void
|
||||||
|
{
|
||||||
|
$slugs = array_column(TaxExemptionReason::options(), 'value');
|
||||||
|
$this->assertSame(['ustg_19', 'ustg_4_24', 'ustg_4_25a', 'ustg_4_22a', 'custom'], $slugs);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user