Creating Participation refunds
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Gründe für die Erstattung eines Teilnahmebeitrags -- DB-gestützt wie die Befreiungsgründe der
|
||||
* Umsatzsteuer, damit Label und der auf dem Beleg erscheinende Text zentral pflegbar sind.
|
||||
*
|
||||
* App-weit und ohne `tenant`-Spalte: die Gründe sind Stammdaten, mandantenspezifisch ist nur der
|
||||
* einzelne Erstattungsvorgang.
|
||||
*/
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('refund_reasons', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* Ein Erstattungsvorgang je Anmeldung.
|
||||
*
|
||||
* Der Vorgang läuft in zwei Schritten: die Aktionsleitung gibt Betrag und Grund frei (`pending`), der
|
||||
* Teili ergänzt über den Token-Link seine Bankverbindung (`accepted`). Solange nicht bestätigt wurde,
|
||||
* kann die Aktionsleitung abbrechen (`cancelled`) -- der Link verhält sich danach so, als hätte es nie
|
||||
* eine Freigabe gegeben.
|
||||
*
|
||||
* `event_participants.amount_paid` bleibt in jedem Fall unangetastet: der Vorgang ist freigegeben, aber
|
||||
* noch nicht ausgezahlt. Die Verrechnung kommt mit der Auszahlung.
|
||||
*
|
||||
* Bewusst KEIN Unique auf `event_participant_id`: abgebrochene Vorgänge bleiben als Historie stehen, und
|
||||
* danach darf eine neue Freigabe entstehen. Dass es höchstens einen offenen Vorgang gibt, setzt
|
||||
* ParticipantRefundRepository::openFor() durch.
|
||||
*/
|
||||
return new class extends Migration {
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('participant_refunds', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Der Erklärungssatz, den der Teili auf der Erstattungsseite bestätigt und der anschließend auf dem
|
||||
* Eigenbeleg steht.
|
||||
*
|
||||
* Er liegt in `page_texts` und nicht in der Dokumentvorlage, damit Seite und Beleg denselben Wortlaut
|
||||
* zeigen: was der Teili gelesen hat, muss wörtlich das sein, was das PDF ihm zuschreibt. Zwei getrennte
|
||||
* Quellen würden früher oder später auseinanderlaufen.
|
||||
*
|
||||
* Als Migration und nicht als Seed-Skript, weil ohne diesen Text weder die Seite noch der Beleg
|
||||
* vollständig sind -- anders als bei den Dokumentvorlagen, wo ein Release gepflegte Texte nicht
|
||||
* überschreiben soll. `updateOrInsert` lässt eine bereits angepasste Fassung deshalb auch in Ruhe:
|
||||
* geschrieben wird nur, was noch fehlt.
|
||||
*/
|
||||
return new class extends Migration {
|
||||
private const string NAME = 'CONFIRMATION_PARTICIPANT_REFUND';
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
$existing = DB::table('page_texts')->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();
|
||||
}
|
||||
};
|
||||
@@ -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<user> -p<pass> <db> < 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', '<img class="edge" src="{asset:edge}" alt="" />
|
||||
|
||||
<div class="page">
|
||||
|
||||
<table class="header-table">
|
||||
<tr>
|
||||
<td style="width: 45%; padding-top: 4mm;">
|
||||
{block:header_sender_return}
|
||||
{block:header_recipient}
|
||||
</td>
|
||||
<td style="width: 9%; vertical-align: middle; padding-top: 4mm; padding-right: 6mm;">
|
||||
{block:emblem}
|
||||
</td>
|
||||
<td style="width: 46%; vertical-align: top; padding-top: 2mm;">
|
||||
{block:logo}
|
||||
{block:sender_data}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
{block:subject}
|
||||
{block:body}
|
||||
{block:footer}
|
||||
|
||||
</div>', 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', '<div class="absender-rueck">{sender_name}{if:sender_address_1} · {sender_address_1}{/if:sender_address_1} · {sender_postcode} {sender_city}</div>', 30, 1, NOW(), NOW()),
|
||||
('participant_refund', 'header_recipient', '<div class="empfaenger">
|
||||
<div class="empfaenger-bold">{recipient_name}</div>
|
||||
<div>{recipient_address_1}</div>
|
||||
{if:recipient_address_2}<div>{recipient_address_2}</div>{/if:recipient_address_2}
|
||||
<div>{recipient_postcode} {recipient_city}</div>
|
||||
</div>', 40, 1, NOW(), NOW()),
|
||||
('participant_refund', 'emblem', '<img src="{asset:emblem}" class="emblem-img" alt="" />', 50, 1, NOW(), NOW()),
|
||||
('participant_refund', 'logo', '<img src="{asset:logo}" class="logo-img" alt="" />', 60, 1, NOW(), NOW()),
|
||||
('participant_refund', 'sender_data', '<div class="absender-block">
|
||||
<div class="absender-name">{sender_name}</div>
|
||||
{if:sender_address_2}<div>{sender_address_2}</div>{/if:sender_address_2}
|
||||
{if:sender_address_3}<div>{sender_address_3}</div>{/if:sender_address_3}
|
||||
{if:sender_address_1}<div style="margin-top:2.5mm;">{sender_address_1}</div>{/if:sender_address_1}
|
||||
<div>{sender_postcode} {sender_city}</div>
|
||||
{if:sender_email}<div style="margin-top:2.5mm;">{sender_email}</div>{/if:sender_email}
|
||||
{if:sender_phone}<div>{sender_phone}</div>{/if:sender_phone}
|
||||
{if:sender_tax_number}<div style="margin-top:2.5mm;">Steuernummer: {sender_tax_number}</div>{/if:sender_tax_number}
|
||||
{if:sender_vat_id}<div>USt-IdNr.: {sender_vat_id}</div>{/if:sender_vat_id}
|
||||
</div>', 70, 1, NOW(), NOW()),
|
||||
('participant_refund', 'subject', '<div class="betreff">Rückerstattung Nr. {document_number}</div>
|
||||
|
||||
<table class="ref-table">
|
||||
<tr>
|
||||
<td class="ref-key">Belegdatum:</td>
|
||||
<td>{document_date}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="ref-key">Veranstaltung:</td>
|
||||
<td>{event_name}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="ref-key">Zeitraum:</td>
|
||||
<td>{service_period}</td>
|
||||
</tr>
|
||||
{if:unregistered_at}<tr>
|
||||
<td class="ref-key">Abgemeldet am:</td>
|
||||
<td>{unregistered_at}</td>
|
||||
</tr>{/if:unregistered_at}
|
||||
</table>', 80, 1, NOW(), NOW()),
|
||||
('participant_refund', 'body', '<div class="intro-text">Ich konnte an der Veranstaltung nicht oder nur teilweise teilnehmen und bitte um die Rückerstattung wie folgt:</div>
|
||||
|
||||
{details_table}
|
||||
|
||||
<div class="declaration">{declaration_text}</div>', 90, 1, NOW(), NOW()),
|
||||
('participant_refund', 'footer', '', 100, 1, NOW(), NOW());
|
||||
Reference in New Issue
Block a user