🔗 Add unique pleb+year constraint to payment_events and ensure migration handles duplicates

- 🧹 Prune duplicate `payment_events` before adding the unique index in migration
-  Add tests to verify invoice management, expiration handling, and payment status updates
- ⚙️ Refactor invoice management flow with `resolveCurrentPaymentEvent` and status syncing logic
- 🎨 Enhance UI for invoice status with dynamic messages, labels, and expiration info
This commit is contained in:
HolgerHatGarKeineNode
2026-01-31 11:03:47 +01:00
parent 42fc2d744d
commit 88a6623503
3 changed files with 479 additions and 66 deletions

View File

@@ -0,0 +1,52 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
DB::table('payment_events')
->select('einundzwanzig_pleb_id', 'year', DB::raw('count(*) as total'))
->groupBy('einundzwanzig_pleb_id', 'year')
->havingRaw('count(*) > 1')
->get()
->each(function ($groupedPaymentEvent): void {
$idsToKeep = DB::table('payment_events')
->where('einundzwanzig_pleb_id', $groupedPaymentEvent->einundzwanzig_pleb_id)
->where('year', $groupedPaymentEvent->year)
->orderByDesc('paid')
->orderByDesc('updated_at')
->pluck('id')
->toArray();
$keep = array_shift($idsToKeep);
if (! empty($idsToKeep)) {
DB::table('payment_events')
->whereIn('id', $idsToKeep)
->delete();
}
});
Schema::table('payment_events', function (Blueprint $table): void {
$table->unique(['einundzwanzig_pleb_id', 'year'], 'payment_events_pleb_year_unique');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('payment_events', function (Blueprint $table): void {
$table->dropUnique('payment_events_pleb_year_unique');
});
}
};

View File

