Teilnahmerechnungen erstellen
This commit is contained in:
@@ -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,
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user