Development 4.6.0 #12
+17
-3
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Domains\Admin\Actions\UpdateAvailablePaymentMethod;
|
namespace App\Domains\Admin\Actions\UpdateAvailablePaymentMethod;
|
||||||
|
|
||||||
use App\Domains\Admin\Actions\RemovePaymentMethodUsage\RemovePaymentMethodUsageAction;
|
use App\Domains\Admin\Actions\RemovePaymentMethodUsage\RemovePaymentMethodUsageAction;
|
||||||
|
use App\Models\PaymentMethod;
|
||||||
|
|
||||||
class UpdateAvailablePaymentMethodAction
|
class UpdateAvailablePaymentMethodAction
|
||||||
{
|
{
|
||||||
@@ -13,16 +14,29 @@ class UpdateAvailablePaymentMethodAction
|
|||||||
public function execute(): UpdateAvailablePaymentMethodResponse
|
public function execute(): UpdateAvailablePaymentMethodResponse
|
||||||
{
|
{
|
||||||
$response = new UpdateAvailablePaymentMethodResponse();
|
$response = new UpdateAvailablePaymentMethodResponse();
|
||||||
$wasActive = $this->request->availablePaymentMethod->active;
|
$paymentMethod = $this->request->availablePaymentMethod;
|
||||||
|
$wasActive = $paymentMethod->active;
|
||||||
|
|
||||||
$this->request->availablePaymentMethod->update([
|
// Nur bekannte Options-Keys übernehmen -- das Schema (PaymentMethod::OPTIONS) ist die Autorität.
|
||||||
|
$configuration = PaymentMethod::sanitizeConfiguration($paymentMethod->slug, $this->request->configuration);
|
||||||
|
|
||||||
|
// Guard: Aktivierung nur erlaubt, wenn alle Pflicht-Optionen befüllt sind.
|
||||||
|
if ($this->request->active && !PaymentMethod::isConfigurationComplete($paymentMethod->slug, $configuration)) {
|
||||||
|
$response->success = false;
|
||||||
|
$response->message = 'Bitte zuerst alle Pflichtfelder ausfüllen, bevor die Zahlungsmethode aktiviert wird.';
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
$paymentMethod->update([
|
||||||
'name' => $this->request->name,
|
'name' => $this->request->name,
|
||||||
'description' => $this->request->description,
|
'description' => $this->request->description,
|
||||||
'active' => $this->request->active,
|
'active' => $this->request->active,
|
||||||
|
'configuration' => $configuration,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if ($wasActive && !$this->request->active) {
|
if ($wasActive && !$this->request->active) {
|
||||||
(new RemovePaymentMethodUsageAction())->execute($this->request->availablePaymentMethod);
|
(new RemovePaymentMethodUsageAction())->execute($paymentMethod);
|
||||||
}
|
}
|
||||||
|
|
||||||
$response->success = true;
|
$response->success = true;
|
||||||
|
|||||||
+4
@@ -6,11 +6,15 @@ use App\Models\AvailablePaymentMethod;
|
|||||||
|
|
||||||
class UpdateAvailablePaymentMethodRequest
|
class UpdateAvailablePaymentMethodRequest
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $configuration
|
||||||
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public AvailablePaymentMethod $availablePaymentMethod,
|
public AvailablePaymentMethod $availablePaymentMethod,
|
||||||
public string $name,
|
public string $name,
|
||||||
public ?string $description,
|
public ?string $description,
|
||||||
public bool $active,
|
public bool $active,
|
||||||
|
public array $configuration = [],
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Domains\Admin\Controllers;
|
namespace App\Domains\Admin\Controllers;
|
||||||
|
|
||||||
use App\Models\AvailablePaymentMethod;
|
use App\Models\AvailablePaymentMethod;
|
||||||
|
use App\Resources\AvailablePaymentMethodResource;
|
||||||
use App\Scopes\CommonController;
|
use App\Scopes\CommonController;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -12,7 +13,8 @@ class TenantPaymentMethodsGetController extends CommonController
|
|||||||
public function __invoke(Request $request): JsonResponse
|
public function __invoke(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'paymentMethods' => AvailablePaymentMethod::all(),
|
'paymentMethods' => AvailablePaymentMethod::all()
|
||||||
|
->map(fn (AvailablePaymentMethod $method) => new AvailablePaymentMethodResource($method)->toArray($request)),
|
||||||
'saveEndpoint' => '/api/v1/admin/tenant/payment-methods',
|
'saveEndpoint' => '/api/v1/admin/tenant/payment-methods',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class TenantPaymentMethodsUpdateController extends CommonController
|
|||||||
name: $request->input('name'),
|
name: $request->input('name'),
|
||||||
description: $request->input('description'),
|
description: $request->input('description'),
|
||||||
active: (bool) $request->input('active'),
|
active: (bool) $request->input('active'),
|
||||||
|
configuration: (array) $request->input('configuration', []),
|
||||||
));
|
));
|
||||||
|
|
||||||
$response = $action->execute();
|
$response = $action->execute();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import FullScreenModal from "../../../../Views/Components/FullScreenModal.vue";
|
import FullScreenModal from "../../../../Views/Components/FullScreenModal.vue";
|
||||||
import { useAjax } from "../../../../../resources/js/components/ajaxHandler.js";
|
import { useAjax } from "../../../../../resources/js/components/ajaxHandler.js";
|
||||||
import { toast } from "vue3-toastify";
|
import { toast } from "vue3-toastify";
|
||||||
@@ -15,14 +15,31 @@ const { request } = useAjax()
|
|||||||
|
|
||||||
const paymentMethods = ref(props.data.paymentMethods ?? [])
|
const paymentMethods = ref(props.data.paymentMethods ?? [])
|
||||||
const editing = ref(null)
|
const editing = ref(null)
|
||||||
const form = ref({ name: '', description: '', active: true })
|
const form = ref({ name: '', description: '', active: true, configuration: {} })
|
||||||
|
|
||||||
|
// Sind alle Pflichtfelder des aktuell bearbeiteten Moduls befüllt? Der Server ist die
|
||||||
|
// eigentliche Autorität (Guard), das hier dient nur der Bedienführung.
|
||||||
|
const requiredComplete = computed(() => {
|
||||||
|
if (!editing.value) return true
|
||||||
|
return (editing.value.optionsSchema ?? [])
|
||||||
|
.filter(option => option.required)
|
||||||
|
.every(option => {
|
||||||
|
const value = form.value.configuration[option.name]
|
||||||
|
return value !== undefined && value !== null && value !== ''
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
function edit(paymentMethod) {
|
function edit(paymentMethod) {
|
||||||
editing.value = paymentMethod
|
editing.value = paymentMethod
|
||||||
|
const configuration = {}
|
||||||
|
for (const option of paymentMethod.optionsSchema ?? []) {
|
||||||
|
configuration[option.name] = paymentMethod.configuration?.[option.name] ?? ''
|
||||||
|
}
|
||||||
form.value = {
|
form.value = {
|
||||||
name: paymentMethod.name,
|
name: paymentMethod.name,
|
||||||
description: paymentMethod.description ?? '',
|
description: paymentMethod.description ?? '',
|
||||||
active: paymentMethod.active,
|
active: paymentMethod.active,
|
||||||
|
configuration,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +96,17 @@ async function save() {
|
|||||||
<tr><th>Name:</th><td><input type="text" v-model="form.name" class="form-input" /></td></tr>
|
<tr><th>Name:</th><td><input type="text" v-model="form.name" class="form-input" /></td></tr>
|
||||||
<tr><th>Slug:</th><td>{{ editing.slug }}</td></tr>
|
<tr><th>Slug:</th><td>{{ editing.slug }}</td></tr>
|
||||||
<tr><th>Beschreibung:</th><td><textarea v-model="form.description" class="form-input"></textarea></td></tr>
|
<tr><th>Beschreibung:</th><td><textarea v-model="form.description" class="form-input"></textarea></td></tr>
|
||||||
<tr><th>Aktiv:</th><td><input type="checkbox" v-model="form.active" /></td></tr>
|
<tr v-for="option in editing.optionsSchema ?? []" :key="option.name">
|
||||||
|
<th>{{ option.label }}<span v-if="option.required" class="required-marker">*</span>:</th>
|
||||||
|
<td><input type="text" v-model="form.configuration[option.name]" class="form-input" /></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Aktiv:</th>
|
||||||
|
<td>
|
||||||
|
<input type="checkbox" v-model="form.active" :disabled="!requiredComplete && !form.active" />
|
||||||
|
<span v-if="!requiredComplete" class="hint">Bitte zuerst alle Pflichtfelder (*) ausfüllen, um zu aktivieren.</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
<button class="btn-save" @click="save">Speichern</button>
|
<button class="btn-save" @click="save">Speichern</button>
|
||||||
@@ -177,6 +204,18 @@ async function save() {
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.required-marker {
|
||||||
|
color: #ef4444;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-group {
|
.btn-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ class CreateEventCommand {
|
|||||||
EventPaymentMethods::create([
|
EventPaymentMethods::create([
|
||||||
'event_id' => $event->id,
|
'event_id' => $event->id,
|
||||||
'slug' => $availablePaymentMethod->slug,
|
'slug' => $availablePaymentMethod->slug,
|
||||||
|
'configuration' => $availablePaymentMethod->configuration ?? [],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
namespace App\Domains\Event\Actions\UpdateEvent;
|
namespace App\Domains\Event\Actions\UpdateEvent;
|
||||||
|
|
||||||
|
use App\Models\AvailablePaymentMethod;
|
||||||
|
use App\Models\PaymentMethod;
|
||||||
|
use App\RelationModels\EventPaymentMethods;
|
||||||
|
|
||||||
class UpdateEventCommand {
|
class UpdateEventCommand {
|
||||||
public UpdateEventRequest $request;
|
public UpdateEventRequest $request;
|
||||||
public function __construct(UpdateEventRequest $request) {
|
public function __construct(UpdateEventRequest $request) {
|
||||||
@@ -32,7 +36,6 @@ class UpdateEventCommand {
|
|||||||
|
|
||||||
$this->request->event->resetAllowedEatingHabits();
|
$this->request->event->resetAllowedEatingHabits();
|
||||||
$this->request->event->resetContributingLocalGroups();
|
$this->request->event->resetContributingLocalGroups();
|
||||||
$this->request->event->resetPaymentMethods();
|
|
||||||
|
|
||||||
foreach($this->request->eatingHabits as $eatingHabit) {
|
foreach($this->request->eatingHabits as $eatingHabit) {
|
||||||
$this->request->event->eatingHabits()->attach($eatingHabit);
|
$this->request->event->eatingHabits()->attach($eatingHabit);
|
||||||
@@ -42,13 +45,58 @@ class UpdateEventCommand {
|
|||||||
$this->request->event->localGroups()->attach($contributingLocalGroup);
|
$this->request->event->localGroups()->attach($contributingLocalGroup);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach($this->request->paymentMethods as $paymentMethod) {
|
$this->syncPaymentMethods();
|
||||||
$this->request->event->paymentMethods()->attach($paymentMethod);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->request->event->save();
|
$this->request->event->save();
|
||||||
$response->success = true;
|
$response->success = true;
|
||||||
|
|
||||||
return $response;
|
return $response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Differenzieller Sync der Zahlungsmethoden mit Snapshot-Copy-Semantik:
|
||||||
|
* - entfernte Methoden werden gelöscht,
|
||||||
|
* - neue Methoden erhalten einen Snapshot der Tenant-Config (oder eines expliziten Overrides),
|
||||||
|
* - bestehende Methoden behalten ihre pro-Event-Config, sofern kein Override übergeben wird.
|
||||||
|
*/
|
||||||
|
private function syncPaymentMethods(): void
|
||||||
|
{
|
||||||
|
$event = $this->request->event;
|
||||||
|
$desiredSlugs = $this->request->paymentMethods;
|
||||||
|
$overrides = $this->request->paymentMethodConfigurations;
|
||||||
|
|
||||||
|
$existing = EventPaymentMethods::where('event_id', $event->id)->get()->keyBy('slug');
|
||||||
|
|
||||||
|
// Nicht mehr gewünschte Methoden entfernen.
|
||||||
|
foreach ($existing as $slug => $row) {
|
||||||
|
if (!in_array($slug, $desiredSlugs, true)) {
|
||||||
|
$row->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tenant-Instanzen (für Snapshot-Kopie neuer Methoden) laden.
|
||||||
|
$tenantMethods = AvailablePaymentMethod::whereIn('slug', $desiredSlugs)->get()->keyBy('slug');
|
||||||
|
|
||||||
|
foreach ($desiredSlugs as $slug) {
|
||||||
|
$override = $overrides[$slug] ?? null;
|
||||||
|
|
||||||
|
if (isset($existing[$slug])) {
|
||||||
|
// Bestehende Zuweisung: Config nur bei explizitem Override anpassen, sonst unverändert lassen.
|
||||||
|
if ($override !== null) {
|
||||||
|
$row = $existing[$slug];
|
||||||
|
$row->configuration = PaymentMethod::sanitizeConfiguration($slug, $override);
|
||||||
|
$row->save();
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Neue Zuweisung: expliziter Override oder Snapshot der Tenant-Config.
|
||||||
|
$snapshot = $override ?? ($tenantMethods[$slug]->configuration ?? []);
|
||||||
|
EventPaymentMethods::create([
|
||||||
|
'event_id' => $event->id,
|
||||||
|
'slug' => $slug,
|
||||||
|
'configuration' => PaymentMethod::sanitizeConfiguration($slug, $snapshot ?? []),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,16 @@ class UpdateEventRequest {
|
|||||||
public array $eatingHabits;
|
public array $eatingHabits;
|
||||||
public array $paymentMethods;
|
public array $paymentMethods;
|
||||||
|
|
||||||
public function __construct(Event $event, string $eventName, string $eventLocation, string $postalCode, string $email, DateTime $earlyBirdEnd, DateTime $registrationFinalEnd, int $alcoholicsAge, bool $sendWeeklyReports, bool $registrationAllowed, Amount $flatSupport, Amount $supportPerPerson, array $contributingLocalGroups, array $eatingHabits, array $paymentMethods) {
|
/**
|
||||||
|
* Optionale pro-Event-Config-Overrides je Zahlungsmethode: [slug => [option => value]].
|
||||||
|
* Fehlt ein Slug, bleibt die bestehende Config erhalten bzw. wird beim ersten Zuweisen
|
||||||
|
* aus der Tenant-Config als Snapshot kopiert.
|
||||||
|
*
|
||||||
|
* @var array<string, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public array $paymentMethodConfigurations;
|
||||||
|
|
||||||
|
public function __construct(Event $event, string $eventName, string $eventLocation, string $postalCode, string $email, DateTime $earlyBirdEnd, DateTime $registrationFinalEnd, int $alcoholicsAge, bool $sendWeeklyReports, bool $registrationAllowed, Amount $flatSupport, Amount $supportPerPerson, array $contributingLocalGroups, array $eatingHabits, array $paymentMethods, array $paymentMethodConfigurations = []) {
|
||||||
$this->event = $event;
|
$this->event = $event;
|
||||||
$this->eventName = $eventName;
|
$this->eventName = $eventName;
|
||||||
$this->eventLocation = $eventLocation;
|
$this->eventLocation = $eventLocation;
|
||||||
@@ -39,5 +48,6 @@ class UpdateEventRequest {
|
|||||||
$this->contributingLocalGroups = $contributingLocalGroups;
|
$this->contributingLocalGroups = $contributingLocalGroups;
|
||||||
$this->eatingHabits = $eatingHabits;
|
$this->eatingHabits = $eatingHabits;
|
||||||
$this->paymentMethods = $paymentMethods;
|
$this->paymentMethods = $paymentMethods;
|
||||||
|
$this->paymentMethodConfigurations = $paymentMethodConfigurations;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ class DetailsController extends CommonController {
|
|||||||
$contributinLocalGroups = $request->input('contributingLocalGroups');
|
$contributinLocalGroups = $request->input('contributingLocalGroups');
|
||||||
$eatingHabits = $request->input('eatingHabits');
|
$eatingHabits = $request->input('eatingHabits');
|
||||||
$paymentMethods = $request->input('paymentMethods');
|
$paymentMethods = $request->input('paymentMethods');
|
||||||
|
$paymentMethodConfigurations = (array) $request->input('paymentMethodConfigurations', []);
|
||||||
|
|
||||||
$eventUpdateRequest = new UpdateEventRequest(
|
$eventUpdateRequest = new UpdateEventRequest(
|
||||||
$event,
|
$event,
|
||||||
@@ -59,7 +60,8 @@ class DetailsController extends CommonController {
|
|||||||
$supportPerPerson,
|
$supportPerPerson,
|
||||||
$contributinLocalGroups,
|
$contributinLocalGroups,
|
||||||
$eatingHabits,
|
$eatingHabits,
|
||||||
$paymentMethods
|
$paymentMethods,
|
||||||
|
$paymentMethodConfigurations
|
||||||
);
|
);
|
||||||
|
|
||||||
$eventUpdateCommand = new UpdateEventCommand($eventUpdateRequest);
|
$eventUpdateCommand = new UpdateEventCommand($eventUpdateRequest);
|
||||||
|
|||||||
@@ -22,6 +22,8 @@
|
|||||||
const contributingLocalGroups = ref([])
|
const contributingLocalGroups = ref([])
|
||||||
const eatingHabits = ref([]);
|
const eatingHabits = ref([]);
|
||||||
const paymentMethods = ref([]);
|
const paymentMethods = ref([]);
|
||||||
|
// pro-Event-Config je Zahlungsmethode: { slug: { option: value } }
|
||||||
|
const paymentMethodConfigurations = reactive({});
|
||||||
|
|
||||||
const errors = reactive({})
|
const errors = reactive({})
|
||||||
|
|
||||||
@@ -49,6 +51,21 @@
|
|||||||
contributingLocalGroups.value = props.event.contributingLocalGroups?.map(t => t.id) ?? []
|
contributingLocalGroups.value = props.event.contributingLocalGroups?.map(t => t.id) ?? []
|
||||||
eatingHabits.value = props.event.eatingHabits?.map(t => t.id) ?? []
|
eatingHabits.value = props.event.eatingHabits?.map(t => t.id) ?? []
|
||||||
paymentMethods.value = props.event.paymentMethods?.map(t => t.slug) ?? []
|
paymentMethods.value = props.event.paymentMethods?.map(t => t.slug) ?? []
|
||||||
|
|
||||||
|
// Pro-Event-Config vorbelegen: bereits gespeicherte Pivot-Config des Events hat Vorrang,
|
||||||
|
// sonst der Tenant-Standard (Snapshot-Quelle für neu zugewiesene Methoden).
|
||||||
|
const eventConfigBySlug = {}
|
||||||
|
for (const pm of props.event.paymentMethods ?? []) {
|
||||||
|
eventConfigBySlug[pm.slug] = pm.configuration ?? {}
|
||||||
|
}
|
||||||
|
for (const method of dynmicProps.paymentMethods ?? []) {
|
||||||
|
const source = eventConfigBySlug[method.slug] ?? method.configuration ?? {}
|
||||||
|
const config = {}
|
||||||
|
for (const option of method.optionsSchema ?? []) {
|
||||||
|
config[option.name] = source[option.name] ?? ''
|
||||||
|
}
|
||||||
|
paymentMethodConfigurations[method.slug] = config
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
@@ -80,6 +97,7 @@
|
|||||||
contributingLocalGroups: contributingLocalGroups.value,
|
contributingLocalGroups: contributingLocalGroups.value,
|
||||||
eatingHabits: eatingHabits.value,
|
eatingHabits: eatingHabits.value,
|
||||||
paymentMethods: paymentMethods.value,
|
paymentMethods: paymentMethods.value,
|
||||||
|
paymentMethodConfigurations: paymentMethodConfigurations,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -220,6 +238,16 @@
|
|||||||
<td>
|
<td>
|
||||||
<input type="checkbox" :id="'paymentmethod' + paymentMethod.id" :value="paymentMethod.slug" v-model="paymentMethods" />
|
<input type="checkbox" :id="'paymentmethod' + paymentMethod.id" :value="paymentMethod.slug" v-model="paymentMethods" />
|
||||||
<label style="padding-left: 5px;" :for="'paymentmethod' + paymentMethod.id">{{paymentMethod.name}}</label>
|
<label style="padding-left: 5px;" :for="'paymentmethod' + paymentMethod.id">{{paymentMethod.name}}</label>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="paymentMethods.includes(paymentMethod.slug) && (paymentMethod.optionsSchema?.length ?? 0) > 0"
|
||||||
|
class="payment-method-config"
|
||||||
|
>
|
||||||
|
<div v-for="option in paymentMethod.optionsSchema" :key="option.name" class="payment-method-config-row">
|
||||||
|
<label>{{ option.label }}<span v-if="option.required" class="required-marker">*</span></label>
|
||||||
|
<input type="text" v-model="paymentMethodConfigurations[paymentMethod.slug][option.name]" class="width-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
@@ -278,4 +306,26 @@
|
|||||||
width: 250px;
|
width: 250px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.payment-method-config {
|
||||||
|
margin: 6px 0 12px 24px;
|
||||||
|
padding-left: 10px;
|
||||||
|
border-left: 2px solid #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-method-config-row {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-method-config-row label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #374151;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.required-marker {
|
||||||
|
color: #ef4444;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
|||||||
* @property string $name
|
* @property string $name
|
||||||
* @property ?string $description
|
* @property ?string $description
|
||||||
* @property bool $active
|
* @property bool $active
|
||||||
|
* @property ?array $configuration
|
||||||
*/
|
*/
|
||||||
class AvailablePaymentMethod extends InstancedModel
|
class AvailablePaymentMethod extends InstancedModel
|
||||||
{
|
{
|
||||||
@@ -24,10 +25,12 @@ class AvailablePaymentMethod extends InstancedModel
|
|||||||
'name',
|
'name',
|
||||||
'description',
|
'description',
|
||||||
'active',
|
'active',
|
||||||
|
'configuration',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'active' => 'boolean',
|
'active' => 'boolean',
|
||||||
|
'configuration' => 'array',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function paymentMethod(): BelongsTo
|
public function paymentMethod(): BelongsTo
|
||||||
@@ -35,6 +38,14 @@ class AvailablePaymentMethod extends InstancedModel
|
|||||||
return $this->belongsTo(PaymentMethod::class, 'slug', 'slug');
|
return $this->belongsTo(PaymentMethod::class, 'slug', 'slug');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sind alle Pflicht-Optionen dieses Moduls (siehe PaymentMethod::OPTIONS) befüllt?
|
||||||
|
*/
|
||||||
|
public function isComplete(): bool
|
||||||
|
{
|
||||||
|
return PaymentMethod::isConfigurationComplete($this->slug, $this->configuration ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
public function events(): BelongsToMany
|
public function events(): BelongsToMany
|
||||||
{
|
{
|
||||||
// Pivot verknüpft über den Slug -- Event::paymentMethods() greift daher automatisch nur
|
// Pivot verknüpft über den Slug -- Event::paymentMethods() greift daher automatisch nur
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use App\Casts\AmountCast;
|
|||||||
use App\Enumerations\EatingHabit;
|
use App\Enumerations\EatingHabit;
|
||||||
use App\Enumerations\ParticipationFeeType;
|
use App\Enumerations\ParticipationFeeType;
|
||||||
use App\RelationModels\EventParticipationFee;
|
use App\RelationModels\EventParticipationFee;
|
||||||
|
use App\RelationModels\EventPaymentMethods;
|
||||||
use App\Resources\EventResource;
|
use App\Resources\EventResource;
|
||||||
use App\Scopes\CommonModel;
|
use App\Scopes\CommonModel;
|
||||||
use App\Scopes\InstancedModel;
|
use App\Scopes\InstancedModel;
|
||||||
@@ -157,7 +158,10 @@ class Event extends InstancedModel
|
|||||||
// Pivot verknüpft über den Slug (payment_methods.slug), nicht über die numerische id
|
// Pivot verknüpft über den Slug (payment_methods.slug), nicht über die numerische id
|
||||||
// von available_payment_methods -- die tenant-spezifische Instanz wird dadurch implizit
|
// von available_payment_methods -- die tenant-spezifische Instanz wird dadurch implizit
|
||||||
// über den globalen SiteScope von AvailablePaymentMethod (gefiltert auf app('tenant')) aufgelöst.
|
// über den globalen SiteScope von AvailablePaymentMethod (gefiltert auf app('tenant')) aufgelöst.
|
||||||
return $this->belongsToMany(AvailablePaymentMethod::class, 'event_payment_methods', 'event_id', 'slug', 'id', 'slug')->withTimestamps();
|
return $this->belongsToMany(AvailablePaymentMethod::class, 'event_payment_methods', 'event_id', 'slug', 'id', 'slug')
|
||||||
|
->using(EventPaymentMethods::class)
|
||||||
|
->withPivot('configuration')
|
||||||
|
->withTimestamps();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function resetContributingLocalGroups() {
|
public function resetContributingLocalGroups() {
|
||||||
|
|||||||
@@ -24,7 +24,84 @@ class PaymentMethod extends CommonModel
|
|||||||
self::PAYMENT_NOT_DEFINED => ['name' => 'Sonstiges (Barzahlung, Zahlung vor Ort)', 'description' => null],
|
self::PAYMENT_NOT_DEFINED => ['name' => 'Sonstiges (Barzahlung, Zahlung vor Ort)', 'description' => null],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options-Schema je Zahlungsmodul. Definiert im Code (nicht in der DB), damit es
|
||||||
|
* nicht mit dem Modul-Code auseinanderdriftet. In der DB stehen nur die Werte
|
||||||
|
* (configuration-JSON auf available_payment_methods bzw. event_payment_methods).
|
||||||
|
*
|
||||||
|
* Jede Option: ['name' => string, 'label' => string, 'type' => string, 'required' => bool]
|
||||||
|
*
|
||||||
|
* @var array<string, array<int, array{name: string, label: string, type: string, required: bool}>>
|
||||||
|
*/
|
||||||
|
public const array OPTIONS = [
|
||||||
|
self::PAYMENT_ACCOUNT_TRANSACTION => [
|
||||||
|
['name' => 'account_owner', 'label' => 'Kontoinhaber', 'type' => 'string', 'required' => true],
|
||||||
|
['name' => 'iban', 'label' => 'IBAN', 'type' => 'string', 'required' => true],
|
||||||
|
['name' => 'bic', 'label' => 'BIC', 'type' => 'string', 'required' => false],
|
||||||
|
],
|
||||||
|
self::PAYMENT_NOT_DEFINED => [],
|
||||||
|
];
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'slug',
|
'slug',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options-Schema für einen Slug.
|
||||||
|
*
|
||||||
|
* @return array<int, array{name: string, label: string, type: string, required: bool}>
|
||||||
|
*/
|
||||||
|
public static function optionsFor(string $slug): array
|
||||||
|
{
|
||||||
|
return self::OPTIONS[$slug] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Namen aller Pflicht-Optionen eines Slugs.
|
||||||
|
*
|
||||||
|
* @return array<int, string>
|
||||||
|
*/
|
||||||
|
public static function requiredOptionKeys(string $slug): array
|
||||||
|
{
|
||||||
|
return array_values(array_map(
|
||||||
|
static fn (array $option) => $option['name'],
|
||||||
|
array_filter(self::optionsFor($slug), static fn (array $option) => $option['required'] ?? false)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reduziert eine Konfiguration auf die im Code definierten Options-Keys des Slugs.
|
||||||
|
* Unbekannte Keys werden verworfen -- das Schema (self::OPTIONS) ist die Autorität.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $config
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public static function sanitizeConfiguration(string $slug, array $config): array
|
||||||
|
{
|
||||||
|
$result = [];
|
||||||
|
foreach (self::optionsFor($slug) as $option) {
|
||||||
|
if (array_key_exists($option['name'], $config)) {
|
||||||
|
$result[$option['name']] = $config[$option['name']];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prüft, ob alle Pflicht-Optionen eines Slugs in der Konfiguration befüllt (non-empty) sind.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $config
|
||||||
|
*/
|
||||||
|
public static function isConfigurationComplete(string $slug, array $config): bool
|
||||||
|
{
|
||||||
|
foreach (self::requiredOptionKeys($slug) as $key) {
|
||||||
|
$value = $config[$key] ?? null;
|
||||||
|
if ($value === null || $value === '' || $value === []) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use App\Models\Tenant;
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Repositories\EventRepository;
|
use App\Repositories\EventRepository;
|
||||||
use App\Repositories\PageTextRepository;
|
use App\Repositories\PageTextRepository;
|
||||||
|
use App\Resources\AvailablePaymentMethodResource;
|
||||||
use App\Resources\UserResource;
|
use App\Resources\UserResource;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
@@ -183,12 +184,13 @@ class GlobalDataProvider {
|
|||||||
return $activeUsers;
|
return $activeUsers;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getEventSettingData() : JsonResponse {
|
public function getEventSettingData(Request $request) : JsonResponse {
|
||||||
return response()->json(
|
return response()->json(
|
||||||
[
|
[
|
||||||
'localGroups' => Tenant::where(['is_active_local_group' => true])->get(),
|
'localGroups' => Tenant::where(['is_active_local_group' => true])->get(),
|
||||||
'eatingHabits' => EatingHabit::all(),
|
'eatingHabits' => EatingHabit::all(),
|
||||||
'paymentMethods' => AvailablePaymentMethod::where('active', true)->get(),
|
'paymentMethods' => AvailablePaymentMethod::where('active', true)->get()
|
||||||
|
->map(fn (AvailablePaymentMethod $method) => new AvailablePaymentMethodResource($method)->toArray($request)),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,22 @@
|
|||||||
|
|
||||||
namespace App\RelationModels;
|
namespace App\RelationModels;
|
||||||
|
|
||||||
use App\Scopes\CommonModel;
|
use Illuminate\Database\Eloquent\Relations\Pivot;
|
||||||
|
|
||||||
class EventPaymentMethods extends CommonModel
|
/**
|
||||||
|
* @property int $event_id
|
||||||
|
* @property string $slug
|
||||||
|
* @property ?array $configuration
|
||||||
|
*/
|
||||||
|
class EventPaymentMethods extends Pivot
|
||||||
{
|
{
|
||||||
protected $table = 'event_payment_methods';
|
protected $table = 'event_payment_methods';
|
||||||
protected $fillable = ['event_id', 'slug'];
|
|
||||||
|
public $incrementing = true;
|
||||||
|
|
||||||
|
protected $fillable = ['event_id', 'slug', 'configuration'];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'configuration' => 'array',
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Resources;
|
namespace App\Resources;
|
||||||
|
|
||||||
use App\Models\AvailablePaymentMethod;
|
use App\Models\AvailablePaymentMethod;
|
||||||
|
use App\Models\PaymentMethod;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
class AvailablePaymentMethodResource extends JsonResource {
|
class AvailablePaymentMethodResource extends JsonResource {
|
||||||
@@ -13,12 +14,21 @@ class AvailablePaymentMethodResource extends JsonResource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function toArray($request) : array {
|
public function toArray($request) : array {
|
||||||
|
// Wird die Instanz über Event::paymentMethods() geladen, liegt die pro-Event-Konfiguration
|
||||||
|
// im Pivot; ansonsten (Tenant-Kontext) gilt die Konfiguration der Instanz selbst.
|
||||||
|
$pivot = $this->availablePaymentMethod->pivot ?? null;
|
||||||
|
$configuration = $pivot !== null
|
||||||
|
? ($pivot->configuration ?? [])
|
||||||
|
: ($this->availablePaymentMethod->configuration ?? []);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $this->availablePaymentMethod->id,
|
'id' => $this->availablePaymentMethod->id,
|
||||||
'slug' => $this->availablePaymentMethod->slug,
|
'slug' => $this->availablePaymentMethod->slug,
|
||||||
'name' => $this->availablePaymentMethod->name,
|
'name' => $this->availablePaymentMethod->name,
|
||||||
'description' => $this->availablePaymentMethod->description,
|
'description' => $this->availablePaymentMethod->description,
|
||||||
'active' => $this->availablePaymentMethod->active,
|
'active' => $this->availablePaymentMethod->active,
|
||||||
|
'configuration' => (object) $configuration,
|
||||||
|
'optionsSchema' => PaymentMethod::optionsFor($this->availablePaymentMethod->slug),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration {
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('available_payment_methods', function (Blueprint $table) {
|
||||||
|
$table->json('configuration')->nullable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('available_payment_methods', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('configuration');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration {
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('event_payment_methods', function (Blueprint $table) {
|
||||||
|
$table->json('configuration')->nullable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('event_payment_methods', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('configuration');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reparatur-Migration für eine Drift in event_payment_methods:
|
||||||
|
*
|
||||||
|
* Eine frühere Version von 2026_07_28_140020_create_event_payment_methods hatte die Verknüpfung
|
||||||
|
* über available_payment_method_id (FK auf available_payment_methods.id) modelliert. Die Migration
|
||||||
|
* wurde später in-place auf slug (FK auf payment_methods.slug) umgestellt -- Systeme, die noch die
|
||||||
|
* alte Version ausgeführt haben, tragen daher available_payment_method_id statt slug.
|
||||||
|
*
|
||||||
|
* Diese Migration konvertiert solche Systeme verlustfrei auf slug. Auf Systemen, die bereits die
|
||||||
|
* korrekte slug-Struktur haben (frische Installationen), ist sie ein No-Op.
|
||||||
|
*/
|
||||||
|
return new class extends Migration {
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
// Frisch / bereits korrekt -> nichts zu tun.
|
||||||
|
if (!Schema::hasColumn('event_payment_methods', 'available_payment_method_id')
|
||||||
|
|| Schema::hasColumn('event_payment_methods', 'slug')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. slug zunächst nullable anlegen (für den Backfill).
|
||||||
|
Schema::table('event_payment_methods', function (Blueprint $table) {
|
||||||
|
$table->string('slug')->nullable()->after('event_id');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. slug aus der verknüpften available_payment_methods-Zeile übernehmen.
|
||||||
|
DB::statement('
|
||||||
|
UPDATE event_payment_methods epm
|
||||||
|
JOIN available_payment_methods apm ON apm.id = epm.available_payment_method_id
|
||||||
|
SET epm.slug = apm.slug
|
||||||
|
');
|
||||||
|
|
||||||
|
// 3. Alte Spalte samt FK entfernen.
|
||||||
|
Schema::table('event_payment_methods', function (Blueprint $table) {
|
||||||
|
$table->dropForeign(['available_payment_method_id']);
|
||||||
|
$table->dropColumn('available_payment_method_id');
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. slug final absichern: NOT NULL, Unique (event_id, slug), FK auf payment_methods.slug.
|
||||||
|
Schema::table('event_payment_methods', function (Blueprint $table) {
|
||||||
|
$table->string('slug')->nullable(false)->change();
|
||||||
|
$table->unique(['event_id', 'slug']);
|
||||||
|
$table->foreign('slug')->references('slug')->on('payment_methods')->restrictOnDelete()->cascadeOnUpdate();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
// Nur umkehren, wenn tatsächlich die slug-Struktur vorliegt.
|
||||||
|
if (!Schema::hasColumn('event_payment_methods', 'slug')
|
||||||
|
|| Schema::hasColumn('event_payment_methods', 'available_payment_method_id')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Schema::table('event_payment_methods', function (Blueprint $table) {
|
||||||
|
$table->unsignedBigInteger('available_payment_method_id')->nullable()->after('event_id');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Backfill über den Tenant des Events, da available_payment_methods pro (tenant, slug) eindeutig ist.
|
||||||
|
DB::statement('
|
||||||
|
UPDATE event_payment_methods epm
|
||||||
|
JOIN events e ON e.id = epm.event_id
|
||||||
|
JOIN available_payment_methods apm ON apm.slug = epm.slug AND apm.tenant = e.tenant
|
||||||
|
SET epm.available_payment_method_id = apm.id
|
||||||
|
');
|
||||||
|
|
||||||
|
Schema::table('event_payment_methods', function (Blueprint $table) {
|
||||||
|
$table->dropForeign(['slug']);
|
||||||
|
$table->dropUnique(['event_id', 'slug']);
|
||||||
|
$table->dropColumn('slug');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('event_payment_methods', function (Blueprint $table) {
|
||||||
|
$table->unsignedBigInteger('available_payment_method_id')->nullable(false)->change();
|
||||||
|
$table->foreign('available_payment_method_id')->references('id')->on('available_payment_methods')->cascadeOnUpdate();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user