diff --git a/.gitignore b/.gitignore index 656cf1b..3f9cf0f 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ Homestead.json Homestead.yaml Thumbs.db /docker-compose.yaml + +# HTML-Report von composer test:coverage +/storage/coverage diff --git a/app/Domains/Admin/Controllers/DocumentTemplatesGetController.php b/app/Domains/Admin/Controllers/DocumentTemplatesGetController.php index cb3bfb6..9b4b437 100644 --- a/app/Domains/Admin/Controllers/DocumentTemplatesGetController.php +++ b/app/Domains/Admin/Controllers/DocumentTemplatesGetController.php @@ -2,47 +2,38 @@ namespace App\Domains\Admin\Controllers; -use App\Domains\ParticipantInvoice\ParticipantInvoiceTokens; use App\Models\DocumentAsset; use App\Models\DocumentTemplate; +use App\Models\DocumentTypeCatalog; use App\Scopes\CommonController; use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; class DocumentTemplatesGetController extends CommonController { - /** Blöcke, die Layout-HTML bzw. CSS enthalten und deshalb im Quelltext-Editor gepflegt werden. */ - private const array SOURCE_BLOCKS = [ - DocumentTemplate::BLOCK_LAYOUT, - DocumentTemplate::BLOCK_STYLE, - ]; - - private const array BLOCK_LABELS = [ - DocumentTemplate::BLOCK_LAYOUT => 'Seitengerüst', - DocumentTemplate::BLOCK_STYLE => 'Gestaltung (CSS)', - 'header_sender_return' => 'Rücksendezeile', - 'header_recipient' => 'Empfängeranschrift', - 'emblem' => 'Emblem', - 'logo' => 'Logo', - 'sender_data' => 'Absenderangaben', - 'subject' => 'Betreff und Referenzdaten', - DocumentTemplate::BLOCK_BODY => 'Rechnungsinhalt', - 'footer' => 'Grußformel und Fußnote', - ]; - - public function __invoke(): JsonResponse + public function __invoke(Request $request): JsonResponse { - $blocks = DocumentTemplate::forType(DocumentTemplate::TYPE_PARTICIPANT_INVOICE) + $documentType = (string) $request->input('type', DocumentTypeCatalog::default()); + + if (!DocumentTypeCatalog::has($documentType)) { + abort(422, 'Unbekannte Dokumentart.'); + } + + $blocks = DocumentTemplate::forType($documentType) ->values() ->map(fn(DocumentTemplate $block): array => [ 'block' => $block->block, - 'label' => self::BLOCK_LABELS[$block->block] ?? $block->block, + 'label' => DocumentTypeCatalog::blockLabel($documentType, $block->block), 'content' => (string) $block->content, 'editable' => $block->editable, - 'source' => in_array($block->block, self::SOURCE_BLOCKS, true), + 'source' => in_array($block->block, DocumentTypeCatalog::SOURCE_BLOCKS, true), ]); return response()->json([ + 'documentType' => $documentType, + 'documentTypes' => DocumentTypeCatalog::options(), 'blocks' => $blocks, + // Die Bilder gelten für alle Dokumentarten -- sie hängen nicht am Typ. 'assets' => DocumentAsset::orderBy('name')->get()->map(fn(DocumentAsset $asset): array => [ 'name' => $asset->name, 'label' => $asset->label, @@ -50,7 +41,7 @@ class DocumentTemplatesGetController extends CommonController 'token' => '{asset:' . $asset->name . '}', 'preview' => $asset->toDataUri(), ]), - 'tokenGroups' => ParticipantInvoiceTokens::groups(), + 'tokenGroups' => DocumentTypeCatalog::tokenGroups($documentType), ]); } } diff --git a/app/Domains/Admin/Controllers/DocumentTemplatesPreviewController.php b/app/Domains/Admin/Controllers/DocumentTemplatesPreviewController.php index fd0bab2..e03071d 100644 --- a/app/Domains/Admin/Controllers/DocumentTemplatesPreviewController.php +++ b/app/Domains/Admin/Controllers/DocumentTemplatesPreviewController.php @@ -2,8 +2,7 @@ namespace App\Domains\Admin\Controllers; -use App\Domains\ParticipantInvoice\ParticipantInvoiceTokens; -use App\Models\DocumentTemplate; +use App\Models\DocumentTypeCatalog; use App\Providers\DocumentTemplateRenderProvider; use App\Providers\PdfGenerateAndDownloadProvider; use App\Scopes\CommonController; @@ -17,10 +16,16 @@ class DocumentTemplatesPreviewController extends CommonController { public function __invoke(Request $request): Response { + $documentType = (string) $request->input('type', DocumentTypeCatalog::default()); + + if (!DocumentTypeCatalog::has($documentType)) { + abort(422, 'Unbekannte Dokumentart.'); + } + $html = new DocumentTemplateRenderProvider( - DocumentTemplate::TYPE_PARTICIPANT_INVOICE, + $documentType, (array) $request->input('blocks', []), - )->render(ParticipantInvoiceTokens::sample()); + )->render(DocumentTypeCatalog::sampleTokens($documentType)); return response(PdfGenerateAndDownloadProvider::fromHtml($html, 'portrait'), 200, [ 'Content-Type' => 'application/pdf', diff --git a/app/Domains/Admin/Controllers/DocumentTemplatesUpdateController.php b/app/Domains/Admin/Controllers/DocumentTemplatesUpdateController.php index afe53fa..876734d 100644 --- a/app/Domains/Admin/Controllers/DocumentTemplatesUpdateController.php +++ b/app/Domains/Admin/Controllers/DocumentTemplatesUpdateController.php @@ -4,7 +4,7 @@ namespace App\Domains\Admin\Controllers; use App\Domains\Admin\Actions\UpdateDocumentTemplate\UpdateDocumentTemplateAction; use App\Domains\Admin\Actions\UpdateDocumentTemplate\UpdateDocumentTemplateRequest; -use App\Models\DocumentTemplate; +use App\Models\DocumentTypeCatalog; use App\Scopes\CommonController; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -13,8 +13,16 @@ class DocumentTemplatesUpdateController extends CommonController { public function __invoke(Request $request): JsonResponse { + $documentType = (string) $request->input('type', DocumentTypeCatalog::default()); + + // Die Dokumentart bestimmt, welche Zeilen geschrieben werden -- ungeprüft weitergereicht wäre + // sie ein Weg, in beliebige Vorlagen zu schreiben. + if (!DocumentTypeCatalog::has($documentType)) { + abort(422, 'Unbekannte Dokumentart.'); + } + $action = new UpdateDocumentTemplateAction(new UpdateDocumentTemplateRequest( - documentType: DocumentTemplate::TYPE_PARTICIPANT_INVOICE, + documentType: $documentType, blocks: (array) $request->input('blocks', []), )); diff --git a/app/Domains/Admin/Views/DocumentTemplates.vue b/app/Domains/Admin/Views/DocumentTemplates.vue index df517d1..2bd66e3 100644 --- a/app/Domains/Admin/Views/DocumentTemplates.vue +++ b/app/Domains/Admin/Views/DocumentTemplates.vue @@ -12,6 +12,10 @@ const blocks = ref([]) const assets = ref([]) const tokenGroups = ref({}) +// Die Vorlagen sind nach Dokumentart getrennt; der Umschalter lädt jeweils deren Blöcke neu. +const documentType = ref(null) +const documentTypes = ref([]) + const activeBlock = ref(null) const saving = ref(false) @@ -33,12 +37,15 @@ function selectBlock(block) { onMounted(load) async function load() { - const data = await request('/api/v1/admin/document-templates', {method: 'GET'}) + const query = documentType.value ? '?type=' + encodeURIComponent(documentType.value) : '' + const data = await request('/api/v1/admin/document-templates' + query, {method: 'GET'}) if (!data) { toast.error('Die Vorlage konnte nicht geladen werden.') return } + documentType.value = data.documentType + documentTypes.value = data.documentTypes ?? [] blocks.value = data.blocks ?? [] assets.value = data.assets ?? [] tokenGroups.value = data.tokenGroups ?? {} @@ -47,6 +54,15 @@ async function load() { await refreshPreview() } +/** Beim Wechsel der Dokumentart alles neu holen -- Blöcke und Platzhalter sind je Art andere. */ +async function selectDocumentType(type) { + if (type === documentType.value) return + + documentType.value = type + caret.value = null + await load() +} + /** Nur editierbare Blöcke werden gesendet; der generierte Rechnungsinhalt bleibt unverändert. */ function editableBlocks() { return Object.fromEntries( @@ -60,7 +76,7 @@ async function save() { try { const response = await request('/api/v1/admin/document-templates', { method: 'POST', - body: {blocks: editableBlocks()}, + body: {type: documentType.value, blocks: editableBlocks()}, }) if (response?.status === 'success') { @@ -83,7 +99,7 @@ async function refreshPreview() { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content ?? '', }, - body: JSON.stringify({blocks: editableBlocks()}), + body: JSON.stringify({type: documentType.value, blocks: editableBlocks()}), }) if (!response.ok) { @@ -217,14 +233,28 @@ function onFileChosen(event) { @@ -460,6 +592,48 @@ function mailToGroup(groupKey) { + +

+ {{ showParticipant?.fullname }} hat + {{ showParticipant?.amountPaid?.readable }} gezahlt. Nach der Freigabe erhält + der Teili eine E-Mail und trägt seine Bankverbindung selbst ein. +

