mirror of
https://github.com/HolgerHatGarKeineNode/einundzwanzig-nostr.git
synced 2026-02-04 15:53:17 +00:00
🔗 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:
@@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user