diff --git a/app/Http/Controllers/Admin/AdminPaymentController.php b/app/Http/Controllers/Admin/AdminPaymentController.php new file mode 100644 index 000000000..ce4466bc6 --- /dev/null +++ b/app/Http/Controllers/Admin/AdminPaymentController.php @@ -0,0 +1,72 @@ + + */ + private const ALLOWED_SORT_COLUMNS = [ + 'id', + 'created_at', + 'username', + 'email', + 'invoice_amount', + 'payment_status', + 'invoice_status', + 'order_id', + ]; + + public function index(Request $request): View + { + $this->setAdminPrefs(); + + $meta_title = $title = 'Payments'; + + $filters = [ + 'username' => $request->string('username')->trim()->value(), + 'email' => $request->string('email')->trim()->value(), + 'payment_status' => $request->string('payment_status')->trim()->value(), + 'invoice_status' => $request->string('invoice_status')->trim()->value(), + ]; + + $sort = $request->string('sort')->value(); + if (! \in_array($sort, self::ALLOWED_SORT_COLUMNS, true)) { + $sort = 'created_at'; + } + + $order = strtolower($request->string('order')->value()) === 'asc' ? 'asc' : 'desc'; + + $payments = Payment::query() + ->filter($filters) + ->orderBy($sort, $order) + ->paginate((int) config('nntmux.items_per_page')) + ->withQueryString(); + + $paymentStatuses = BtcPaymentController::paymentStatusesForAdminFilter(); + $invoiceStatuses = BtcPaymentController::invoiceStatusesForAdminFilter(); + + $this->viewData = array_merge($this->viewData, [ + 'payments' => $payments, + 'filters' => $filters, + 'sort' => $sort, + 'order' => $order, + 'paymentStatuses' => $paymentStatuses, + 'invoiceStatuses' => $invoiceStatuses, + 'title' => $title, + 'meta_title' => $meta_title, + 'page_title' => $title, + ]); + + return view('admin.payments.index', $this->viewData); + } +} diff --git a/app/Http/Controllers/BtcPaymentController.php b/app/Http/Controllers/BtcPaymentController.php index dbeabfb5b..24bbe94f6 100644 --- a/app/Http/Controllers/BtcPaymentController.php +++ b/app/Http/Controllers/BtcPaymentController.php @@ -28,7 +28,7 @@ class BtcPaymentController extends BasePageController if ($payload['type'] === 'InvoicePaymentSettled') { $user = User::query()->where('email', '=', $payload['metadata']['buyerEmail'])->first(); if ($user) { - $checkOrder = Payment::query()->where('invoice_id', '=', $payload['invoiceId'])->where('payment_status', '=', 'Settled')->first(); + $checkOrder = Payment::query()->where('invoice_id', '=', $payload['invoiceId'])->where('payment_status', '=', Payment::PAYMENT_STATUS_SETTLED)->first(); if ($checkOrder !== null) { Log::channel('btc_payment')->error('Duplicate BTCPay webhook: '.$payload['webhookId']); @@ -59,8 +59,8 @@ class BtcPaymentController extends BasePageController if ($payload['type'] === 'InvoiceSettled') { // Check if we have the invoice_id in payments table and if we do, update the user account - $checkOrder = Payment::query()->where('invoice_id', '=', $payload['invoiceId'])->where('payment_status', '=', 'Settled')->where(function ($query) { - return $query->where('invoice_status', 'Pending')->orWhereNull('invoice_status'); + $checkOrder = Payment::query()->where('invoice_id', '=', $payload['invoiceId'])->where('payment_status', '=', Payment::PAYMENT_STATUS_SETTLED)->where(function ($query) { + return $query->where('invoice_status', Payment::INVOICE_STATUS_PENDING)->orWhereNull('invoice_status'); })->first(); if ($checkOrder !== null) { $user = User::query()->where('email', '=', $checkOrder->email)->first(); @@ -85,7 +85,7 @@ class BtcPaymentController extends BasePageController } User::updateUserRole($user->id, $roleName, addYears: $addYears); - $checkOrder->update(['invoice_status' => 'Settled']); + $checkOrder->update(['invoice_status' => Payment::INVOICE_STATUS_SETTLED]); Log::channel('btc_payment')->info('User: '.$user->username.' upgraded to '.$roleName.' (+'.$addYears.' years) for BTCPay webhook: '.$checkOrder->webhook_id); return response('OK', 200); @@ -99,4 +99,33 @@ class BtcPaymentController extends BasePageController return response('OK', 200); } + + /** + * Payment.status values from BTCPay Greenfield API (payload.payment.status). Used for admin filters. + * + * @return list + */ + public static function paymentStatusesForAdminFilter(): array + { + return [ + 'Pending', + 'Processing', + Payment::PAYMENT_STATUS_SETTLED, + 'Invalid', + 'Manual', + ]; + } + + /** + * invoice_status values used in our DB (migration default Pending; webhook sets Settled after role upgrade). + * + * @return list + */ + public static function invoiceStatusesForAdminFilter(): array + { + return [ + Payment::INVOICE_STATUS_PENDING, + Payment::INVOICE_STATUS_SETTLED, + ]; + } } diff --git a/app/Models/Payment.php b/app/Models/Payment.php index 43b1c0881..67d188d74 100644 --- a/app/Models/Payment.php +++ b/app/Models/Payment.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Models; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -12,4 +13,45 @@ class Payment extends Model use HasFactory; // @phpstan-ignore missingType.generics protected $guarded = []; + + /** Stored from BTCPay webhook payload.payment.status; duplicate checks use Settled. */ + public const PAYMENT_STATUS_SETTLED = 'Settled'; + + /** Migration default; webhook clears to Settled after role upgrade. */ + public const INVOICE_STATUS_PENDING = 'Pending'; + + public const INVOICE_STATUS_SETTLED = 'Settled'; + + /** + * @param Builder $query + * @param array{username?: string, email?: string, payment_status?: string, invoice_status?: string} $filters + * @return Builder + */ + public function scopeFilter(Builder $query, array $filters): Builder + { + if (! empty($filters['username'])) { + $query->where('username', 'like', '%'.$filters['username'].'%'); + } + + if (! empty($filters['email'])) { + $query->where('email', 'like', '%'.$filters['email'].'%'); + } + + if (! empty($filters['payment_status'])) { + $query->where('payment_status', $filters['payment_status']); + } + + if (! empty($filters['invoice_status'])) { + if ($filters['invoice_status'] === self::INVOICE_STATUS_PENDING) { + $query->where(function (Builder $q): void { + $q->whereNull('invoice_status') + ->orWhere('invoice_status', self::INVOICE_STATUS_PENDING); + }); + } else { + $query->where('invoice_status', $filters['invoice_status']); + } + } + + return $query; + } } diff --git a/resources/views/admin/payments/index.blade.php b/resources/views/admin/payments/index.blade.php new file mode 100644 index 000000000..eab279f5f --- /dev/null +++ b/resources/views/admin/payments/index.blade.php @@ -0,0 +1,237 @@ +@extends('layouts.admin') + +@section('title', $title ?? 'Payments') + +@section('content') +
+ + + + + + BTCPay webhook records appear here after invoices are settled. Use filters to find a user or status. + + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + + + Reset + +
+
+
+ + @if($payments->count() > 0) + + + +
+ ID + +
+
+ +
+ Date + +
+
+ +
+ Username + +
+
+ +
+ Email + +
+
+ Item + +
+ Order ID + +
+
+ +
+ Amount + +
+
+ Method + +
+ Pay. status + +
+
+ +
+ Inv. status + +
+
+
+ + @foreach($payments as $payment) + + {{ $payment->id }} + + {{ $payment->created_at?->timezone(config('app.timezone'))->format('Y-m-d H:i') ?? '—' }} + + {{ $payment->username }} + {{ $payment->email }} + {{ Str::limit($payment->item_description, 48) }} + {{ Str::limit($payment->order_id, 24) }} + {{ $payment->invoice_amount }} + {{ Str::limit($payment->payment_method, 20) }} + + @if(strcasecmp((string) $payment->payment_status, \App\Models\Payment::PAYMENT_STATUS_SETTLED) === 0) + + {{ $payment->payment_status }} + + @else + + {{ $payment->payment_status }} + + @endif + + + @php + $inv = $payment->invoice_status ?? \App\Models\Payment::INVOICE_STATUS_PENDING; + @endphp + @if(strcasecmp((string) $inv, \App\Models\Payment::INVOICE_STATUS_SETTLED) === 0) + + {{ $inv }} + + @else + + {{ $inv }} + + @endif + + + @endforeach +
+ +
+ {{ $payments->withQueryString()->links() }} +
+ +
+
+ Total: {{ $payments->total() }} payments (this filter) +
+
+ @else + + @endif +
+
+@endsection diff --git a/resources/views/partials/admin-menu.blade.php b/resources/views/partials/admin-menu.blade.php index eb3f6ea6f..35ff5ef68 100644 --- a/resources/views/partials/admin-menu.blade.php +++ b/resources/views/partials/admin-menu.blade.php @@ -43,6 +43,14 @@ + + +