+ +
+ +
+ Euro +
+ +
+ +
+ + + +
+ +
+ + + +
+ + +
+ diff --git a/app/Enumerations/RefundReason.php b/app/Enumerations/RefundReason.php new file mode 100644 index 0000000..5932c6b --- /dev/null +++ b/app/Enumerations/RefundReason.php @@ -0,0 +1,65 @@ + 'boolean', + 'sort_order' => 'integer', + ]; + + /** + * Der Text, der auf dem Beleg unter „Grund" steht. Bei einem Grund, der einen Freitext verlangt, + * ist der hinterlegte Text der der Aktionsleitung. + */ + public function documentText(?string $note = null): string + { + return $this->requires_note ? trim((string) $note) : (string) $this->document_text; + } + + /** + * Optionen für das Frontend. `requiresNote` steuert dort das Freitextfeld. + * + * @return array + */ + public static function options(): array + { + return self::orderBy('sort_order')->get() + ->map(static fn (self $reason): array => [ + 'value' => $reason->slug, + 'label' => $reason->name, + 'requiresNote' => $reason->requires_note, + ]) + ->all(); + } +} diff --git a/app/Mail/ParticipantRefundMails/RefundAcceptedMail.php b/app/Mail/ParticipantRefundMails/RefundAcceptedMail.php new file mode 100644 index 0000000..fa5ad5d --- /dev/null +++ b/app/Mail/ParticipantRefundMails/RefundAcceptedMail.php @@ -0,0 +1,73 @@ +participant->event()->first()->name + ), + ); + } + + public function content(): Content + { + $event = $this->participant->event()->first(); + + return new Content( + view: 'emails.events.refund_accepted', + with: [ + 'name' => $this->participant->getNicename(), + 'eventTitle' => $event->name, + 'eventEmail' => $event->email, + 'amount' => $this->refund->amount?->toString() ?? '0,00 Euro', + 'reason' => $this->refund->reasonLabel(), + 'accountOwner' => $this->refund->account_owner, + 'accountIban' => Iban::format((string) $this->refund->account_iban), + 'hasDocument' => $this->pdfContent !== null, + ], + ); + } + + /** + * @return array + */ + public function attachments(): array + { + if ($this->pdfContent === null) { + return []; + } + + return [ + Attachment::fromData(fn (): string => $this->pdfContent, $this->pdfFilename ?? 'Rueckerstattung.pdf') + ->withMime('application/pdf'), + ]; + } +} diff --git a/app/Mail/ParticipantRefundMails/RefundReleasedMail.php b/app/Mail/ParticipantRefundMails/RefundReleasedMail.php new file mode 100644 index 0000000..605b694 --- /dev/null +++ b/app/Mail/ParticipantRefundMails/RefundReleasedMail.php @@ -0,0 +1,59 @@ +participant->event()->first()->name + ), + ); + } + + public function content(): Content + { + $event = $this->participant->event()->first(); + + return new Content( + view: 'emails.events.refund_released', + with: [ + 'name' => $this->participant->getNicename(), + 'eventTitle' => $event->name, + 'eventEmail' => $event->email, + 'amount' => $this->refund->amount?->toString() ?? '0,00 Euro', + 'reason' => $this->refund->reasonLabel(), + 'reasonNote' => $this->refund->reason_note, + // Absolute URL: der Link muss aus jedem Postfach heraus funktionieren. + 'link' => url('/rueckerstattung/' . $this->refund->token), + ], + ); + } + + /** + * @return array + */ + public function attachments(): array + { + return []; + } +} diff --git a/app/Models/DocumentTemplate.php b/app/Models/DocumentTemplate.php index 58aef3e..16d9b48 100644 --- a/app/Models/DocumentTemplate.php +++ b/app/Models/DocumentTemplate.php @@ -19,13 +19,15 @@ class DocumentTemplate extends CommonModel { public const string TYPE_PARTICIPANT_INVOICE = 'participant_invoice'; + public const string TYPE_PARTICIPANT_REFUND = 'participant_refund'; + /** Seitengerüst -- enthält die `{block:...}`-Platzhalter und bestimmt damit die Anordnung. */ public const string BLOCK_LAYOUT = 'layout'; /** CSS des Dokuments. */ public const string BLOCK_STYLE = 'style'; - /** Der generierte Teil (Positionstabelle, Summen, Schlusssatz) -- nicht editierbar. */ + /** Der Inhaltsblock. Bei der Rechnung generiert und gesperrt, beim Erstattungsbeleg pflegbar. */ public const string BLOCK_BODY = 'body'; protected $table = 'document_templates'; diff --git a/app/Models/DocumentTypeCatalog.php b/app/Models/DocumentTypeCatalog.php new file mode 100644 index 0000000..b86c4c0 --- /dev/null +++ b/app/Models/DocumentTypeCatalog.php @@ -0,0 +1,125 @@ + + */ + private const array COMMON_BLOCK_LABELS = [ + DocumentTemplate::BLOCK_LAYOUT => 'Seitengerüst', + DocumentTemplate::BLOCK_STYLE => 'Gestaltung (CSS)', + 'header_sender_return' => 'Rücksendezeile', + 'header_recipient' => 'Empfängeranschrift', + 'emblem' => 'Emblem', + 'logo' => 'Logo', + 'sender_data' => 'Absenderangaben', + 'subject' => 'Betreff und Referenzdaten', + ]; + + /** + * @return array}> + */ + public static function all(): array + { + return [ + DocumentTemplate::TYPE_PARTICIPANT_INVOICE => [ + 'label' => 'Teilnahmerechnung', + 'tokens' => ParticipantInvoiceTokens::class, + 'blockLabels' => self::COMMON_BLOCK_LABELS + [ + DocumentTemplate::BLOCK_BODY => 'Rechnungsinhalt', + 'footer' => 'Grußformel und Fußnote', + ], + ], + DocumentTemplate::TYPE_PARTICIPANT_REFUND => [ + 'label' => 'Erstattungsbeleg', + 'tokens' => ParticipantRefundTokens::class, + 'blockLabels' => self::COMMON_BLOCK_LABELS + [ + DocumentTemplate::BLOCK_BODY => 'Erklärung und Angaben zur Erstattung', + 'footer' => 'Fußbereich', + ], + ], + ]; + } + + public static function has(string $documentType): bool + { + return array_key_exists($documentType, self::all()); + } + + /** Die Dokumentart, oder null wenn sie nicht im Katalog steht. */ + public static function get(string $documentType): ?array + { + return self::all()[$documentType] ?? null; + } + + public static function default(): string + { + return DocumentTemplate::TYPE_PARTICIPANT_INVOICE; + } + + public static function blockLabel(string $documentType, string $block): string + { + return self::get($documentType)['blockLabels'][$block] ?? $block; + } + + /** + * Platzhalter-Gruppen der Dokumentart -- für die Liste im Formular. + * + * @return array> + */ + public static function tokenGroups(string $documentType): array + { + $tokens = self::get($documentType)['tokens'] ?? null; + + return $tokens === null ? [] : $tokens::groups(); + } + + /** + * Beispielwerte der Dokumentart -- für die Vorschau. + * + * @return array + */ + public static function sampleTokens(string $documentType): array + { + $tokens = self::get($documentType)['tokens'] ?? null; + + return $tokens === null ? [] : $tokens::sample(); + } + + /** + * Die Auswahl für den Umschalter im Formular. + * + * @return array + */ + public static function options(): array + { + $options = []; + + foreach (self::all() as $type => $definition) { + $options[] = ['value' => $type, 'label' => $definition['label']]; + } + + return $options; + } +} diff --git a/app/Models/ParticipantRefund.php b/app/Models/ParticipantRefund.php new file mode 100644 index 0000000..89190ac --- /dev/null +++ b/app/Models/ParticipantRefund.php @@ -0,0 +1,105 @@ + AmountCast::class, + 'released_at' => 'datetime', + 'accepted_at' => 'datetime', + 'cancelled_at' => 'datetime', + ]; + + public function participant(): BelongsTo + { + return $this->belongsTo(EventParticipant::class, 'event_participant_id'); + } + + public function event(): BelongsTo + { + return $this->belongsTo(Event::class); + } + + /** + * Der Grund als Stammdatensatz. Nicht `reason()`, weil das die Spalte `reason` verdecken würde. + */ + public function reasonRelation(): BelongsTo + { + return $this->belongsTo(RefundReason::class, 'reason', 'slug'); + } + + public function isPending(): bool + { + return $this->status === self::STATUS_PENDING; + } + + public function isAccepted(): bool + { + return $this->status === self::STATUS_ACCEPTED; + } + + /** Der auf dem Beleg auszuweisende Grundtext -- bei Freitext-Gründen der Text der Aktionsleitung. */ + public function reasonText(): string + { + return $this->reasonRelation()->first()?->documentText($this->reason_note) ?? ''; + } + + public function reasonLabel(): string + { + return (string) ($this->reasonRelation()->first()?->name ?? ''); + } +} diff --git a/app/Providers/GlobalDataProvider.php b/app/Providers/GlobalDataProvider.php index 5a3b08a..c9618a1 100644 --- a/app/Providers/GlobalDataProvider.php +++ b/app/Providers/GlobalDataProvider.php @@ -4,6 +4,7 @@ namespace App\Providers; use App\Enumerations\EatingHabit; use App\Enumerations\InvoiceType; +use App\Enumerations\RefundReason; use App\Enumerations\UserRole; use App\Models\AvailablePaymentMethod; use App\Models\Tenant; @@ -189,6 +190,11 @@ class GlobalDataProvider { return $activeUsers; } + /** Auswahl der Erstattungsgründe für den Dialog „Beitrag erstatten". */ + public function getRefundReasons() : JsonResponse { + return response()->json(RefundReason::options()); + } + public function getEventSettingData(Request $request) : JsonResponse { return response()->json( [ diff --git a/app/Repositories/ParticipantRefundRepository.php b/app/Repositories/ParticipantRefundRepository.php new file mode 100644 index 0000000..008d1bc --- /dev/null +++ b/app/Repositories/ParticipantRefundRepository.php @@ -0,0 +1,41 @@ +id) + ->where('status', ParticipantRefund::STATUS_PENDING) + ->first(); + } + + /** + * Der für die Anzeige maßgebliche Vorgang: der offene, sonst der zuletzt bestätigte. Abgebrochene + * Vorgänge bleiben außen vor -- für die Aktionsleitung sieht die Anmeldung danach aus wie vorher. + */ + public function currentFor(EventParticipant $participant): ?ParticipantRefund + { + return ParticipantRefund::where('event_participant_id', $participant->id) + ->whereIn('status', [ParticipantRefund::STATUS_PENDING, ParticipantRefund::STATUS_ACCEPTED]) + ->orderByDesc('id') + ->first(); + } + + /** + * Der Vorgang zu einem öffentlichen Link. + * + * Der Token ist die einzige Autorisierung -- dasselbe Modell wie bei /print-girocode/{identifier}. + * Der Mandant kommt über den globalen SiteScope aus dem Host der Anfrage: ein Token einer anderen + * Instanz findet hier nichts. + */ + public function getByToken(string $token): ?ParticipantRefund + { + return ParticipantRefund::where('token', $token)->first(); + } +} diff --git a/app/Resources/EventParticipantResource.php b/app/Resources/EventParticipantResource.php index 2dbbab3..1258e2a 100644 --- a/app/Resources/EventParticipantResource.php +++ b/app/Resources/EventParticipantResource.php @@ -8,6 +8,7 @@ use App\Enumerations\ParticipationType; use App\Models\AvailablePaymentMethod; use App\Models\EventParticipant; use App\Models\PaymentMethod; +use App\Repositories\ParticipantRefundRepository; use App\ValueObjects\Age; use Illuminate\Http\Resources\Json\JsonResource; @@ -74,6 +75,10 @@ class EventParticipantResource extends JsonResource // Numerisch, damit das Frontend rechnen bzw. auf "kostenlos" prüfen kann -- `value` oben ist // ein Amount-Objekt und serialisiert nicht. 'amountExpectedValue' => $this->resource->amount?->getAmount() ?? 0.0, + 'amountPaidValue' => $this->resource->amount_paid?->getAmount() ?? 0.0, + // Der laufende bzw. bestätigte Erstattungsvorgang -- null, wenn keiner existiert oder + // der letzte abgebrochen wurde. + 'refund' => $this->refund($request), 'alcoholicsAllowed' => new Age($this->resource->birthday)->getAge() >= $event->alcoholics_age, 'localGroupPostcode' => $this->resource->localGroup()->first()?->postcode ?? '00000', 'localGroupCity' => $this->resource->localGroup()->first()?->city ?? '00000', @@ -106,4 +111,17 @@ class EventParticipantResource extends JsonResource ] ); } + + /** + * Der für die Anzeige maßgebliche Erstattungsvorgang. + * + * Abgebrochene Vorgänge bleiben außen vor: für die Aktionsleitung soll die Anmeldung danach + * aussehen wie vor der Freigabe. + */ + private function refund($request): ?array + { + $refund = new ParticipantRefundRepository()->currentFor($this->resource); + + return $refund?->toResource()->toArray($request); + } } diff --git a/app/Resources/ParticipantRefundResource.php b/app/Resources/ParticipantRefundResource.php new file mode 100644 index 0000000..433be0e --- /dev/null +++ b/app/Resources/ParticipantRefundResource.php @@ -0,0 +1,37 @@ +resource->toArray()` als Basis wie in {@see EventParticipantResource}: die + * Bankverbindung darf nur dorthin, wo sie hingehört (Beleg und Auszahlung), nicht in jede + * Teilnehmerliste. Deshalb werden die Felder hier einzeln aufgeführt. + */ +class ParticipantRefundResource extends JsonResource +{ + public function __construct(ParticipantRefund $refund) + { + parent::__construct($refund); + } + + public function toArray($request): array + { + return [ + 'token' => $this->resource->token, + 'status' => $this->resource->status, + 'amount' => $this->resource->amount?->toString() ?? '0,00 Euro', + 'amountValue' => $this->resource->amount?->getAmount() ?? 0.0, + 'reason' => $this->resource->reason, + 'reasonLabel' => $this->resource->reasonLabel(), + 'reasonNote' => $this->resource->reason_note, + 'releasedAt' => $this->resource->released_at?->format('d.m.Y'), + 'acceptedAt' => $this->resource->accepted_at?->format('d.m.Y'), + 'cancelledAt' => $this->resource->cancelled_at?->format('d.m.Y'), + ]; + } +} diff --git a/app/Scopes/CommonController.php b/app/Scopes/CommonController.php index 695b531..0969105 100644 --- a/app/Scopes/CommonController.php +++ b/app/Scopes/CommonController.php @@ -12,6 +12,7 @@ use App\Repositories\EventParticipantRepository; use App\Repositories\EventRepository; use App\Repositories\InvoiceRepository; use App\Repositories\PageTextRepository; +use App\Repositories\ParticipantRefundRepository; use App\Repositories\UserRepository; abstract class CommonController { @@ -24,6 +25,7 @@ abstract class CommonController { protected InvoiceRepository $invoices; protected EventRepository $events; protected EventParticipantRepository $eventParticipants; + protected ParticipantRefundRepository $participantRefunds; protected EstimatesRepository $estimates; protected AdminUserRepository $adminUsers; protected AdminTenantRepository $adminTenants; @@ -36,6 +38,7 @@ abstract class CommonController { $this->invoices = new InvoiceRepository(); $this->events = new EventRepository(); $this->eventParticipants = new EventParticipantRepository(); + $this->participantRefunds = new ParticipantRefundRepository(); $this->estimates = new EstimatesRepository(); $this->adminUsers = new AdminUserRepository(); $this->adminTenants = new AdminTenantRepository(); diff --git a/app/Support/Iban.php b/app/Support/Iban.php new file mode 100644 index 0000000..9f4a15c --- /dev/null +++ b/app/Support/Iban.php @@ -0,0 +1,81 @@ + + */ + private const array LENGTHS = [ + 'AD' => 24, 'AT' => 20, 'BE' => 16, 'BG' => 22, 'CH' => 21, 'CY' => 28, 'CZ' => 24, + 'DE' => 22, 'DK' => 18, 'EE' => 20, 'ES' => 24, 'FI' => 18, 'FR' => 27, 'GB' => 22, + 'GI' => 23, 'GR' => 27, 'HR' => 21, 'HU' => 28, 'IE' => 22, 'IS' => 26, 'IT' => 27, + 'LI' => 21, 'LT' => 20, 'LU' => 20, 'LV' => 21, 'MC' => 27, 'MT' => 31, 'NL' => 18, + 'NO' => 15, 'PL' => 28, 'PT' => 25, 'RO' => 24, 'SE' => 24, 'SI' => 19, 'SK' => 24, + 'SM' => 27, 'VA' => 22, + ]; + + /** Leerzeichen raus, Großbuchstaben -- die kanonische Form, die gespeichert wird. */ + public static function normalize(string $iban): string + { + return strtoupper(preg_replace('/\s+/', '', $iban) ?? ''); + } + + public static function isValid(string $iban): bool + { + $iban = self::normalize($iban); + + if (preg_match('/^[A-Z]{2}[0-9]{2}[A-Z0-9]{10,30}$/', $iban) !== 1) { + return false; + } + + $expected = self::LENGTHS[substr($iban, 0, 2)] ?? null; + if ($expected !== null && strlen($iban) !== $expected) { + return false; + } + + return self::checksum($iban) === 1; + } + + /** In Vierergruppen -- die Schreibweise auf Belegen und Formularen. */ + public static function format(string $iban): string + { + return trim(chunk_split(self::normalize($iban), 4, ' ')); + } + + /** + * Mod 97-10: die ersten vier Zeichen ans Ende, Buchstaben durch ihre Position + 9 ersetzen + * (A = 10 … Z = 35), das Ergebnis modulo 97. Eine gültige IBAN ergibt 1. + * + * Der Rest wird stellenweise fortgeschrieben, weil die Zahl sonst jeden Integer sprengt. + */ + private static function checksum(string $iban): int + { + $rearranged = substr($iban, 4) . substr($iban, 0, 4); + + $remainder = 0; + foreach (str_split($rearranged) as $char) { + $value = ctype_digit($char) ? $char : (string) (ord($char) - 55); + + foreach (str_split($value) as $digit) { + $remainder = ($remainder * 10 + (int) $digit) % 97; + } + } + + return $remainder; + } +} diff --git a/composer.json b/composer.json index 989a38e..7be9f30 100644 --- a/composer.json +++ b/composer.json @@ -57,6 +57,10 @@ "@php artisan config:clear --ansi", "@php artisan test" ], + "test:coverage": [ + "@php artisan config:clear --ansi", + "@php -d pcov.enabled=1 vendor/bin/phpunit --coverage-html storage/coverage --coverage-text" + ], "post-autoload-dump": [ "Illuminate\\Foundation\\ComposerScripts::postAutoloadDump", "@php artisan package:discover --ansi" diff --git a/database/migrations/2026_09_03_140010_create_refund_reasons.php b/database/migrations/2026_09_03_140010_create_refund_reasons.php new file mode 100644 index 0000000..4587505 --- /dev/null +++ b/database/migrations/2026_09_03_140010_create_refund_reasons.php @@ -0,0 +1,63 @@ +string('slug')->primary(); + $table->string('name'); + $table->text('document_text')->nullable(); + $table->boolean('requires_note')->default(false); + $table->integer('sort_order')->default(0); + $table->timestamps(); + }); + + DB::table('refund_reasons')->insert([ + [ + 'slug' => 'sickness', + 'name' => 'Krankheitsbedingte Absage', + 'document_text' => 'Die Teilnahme konnte krankheitsbedingt nicht angetreten werden.', + 'requires_note' => false, + 'sort_order' => 10, + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'slug' => 'event_cancelled', + 'name' => 'Ausfall der Veranstaltung', + 'document_text' => 'Die Veranstaltung wurde abgesagt und konnte nicht stattfinden.', + 'requires_note' => false, + 'sort_order' => 20, + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + // Der Text entsteht erst aus dem Freitext der Aktionsleitung -- deshalb hier leer. + 'slug' => 'other', + 'name' => 'Sonstiger Grund', + 'document_text' => null, + 'requires_note' => true, + 'sort_order' => 30, + 'created_at' => now(), + 'updated_at' => now(), + ], + ]); + } + + public function down(): void + { + Schema::dropIfExists('refund_reasons'); + } +}; diff --git a/database/migrations/2026_09_03_140020_create_participant_refunds.php b/database/migrations/2026_09_03_140020_create_participant_refunds.php new file mode 100644 index 0000000..fbacd1c --- /dev/null +++ b/database/migrations/2026_09_03_140020_create_participant_refunds.php @@ -0,0 +1,60 @@ +id(); + $table->string('tenant'); + + $table->foreignId('event_id')->constrained('events', 'id')->cascadeOnDelete()->cascadeOnUpdate(); + $table->foreignId('event_participant_id')->constrained('event_participants', 'id')->cascadeOnDelete()->cascadeOnUpdate(); + + // Der öffentliche Link. Eigener Token statt des Teilnehmer-Identifiers, damit ein Abbruch den + // Link tötet, ohne andere per Identifier erreichbare Funktionen (GiroCode) mitzunehmen. + $table->string('token', 32)->unique(); + $table->string('status'); + + $table->float('amount', 2)->default(0); + $table->string('reason')->nullable(); + $table->text('reason_note')->nullable(); + + $table->string('account_owner')->nullable(); + $table->string('account_iban')->nullable(); + + $table->foreignId('released_by')->nullable()->constrained('users', 'id')->nullOnDelete(); + $table->dateTime('released_at')->nullable(); + $table->dateTime('accepted_at')->nullable(); + $table->dateTime('cancelled_at')->nullable(); + + $table->timestamps(); + + $table->foreign('tenant')->references('slug')->on('tenants')->restrictOnDelete()->cascadeOnUpdate(); + $table->foreign('reason')->references('slug')->on('refund_reasons')->restrictOnDelete()->cascadeOnUpdate(); + }); + } + + public function down(): void + { + Schema::dropIfExists('participant_refunds'); + } +}; diff --git a/database/migrations/2026_09_04_140010_add_participant_refund_confirmation_text.php b/database/migrations/2026_09_04_140010_add_participant_refund_confirmation_text.php new file mode 100644 index 0000000..f632ebd --- /dev/null +++ b/database/migrations/2026_09_04_140010_add_participant_refund_confirmation_text.php @@ -0,0 +1,43 @@ +where('name', self::NAME)->first(); + + if ($existing !== null) { + return; + } + + DB::table('page_texts')->insert([ + 'name' => self::NAME, + 'content' => 'Ich versichere, dass ich den genannten Betrag beglichen habe und nicht ' + . 'anderweitig zurückerstattet bekomme.', + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + public function down(): void + { + DB::table('page_texts')->where('name', self::NAME)->delete(); + } +}; diff --git a/database/seed-scripts/participant-refund-template.sql b/database/seed-scripts/participant-refund-template.sql new file mode 100644 index 0000000..f400776 --- /dev/null +++ b/database/seed-scripts/participant-refund-template.sql @@ -0,0 +1,134 @@ +-- Standard-Vorlage für den Erstattungsbeleg. +-- +-- Bewusst ein manuelles Skript und keine Migration: der Vorlageninhalt wird über die Admin-Oberfläche +-- gepflegt und soll von einem Release nicht überschrieben werden. Einspielen also nur bei der +-- Erstinstallation oder wenn die Vorlage bewusst zurückgesetzt werden soll. +-- +-- docker exec -i mareike-mareike-db mysql -u -p < database/seed-scripts/participant-refund-template.sql +-- +-- Setzt participant-invoice-template.sql voraus: die Bilder ({asset:emblem}, {asset:logo}, +-- {asset:edge}) liegen in `document_assets` und werden von beiden Dokumentarten gemeinsam genutzt. +-- +-- Der Beleg ist kein Schreiben des Verbands, sondern die Erklärung des Teilis ("Ich bitte um …", "Ich +-- versichere …") -- ein Eigenbeleg. +-- +-- Briefkopf samt Anschriftenfeld ist der der Rechnung: die Anschrift der erklärenden Person steht dort +-- und nicht noch einmal im Körper. In der Angabentabelle steht ihr Name, weil dort alles zusammensteht, +-- was sie erklärt -- Kontoinhaber*in kann eine andere Person sein (etwa ein Elternteil). +-- +-- Der Erklärungssatz kommt aus dem Seitentext CONFIRMATION_PARTICIPANT_REFUND, denselben, den der Teili +-- vor dem Absenden ankreuzt. Er steht hier deshalb als Platzhalter und nicht als fester Text. + +DELETE FROM document_templates WHERE document_type = 'participant_refund'; + +INSERT INTO document_templates (document_type, block, content, sort_order, editable, created_at, updated_at) VALUES + ('participant_refund', 'layout', ' + +
+ + + + + + + +
+ {block:header_sender_return} + {block:header_recipient} + + {block:emblem} + + {block:logo} + {block:sender_data} +
+ + {block:subject} + {block:body} + {block:footer} + +
', 10, 1, NOW(), NOW()), + ('participant_refund', 'style', '@page { size: A4 portrait; margin: 0; } +* { margin: 0; padding: 0; box-sizing: border-box; } +body { font-family: \'DejaVu Sans\', sans-serif; font-size: 9.5pt; color: #1a1a1a; background: white; } + +.page { padding: 10mm 18mm 20mm 22mm; } + +/* Kopfbereich */ +.header-table { width: 100%; border-collapse: collapse; margin-bottom: 6mm; } +.header-table td { vertical-align: top; padding: 0; } +.absender-rueck { font-size: 6pt; color: #555; border-bottom: 0.3pt solid #888; padding-bottom: 1.5mm; margin-bottom: 3.5mm; } +.empfaenger { font-size: 10pt; line-height: 1.65; } +.empfaenger-bold { font-weight: bold; } +.logo-img { width: 36mm; display: block; margin-bottom: 4mm; } +.emblem-img { width: 22mm; display: block; margin: 0 auto; } +.absender-block { font-size: 7.5pt; color: #1a4799; line-height: 1.7; } +.absender-name { font-weight: bold; font-size: 8pt; } + +/* Betreff und Referenzdaten */ +.betreff { font-size: 14pt; color: #1a4799; margin-bottom: 5mm; } +.ref-table { border-collapse: collapse; font-size: 8.5pt; margin-bottom: 7mm; } +.ref-table td { padding: 0.9mm 7mm 0.9mm 0; vertical-align: top; } +.ref-key { color: #666; white-space: nowrap; } + +/* Einleitender Satz */ +.intro-text { font-size: 9.5pt; line-height: 1.6; margin-bottom: 5mm; color: #1a1a1a; } + +/* Angaben zur Erstattung */ +.detail-table { width: 100%; border-collapse: collapse; font-size: 9pt; margin-bottom: 5mm; } +.detail-table td { padding: 2.2mm 3mm; border-bottom: 0.3pt solid #d8dde6; vertical-align: top; } +.detail-table tr:nth-child(even) td { background-color: #f7f8fb; } +.detail-table tr:last-child td { border-bottom: none; } +.detail-key { width: 42%; color: #555; } +.detail-val { font-weight: bold; } + +/* Die Versicherung des Teilis unter den Angaben */ +.declaration { font-size: 9.5pt; line-height: 1.6; margin-top: 7mm; } + +/* Gelber Randstreifen mit Knick -- position:fixed, damit er auf jeder Seite steht. */ +.edge { position: fixed; top: 0; left: 0; width: 16mm; height: 297mm; }', 20, 1, NOW(), NOW()), + ('participant_refund', 'header_sender_return', '
{sender_name}{if:sender_address_1} · {sender_address_1}{/if:sender_address_1} · {sender_postcode} {sender_city}
', 30, 1, NOW(), NOW()), + ('participant_refund', 'header_recipient', '
+
{recipient_name}
+
{recipient_address_1}
+ {if:recipient_address_2}
{recipient_address_2}
{/if:recipient_address_2} +
{recipient_postcode} {recipient_city}
+
', 40, 1, NOW(), NOW()), + ('participant_refund', 'emblem', '', 50, 1, NOW(), NOW()), + ('participant_refund', 'logo', '', 60, 1, NOW(), NOW()), + ('participant_refund', 'sender_data', '
+
{sender_name}
+ {if:sender_address_2}
{sender_address_2}
{/if:sender_address_2} + {if:sender_address_3}
{sender_address_3}
{/if:sender_address_3} + {if:sender_address_1}
{sender_address_1}
{/if:sender_address_1} +
{sender_postcode} {sender_city}
+ {if:sender_email}
{sender_email}
{/if:sender_email} + {if:sender_phone}
{sender_phone}
{/if:sender_phone} + {if:sender_tax_number}
Steuernummer: {sender_tax_number}
{/if:sender_tax_number} + {if:sender_vat_id}
USt-IdNr.: {sender_vat_id}
{/if:sender_vat_id} +
', 70, 1, NOW(), NOW()), + ('participant_refund', 'subject', '
Rückerstattung Nr. {document_number}
+ + + + + + + + + + + + + + + {if:unregistered_at} + + + {/if:unregistered_at} +
Belegdatum:{document_date}
Veranstaltung:{event_name}
Zeitraum:{service_period}
Abgemeldet am:{unregistered_at}
', 80, 1, NOW(), NOW()), + ('participant_refund', 'body', '
Ich konnte an der Veranstaltung nicht oder nur teilweise teilnehmen und bitte um die Rückerstattung wie folgt:
+ +{details_table} + +
{declaration_text}
', 90, 1, NOW(), NOW()), + ('participant_refund', 'footer', '', 100, 1, NOW(), NOW()); diff --git a/docker/Dockerfile b/docker/Dockerfile index 8ce4ac6..659801a 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -22,10 +22,14 @@ RUN apt-get install -y nginx \ libxml2-dev \ libmagickwand-dev \ imagemagick \ - && pecl install imagick \ - && docker-php-ext-enable imagick + && pecl install imagick pcov \ + && docker-php-ext-enable imagick pcov #&& rm -rf /var/lib/apt/lists/* \ +# PCOV misst die Testabdeckung. Dauerhaft aktiv kostet es jede Anfrage Laufzeit, deshalb ist es hier +# ausgeschaltet und wird nur beim Testlauf zugeschaltet (composer test:coverage). +RUN echo "pcov.enabled=0" > /usr/local/etc/php/conf.d/zz-pcov.ini + RUN mkdir -p /run/nginx RUN docker-php-ext-install mysqli pdo pdo_mysql mbstring zip exif pcntl gd diff --git a/resources/views/emails/events/refund_accepted.blade.php b/resources/views/emails/events/refund_accepted.blade.php new file mode 100644 index 0000000..6b2804b --- /dev/null +++ b/resources/views/emails/events/refund_accepted.blade.php @@ -0,0 +1,44 @@ + + + +

Hallo {{$name}}!

+

+ vielen Dank – deine Angaben zur Rückerstattung für die Veranstaltung "{{$eventTitle}}" liegen + uns vor. +

+ + + + + + + + + + + + + + + + + + +
Betrag:{{$amount}}
Grund:{{$reason}}
Kontoinhaber*in:{{$accountOwner}}
IBAN:{{$accountIban}}
+ +@if ($hasDocument) +

+ Im Anhang findest du den Beleg über die Rückerstattung als PDF. +

+@endif + +

+ Die Überweisung veranlasst die Aktionsleitung. Stimmt etwas an den obenstehenden Angaben nicht, melde + dich bitte umgehend bei ihr. +

+ +

+ @include('emails.subparts.disclaimer') +

+ + diff --git a/resources/views/emails/events/refund_released.blade.php b/resources/views/emails/events/refund_released.blade.php new file mode 100644 index 0000000..7abcf04 --- /dev/null +++ b/resources/views/emails/events/refund_released.blade.php @@ -0,0 +1,51 @@ + + + +

Hallo {{$name}}!

+

+ du hast dich von der Veranstaltung "{{$eventTitle}}" abgemeldet. Die Aktionsleitung hat dir daraufhin + eine Rückerstattung deines Teilnahmebeitrags freigegeben. +

+ + + + + + + + + + + @if (!empty($reasonNote)) + + + + + @endif +
Betrag:{{$amount}}
Grund:{{$reason}}
Anmerkung:{{$reasonNote}}
+ +

+ Damit wir überweisen können, brauchen wir noch deine Bankverbindung. Bitte trage sie über den + folgenden Link ein: +

+ +

+ Bankverbindung eintragen +

+ +

+ Falls der Knopf nicht funktioniert, kopiere bitte diese Adresse in deinen Browser:
+ {{$link}} +

+ +

+ Solange uns deine Bankverbindung nicht vorliegt, können wir den Betrag nicht auszahlen. Der Betrag + selbst steht fest und lässt sich über den Link nicht ändern – wenn du dazu Fragen hast, wende + dich bitte an die Aktionsleitung. +

+ +

+ @include('emails.subparts.disclaimer') +

+ + diff --git a/routes/web.php b/routes/web.php index 51c968a..7650cfc 100644 --- a/routes/web.php +++ b/routes/web.php @@ -26,6 +26,8 @@ require __DIR__ . '/../app/Domains/Invoice/Routes/api.php'; require __DIR__ . '/../app/Domains/Event/Routes/web.php'; require __DIR__ . '/../app/Domains/Event/Routes/api.php'; require __DIR__ . '/../app/Domains/ParticipantInvoice/Routes/api.php'; +require __DIR__ . '/../app/Domains/ParticipantRefund/Routes/web.php'; +require __DIR__ . '/../app/Domains/ParticipantRefund/Routes/api.php'; require __DIR__ . '/../app/Domains/Budget/Routes/web.php'; require __DIR__ . '/../app/Domains/Budget/Routes/api.php'; require __DIR__ . '/../app/Domains/Legal/Routes/web.php'; @@ -54,6 +56,7 @@ Route::middleware(IdentifyTenant::class)->group(function () { Route::get('/retrieve-invoice-types', [GlobalDataProvider::class, 'getInvoiceTypes']); Route::get('/retrieve-invoice-types-all', [GlobalDataProvider::class, 'getAllInvoiceTypes']); Route::get('/retrieve-event-setting-data', [GlobalDataProvider::class, 'getEventSettingData']); + Route::get('/retrieve-refund-reasons', [GlobalDataProvider::class, 'getRefundReasons']); }); }); diff --git a/storage/temp/Eigenbeleg.pdf b/storage/temp/Eigenbeleg.pdf new file mode 100644 index 0000000..aeb7e01 Binary files /dev/null and b/storage/temp/Eigenbeleg.pdf differ diff --git a/storage/temp/Erstattungsbeleg-Vorschau.pdf b/storage/temp/Erstattungsbeleg-Vorschau.pdf new file mode 100644 index 0000000..3472bda Binary files /dev/null and b/storage/temp/Erstattungsbeleg-Vorschau.pdf differ diff --git a/tests/Feature/DocumentTemplateAdminTest.php b/tests/Feature/DocumentTemplateAdminTest.php index 99fc1e4..ac3b485 100644 --- a/tests/Feature/DocumentTemplateAdminTest.php +++ b/tests/Feature/DocumentTemplateAdminTest.php @@ -212,4 +212,110 @@ class DocumentTemplateAdminTest extends TestCase $this->assertNull(DocumentAsset::where('name', 'altbestand')->first()); } + + /* + |-------------------------------------------------------------------------- + | Mehrere Dokumentarten + |-------------------------------------------------------------------------- + */ + + public function test_the_default_document_type_is_the_invoice(): void + { + $this->actingAs($this->makeUser(UserRole::USER_ROLE_ADMIN)); + + $this->getJson('/api/v1/admin/document-templates') + ->assertOk() + ->assertJsonPath('documentType', DocumentTemplate::TYPE_PARTICIPANT_INVOICE) + ->assertJsonPath('documentTypes.0.value', DocumentTemplate::TYPE_PARTICIPANT_INVOICE) + ->assertJsonPath('documentTypes.1.value', DocumentTemplate::TYPE_PARTICIPANT_REFUND); + } + + public function test_the_refund_type_returns_its_own_blocks_and_tokens(): void + { + $this->actingAs($this->makeUser(UserRole::USER_ROLE_ADMIN)); + + DocumentTemplate::create([ + 'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND, + 'block' => 'subject', + 'content' => 'Rückerstattung {document_number}', + 'sort_order' => 10, + ]); + + $response = $this->getJson('/api/v1/admin/document-templates?type=participant_refund'); + + $response->assertOk(); + $response->assertJsonPath('documentType', DocumentTemplate::TYPE_PARTICIPANT_REFUND); + $response->assertJsonCount(1, 'blocks'); + $response->assertJsonPath('blocks.0.block', 'subject'); + // Die Platzhalter sind die des Belegs, nicht die der Rechnung. + $response->assertJsonPath('tokenGroups.refund.tokens.refund_amount.description', 'Erstattungsbetrag'); + $response->assertJsonMissingPath('tokenGroups.body.tokens.positions_table'); + } + + public function test_saving_writes_only_into_the_selected_type(): void + { + $this->actingAs($this->makeUser(UserRole::USER_ROLE_ADMIN)); + + DocumentTemplate::create([ + 'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND, + 'block' => 'footer', + 'content' => 'Beleg-Fuß', + 'sort_order' => 20, + ]); + + $this->postJson('/api/v1/admin/document-templates', [ + 'type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND, + 'blocks' => ['footer' => 'neuer Beleg-Fuß'], + ])->assertOk()->assertJsonPath('status', 'success'); + + $this->assertSame('neuer Beleg-Fuß', DocumentTemplate::where('document_type', DocumentTemplate::TYPE_PARTICIPANT_REFUND) + ->where('block', 'footer')->first()->content); + + // Der gleichnamige Block der Rechnung bleibt unberührt. + $this->assertSame('alt', DocumentTemplate::where('document_type', DocumentTemplate::TYPE_PARTICIPANT_INVOICE) + ->where('block', 'footer')->first()->content); + } + + public function test_an_unknown_document_type_is_refused(): void + { + $this->actingAs($this->makeUser(UserRole::USER_ROLE_ADMIN)); + + $this->getJson('/api/v1/admin/document-templates?type=erfunden')->assertStatus(422); + + $this->postJson('/api/v1/admin/document-templates', [ + 'type' => 'erfunden', + 'blocks' => ['footer' => 'egal'], + ])->assertStatus(422); + + $this->post('/api/v1/admin/document-templates/preview', [ + 'type' => 'erfunden', + 'blocks' => [], + ])->assertStatus(422); + + // Die Rechnungsvorlage darf davon nichts abbekommen haben. + $this->assertSame('alt', DocumentTemplate::where('document_type', DocumentTemplate::TYPE_PARTICIPANT_INVOICE) + ->where('block', 'footer')->first()->content); + } + + public function test_preview_renders_the_refund_with_its_sample_data(): void + { + $this->actingAs($this->makeUser(UserRole::USER_ROLE_ADMIN)); + + DocumentTemplate::create([ + 'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND, + 'block' => DocumentTemplate::BLOCK_LAYOUT, + 'content' => '
{document_number}
', + 'sort_order' => 10, + ]); + + $response = $this->post('/api/v1/admin/document-templates/preview', [ + 'type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND, + 'blocks' => [], + ]); + + $response->assertOk(); + $response->assertHeader('Content-Type', 'application/pdf'); + $this->assertStringStartsWith('%PDF', $response->getContent()); + } } + diff --git a/tests/Feature/ParticipantRefundTest.php b/tests/Feature/ParticipantRefundTest.php new file mode 100644 index 0000000..819dabe --- /dev/null +++ b/tests/Feature/ParticipantRefundTest.php @@ -0,0 +1,797 @@ +tenant = Tenant::create([ + 'slug' => 'wm', + 'name' => 'Wilde Möhre', + 'address_1' => 'Musterweg 1', + 'email' => 't@example.com', + 'email_finance' => 'finance@example.com', + // Muss dem Host der Testanfragen entsprechen, sonst weist IdentifyTenant sie mit 404 ab. + 'url' => parse_url(config('app.url'), PHP_URL_HOST), + 'account_name' => 'Test e.V.', + 'account_iban' => 'DE00', + 'account_bic' => 'XY', + 'city' => 'Stadt', + 'postcode' => '00000', + 'invoice_prefix' => 'WM', + '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']); + PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION]); + PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_NOT_DEFINED]); + EfzStatus::create(['slug' => EfzStatus::EFZ_STATUS_NOT_REQUIRED, 'name' => 'Nicht erforderlich']); + + foreach ([UserRole::USER_ROLE_ADMIN, UserRole::USER_ROLE_GROUP_LEADER, UserRole::USER_ROLE_USER] as $role) { + UserRole::create(["slug" => $role, "name" => $role]); + } + + $this->seedTemplate(); + + Mail::fake(); + } + + /** Minimale Vorlage: alles, was der Beleg braucht, in wenigen Blöcken. */ + private function seedTemplate(): void + { + DocumentTemplate::create([ + 'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND, + 'block' => DocumentTemplate::BLOCK_LAYOUT, + 'content' => '
{block:subject}{block:body}
', + 'sort_order' => 10, + ]); + + DocumentTemplate::create([ + 'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND, + 'block' => DocumentTemplate::BLOCK_STYLE, + 'content' => 'body { font-size: 10pt; }', + 'sort_order' => 20, + ]); + + DocumentTemplate::create([ + 'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND, + 'block' => 'subject', + 'content' => '

Rückerstattung {document_number}

{document_date} / {event_name}

', + 'sort_order' => 30, + ]); + + DocumentTemplate::create([ + 'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND, + 'block' => DocumentTemplate::BLOCK_BODY, + 'content' => '
{recipient_name}
{details_table}', + 'sort_order' => 40, + ]); + } + + private function makeEvent(array $attributes = []): Event + { + $fee = EventParticipationFee::create([ + 'tenant' => $this->tenant->slug, + 'type' => 'participant', + 'name' => 'Sippe', + 'description' => null, + 'amount_standard' => 60.0, + 'amount_reduced' => null, + 'amount_solidarity' => null, + ]); + + return Event::create(array_merge([ + 'tenant' => $this->tenant->slug, + 'name' => 'Sommerlager', + 'identifier' => 'evt-' . uniqid(), + 'location' => 'Ort', + 'postal_code' => '00000', + 'email' => 'e@example.com', + 'start_date' => '2026-07-16', + 'end_date' => '2026-07-20', + 'early_bird_end' => '2026-06-20', + 'registration_final_end' => '2026-07-01', + 'early_bird_end_amount_increase' => 0, + 'account_owner' => 'Owner', + 'account_iban' => 'DE00', + 'participation_fee_type' => 'fixed', + 'participation_fee_1' => $fee->id, + 'pay_per_day' => true, + 'pay_direct' => false, + 'tax_liable' => false, + 'vat_rate' => 0, + 'vat_pricing_mode' => 'inclusive', + 'invoice_key' => 'WM-V-20260701', + ], $attributes)); + } + + /** Standardfall dieser Tests: abgemeldet und vollständig bezahlt. */ + private function makeParticipant(Event $event, array $attributes = []): EventParticipant + { + $this->sequence++; + + return $event->participants()->create(array_merge([ + 'tenant' => $this->tenant->slug, + 'identifier' => 'p-' . uniqid(), + 'invoice_sequence' => $this->sequence, + 'firstname' => 'Mika', + 'lastname' => 'Muster', + 'participation_type' => 'participant', + 'fee_type' => 'standard', + 'sibling_reduction' => false, + 'local_group' => $this->tenant->slug, + 'birthday' => '2000-01-01', + 'address_1' => 'Beispielstraße 3', + 'postcode' => '11111', + 'city' => 'Beispielstadt', + 'email_1' => 'mika@example.com', + 'phone_1' => '0170 0000000', + 'arrival_date' => '2026-07-16', + 'departure_date' => '2026-07-20', + 'arrival_eating' => 1, + 'departure_eating' => 1, + 'amount' => 300.0, + 'amount_paid' => 300.0, + 'unregistered_at' => '2026-06-12', + 'payment_purpose' => 'Sommerlager', + 'payment_method' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION, + 'efz_status' => EfzStatus::EFZ_STATUS_NOT_REQUIRED, + ], $attributes)); + } + + private function release( + EventParticipant $participant, + float $amount = 300.0, + string $reason = RefundReason::SICKNESS, + ?string $note = null, + ) { + return new ReleaseRefundCommand(new ReleaseRefundRequest( + participant: $participant, + amount: new Amount($amount, 'Euro'), + reason: $reason, + reasonNote: $note, + ))->execute(); + } + + private function accept( + ?ParticipantRefund $refund, + string $owner = 'Mika Muster', + string $iban = 'DE02120300000000202051', + bool $declarationAccepted = true, + ) { + return new AcceptRefundCommand(new AcceptRefundRequest( + refund: $refund, + accountOwner: $owner, + accountIban: $iban, + declarationAccepted: $declarationAccepted, + ))->execute(); + } + + /* + |-------------------------------------------------------------------------- + | Freigabe + |-------------------------------------------------------------------------- + */ + + public function test_release_creates_a_pending_refund_with_a_token(): void + { + $participant = $this->makeParticipant($this->makeEvent()); + + $response = $this->release($participant); + + $this->assertTrue($response->success); + $this->assertSame(ParticipantRefund::STATUS_PENDING, $response->refund->status); + $this->assertSame(32, strlen($response->refund->token)); + $this->assertEqualsWithDelta(300.0, $response->refund->amount->getAmount(), 0.001); + $this->assertNotNull($response->refund->released_at); + } + + public function test_release_notifies_participant_and_contact_person(): void + { + $participant = $this->makeParticipant($this->makeEvent(), ['email_2' => 'eltern@example.com']); + + $this->release($participant); + + Mail::assertSent(RefundReleasedMail::class, 2); + Mail::assertSent(RefundReleasedMail::class, fn ($mail) => $mail->hasTo('mika@example.com')); + Mail::assertSent(RefundReleasedMail::class, fn ($mail) => $mail->hasTo('eltern@example.com')); + } + + public function test_release_sends_only_one_mail_without_contact_person(): void + { + $this->release($this->makeParticipant($this->makeEvent())); + + Mail::assertSent(RefundReleasedMail::class, 1); + } + + public function test_release_is_rejected_for_a_registered_participant(): void + { + $participant = $this->makeParticipant($this->makeEvent(), ['unregistered_at' => null]); + + $response = $this->release($participant); + + $this->assertFalse($response->success); + $this->assertStringContainsString('abgemeldete', $response->message); + $this->assertSame(0, ParticipantRefund::count()); + } + + public function test_release_is_rejected_above_the_paid_amount(): void + { + $participant = $this->makeParticipant($this->makeEvent(), ['amount_paid' => 100.0]); + + $response = $this->release($participant, 150.0); + + $this->assertFalse($response->success); + $this->assertStringContainsString('nicht übersteigen', $response->message); + } + + public function test_release_accepts_a_partial_amount(): void + { + $participant = $this->makeParticipant($this->makeEvent()); + + $response = $this->release($participant, 220.0); + + $this->assertTrue($response->success); + $this->assertEqualsWithDelta(220.0, $response->refund->amount->getAmount(), 0.001); + } + + public function test_release_is_rejected_without_an_amount(): void + { + $response = $this->release($this->makeParticipant($this->makeEvent()), 0.0); + + $this->assertFalse($response->success); + $this->assertStringContainsString('größer als 0', $response->message); + } + + public function test_release_is_rejected_for_an_unknown_reason(): void + { + $response = $this->release($this->makeParticipant($this->makeEvent()), 300.0, 'erfunden'); + + $this->assertFalse($response->success); + $this->assertStringContainsString('Erstattungsgrund', $response->message); + } + + public function test_free_text_reason_requires_a_note(): void + { + $participant = $this->makeParticipant($this->makeEvent()); + + $response = $this->release($participant, 300.0, RefundReason::OTHER, ' '); + + $this->assertFalse($response->success); + $this->assertStringContainsString('Erläuterung', $response->message); + } + + public function test_note_is_only_stored_for_reasons_that_require_it(): void + { + $event = $this->makeEvent(); + + $withNote = $this->release($this->makeParticipant($event), 300.0, RefundReason::OTHER, 'Umzug'); + $this->assertSame('Umzug', $withNote->refund->reason_note); + + $ignored = $this->release($this->makeParticipant($event), 300.0, RefundReason::SICKNESS, 'wird verworfen'); + $this->assertNull($ignored->refund->reason_note); + } + + public function test_a_second_release_is_rejected_while_one_is_open(): void + { + $participant = $this->makeParticipant($this->makeEvent()); + $this->release($participant); + + $response = $this->release($participant); + + $this->assertFalse($response->success); + $this->assertStringContainsString('läuft bereits', $response->message); + $this->assertSame(1, ParticipantRefund::count()); + } + + public function test_a_new_release_is_possible_after_a_cancellation(): void + { + $participant = $this->makeParticipant($this->makeEvent()); + $first = $this->release($participant); + + new CancelRefundCommand(new CancelRefundRequest($first->refund))->execute(); + + $this->assertTrue($this->release($participant)->success); + $this->assertSame(2, ParticipantRefund::count()); + } + + /* + |-------------------------------------------------------------------------- + | Bestätigung durch den Teili + |-------------------------------------------------------------------------- + */ + + public function test_accept_stores_the_normalized_bank_details(): void + { + $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; + + $response = $this->accept($refund, 'Mika Muster', 'de02 1203 0000 0000 2020 51'); + + $this->assertTrue($response->success); + + $refund->refresh(); + $this->assertSame(ParticipantRefund::STATUS_ACCEPTED, $refund->status); + $this->assertSame('Mika Muster', $refund->account_owner); + $this->assertSame('DE02120300000000202051', $refund->account_iban); + $this->assertNotNull($refund->accepted_at); + } + + public function test_accept_sends_the_confirmation_with_the_document_attached(): void + { + $participant = $this->makeParticipant($this->makeEvent(), ['email_2' => 'eltern@example.com']); + $refund = $this->release($participant)->refund; + + $this->accept($refund); + + Mail::assertSent(RefundAcceptedMail::class, 2); + $expected = sprintf( + 'Rueckerstattung-WM-V-20260701-%s-R.pdf', + str_pad((string) $participant->invoice_sequence, 4, '0', STR_PAD_LEFT) + ); + + Mail::assertSent(RefundAcceptedMail::class, function (RefundAcceptedMail $mail) use ($expected): bool { + $attachments = $mail->attachments(); + + return count($attachments) === 1 && $attachments[0]->as === $expected; + }); + } + + public function test_accept_rejects_an_invalid_iban(): void + { + $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; + + // Gültige Struktur und Länge, aber falsche Prüfziffer -- ein klassischer Zahlendreher. + $response = $this->accept($refund, 'Mika Muster', 'DE02120300000000202015'); + + $this->assertFalse($response->success); + $this->assertArrayHasKey('accountIban', $response->errorTypes); + $this->assertSame(ParticipantRefund::STATUS_PENDING, $refund->fresh()->status); + } + + /* + |-------------------------------------------------------------------------- + | Die Erklärung + |-------------------------------------------------------------------------- + */ + + public function test_accept_is_rejected_without_the_declaration(): void + { + $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; + + $response = $this->accept($refund, declarationAccepted: false); + + $this->assertFalse($response->success); + $this->assertArrayHasKey('declaration', $response->errorTypes); + $this->assertSame(ParticipantRefund::STATUS_PENDING, $refund->fresh()->status); + $this->assertNull($refund->fresh()->account_iban); + } + + public function test_the_declaration_cannot_be_skipped_over_http(): void + { + $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; + + // Ohne die Prüfung im Command ließe sich die Erklärung mit einem direkten Aufruf übergehen -- + // und der Beleg schriebe dem Teili eine Zusicherung zu, die er nie abgegeben hat. + $this->postJson('/api/v1/participant-refund/' . $refund->token . '/accept', [ + 'accountOwner' => 'Mika Muster', + 'accountIban' => 'DE02 1203 0000 0000 2020 51', + ]) + ->assertOk() + ->assertJsonPath('status', 'error') + ->assertJsonStructure(['error_types' => ['declaration']]); + + $this->assertSame(ParticipantRefund::STATUS_PENDING, $refund->fresh()->status); + } + + public function test_the_public_page_serves_the_declaration_text_name(): void + { + $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; + + $this->get('/rueckerstattung/' . $refund->token) + ->assertOk() + ->assertInertia(fn ($page) => $page->where('state', 'open')); + + // Den Wortlaut holt die Seite über dieselbe Quelle, aus der ihn auch der Beleg nimmt. + $this->get('/api/v1/core/retrieve-text-resource/' . CreateRefundDocumentCommand::DECLARATION_TEXT) + ->assertOk() + ->assertSee('Ich versichere', false); + } + + public function test_accept_requires_an_account_owner(): void + { + $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; + + $response = $this->accept($refund, ' '); + + $this->assertFalse($response->success); + $this->assertArrayHasKey('accountOwner', $response->errorTypes); + } + + public function test_accept_on_an_unknown_token_reports_no_open_refund(): void + { + $response = $this->accept(null); + + $this->assertFalse($response->success); + $this->assertSame(AcceptRefundCommand::NO_OPEN_REFUND, $response->message); + } + + public function test_accept_on_a_cancelled_refund_reports_no_open_refund(): void + { + $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; + new CancelRefundCommand(new CancelRefundRequest($refund))->execute(); + + $response = $this->accept($refund->fresh()); + + $this->assertFalse($response->success); + $this->assertSame(AcceptRefundCommand::NO_OPEN_REFUND, $response->message); + } + + public function test_accept_twice_does_not_change_the_stored_details(): void + { + $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; + $this->accept($refund, 'Mika Muster', 'DE02120300000000202051'); + + $response = $this->accept($refund->fresh(), 'Wer Anders', 'DE02500105170137075030'); + + $this->assertFalse($response->success); + $this->assertSame('Mika Muster', $refund->fresh()->account_owner); + } + + /* + |-------------------------------------------------------------------------- + | Abbruch + |-------------------------------------------------------------------------- + */ + + public function test_cancel_marks_the_refund_and_hides_it_from_the_participant(): void + { + $participant = $this->makeParticipant($this->makeEvent()); + $refund = $this->release($participant)->refund; + + $response = new CancelRefundCommand(new CancelRefundRequest($refund))->execute(); + + $this->assertTrue($response->success); + $this->assertSame(ParticipantRefund::STATUS_CANCELLED, $refund->fresh()->status); + $this->assertNotNull($refund->fresh()->cancelled_at); + + // Für die Anmeldung sieht es danach aus wie vor der Freigabe. + $repository = new ParticipantRefundRepository(); + $this->assertNull($repository->openFor($participant)); + $this->assertNull($repository->currentFor($participant)); + } + + public function test_cancel_sends_no_mail(): void + { + $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; + Mail::fake(); + + new CancelRefundCommand(new CancelRefundRequest($refund))->execute(); + + Mail::assertNothingSent(); + } + + public function test_cancel_is_rejected_after_acceptance(): void + { + $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; + $this->accept($refund); + + $response = new CancelRefundCommand(new CancelRefundRequest($refund->fresh()))->execute(); + + $this->assertFalse($response->success); + $this->assertStringContainsString('bereits bestätigt', $response->message); + $this->assertSame(ParticipantRefund::STATUS_ACCEPTED, $refund->fresh()->status); + } + + /* + |-------------------------------------------------------------------------- + | Der gezahlte Beitrag bleibt unangetastet + |-------------------------------------------------------------------------- + */ + + public function test_amount_paid_is_untouched_through_the_whole_process(): void + { + $participant = $this->makeParticipant($this->makeEvent()); + + $refund = $this->release($participant)->refund; + $this->assertEqualsWithDelta(300.0, $participant->fresh()->amount_paid->getAmount(), 0.001); + + $this->accept($refund); + $this->assertEqualsWithDelta(300.0, $participant->fresh()->amount_paid->getAmount(), 0.001); + $this->assertEqualsWithDelta(300.0, $participant->fresh()->amount->getAmount(), 0.001); + } + + /* + |-------------------------------------------------------------------------- + | Über HTTP: Routen, Zugriffsschutz und die öffentliche Seite + |-------------------------------------------------------------------------- + */ + + private function makeAdmin(): User + { + return User::create([ + 'username' => 'al-' . uniqid() . '@example.com', + 'email' => 'al-' . uniqid() . '@example.com', + 'firstname' => 'Aktions', + 'lastname' => 'Leitung', + 'password' => bcrypt('secret'), + 'local_group' => $this->tenant->slug, + 'user_role_main' => UserRole::USER_ROLE_ADMIN, + 'user_role_local_group' => UserRole::USER_ROLE_USER, + 'active' => true, + ]); + } + + public function test_release_over_http_creates_the_refund(): void + { + $this->actingAs($this->makeAdmin()); + $participant = $this->makeParticipant($this->makeEvent()); + + $response = $this->postJson('/api/v1/participant-refund/' . $participant->identifier . '/release', [ + 'amount' => '220,50', + 'reason' => RefundReason::SICKNESS, + ]); + + $response->assertOk()->assertJsonPath('status', 'success'); + $response->assertJsonPath('refund.status', ParticipantRefund::STATUS_PENDING); + + $refund = ParticipantRefund::first(); + $this->assertEqualsWithDelta(220.5, $refund->amount->getAmount(), 0.001); + + // Die Antwort trägt den Vorgang für die Liste -- aber nicht den Token-Link als Bankdaten. + $response->assertJsonPath('refund.amountValue', 220.5); + } + + public function test_release_requires_a_login(): void + { + $participant = $this->makeParticipant($this->makeEvent()); + + $this->post('/api/v1/participant-refund/' . $participant->identifier . '/release', [ + 'amount' => '300', + 'reason' => RefundReason::SICKNESS, + ])->assertRedirect('/login'); + + $this->assertSame(0, ParticipantRefund::count()); + } + + public function test_the_public_page_shows_the_open_refund(): void + { + $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; + + $response = $this->get('/rueckerstattung/' . $refund->token); + + $response->assertOk(); + $response->assertInertia(fn ($page) => $page + // shouldExist=false: die Pages liegen unter app/Domains/**/Views, nicht dort, wo der + // Finder von Inertia sucht. + ->component('ParticipantRefund/Views/RefundPage', false) + ->where('state', 'open') + ->where('amount', '300,00 Euro') + ->where('reason', 'Krankheitsbedingte Absage')); + } + + public function test_the_public_page_never_exposes_bank_details(): void + { + $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; + $this->accept($refund); + + $response = $this->get('/rueckerstattung/' . $refund->fresh()->token); + + $response->assertOk(); + $response->assertInertia(fn ($page) => $page->where('state', 'accepted')); + $response->assertDontSee('DE02120300000000202051'); + } + + public function test_the_public_page_hides_a_cancelled_refund(): void + { + $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; + new CancelRefundCommand(new CancelRefundRequest($refund))->execute(); + + $this->get('/rueckerstattung/' . $refund->token) + ->assertOk() + ->assertInertia(fn ($page) => $page->where('state', 'unavailable')); + } + + public function test_an_unknown_token_shows_the_same_page_as_a_cancelled_one(): void + { + $this->get('/rueckerstattung/' . str_repeat('x', 32)) + ->assertOk() + ->assertInertia(fn ($page) => $page + ->where('state', 'unavailable') + ->where('message', AcceptRefundCommand::NO_OPEN_REFUND)); + } + + public function test_accept_over_http_works_without_a_login(): void + { + $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; + + $this->postJson('/api/v1/participant-refund/' . $refund->token . '/accept', [ + 'accountOwner' => 'Mika Muster', + 'accountIban' => 'DE02 1203 0000 0000 2020 51', + 'declarationAccepted' => true, + ])->assertOk()->assertJsonPath('status', 'success'); + + $this->assertSame('DE02120300000000202051', $refund->fresh()->account_iban); + } + + public function test_field_errors_come_back_with_status_200(): void + { + $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; + + // Der HttpClient des Frontends verwirft Antworten mit Fehlerstatus -- Feldfehler müssen daher + // mit 200 zurückkommen, sonst sieht der Teili nie, was er falsch gemacht hat. + $this->postJson('/api/v1/participant-refund/' . $refund->token . '/accept', [ + 'accountOwner' => 'Mika Muster', + 'accountIban' => 'DE02120300000000202015', + 'declarationAccepted' => true, + ]) + ->assertOk() + ->assertJsonPath('status', 'error') + ->assertJsonStructure(['error_types' => ['accountIban']]); + } + + public function test_the_document_is_not_public(): void + { + $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; + $this->accept($refund); + + $this->get('/api/v1/participant-refund/' . $refund->fresh()->token . '/document') + ->assertRedirect('/login'); + } + + public function test_the_document_can_be_downloaded_by_the_event_management(): void + { + $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; + $this->accept($refund); + + $this->actingAs($this->makeAdmin()); + + $response = $this->get('/api/v1/participant-refund/' . $refund->fresh()->token . '/document'); + + $response->assertOk(); + $response->assertHeader('Content-Type', 'application/pdf'); + $this->assertStringStartsWith('%PDF', $response->getContent()); + } + + public function test_cancel_requires_a_login(): void + { + $refund = $this->release($this->makeParticipant($this->makeEvent()))->refund; + + $this->post('/api/v1/participant-refund/' . $refund->token . '/cancel') + ->assertRedirect('/login'); + + $this->assertSame(ParticipantRefund::STATUS_PENDING, $refund->fresh()->status); + } + + /* + |-------------------------------------------------------------------------- + | Die Mails müssen sich auch wirklich rendern lassen + |-------------------------------------------------------------------------- + */ + + public function test_the_release_mail_renders_with_the_link(): void + { + $participant = $this->makeParticipant($this->makeEvent()); + $refund = $this->release($participant, 220.0, RefundReason::OTHER, 'Umzug')->refund; + + $html = new RefundReleasedMail($participant, $refund)->render(); + + $this->assertStringContainsString('220,00 Euro', $html); + $this->assertStringContainsString('Sonstiger Grund', $html); + $this->assertStringContainsString('Umzug', $html); + $this->assertStringContainsString('/rueckerstattung/' . $refund->token, $html); + // Der Baustein aus emails.subparts.disclaimer braucht eventTitle und eventEmail. + $this->assertStringContainsString('Sommerlager', $html); + $this->assertStringContainsString('e@example.com', $html); + } + + public function test_the_release_mail_omits_the_note_row_when_there_is_none(): void + { + $participant = $this->makeParticipant($this->makeEvent()); + $refund = $this->release($participant)->refund; + + $html = new RefundReleasedMail($participant, $refund)->render(); + + $this->assertStringNotContainsString('Anmerkung:', $html); + } + + public function test_the_acceptance_mail_renders_with_the_bank_details(): void + { + $participant = $this->makeParticipant($this->makeEvent()); + $refund = $this->release($participant)->refund; + $this->accept($refund); + + $html = new RefundAcceptedMail($participant, $refund->fresh(), 'PDF-Inhalt', 'beleg.pdf')->render(); + + $this->assertStringContainsString('300,00 Euro', $html); + $this->assertStringContainsString('Mika Muster', $html); + // Im Anschreiben steht die IBAN in Vierergruppen. + $this->assertStringContainsString('DE02 1203 0000 0000 2020 51', $html); + $this->assertStringContainsString('Anhang', $html); + } + + public function test_the_acceptance_mail_renders_without_a_document(): void + { + $participant = $this->makeParticipant($this->makeEvent()); + $refund = $this->release($participant)->refund; + $this->accept($refund); + + $mail = new RefundAcceptedMail($participant, $refund->fresh()); + + $this->assertSame([], $mail->attachments()); + $this->assertStringNotContainsString('Anhang', $mail->render()); + } + + /* + |-------------------------------------------------------------------------- + | Gründe als Auswahl fürs Frontend + |-------------------------------------------------------------------------- + */ + + public function test_reason_options_are_ordered_and_flag_the_free_text(): void + { + $options = RefundReason::options(); + + $this->assertSame( + ['sickness', 'event_cancelled', 'other'], + array_column($options, 'value') + ); + $this->assertSame('Krankheitsbedingte Absage', $options[0]['label']); + $this->assertFalse($options[0]['requiresNote']); + $this->assertTrue($options[2]['requiresNote']); + } + + public function test_reason_options_are_served_over_http(): void + { + $this->getJson('/api/v1/core/retrieve-refund-reasons') + ->assertOk() + ->assertJsonCount(3) + ->assertJsonPath('0.value', RefundReason::SICKNESS) + ->assertJsonPath('2.requiresNote', true); + } +} + + diff --git a/tests/Feature/RefundDocumentTest.php b/tests/Feature/RefundDocumentTest.php new file mode 100644 index 0000000..f399337 --- /dev/null +++ b/tests/Feature/RefundDocumentTest.php @@ -0,0 +1,434 @@ +tenant = Tenant::create([ + 'slug' => 'wm', + 'name' => 'Wilde Möhre', + 'invoice_sender_name' => 'Wilde Möhre e.V.', + 'address_1' => 'Musterweg 1', + 'email' => 't@example.com', + 'email_finance' => 'finance@example.com', + 'url' => parse_url(config('app.url'), PHP_URL_HOST), + 'account_name' => 'Test e.V.', + 'account_iban' => 'DE00', + 'account_bic' => 'XY', + 'city' => 'Lommatzsch', + 'postcode' => '01623', + 'invoice_prefix' => 'WM', + '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']); + PaymentMethod::create(['slug' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION]); + EfzStatus::create(['slug' => EfzStatus::EFZ_STATUS_NOT_REQUIRED, 'name' => 'Nicht erforderlich']); + + $this->seedTemplate(); + } + + /** Die Vorlage setzt hier jeden Platzhalter ein, damit die Tests am gerenderten HTML prüfen können. */ + private function seedTemplate(): void + { + DocumentTemplate::create([ + 'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND, + 'block' => DocumentTemplate::BLOCK_LAYOUT, + 'content' => '
{block:header_recipient}{block:subject}{block:body}
', + 'sort_order' => 10, + ]); + + DocumentTemplate::create([ + 'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND, + 'block' => 'header_recipient', + 'content' => '

{recipient_name} / {recipient_address_1} / {recipient_postcode} {recipient_city}

', + 'sort_order' => 35, + ]); + + DocumentTemplate::create([ + 'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND, + 'block' => DocumentTemplate::BLOCK_STYLE, + 'content' => 'body { font-size: 10pt; }', + 'sort_order' => 20, + ]); + + DocumentTemplate::create([ + 'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND, + 'block' => 'subject', + 'content' => '

Rückerstattung {document_number}

' + . '

{document_date} / {event_name} / {service_period} / {unregistered_at}

' + . '

{sender_name} / {recipient_name}

', + 'sort_order' => 30, + ]); + + DocumentTemplate::create([ + 'document_type' => DocumentTemplate::TYPE_PARTICIPANT_REFUND, + 'block' => DocumentTemplate::BLOCK_BODY, + 'content' => '{details_table}

{refund_amount} / {refund_reason} / {refund_reason_text}

' + . '

{account_owner} / {account_iban}

' + . '

{paid_amount} / {invoice_number}

' + . '

{declaration_text}

', + 'sort_order' => 40, + ]); + } + + private function makeEvent(array $attributes = []): Event + { + $fee = EventParticipationFee::create([ + 'tenant' => $this->tenant->slug, + 'type' => 'participant', + 'name' => 'Sippe', + 'description' => null, + 'amount_standard' => 60.0, + 'amount_reduced' => null, + 'amount_solidarity' => null, + ]); + + return Event::create(array_merge([ + 'tenant' => $this->tenant->slug, + 'name' => 'Sommerlager', + 'identifier' => 'evt-' . uniqid(), + 'location' => 'Ort', + 'postal_code' => '00000', + 'email' => 'e@example.com', + 'start_date' => '2026-07-16', + 'end_date' => '2026-07-20', + 'early_bird_end' => '2026-06-20', + 'registration_final_end' => '2026-07-01', + 'early_bird_end_amount_increase' => 0, + 'account_owner' => 'Owner', + 'account_iban' => 'DE00', + 'participation_fee_type' => 'fixed', + 'participation_fee_1' => $fee->id, + 'pay_per_day' => true, + 'pay_direct' => false, + 'tax_liable' => false, + 'vat_rate' => 0, + 'vat_pricing_mode' => 'inclusive', + 'invoice_key' => 'WM-V-20260701', + ], $attributes)); + } + + private function makeParticipant(Event $event, array $attributes = []): EventParticipant + { + return $event->participants()->create(array_merge([ + 'tenant' => $this->tenant->slug, + 'identifier' => 'p-' . uniqid(), + 'invoice_sequence' => 5, + 'firstname' => 'Mika', + 'lastname' => 'Muster', + 'participation_type' => 'participant', + 'fee_type' => 'standard', + 'sibling_reduction' => false, + 'local_group' => $this->tenant->slug, + 'birthday' => '2000-01-01', + 'address_1' => 'Beispielstraße 3', + 'postcode' => '11111', + 'city' => 'Beispielstadt', + 'email_1' => 'mika@example.com', + 'phone_1' => '0170 0000000', + 'arrival_date' => '2026-07-16', + 'departure_date' => '2026-07-20', + 'arrival_eating' => 1, + 'departure_eating' => 1, + 'amount' => 300.0, + 'amount_paid' => 300.0, + 'unregistered_at' => '2026-06-12', + 'payment_purpose' => 'Sommerlager', + 'payment_method' => PaymentMethod::PAYMENT_ACCOUNT_TRANSACTION, + 'efz_status' => EfzStatus::EFZ_STATUS_NOT_REQUIRED, + ], $attributes)); + } + + /** Ein bestätigter Vorgang -- der Beleg entsteht erst dann. */ + private function makeRefund(array $attributes = [], array $eventAttributes = []): ParticipantRefund + { + $event = $this->makeEvent($eventAttributes); + $participant = $this->makeParticipant($event); + + return ParticipantRefund::create(array_merge([ + 'tenant' => $this->tenant->slug, + 'event_id' => $event->id, + 'event_participant_id' => $participant->id, + 'token' => str_repeat('a', 32), + 'status' => ParticipantRefund::STATUS_ACCEPTED, + 'amount' => 300.0, + 'reason' => RefundReason::SICKNESS, + 'account_owner' => 'Mika Muster', + 'account_iban' => 'DE02120300000000202051', + 'released_at' => '2026-06-14 10:00:00', + 'accepted_at' => '2026-06-18 09:30:00', + ], $attributes)); + } + + private function document(ParticipantRefund $refund) + { + return new CreateRefundDocumentCommand(new CreateRefundDocumentRequest($refund))->execute(); + } + + /** Das gerenderte HTML -- die Zwischenstufe vor dem PDF, an der sich der Inhalt prüfen lässt. */ + private function html(ParticipantRefund $refund): string + { + $command = new CreateRefundDocumentCommand(new CreateRefundDocumentRequest($refund)); + $number = new ReflectionMethod($command, 'documentNumber')->invoke($command); + $tokens = new ReflectionMethod($command, 'buildTokens')->invoke($command, $number); + + return new DocumentTemplateRenderProvider(DocumentTemplate::TYPE_PARTICIPANT_REFUND)->render($tokens); + } + + /* + |-------------------------------------------------------------------------- + | Nummer und PDF + |-------------------------------------------------------------------------- + */ + + public function test_document_number_extends_the_invoice_number(): void + { + $response = $this->document($this->makeRefund()); + + $this->assertTrue($response->success); + $this->assertSame('WM-V-20260701-0005-R', $response->documentNumber); + $this->assertSame('Rueckerstattung-WM-V-20260701-0005-R.pdf', $response->filename); + } + + public function test_a_pdf_is_produced(): void + { + $response = $this->document($this->makeRefund()); + + $this->assertStringStartsWith('%PDF', $response->pdfContent); + } + + public function test_no_document_without_an_invoice_key(): void + { + $response = $this->document($this->makeRefund(eventAttributes: ['invoice_key' => null])); + + $this->assertFalse($response->success); + $this->assertStringContainsString('Belegnummer', $response->message); + } + + public function test_no_document_before_the_participant_confirmed(): void + { + $response = $this->document($this->makeRefund([ + 'status' => ParticipantRefund::STATUS_PENDING, + 'account_owner' => null, + 'account_iban' => null, + 'accepted_at' => null, + ])); + + $this->assertFalse($response->success); + $this->assertStringContainsString('bestätigt', $response->message); + } + + /* + |-------------------------------------------------------------------------- + | Inhalt + |-------------------------------------------------------------------------- + */ + + public function test_document_shows_amount_reason_and_bank_details(): void + { + $html = $this->html($this->makeRefund()); + + $this->assertStringContainsString('300,00', $html); + $this->assertStringContainsString('Krankheitsbedingte Absage', $html); + $this->assertStringContainsString('Die Teilnahme konnte krankheitsbedingt nicht angetreten werden.', $html); + $this->assertStringContainsString('Mika Muster', $html); + // Im Beleg steht die IBAN in Vierergruppen. + $this->assertStringContainsString('DE02 1203 0000 0000 2020 51', $html); + } + + public function test_document_shows_event_period_and_cancellation_date(): void + { + $html = $this->html($this->makeRefund()); + + $this->assertStringContainsString('Sommerlager', $html); + $this->assertStringContainsString('16.07.2026 – 20.07.2026', $html); + $this->assertStringContainsString('12.06.2026', $html); + // Belegdatum ist der Tag der Bestätigung. + $this->assertStringContainsString('18.06.2026', $html); + } + + public function test_the_reason_is_a_row_in_the_table_not_a_block_below_it(): void + { + $refund = $this->makeRefund(); + + $command = new CreateRefundDocumentCommand(new CreateRefundDocumentRequest($refund)); + $details = new ReflectionMethod($command, 'renderDetails')->invoke($command); + + $this->assertStringContainsString( + 'Begründung' + . 'Die Teilnahme konnte krankheitsbedingt nicht angetreten werden.', + $details + ); + + // Der frühere Fließtext-Block unter der Tabelle ist verschwunden. + $this->assertStringNotContainsString('reason-note', $details); + $this->assertStringEndsWith('', $details); + } + + public function test_the_reason_row_is_dropped_when_there_is_no_text(): void + { + RefundReason::find(RefundReason::SICKNESS)->update(['document_text' => '']); + + $command = new CreateRefundDocumentCommand(new CreateRefundDocumentRequest($this->makeRefund())); + $details = new ReflectionMethod($command, 'renderDetails')->invoke($command); + + // Eine Beschriftung ohne Wert sieht auf einem Beleg nach Fehler aus. + $this->assertStringNotContainsString('Begründung', $details); + $this->assertStringContainsString('Krankheitsbedingte Absage', $details); + } + + public function test_the_table_lists_the_rows_in_reading_order(): void + { + $command = new CreateRefundDocumentCommand(new CreateRefundDocumentRequest($this->makeRefund())); + $details = new ReflectionMethod($command, 'renderDetails')->invoke($command); + + preg_match_all('/([^<]+)<\/td>/', $details, $matches); + + // Erst wer erklärt, dann worauf sich die Erstattung bezieht, dann warum, dann wohin das Geld geht. + $this->assertSame( + [ + 'Name', + 'Gezahlter Teilnahmebeitrag', + 'Rechnung', + 'Erstattungsbetrag', + 'Grund', + 'Begründung', + 'Kontoinhaber*in', + 'IBAN', + ], + $matches[1] + ); + } + + public function test_the_document_carries_no_contact_details(): void + { + // Für die Abrechnung genügen Name und Anschrift; E-Mail und Telefon gehören nicht auf einen + // Beleg, der durch die Buchhaltung und ins Archiv wandert. + $html = $this->html($this->makeRefund()); + + $this->assertStringNotContainsString('mika@example.com', $html); + $this->assertStringNotContainsString('0170 0000000', $html); + } + + public function test_document_shows_the_paid_amount_next_to_the_refund(): void + { + // Teilerstattung: 220 von 300 gezahlten Euro. + $html = $this->html($this->makeRefund(['amount' => 220.0])); + + $this->assertStringContainsString('300,00', $html); + $this->assertStringContainsString('220,00', $html); + // Die Nummer der Teilnahmerechnung, ohne das -R des Belegs. + $this->assertStringContainsString('WM-V-20260701-0005', $html); + } + + public function test_document_carries_the_declaration(): void + { + $html = $this->html($this->makeRefund()); + + $this->assertStringContainsString('Ich versichere, dass ich den genannten Betrag beglichen habe', $html); + } + + public function test_the_declaration_comes_from_the_page_text(): void + { + DB::table('page_texts') + ->where('name', CreateRefundDocumentCommand::DECLARATION_TEXT) + ->update(['content' => 'Eigener Wortlaut des Verbands.']); + + $this->assertStringContainsString('Eigener Wortlaut des Verbands.', $this->html($this->makeRefund())); + } + + public function test_the_declaration_falls_back_when_the_page_text_is_missing(): void + { + // Ohne Rückfallwert stünde hier ein Fatal Error auf null -- der Beleg muss trotzdem entstehen. + DB::table('page_texts')->where('name', CreateRefundDocumentCommand::DECLARATION_TEXT)->delete(); + + $this->assertStringContainsString('Ich versichere', $this->html($this->makeRefund())); + } + + public function test_free_text_reason_replaces_the_catalog_text(): void + { + $html = $this->html($this->makeRefund([ + 'reason' => RefundReason::OTHER, + 'reason_note' => 'Umzug in ein anderes Bundesland', + ])); + + $this->assertStringContainsString('Sonstiger Grund', $html); + $this->assertStringContainsString('Umzug in ein anderes Bundesland', $html); + $this->assertStringNotContainsString('krankheitsbedingt', $html); + } + + public function test_sender_is_read_live_from_the_tenant(): void + { + $refund = $this->makeRefund(); + + $this->tenant->update(['invoice_sender_name' => 'Neuer Name e.V.']); + + $this->assertStringContainsString('Neuer Name e.V.', $this->html($refund)); + } + + public function test_single_day_event_shows_one_date(): void + { + $html = $this->html($this->makeRefund(eventAttributes: [ + 'start_date' => '2026-07-16', + 'end_date' => '2026-07-16', + ])); + + $this->assertStringContainsString('16.07.2026', $html); + $this->assertStringNotContainsString('–', $html); + } + + /* + |-------------------------------------------------------------------------- + | Katalog und Werte müssen dieselben Namen kennen + |-------------------------------------------------------------------------- + */ + + public function test_token_catalog_matches_the_generated_values(): void + { + $command = new CreateRefundDocumentCommand(new CreateRefundDocumentRequest($this->makeRefund())); + $tokens = new ReflectionMethod($command, 'buildTokens')->invoke($command, 'WM-V-20260701-0005-R'); + + $catalog = ParticipantRefundTokens::names(); + sort($catalog); + + $generated = array_keys($tokens); + sort($generated); + + $this->assertSame($catalog, $generated); + } +} diff --git a/tests/Unit/IbanTest.php b/tests/Unit/IbanTest.php new file mode 100644 index 0000000..076f713 --- /dev/null +++ b/tests/Unit/IbanTest.php @@ -0,0 +1,59 @@ +assertSame('DE02120300000000202051', Iban::normalize('de02 1203 0000 0000 2020 51')); + $this->assertSame('DE02120300000000202051', Iban::normalize(" DE02\t1203000000002020 51 ")); + } + + public function test_formats_in_groups_of_four(): void + { + $this->assertSame('DE02 1203 0000 0000 2020 51', Iban::format('DE02120300000000202051')); + } + + public function test_accepts_valid_ibans(): void + { + // Offizielle Testnummern der Deutschen Bundesbank bzw. der jeweiligen Zentralbanken. + foreach ([ + 'DE02120300000000202051', + 'DE02500105170137075030', + 'AT026000000001349870', + 'CH0209000000100013997', + 'FR1420041010050500013M02606', + 'NL02ABNA0123456789', + ] as $iban) { + $this->assertTrue(Iban::isValid($iban), $iban . ' sollte gültig sein'); + } + } + + public function test_rejects_a_wrong_check_digit(): void + { + // Zahlendreher in den letzten beiden Stellen -- Struktur und Länge stimmen weiterhin. + $this->assertFalse(Iban::isValid('DE02120300000000202015')); + } + + public function test_rejects_wrong_length_for_the_country(): void + { + $this->assertFalse(Iban::isValid('DE0212030000000020205')); + $this->assertFalse(Iban::isValid('DE021203000000002020511')); + } + + public function test_rejects_malformed_input(): void + { + foreach (['', ' ', 'kein IBAN', '1202120300000000202051', 'DEAB120300000000202051'] as $iban) { + $this->assertFalse(Iban::isValid($iban), $iban . ' sollte ungültig sein'); + } + } + + public function test_validates_the_normalized_form(): void + { + $this->assertTrue(Iban::isValid('de02 1203 0000 0000 2020 51')); + } +}