Teilnahmerechnungen erstellen

This commit is contained in:
2026-09-03 00:08:46 +02:00
parent a61344395a
commit 9c4c28e566
79 changed files with 4303 additions and 75 deletions
@@ -0,0 +1,87 @@
<?php
namespace App\Domains\Admin\Actions\UpdateDocumentAsset;
use App\Models\DocumentAsset;
/**
* Legt ein Bild für die Dokumentvorlagen an oder ersetzt es.
*
* Gespeichert wird der base64-Payload in der Datenbank -- so überlebt das Bild ein Release (das als
* `git checkout` alles unter `public/` überschreibt) und wandert mit jedem Dump mit.
*/
class UpdateDocumentAssetAction
{
/** Über 2 MB wird die Vorlage träge und das PDF unnötig groß. */
private const int MAX_BYTES = 2 * 1024 * 1024;
/** @var array<string, string> */
private const array ALLOWED_MIME_TYPES = [
'image/png' => 'png',
'image/jpeg' => 'jpg',
'image/gif' => 'gif',
'image/svg+xml' => 'svg',
];
public function __construct(private readonly UpdateDocumentAssetRequest $request)
{
}
public function execute(): UpdateDocumentAssetResponse
{
$response = new UpdateDocumentAssetResponse();
$name = $this->normalizeName($this->request->name);
if ($name === null) {
$response->message = 'Bitte einen Namen aus Kleinbuchstaben, Ziffern, Bindestrich oder Unterstrich angeben.';
return $response;
}
$asset = DocumentAsset::where('name', $name)->first();
$file = $this->request->file;
if ($file === null && $asset === null) {
$response->message = 'Für ein neues Bild wird eine Datei benötigt.';
return $response;
}
$attributes = ['name' => $name, 'label' => $this->request->label];
if ($file !== null) {
if (!array_key_exists($file->getMimeType(), self::ALLOWED_MIME_TYPES)) {
$response->message = 'Nur PNG, JPEG, GIF oder SVG sind zulässig.';
return $response;
}
if ($file->getSize() > self::MAX_BYTES) {
$response->message = 'Das Bild darf höchstens 2 MB groß sein.';
return $response;
}
$attributes['mime'] = $file->getMimeType();
$attributes['data'] = base64_encode(file_get_contents($file->getRealPath()));
}
$response->asset = $asset === null
? DocumentAsset::create($attributes)
: tap($asset)->update($attributes);
$response->success = true;
$response->message = 'Das Bild wurde gespeichert.';
return $response;
}
/** Namen sind Slugs, damit `{asset:name}` eindeutig erkennbar bleibt. */
private function normalizeName(string $name): ?string
{
$name = strtolower(trim($name));
return preg_match('/^[a-z0-9_-]+$/', $name) === 1 ? $name : null;
}
}
@@ -0,0 +1,16 @@
<?php
namespace App\Domains\Admin\Actions\UpdateDocumentAsset;
use Illuminate\Http\UploadedFile;
class UpdateDocumentAssetRequest
{
public function __construct(
/** Slug, unter dem das Bild in der Vorlage als `{asset:name}` referenziert wird. */
public readonly string $name,
public readonly ?string $label = null,
public readonly ?UploadedFile $file = null,
) {
}
}
@@ -0,0 +1,14 @@
<?php
namespace App\Domains\Admin\Actions\UpdateDocumentAsset;
use App\Models\DocumentAsset;
class UpdateDocumentAssetResponse
{
public bool $success = false;
public ?string $message = null;
public ?DocumentAsset $asset = null;
}
@@ -0,0 +1,73 @@
<?php
namespace App\Domains\Admin\Actions\UpdateDocumentTemplate;
use App\Models\DocumentTemplate;
/**
* Speichert die Blockinhalte einer Dokumentvorlage.
*
* Es werden nur bereits angelegte, als editierbar markierte Blöcke geschrieben. Neue Blöcke entstehen
* nicht über das Formular -- welche Blöcke eine Dokumentart hat, gibt die Vorlage vor (das Layout
* verweist mit `{block:...}` auf sie).
*/
class UpdateDocumentTemplateAction
{
public function __construct(private readonly UpdateDocumentTemplateRequest $request)
{
}
public function execute(): UpdateDocumentTemplateResponse
{
$response = new UpdateDocumentTemplateResponse();
$existing = DocumentTemplate::forType($this->request->documentType);
// Erst prüfen, dann schreiben -- sonst bliebe bei einem Fehler ein halb gespeicherter Stand übrig.
$rejection = $this->rejectBareAssetInStyle();
if ($rejection !== null) {
$response->message = $rejection;
return $response;
}
foreach ($this->request->blocks as $block => $content) {
$template = $existing->get($block);
if ($template === null || !$template->editable) {
$response->skipped[] = $block;
continue;
}
$template->update(['content' => (string) $content]);
}
$response->success = true;
$response->message = $response->skipped === []
? 'Die Vorlage wurde gespeichert.'
: 'Die Vorlage wurde gespeichert; nicht editierbare Blöcke blieben unverändert.';
return $response;
}
/**
* Ein Bild-Platzhalter, der im CSS nicht in `url(...)` steht, expandiert zu einem Data-URI, das
* dompdf nicht auslagern kann -- der Parser läuft dann minutenlang und der FPM-Worker stirbt am
* Zeitlimit (502). Solches CSS wird gar nicht erst gespeichert.
*
* @return string|null Fehlermeldung, oder null wenn nichts zu beanstanden ist.
*/
private function rejectBareAssetInStyle(): ?string
{
$style = $this->request->blocks[DocumentTemplate::BLOCK_STYLE] ?? null;
if ($style === null || preg_match('/(?<![("\'])\{asset:([a-z0-9_-]+)}/i', (string) $style, $match) !== 1) {
return null;
}
return sprintf(
'Im CSS-Block steht der Bild-Platzhalter {asset:%1$s} direkt im Text. Dort gehört er in url("{asset:%1$s}").',
$match[1]
);
}
}
@@ -0,0 +1,15 @@
<?php
namespace App\Domains\Admin\Actions\UpdateDocumentTemplate;
class UpdateDocumentTemplateRequest
{
/**
* @param array<string, string> $blocks Blockinhalte, adressiert über den Block-Slug.
*/
public function __construct(
public readonly string $documentType,
public readonly array $blocks,
) {
}
}
@@ -0,0 +1,13 @@
<?php
namespace App\Domains\Admin\Actions\UpdateDocumentTemplate;
class UpdateDocumentTemplateResponse
{
public bool $success = false;
public ?string $message = null;
/** @var array<int, string> Blöcke, die nicht gespeichert wurden (unbekannt oder nicht editierbar). */
public array $skipped = [];
}
@@ -2,6 +2,8 @@
namespace App\Domains\Admin\Actions\UpdateTenantContact;
use App\Support\Text;
class UpdateTenantContactAction
{
public function __construct(private UpdateTenantContactRequest $request)
@@ -15,8 +17,14 @@ class UpdateTenantContactAction
$this->request->tenant->update([
'email' => $this->request->email,
'email_finance' => $this->request->emailFinance,
'invoice_sender_name' => Text::nullIfBlank($this->request->invoiceSenderName),
// Leergelassene optionale Felder als NULL, damit sie im Briefkopf sauber wegfallen.
'address_1' => Text::nullIfBlank($this->request->address1),
'address_2' => Text::nullIfBlank($this->request->address2),
'address_3' => Text::nullIfBlank($this->request->address3),
'postcode' => $this->request->postcode,
'city' => $this->request->city,
'phone' => Text::nullIfBlank($this->request->phone),
]);
$response->success = true;
@@ -12,6 +12,14 @@ class UpdateTenantContactRequest
public string $emailFinance,
public string $postcode,
public string $city,
/** Bezeichnung des Rechnungsstellers; leer bedeutet "Name des Mandanten verwenden". */
public ?string $invoiceSenderName = null,
/** Straße und Hausnummer -- Pflichtangabe auf Rechnungen nach § 14 Abs. 4 UStG. */
public ?string $address1 = null,
/** Optionale Zusatzzeile, z.B. "c/o ...". */
public ?string $address2 = null,
public ?string $address3 = null,
public ?string $phone = null,
) {
}
}
@@ -3,6 +3,8 @@
namespace App\Domains\Admin\Actions\UpdateTenantTax;
use App\Enumerations\VatPricingMode;
use App\Models\Tenant;
use App\Support\Text;
class UpdateTenantTaxAction
{
@@ -14,14 +16,23 @@ class UpdateTenantTaxAction
{
$response = new UpdateTenantTaxResponse();
// Präfixe sind großgeschrieben; null heißt "nicht mitgeschickt" und damit "unverändert".
$prefix = Text::nullIfBlank(strtoupper((string) $this->request->invoicePrefix));
if ($prefix !== null && $this->prefixTakenByAnotherTenant($prefix)) {
$response->message = sprintf('Das Rechnungs-Präfix "%s" wird bereits von einem anderen Mandanten genutzt.', $prefix);
return $response;
}
if ($this->request->taxLiable) {
$this->request->tenant->update([
$this->request->tenant->update($this->withInvoiceDetails([
'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,
]);
], $prefix));
$response->success = true;
$response->message = 'Steuerinformationen wurden gespeichert.';
@@ -45,13 +56,13 @@ class UpdateTenantTaxAction
return $response;
}
$this->request->tenant->update([
$this->request->tenant->update($this->withInvoiceDetails([
'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,
]);
], $prefix));
$response->success = true;
$response->message = 'Steuerinformationen wurden gespeichert.';
@@ -63,4 +74,31 @@ class UpdateTenantTaxAction
{
return max(0, min(100, $vatRate));
}
/**
* Ergänzt die Rechnungsstellerangaben. Sie gelten unabhängig davon, ob Steuerpflicht besteht -- eine
* Steuernummer gehört auch auf eine steuerfreie Rechnung.
*
* @param array<string, mixed> $attributes
* @return array<string, mixed>
*/
private function withInvoiceDetails(array $attributes, ?string $prefix): array
{
$attributes['tax_number'] = Text::nullIfBlank($this->request->taxNumber);
$attributes['vat_id'] = Text::nullIfBlank($this->request->vatId);
// Nicht mitgeschickt heißt "unverändert", nicht "leeren".
if ($prefix !== null) {
$attributes['invoice_prefix'] = $prefix;
}
return $attributes;
}
private function prefixTakenByAnotherTenant(string $prefix): bool
{
return Tenant::where('invoice_prefix', $prefix)
->where('id', '!=', $this->request->tenant->id)
->exists();
}
}
@@ -15,6 +15,14 @@ class UpdateTenantTaxRequest
public ?TaxExemptionReason $exemptionReason,
public ?string $exemptionNote,
public VatPricingMode $vatPricingMode,
/** Steuernummer bzw. USt-IdNr. -- Pflichtangabe auf Rechnungen (§ 14 Abs. 4 Nr. 2 UStG). */
public ?string $taxNumber = null,
public ?string $vatId = null,
/**
* Erster Block der Rechnungsnummer. `null` heißt "unverändert lassen" -- im Self-Service wird das
* Feld gar nicht erst mitgeschickt, damit ein bestehender Nummernkreis nicht bricht.
*/
public ?string $invoicePrefix = null,
)
{
}