From 88a66235039219731554335e516f22986b416d60 Mon Sep 17 00:00:00 2001 From: HolgerHatGarKeineNode Date: Sat, 31 Jan 2026 11:03:47 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=97=20Add=20unique=20`pleb+year`=20con?= =?UTF-8?q?straint=20to=20`payment=5Fevents`=20and=20ensure=20migration=20?= =?UTF-8?q?handles=20duplicates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 🧹 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 --- ...leb_year_index_to_payment_events_table.php | 52 +++ .../livewire/association/profile.blade.php | 371 ++++++++++++++---- .../Livewire/Association/ProfileTest.php | 122 ++++++ 3 files changed, 479 insertions(+), 66 deletions(-) create mode 100644 database/migrations/2026_01_31_085840_add_unique_pleb_year_index_to_payment_events_table.php diff --git a/database/migrations/2026_01_31_085840_add_unique_pleb_year_index_to_payment_events_table.php b/database/migrations/2026_01_31_085840_add_unique_pleb_year_index_to_payment_events_table.php new file mode 100644 index 0000000..0c0434b --- /dev/null +++ b/database/migrations/2026_01_31_085840_add_unique_pleb_year_index_to_payment_events_table.php @@ -0,0 +1,52 @@ +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'); + }); + } +}; diff --git a/resources/views/livewire/association/profile.blade.php b/resources/views/livewire/association/profile.blade.php index e25e901..5536c81 100644 --- a/resources/views/livewire/association/profile.blade.php +++ b/resources/views/livewire/association/profile.blade.php @@ -3,9 +3,13 @@ use App\Livewire\Forms\ApplicationForm; use App\Livewire\Forms\ProfileForm; use App\Models\EinundzwanzigPleb; +use App\Models\PaymentEvent; use App\Support\NostrAuth; use App\Traits\NostrFetcherTrait; +use Carbon\Carbon; use Flux\Flux; +use Illuminate\Support\Collection; +use Illuminate\Support\Str; use Livewire\Component; use swentel\nostr\Event\Event as NostrEvent; use swentel\nostr\Filter\Filter; @@ -44,6 +48,20 @@ new class extends Component { 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 bool $currentYearIsPaid = false; @@ -61,43 +79,43 @@ new class extends Component { public function mount(): void { - if (NostrAuth::check()) { - $this->currentPubkey = NostrAuth::pubkey(); - $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 (!NostrAuth::check()) { + return; + } - if ($this->currentPleb->nip05_handle) { - // Get all NIP-05 handles for the current pubkey - $this->nip05VerifiedHandles = $this->getNip05HandlesForPubkey($this->currentPubkey); + $this->currentPubkey = NostrAuth::pubkey(); + $this->currentPleb = EinundzwanzigPleb::query() + ->with([ + 'paymentEvents' => fn($query) => $query->where('year', date('Y')), + 'profile', + ]) + ->where('pubkey', $this->currentPubkey)->first(); - if (count($this->nip05VerifiedHandles) > 0) { - $this->nip05Verified = true; - $this->nip05VerifiedHandle = $this->nip05VerifiedHandles[0]; + if (!$this->currentPleb) { + return; + } - // Check if verified handle differs from database handle - if (!in_array($this->profileForm->nip05Handle, $this->nip05VerifiedHandles, true)) { - $this->nip05HandleMismatch = true; - } - } + $this->profileForm->setPleb($this->currentPleb); + $this->form->setPleb($this->currentPleb); + + 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 @@ -170,10 +188,14 @@ new class extends Component { public function pay($comment): mixed { - $paymentEvent = $this->currentPleb - ->paymentEvents() - ->where('year', date('Y')) - ->first(); + if (!$this->currentPleb) { + return redirect()->route('association.profile'); + } + + $paymentEvent = $this->resolveCurrentPaymentEvent(); + $this->resetInvoiceMeta(); + $paymentEvent = $this->syncPaymentEventStatus($paymentEvent); + if ($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(); - $paymentEvent->btc_pay_invoice = $response->json()['id']; + + $invoice = $response->json(); + $paymentEvent->btc_pay_invoice = $invoice['id']; $paymentEvent->save(); - return redirect()->away($response->json()['checkoutLink']); - } catch (\Exception $e) { + $this->applyInvoiceMeta($invoice); + $this->invoiceStatusVariant = 'info'; + $this->invoiceStatusMessage = 'Rechnung erstellt. Bitte bezahle sie vor Ablauf.'; + + return redirect()->away($invoice['checkoutLink']); + } catch (\Throwable $e) { Flux::toast( 'Fehler beim Erstellen der Rechnung. Bitte versuche es später erneut: '.$e->getMessage(), variant: 'danger', @@ -218,38 +246,214 @@ new class extends Component { public function listenForPayment(): void { - $paymentEvent = $this->currentPleb - ->paymentEvents() - ->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 (!$this->currentPleb) { + return; } - if ($paymentEvent->paid) { - $this->currentYearIsPaid = true; - } - $paymentEvent = $paymentEvent->refresh(); + + $paymentEvent = $this->resolveCurrentPaymentEvent(); + + $this->resetInvoiceMeta(); + + $paymentEvent = $this->syncPaymentEventStatus($paymentEvent); + $this->currentYearIsPaid = (bool) $paymentEvent->paid; + $this->payments = $this->currentPleb ->paymentEvents() ->where('paid', true) ->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 { 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->setKind(32121); $note->setContent( @@ -286,7 +498,7 @@ new class extends Component { $relay->setMessage($eventMessage); $result = $relay->send(); - $this->currentPleb->paymentEvents()->create([ + return $this->currentPleb->paymentEvents()->create([ 'year' => date('Y'), 'event_id' => $result->eventId, 'amount' => $this->amountToPay, @@ -1039,7 +1251,7 @@ new class extends Component { @if($currentPleb && $currentPleb->association_status->value > 1) - +
@@ -1055,6 +1267,33 @@ new class extends Component {

+ @if($invoiceStatusMessage) + +
+ +
+

+ {{ $invoiceStatusLabel ?? 'Rechnungsstatus' }} + @if($invoiceStatus) + ({{ $invoiceStatus }}) + @endif +

+

+ {{ $invoiceStatusMessage }} +

+ @if($invoiceExpiresIn && in_array($invoiceStatus, ['New', 'Processing'], true)) +

+ Gültig noch {{ $invoiceExpiresIn }} + @if($invoiceExpiresAtDisplay) + (bis {{ $invoiceExpiresAtDisplay }}) + @endif +

+ @endif +
+
+
+ @endif + @php $latestEvent = collect($events)->sortByDesc('created_at')->first(); @endphp diff --git a/tests/Feature/Livewire/Association/ProfileTest.php b/tests/Feature/Livewire/Association/ProfileTest.php index 960330a..f7b503e 100644 --- a/tests/Feature/Livewire/Association/ProfileTest.php +++ b/tests/Feature/Livewire/Association/ProfileTest.php @@ -168,3 +168,125 @@ it('can initiate payment', function () { $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); +});