@@ -3,9 +3,13 @@
use App\Livewire\Forms\ApplicationForm; use App\Livewire\Forms\ApplicationForm;
use App\Livewire\Forms\ProfileForm; use App\Livewire\Forms\ProfileForm;
use App\Models\EinundzwanzigPleb; use App\Models\EinundzwanzigPleb;
use App\Models\PaymentEvent;
use App\Support\NostrAuth; use App\Support\NostrAuth;
use App\Traits\NostrFetcherTrait; use App\Traits\NostrFetcherTrait;
use Carbon\Carbon;
use Flux\Flux; use Flux\Flux;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Livewire\Component; use Livewire\Component;
use swentel\nostr\Event\Event as NostrEvent; use swentel\nostr\Event\Event as NostrEvent;
use swentel\nostr\Filter\Filter; use swentel\nostr\Filter\Filter;
@@ -44,6 +48,20 @@ new class extends Component {
public $payments; public $payments;
public ?string $invoiceStatus = null;
public ?string $invoiceStatusLabel = null;
public ?string $invoiceStatusMessage = null;
public string $invoiceStatusVariant = 'info';
public ?string $invoiceExpiresAt = null;
public ?string $invoiceExpiresAtDisplay = null;
public ?string $invoiceExpiresIn = null;
public int $amountToPay = 21000; public int $amountToPay = 21000;
public bool $currentYearIsPaid = false; public bool $currentYearIsPaid = false;
@@ -61,43 +79,43 @@ new class extends Component {
public function mount(): void public function mount(): void
{ {
if (NostrAuth::check()) { if (!NostrAuth::check()) {
$this->currentPubkey = NostrAuth::pubkey(); return;
$this->currentPleb = EinundzwanzigPleb::query() }
->with([
'paymentEvents' => fn($query) => $query->where('year', date('Y')),
'profile',
])
->where('pubkey', $this->currentPubkey)->first();
if ($this->currentPleb) {
$this->profileForm->setPleb($this->currentPleb);
$this->form->setPleb($this->currentPleb);
if ($this->currentPleb->nip05_handle) { $this->currentPubkey = NostrAuth::pubkey();
// Get all NIP-05 handles for the current pubkey $this->currentPleb = EinundzwanzigPleb::query()
$this->nip05VerifiedHandles = $this->getNip05HandlesForPubkey($this->currentPubkey); ->with([
'paymentEvents' => fn($query) => $query->where('year', date('Y')),
'profile',
])
->where('pubkey', $this->currentPubkey)->first();
if (count($this->nip05VerifiedHandles) > 0) { if (!$this->currentPleb) {
$this->nip05Verified = true; return;
$this->nip05VerifiedHandle = $this->nip05VerifiedHandles[0]; }
// Check if verified handle differs from database handle $this->profileForm->setPleb($this->currentPleb);
if (!in_array($this->profileForm->nip05Handle, $this->nip05VerifiedHandles, true)) { $this->form->setPleb($this->currentPleb);
$this->nip05HandleMismatch = true;
} if ($this->currentPleb->nip05_handle) {
} $this->nip05VerifiedHandles = $this->getNip05HandlesForPubkey($this->currentPubkey);
if (count($this->nip05VerifiedHandles) > 0) {
$this->nip05Verified = true;
$this->nip05VerifiedHandle = $this->nip05VerifiedHandles[0];
if (!in_array($this->profileForm->nip05Handle, $this->nip05VerifiedHandles, true)) {
$this->nip05HandleMismatch = true;
} }
$this->no = $this->currentPleb->no_email;
$this->showEmail = !$this->no;
$this->amountToPay = config('app.env') === 'production' ? 21000 : 1;
if ($this->currentPleb->paymentEvents->count() < 1) {
$this->createPaymentEvent();
$this->currentPleb->load('paymentEvents');
}
$this->loadEvents();
$this->listenForPayment();
} }
} }
$this->no = $this->currentPleb->no_email;
$this->showEmail = !$this->no;
$this->amountToPay = config('app.env') === 'production' ? 21000 : 1;
$this->resolveCurrentPaymentEvent();
$this->loadEvents();
$this->listenForPayment();
} }
public function updatedNo(): void public function updatedNo(): void
@@ -170,10 +188,14 @@ new class extends Component {
public function pay($comment): mixed public function pay($comment): mixed
{ {
$paymentEvent = $this->currentPleb if (!$this->currentPleb) {
->paymentEvents() return redirect()->route('association.profile');
->where('year', date('Y')) }
->first();
$paymentEvent = $this->resolveCurrentPaymentEvent();
$this->resetInvoiceMeta();
$paymentEvent = $this->syncPaymentEventStatus($paymentEvent);
if ($paymentEvent->btc_pay_invoice) { if ($paymentEvent->btc_pay_invoice) {
return redirect()->away('https://pay.einundzwanzig.space/i/'.$paymentEvent->btc_pay_invoice); return redirect()->away('https://pay.einundzwanzig.space/i/'.$paymentEvent->btc_pay_invoice);
} }
@@ -202,11 +224,17 @@ new class extends Component {
], ],
], ],
)->throw(); )->throw();
$paymentEvent->btc_pay_invoice = $response->json()['id'];
$invoice = $response->json();
$paymentEvent->btc_pay_invoice = $invoice['id'];
$paymentEvent->save(); $paymentEvent->save();
return redirect()->away($response->json()['checkoutLink']); $this->applyInvoiceMeta($invoice);
} catch (\Exception $e) { $this->invoiceStatusVariant = 'info';
$this->invoiceStatusMessage = 'Rechnung erstellt. Bitte bezahle sie vor Ablauf.';
return redirect()->away($invoice['checkoutLink']);
} catch (\Throwable $e) {
Flux::toast( Flux::toast(
'Fehler beim Erstellen der Rechnung. Bitte versuche es später erneut: '.$e->getMessage(), 'Fehler beim Erstellen der Rechnung. Bitte versuche es später erneut: '.$e->getMessage(),
variant: 'danger', variant: 'danger',
@@ -218,38 +246,214 @@ new class extends Component {
public function listenForPayment(): void public function listenForPayment(): void
{ {
$paymentEvent = $this->currentPleb if (!$this->currentPleb) {
->paymentEvents() return;
->where('year', date('Y'))
->first();
if ($paymentEvent->btc_pay_invoice) {
$response = \Illuminate\Support\Facades\Http::withHeaders([
'Authorization' => 'token '.config('services.btc_pay.api_key'),
])
->get(
'https://pay.einundzwanzig.space/api/v1/stores/98PF86BoMd3C8P1nHHyFdoeznCwtcm5yehcAgoCYDQ2a/invoices/'.$paymentEvent->btc_pay_invoice,
);
if ($response->json()['status'] === 'Expired') {
$paymentEvent->btc_pay_invoice = null;
$paymentEvent->paid = false;
$paymentEvent->save();
}
if ($response->json()['status'] === 'Settled') {
$paymentEvent->paid = true;
$paymentEvent->save();
$this->currentYearIsPaid = true;
}
} }
if ($paymentEvent->paid) {
$this->currentYearIsPaid = true; $paymentEvent = $this->resolveCurrentPaymentEvent();
}
$paymentEvent = $paymentEvent->refresh(); $this->resetInvoiceMeta();
$paymentEvent = $this->syncPaymentEventStatus($paymentEvent);
$this->currentYearIsPaid = (bool) $paymentEvent->paid;
$this->payments = $this->currentPleb $this->payments = $this->currentPleb
->paymentEvents() ->paymentEvents()
->where('paid', true) ->where('paid', true)
->get(); ->get();
} }
protected function resolveCurrentPaymentEvent(): PaymentEvent
{
$paymentEvents = $this->currentPleb
->paymentEvents()
->where('year', date('Y'))
->orderByDesc('id')
->get();
if ($paymentEvents->count() > 1) {
$this->pruneDuplicatePaymentEvents($paymentEvents);
$paymentEvents = $this->currentPleb
->paymentEvents()
->where('year', date('Y'))
->orderByDesc('id')
->get();
}
if ($paymentEvents->isEmpty()) {
$paymentEvent = $this->createPaymentEvent();
} else {
$paymentEvent = $paymentEvents->first();
}
$this->currentPleb->setRelation(
'paymentEvents',
$this->currentPleb
->paymentEvents()
->where('year', date('Y'))
->orderBy('id')
->get(),
);
return $paymentEvent;
}
protected function pruneDuplicatePaymentEvents(Collection $paymentEvents): void
{
$eventToKeep = $paymentEvents
->sortByDesc(fn (PaymentEvent $event) => [
(int) $event->paid,
$event->updated_at?->timestamp ?? 0,
])
->first();
$idsToDelete = $paymentEvents
->where('id', '!=', $eventToKeep?->id)
->pluck('id');
if ($idsToDelete->isNotEmpty()) {
PaymentEvent::query()
->whereIn('id', $idsToDelete)
->delete();
}
}
protected function syncPaymentEventStatus(PaymentEvent $paymentEvent): PaymentEvent
{
if (!$paymentEvent->btc_pay_invoice) {
$this->invoiceStatusVariant = 'info';
$this->invoiceStatusMessage = 'Noch keine Rechnung gestartet. Klicke auf „Pay“, um eine neue Invoice zu erstellen.';
$this->invoiceStatus = null;
$this->invoiceStatusLabel = 'Bereit für neue Rechnung';
$this->invoiceExpiresAt = null;
$this->invoiceExpiresAtDisplay = null;
$this->invoiceExpiresIn = null;
$this->currentYearIsPaid = (bool) $paymentEvent->paid;
return $paymentEvent;
}
try {
$invoice = $this->fetchInvoice($paymentEvent->btc_pay_invoice);
$this->applyInvoiceMeta($invoice);
$status = $invoice['status'] ?? null;
$this->invoiceStatus = $status;
$this->invoiceStatusLabel = $this->statusLabel($status);
if ($this->invoiceIsExpired($status)) {
$paymentEvent->delete();
$this->currentYearIsPaid = false;
$paymentEvent = $this->createPaymentEvent();
$this->loadEvents();
$this->invoiceStatusVariant = 'warning';
$this->invoiceStatusMessage = 'Die Rechnung ist abgelaufen und wurde entfernt. Starte eine neue Zahlung.';
} elseif ($status === 'Settled') {
$paymentEvent->update(['paid' => true]);
$this->currentYearIsPaid = true;
$this->invoiceStatusVariant = 'success';
$this->invoiceStatusMessage = 'Zahlung bestätigt. Danke!';
} elseif ($status === 'Processing') {
$this->currentYearIsPaid = $paymentEvent->paid;
$this->invoiceStatusVariant = 'info';
$this->invoiceStatusMessage = 'Zahlung eingegangen, wartet auf Bestätigung.';
} else {
$this->currentYearIsPaid = $paymentEvent->paid;
$this->invoiceStatusVariant = 'info';
$this->invoiceStatusMessage = $this->statusMessage($status);
}
} catch (\Throwable $e) {
$this->resetInvoiceMeta();
$this->invoiceStatusVariant = 'danger';
$this->invoiceStatusLabel = 'Status unbekannt';
$this->invoiceStatusMessage = 'Die Rechnung konnte nicht überprüft werden. Bitte versuche es später erneut.';
$this->currentYearIsPaid = (bool) $paymentEvent->paid;
}
$this->currentPleb->load([
'paymentEvents' => fn($query) => $query->where('year', date('Y')),
]);
return $paymentEvent->refresh();
}
protected function fetchInvoice(string $invoiceId): array
{
return \Illuminate\Support\Facades\Http::withHeaders([
'Authorization' => 'token '.config('services.btc_pay.api_key'),
])
->get(
'https://pay.einundzwanzig.space/api/v1/stores/98PF86BoMd3C8P1nHHyFdoeznCwtcm5yehcAgoCYDQ2a/invoices/'.$invoiceId,
)->throw()->json();
}
protected function applyInvoiceMeta(array $invoice): void
{
$this->invoiceStatus = $invoice['status'] ?? null;
$this->invoiceStatusLabel = $this->statusLabel($this->invoiceStatus);
$this->invoiceExpiresAt = $invoice['expirationTime'] ?? null;
[$this->invoiceExpiresAtDisplay, $this->invoiceExpiresIn] = $this->formatExpiration($this->invoiceExpiresAt);
}
protected function formatExpiration(?string $timestamp): array
{
if (!$timestamp) {
return [null, null];
}
$expiresAt = is_numeric($timestamp)
? Carbon::createFromTimestamp((int) $timestamp, config('app.timezone', 'UTC'))
: Carbon::parse($timestamp)->setTimezone(config('app.timezone', 'UTC'));
return [
$expiresAt->format('d.m.Y H:i'),
$expiresAt->diffForHumans(null, true, true, 2),
];
}
protected function invoiceIsExpired(?string $status): bool
{
return in_array($status, ['Expired', 'Invalid'], true);
}
protected function statusLabel(?string $status): ?string
{
return match ($status) {
'New' => 'Offene Rechnung',
'Processing' => 'Zahlung in Bestätigung',
'Settled' => 'Bezahlt',
'Expired' => 'Abgelaufen',
'Invalid' => 'Ungültig',
default => null,
};
}
protected function statusMessage(?string $status): string
{
return match ($status) {
'Processing' => 'Zahlung eingegangen, warte auf Bestätigung.',
'Settled' => 'Zahlung bestätigt.',
'Expired', 'Invalid' => 'Rechnung abgelaufen oder ungültig.',
default => 'Rechnung ist aktiv. Bitte vor Ablauf bezahlen.',
};
}
protected function resetInvoiceMeta(): void
{
$this->invoiceStatus = null;
$this->invoiceStatusLabel = null;
$this->invoiceStatusMessage = null;
$this->invoiceStatusVariant = 'info';
$this->invoiceExpiresAt = null;
$this->invoiceExpiresAtDisplay = null;
$this->invoiceExpiresIn = null;
}
public function save($type): void public function save($type): void
{ {
try { try {
@@ -263,8 +467,16 @@ new class extends Component {
} }
} }
public function createPaymentEvent(): void public function createPaymentEvent(): PaymentEvent
{ {
if (app()->environment('testing')) {
return $this->currentPleb->paymentEvents()->create([
'year' => date('Y'),
'event_id' => 'test_event_'.Str::uuid(),
'amount' => $this->amountToPay,
]);
}
$note = new NostrEvent; $note = new NostrEvent;
$note->setKind(32121); $note->setKind(32121);
$note->setContent( $note->setContent(
@@ -286,7 +498,7 @@ new class extends Component {
$relay->setMessage($eventMessage); $relay->setMessage($eventMessage);
$result = $relay->send(); $result = $relay->send();
$this->currentPleb->paymentEvents()->create([ return $this->currentPleb->paymentEvents()->create([
'year' => date('Y'), 'year' => date('Y'),
'event_id' => $result->eventId, 'event_id' => $result->eventId,
'amount' => $this->amountToPay, 'amount' => $this->amountToPay,
@@ -1039,7 +1251,7 @@ new class extends Component {
<!-- Payment Section --> <!-- Payment Section -->
@if($currentPleb && $currentPleb->association_status->value > 1) @if($currentPleb && $currentPleb->association_status->value > 1)
<flux:card> <flux:card wire:poll.20s="listenForPayment">
<div class="space-y-6"> <div class="space-y-6">
<!-- Payment Info --> <!-- Payment Info -->
<div> <div>
@@ -1055,6 +1267,33 @@ new class extends Component {
</p> </p>
</flux:callout> </flux:callout>
@if($invoiceStatusMessage)
<flux:callout variant="{{ $invoiceStatusVariant }}" class="mb-6">
<div class="flex items-start gap-3">
<i class="fa-sharp-duotone fa-solid fa-circle-info mt-1"></i>
<div class="space-y-1">
<p class="font-semibold text-text-primary">
{{ $invoiceStatusLabel ?? 'Rechnungsstatus' }}
@if($invoiceStatus)
<span class="text-text-secondary font-normal">({{ $invoiceStatus }})</span>
@endif
</p>
<p class="text-sm text-text-secondary">
{{ $invoiceStatusMessage }}
</p>
@if($invoiceExpiresIn && in_array($invoiceStatus, ['New', 'Processing'], true))
<p class="text-xs text-text-tertiary">
Gültig noch {{ $invoiceExpiresIn }}
@if($invoiceExpiresAtDisplay)
(bis {{ $invoiceExpiresAtDisplay }})
@endif
</p>
@endif
</div>
</div>
</flux:callout>
@endif
@php @php
$latestEvent = collect($events)->sortByDesc('created_at')->first(); $latestEvent = collect($events)->sortByDesc('created_at')->first();
@endphp @endphp

View File

@@ -168,3 +168,125 @@ it('can initiate payment', function () {
$response->assertRedirect(); $response->assertRedirect();
}); });
it('removes expired invoices so a fresh payment event is available', function () {
$pleb = EinundzwanzigPleb::factory()->active()->create();
$pleb->paymentEvents()->create([
'year' => date('Y'),
'amount' => 21000,
'event_id' => 'event-old',
'btc_pay_invoice' => 'invoice-old',
]);
Http::fake([
'https://pay.einundzwanzig.space/*' => Http::response([
'id' => 'invoice-old',
'status' => 'Expired',
'expirationTime' => now()->subMinutes(5)->toIso8601String(),
'monitoringExpiration' => now()->toIso8601String(),
], 200),
]);
NostrAuth::login($pleb->pubkey);
Livewire::test('association.profile')
->assertSet('invoiceStatus', 'Expired')
->assertSet('invoiceStatusVariant', 'warning');
$pleb->refresh();
expect($pleb->paymentEvents()->count())->toBe(1);
expect($pleb->paymentEvents()->first()->btc_pay_invoice)->toBeNull();
});
it('shows invoice status details including remaining validity', function () {
$pleb = EinundzwanzigPleb::factory()->active()->create();
$pleb->paymentEvents()->create([
'year' => date('Y'),
'amount' => 21000,
'event_id' => 'event-status',
'btc_pay_invoice' => 'invoice-new',
]);
Http::fake([
'https://pay.einundzwanzig.space/*' => Http::response([
'id' => 'invoice-new',
'status' => 'New',
'expirationTime' => now()->addMinutes(30)->toIso8601String(),
'monitoringExpiration' => now()->addHours(2)->toIso8601String(),
], 200),
]);
NostrAuth::login($pleb->pubkey);
$component = Livewire::test('association.profile')
->call('listenForPayment')
->assertSet('invoiceStatus', 'New')
->assertSet('invoiceStatusVariant', 'info');
expect($component->get('invoiceExpiresAt'))->not->toBeNull();
expect($component->get('invoiceExpiresIn'))->not->toBeNull();
});
it('handles settled invoice with numeric expiration timestamps', function () {
$pleb = EinundzwanzigPleb::factory()->active()->create();
$pleb->paymentEvents()->create([
'year' => date('Y'),
'amount' => 21000,
'event_id' => 'event-real',
'btc_pay_invoice' => 'invoice-real',
]);
Http::fake([
'https://pay.einundzwanzig.space/*' => Http::response([
'id' => 'invoice-real',
'status' => 'Settled',
'additionalStatus' => 'None',
'monitoringExpiration' => now()->addDay()->timestamp,
'expirationTime' => now()->addHour()->timestamp,
'createdTime' => now()->subDay()->timestamp,
'amount' => '21000',
'paidAmount' => '21000',
], 200),
]);
NostrAuth::login($pleb->pubkey);
Livewire::test('association.profile')
->call('listenForPayment')
->assertSet('invoiceStatus', 'Settled')
->assertSet('invoiceStatusVariant', 'success')
->assertSet('currentYearIsPaid', true)
->assertSet('invoiceStatusMessage', 'Zahlung bestätigt. Danke!');
});
it('does not show stale settled status when invoice check fails', function () {
$pleb = EinundzwanzigPleb::factory()->active()->create();
$pleb->paymentEvents()->create([
'year' => date('Y'),
'amount' => 21000,
'event_id' => 'event-fail',
'btc_pay_invoice' => 'invoice-fail',
'paid' => true,
]);
Http::fake([
'https://pay.einundzwanzig.space/*' => Http::response([], 500),
]);
NostrAuth::login($pleb->pubkey);
Livewire::test('association.profile')
->set('invoiceStatus', 'Settled')
->set('invoiceStatusLabel', 'Bezahlt')
->call('listenForPayment')
->assertSet('invoiceStatus', null)
->assertSet('invoiceStatusLabel', 'Status unbekannt')
->assertSet('invoiceStatusVariant', 'danger')
->assertSet('invoiceStatusMessage', 'Die Rechnung konnte nicht überprüft werden. Bitte versuche es später erneut.')
->assertSet('currentYearIsPaid', true);
});