From a01387bb09541817a26f6ac9c6ff28b1bde324d4 Mon Sep 17 00:00:00 2001 From: Leon Morival Date: Wed, 27 May 2026 11:37:29 +0200 Subject: [PATCH] feat: historique paiement --- app/Enums/PaymentTransactionStatus.php | 39 +++ app/Enums/PaymentTransactionType.php | 28 ++ .../Pages/ListPaymentTransactions.php | 11 + .../Pages/ViewPaymentTransaction.php | 11 + .../PaymentTransactionResource.php | 61 +++++ .../Schemas/PaymentTransactionInfolist.php | 100 +++++++ .../Tables/PaymentTransactionsTable.php | 88 ++++++ app/Http/Controllers/BillingController.php | 16 +- .../GrantCreditsFromStripeWebhook.php | 253 +++++++++++++++++- app/Models/CreditProduct.php | 5 + app/Models/PaymentTransaction.php | 67 +++++ app/Models/User.php | 5 + app/Services/PaymentTransactionRecorder.php | 101 +++++++ ...0000_create_payment_transactions_table.php | 44 +++ lang/en/admin.php | 40 +++ lang/en/enums.php | 12 + lang/fr/admin.php | 40 +++ lang/fr/enums.php | 12 + 18 files changed, 920 insertions(+), 13 deletions(-) create mode 100644 app/Enums/PaymentTransactionStatus.php create mode 100644 app/Enums/PaymentTransactionType.php create mode 100644 app/Filament/Resources/PaymentTransactions/Pages/ListPaymentTransactions.php create mode 100644 app/Filament/Resources/PaymentTransactions/Pages/ViewPaymentTransaction.php create mode 100644 app/Filament/Resources/PaymentTransactions/PaymentTransactionResource.php create mode 100644 app/Filament/Resources/PaymentTransactions/Schemas/PaymentTransactionInfolist.php create mode 100644 app/Filament/Resources/PaymentTransactions/Tables/PaymentTransactionsTable.php create mode 100644 app/Models/PaymentTransaction.php create mode 100644 app/Services/PaymentTransactionRecorder.php create mode 100644 database/migrations/2026_05_27_110000_create_payment_transactions_table.php diff --git a/app/Enums/PaymentTransactionStatus.php b/app/Enums/PaymentTransactionStatus.php new file mode 100644 index 0000000..5728181 --- /dev/null +++ b/app/Enums/PaymentTransactionStatus.php @@ -0,0 +1,39 @@ + __('enums.payment_transaction_status.pending'), + self::PAID => __('enums.payment_transaction_status.paid'), + self::CREDITED => __('enums.payment_transaction_status.credited'), + self::FAILED => __('enums.payment_transaction_status.failed'), + self::IGNORED => __('enums.payment_transaction_status.ignored'), + self::ERROR => __('enums.payment_transaction_status.error'), + }; + } + + public function getColor(): string + { + return match ($this) { + self::PENDING => 'gray', + self::PAID => 'info', + self::CREDITED => 'success', + self::FAILED, self::ERROR => 'danger', + self::IGNORED => 'warning', + }; + } +} diff --git a/app/Enums/PaymentTransactionType.php b/app/Enums/PaymentTransactionType.php new file mode 100644 index 0000000..301f8e9 --- /dev/null +++ b/app/Enums/PaymentTransactionType.php @@ -0,0 +1,28 @@ + __('enums.payment_transaction_type.checkout'), + self::SUBSCRIPTION_INVOICE => __('enums.payment_transaction_type.subscription_invoice'), + }; + } + + public function getColor(): string + { + return match ($this) { + self::CHECKOUT => 'success', + self::SUBSCRIPTION_INVOICE => 'info', + }; + } +} diff --git a/app/Filament/Resources/PaymentTransactions/Pages/ListPaymentTransactions.php b/app/Filament/Resources/PaymentTransactions/Pages/ListPaymentTransactions.php new file mode 100644 index 0000000..ecdf574 --- /dev/null +++ b/app/Filament/Resources/PaymentTransactions/Pages/ListPaymentTransactions.php @@ -0,0 +1,11 @@ + ListPaymentTransactions::route('/'), + 'view' => ViewPaymentTransaction::route('/{record}'), + ]; + } +} diff --git a/app/Filament/Resources/PaymentTransactions/Schemas/PaymentTransactionInfolist.php b/app/Filament/Resources/PaymentTransactions/Schemas/PaymentTransactionInfolist.php new file mode 100644 index 0000000..d2ae6a2 --- /dev/null +++ b/app/Filament/Resources/PaymentTransactions/Schemas/PaymentTransactionInfolist.php @@ -0,0 +1,100 @@ +components([ + Section::make(__('admin.payment_transactions.sections.summary')) + ->columns(3) + ->schema([ + TextEntry::make('id') + ->label(__('admin.payment_transactions.fields.id')) + ->copyable(), + TextEntry::make('status') + ->label(__('admin.payment_transactions.fields.status')) + ->badge(), + TextEntry::make('type') + ->label(__('admin.payment_transactions.fields.type')) + ->badge(), + TextEntry::make('user.email') + ->label(__('admin.payment_transactions.fields.user')) + ->placeholder(__('admin.payment_transactions.placeholders.empty')) + ->copyable(), + TextEntry::make('creditProduct.name') + ->label(__('admin.payment_transactions.fields.credit_product')) + ->placeholder(__('admin.payment_transactions.placeholders.empty')), + TextEntry::make('amount') + ->label(__('admin.payment_transactions.fields.amount')) + ->money(fn ($record): string => $record->currency ?: 'eur', divideBy: 100) + ->placeholder(__('admin.payment_transactions.placeholders.empty')), + TextEntry::make('credits_expected') + ->label(__('admin.payment_transactions.fields.credits_expected')) + ->placeholder(__('admin.payment_transactions.placeholders.empty')), + TextEntry::make('credits_granted') + ->label(__('admin.payment_transactions.fields.credits_granted')), + TextEntry::make('processed_at') + ->label(__('admin.payment_transactions.fields.processed_at')) + ->dateTime() + ->placeholder(__('admin.payment_transactions.placeholders.empty')), + ]), + Section::make(__('admin.payment_transactions.sections.stripe')) + ->columns(2) + ->schema([ + TextEntry::make('stripe_event_type') + ->label(__('admin.payment_transactions.fields.stripe_event_type')) + ->placeholder(__('admin.payment_transactions.placeholders.empty')), + TextEntry::make('stripe_event_id') + ->label(__('admin.payment_transactions.fields.stripe_event_id')) + ->copyable() + ->placeholder(__('admin.payment_transactions.placeholders.empty')), + TextEntry::make('stripe_customer_id') + ->label(__('admin.payment_transactions.fields.stripe_customer_id')) + ->copyable() + ->placeholder(__('admin.payment_transactions.placeholders.empty')), + TextEntry::make('stripe_checkout_session_id') + ->label(__('admin.payment_transactions.fields.stripe_checkout_session_id')) + ->copyable() + ->placeholder(__('admin.payment_transactions.placeholders.empty')), + TextEntry::make('stripe_invoice_id') + ->label(__('admin.payment_transactions.fields.stripe_invoice_id')) + ->copyable() + ->placeholder(__('admin.payment_transactions.placeholders.empty')), + TextEntry::make('stripe_payment_intent_id') + ->label(__('admin.payment_transactions.fields.stripe_payment_intent_id')) + ->copyable() + ->placeholder(__('admin.payment_transactions.placeholders.empty')), + TextEntry::make('stripe_subscription_id') + ->label(__('admin.payment_transactions.fields.stripe_subscription_id')) + ->copyable() + ->placeholder(__('admin.payment_transactions.placeholders.empty')), + TextEntry::make('stripe_price_id') + ->label(__('admin.payment_transactions.fields.stripe_price_id')) + ->copyable() + ->placeholder(__('admin.payment_transactions.placeholders.empty')), + ]), + Section::make(__('admin.payment_transactions.sections.error')) + ->schema([ + TextEntry::make('error_message') + ->label(__('admin.payment_transactions.fields.error_message')) + ->placeholder(__('admin.payment_transactions.placeholders.empty')) + ->columnSpanFull(), + ]), + Section::make(__('admin.payment_transactions.sections.payload')) + ->schema([ + TextEntry::make('payload') + ->label(__('admin.payment_transactions.fields.payload')) + ->formatStateUsing(fn (mixed $state): string => json_encode($state ?: [], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}') + ->fontFamily('mono') + ->columnSpanFull(), + ]), + ]); + } +} diff --git a/app/Filament/Resources/PaymentTransactions/Tables/PaymentTransactionsTable.php b/app/Filament/Resources/PaymentTransactions/Tables/PaymentTransactionsTable.php new file mode 100644 index 0000000..d77774c --- /dev/null +++ b/app/Filament/Resources/PaymentTransactions/Tables/PaymentTransactionsTable.php @@ -0,0 +1,88 @@ +defaultSort('created_at', 'desc') + ->columns([ + TextColumn::make('created_at') + ->label(__('admin.payment_transactions.fields.created_at')) + ->dateTime() + ->sortable(), + TextColumn::make('status') + ->label(__('admin.payment_transactions.fields.status')) + ->badge() + ->sortable(), + TextColumn::make('type') + ->label(__('admin.payment_transactions.fields.type')) + ->badge() + ->sortable(), + TextColumn::make('user.email') + ->label(__('admin.payment_transactions.fields.user')) + ->searchable() + ->placeholder(__('admin.payment_transactions.placeholders.empty')), + TextColumn::make('creditProduct.name') + ->label(__('admin.payment_transactions.fields.credit_product')) + ->searchable() + ->placeholder(__('admin.payment_transactions.placeholders.empty')), + TextColumn::make('amount') + ->label(__('admin.payment_transactions.fields.amount')) + ->money(fn ($record): string => $record->currency ?: 'eur', divideBy: 100) + ->sortable() + ->placeholder(__('admin.payment_transactions.placeholders.empty')), + TextColumn::make('credits_expected') + ->label(__('admin.payment_transactions.fields.credits_expected')) + ->numeric(decimalPlaces: 0) + ->sortable() + ->placeholder(__('admin.payment_transactions.placeholders.empty')), + TextColumn::make('credits_granted') + ->label(__('admin.payment_transactions.fields.credits_granted')) + ->numeric(decimalPlaces: 0) + ->sortable(), + TextColumn::make('stripe_event_type') + ->label(__('admin.payment_transactions.fields.stripe_event_type')) + ->toggleable(), + TextColumn::make('stripe_checkout_session_id') + ->label(__('admin.payment_transactions.fields.stripe_checkout_session_id_short')) + ->copyable() + ->searchable() + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('stripe_invoice_id') + ->label(__('admin.payment_transactions.fields.stripe_invoice_id_short')) + ->copyable() + ->searchable() + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('stripe_price_id') + ->label(__('admin.payment_transactions.fields.stripe_price_id')) + ->copyable() + ->searchable() + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('error_message') + ->label(__('admin.payment_transactions.fields.error_message')) + ->limit(60) + ->toggleable(), + ]) + ->filters([ + SelectFilter::make('status') + ->label(__('admin.payment_transactions.fields.status')) + ->options(PaymentTransactionStatus::class), + SelectFilter::make('type') + ->label(__('admin.payment_transactions.fields.type')) + ->options(PaymentTransactionType::class), + ]) + ->recordActions([ + ViewAction::make(), + ]); + } +} diff --git a/app/Http/Controllers/BillingController.php b/app/Http/Controllers/BillingController.php index 36f8f09..7120b72 100644 --- a/app/Http/Controllers/BillingController.php +++ b/app/Http/Controllers/BillingController.php @@ -6,6 +6,7 @@ use App\Enums\CreditProductType; use App\Http\Resources\CreditProductResource; use App\Models\CreditProduct; use App\Services\MobileDeepLink; +use App\Services\PaymentTransactionRecorder; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -25,7 +26,11 @@ class BillingController extends Controller return CreditProductResource::collection($products); } - public function checkout(Request $request, CreditProduct $creditProduct): JsonResponse + public function checkout( + Request $request, + CreditProduct $creditProduct, + PaymentTransactionRecorder $transactions, + ): JsonResponse { abort_unless($creditProduct->is_active, 404); abort_unless(filled($creditProduct->stripe_price_id), 404); @@ -58,6 +63,15 @@ class BillingController extends Controller $session = $checkout->asStripeCheckoutSession(); + $transactions->recordCheckoutSession( + user: $user, + product: $creditProduct, + stripeCheckoutSessionId: $session->id, + stripeCustomerId: $session->customer, + amount: $session->amount_total, + currency: $session->currency, + ); + return response()->json([ 'id' => $session->id, 'url' => $session->url, diff --git a/app/Listeners/GrantCreditsFromStripeWebhook.php b/app/Listeners/GrantCreditsFromStripeWebhook.php index db70cf8..869dc1b 100644 --- a/app/Listeners/GrantCreditsFromStripeWebhook.php +++ b/app/Listeners/GrantCreditsFromStripeWebhook.php @@ -5,12 +5,17 @@ namespace App\Listeners; use App\Enums\CreditLedgerEntryType; use App\Enums\CreditProductType; use App\Models\CreditProduct; +use App\Models\CreditLedgerEntry; use App\Models\User; use App\Services\AiCreditService; +use App\Services\PaymentTransactionRecorder; class GrantCreditsFromStripeWebhook { - public function __construct(private AiCreditService $credits) {} + public function __construct( + private AiCreditService $credits, + private PaymentTransactionRecorder $transactions, + ) {} /** * @param array{payload: array} $event @@ -21,7 +26,10 @@ class GrantCreditsFromStripeWebhook match ($payload['type'] ?? null) { 'checkout.session.completed' => $this->handleCheckoutSessionCompleted($payload), + 'checkout.session.expired' => $this->handleCheckoutSessionExpired($payload), 'invoice.paid' => $this->handleInvoicePaid($payload), + 'invoice.payment_succeeded' => $this->handleInvoicePaid($payload), + 'invoice.payment_failed' => $this->handleInvoicePaymentFailed($payload), default => null, }; } @@ -32,8 +40,30 @@ class GrantCreditsFromStripeWebhook private function handleCheckoutSessionCompleted(array $payload): void { $session = $payload['data']['object'] ?? []; + $eventId = $this->stringValue($payload['id'] ?? null); + $eventType = $this->stringValue($payload['type'] ?? null); + $sessionId = $this->stringValue($session['id'] ?? null); + + if (! $sessionId) { + return; + } if (($session['mode'] ?? null) !== 'payment' || ($session['payment_status'] ?? null) !== 'paid') { + $this->transactions->recordCheckoutSession( + user: $this->userFromCustomer($session['customer'] ?? null), + product: $this->productFromMetadata($session['metadata'] ?? null, CreditProductType::ONE_TIME), + stripeCheckoutSessionId: $sessionId, + status: \App\Enums\PaymentTransactionStatus::IGNORED, + stripeEventId: $eventId, + stripeEventType: $eventType, + stripeCustomerId: $this->stringValue($session['customer'] ?? null), + stripePaymentIntentId: $this->stringValue($session['payment_intent'] ?? null), + amount: $this->intValue($session['amount_total'] ?? null), + currency: $this->stringValue($session['currency'] ?? null), + errorMessage: 'Checkout session is not a paid one-time payment.', + payload: $session, + ); + return; } @@ -41,21 +71,85 @@ class GrantCreditsFromStripeWebhook $user = $this->userFromCustomer($session['customer'] ?? null); if (! $product || ! $user) { + $this->transactions->recordCheckoutSession( + user: $user, + product: $product, + stripeCheckoutSessionId: $sessionId, + status: \App\Enums\PaymentTransactionStatus::ERROR, + stripeEventId: $eventId, + stripeEventType: $eventType, + stripeCustomerId: $this->stringValue($session['customer'] ?? null), + stripePaymentIntentId: $this->stringValue($session['payment_intent'] ?? null), + amount: $this->intValue($session['amount_total'] ?? null), + currency: $this->stringValue($session['currency'] ?? null), + errorMessage: $product + ? 'Stripe customer does not match any user.' + : 'Credit product was not found from checkout metadata.', + payload: $session, + ); + return; } - $this->credits->grant( + $source = 'stripe_checkout_session:'.$sessionId; + $entry = $this->credits->grant( user: $user, credits: $product->credits, type: CreditLedgerEntryType::PURCHASE, product: $product, - source: 'stripe_checkout_session:'.$session['id'], - stripeCheckoutSessionId: $session['id'] ?? null, - stripePaymentIntentId: $session['payment_intent'] ?? null, + source: $source, + stripeCheckoutSessionId: $sessionId, + stripePaymentIntentId: $this->stringValue($session['payment_intent'] ?? null), metadata: [ - 'stripe_event_id' => $payload['id'] ?? null, + 'stripe_event_id' => $eventId, ], ); + + $entry ??= CreditLedgerEntry::query()->where('source', $source)->first(); + + $this->transactions->recordCheckoutSession( + user: $user, + product: $product, + stripeCheckoutSessionId: $sessionId, + status: \App\Enums\PaymentTransactionStatus::CREDITED, + stripeEventId: $eventId, + stripeEventType: $eventType, + stripeCustomerId: $this->stringValue($session['customer'] ?? null), + stripePaymentIntentId: $this->stringValue($session['payment_intent'] ?? null), + amount: $this->intValue($session['amount_total'] ?? null), + currency: $this->stringValue($session['currency'] ?? null), + creditsGranted: $entry ? $product->credits : 0, + ledgerEntry: $entry, + payload: $session, + ); + } + + /** + * @param array $payload + */ + private function handleCheckoutSessionExpired(array $payload): void + { + $session = $payload['data']['object'] ?? []; + $sessionId = $this->stringValue($session['id'] ?? null); + + if (! $sessionId) { + return; + } + + $this->transactions->recordCheckoutSession( + user: $this->userFromCustomer($session['customer'] ?? null), + product: $this->productFromMetadata($session['metadata'] ?? null, CreditProductType::ONE_TIME), + stripeCheckoutSessionId: $sessionId, + status: \App\Enums\PaymentTransactionStatus::FAILED, + stripeEventId: $this->stringValue($payload['id'] ?? null), + stripeEventType: $this->stringValue($payload['type'] ?? null), + stripeCustomerId: $this->stringValue($session['customer'] ?? null), + stripePaymentIntentId: $this->stringValue($session['payment_intent'] ?? null), + amount: $this->intValue($session['amount_total'] ?? null), + currency: $this->stringValue($session['currency'] ?? null), + errorMessage: 'Checkout session expired before payment.', + payload: $session, + ); } /** @@ -64,12 +158,39 @@ class GrantCreditsFromStripeWebhook private function handleInvoicePaid(array $payload): void { $invoice = $payload['data']['object'] ?? []; + $invoiceId = $this->stringValue($invoice['id'] ?? null); + $eventId = $this->stringValue($payload['id'] ?? null); + $eventType = $this->stringValue($payload['type'] ?? null); + + if (! $invoiceId) { + return; + } + $user = $this->userFromCustomer($invoice['customer'] ?? null); if (! $user) { + $this->transactions->recordInvoice( + user: null, + product: null, + stripeInvoiceId: $invoiceId, + stripePriceId: null, + status: \App\Enums\PaymentTransactionStatus::ERROR, + stripeEventId: $eventId, + stripeEventType: $eventType, + stripeCustomerId: $this->stringValue($invoice['customer'] ?? null), + stripePaymentIntentId: $this->stringValue($invoice['payment_intent'] ?? null), + stripeSubscriptionId: $this->stringValue($invoice['subscription'] ?? null), + amount: $this->intValue($invoice['amount_paid'] ?? null), + currency: $this->stringValue($invoice['currency'] ?? null), + errorMessage: 'Stripe customer does not match any user.', + payload: $invoice, + ); + return; } + $matchedLine = false; + foreach (($invoice['lines']['data'] ?? []) as $line) { $priceId = $line['price']['id'] ?? null; @@ -83,23 +204,121 @@ class GrantCreditsFromStripeWebhook ->first(); if (! $product) { + $this->transactions->recordInvoice( + user: $user, + product: null, + stripeInvoiceId: $invoiceId, + stripePriceId: $this->stringValue($priceId), + status: \App\Enums\PaymentTransactionStatus::ERROR, + stripeEventId: $eventId, + stripeEventType: $eventType, + stripeCustomerId: $this->stringValue($invoice['customer'] ?? null), + stripePaymentIntentId: $this->stringValue($invoice['payment_intent'] ?? null), + stripeSubscriptionId: $this->stringValue($invoice['subscription'] ?? null), + amount: $this->intValue($line['amount'] ?? $invoice['amount_paid'] ?? null), + currency: $this->stringValue($invoice['currency'] ?? null), + errorMessage: 'No monthly credit product found for Stripe price.', + payload: $invoice, + ); + continue; } - $this->credits->grant( + $matchedLine = true; + $source = 'stripe_invoice:'.$invoiceId.':price:'.$priceId; + $entry = $this->credits->grant( user: $user, credits: $product->credits, type: CreditLedgerEntryType::SUBSCRIPTION_RENEWAL, product: $product, - source: 'stripe_invoice:'.$invoice['id'].':price:'.$priceId, - stripeInvoiceId: $invoice['id'] ?? null, - stripePaymentIntentId: $invoice['payment_intent'] ?? null, - stripeSubscriptionId: $invoice['subscription'] ?? null, + source: $source, + stripeInvoiceId: $invoiceId, + stripePaymentIntentId: $this->stringValue($invoice['payment_intent'] ?? null), + stripeSubscriptionId: $this->stringValue($invoice['subscription'] ?? null), metadata: [ 'billing_reason' => $invoice['billing_reason'] ?? null, - 'stripe_event_id' => $payload['id'] ?? null, + 'stripe_event_id' => $eventId, ], ); + + $entry ??= CreditLedgerEntry::query()->where('source', $source)->first(); + + $this->transactions->recordInvoice( + user: $user, + product: $product, + stripeInvoiceId: $invoiceId, + stripePriceId: $this->stringValue($priceId), + status: \App\Enums\PaymentTransactionStatus::CREDITED, + stripeEventId: $eventId, + stripeEventType: $eventType, + stripeCustomerId: $this->stringValue($invoice['customer'] ?? null), + stripePaymentIntentId: $this->stringValue($invoice['payment_intent'] ?? null), + stripeSubscriptionId: $this->stringValue($invoice['subscription'] ?? null), + amount: $this->intValue($line['amount'] ?? $invoice['amount_paid'] ?? null), + currency: $this->stringValue($invoice['currency'] ?? null), + creditsGranted: $entry ? $product->credits : 0, + ledgerEntry: $entry, + payload: $invoice, + ); + } + + if (! $matchedLine) { + $this->transactions->recordInvoice( + user: $user, + product: null, + stripeInvoiceId: $invoiceId, + stripePriceId: null, + status: \App\Enums\PaymentTransactionStatus::IGNORED, + stripeEventId: $eventId, + stripeEventType: $eventType, + stripeCustomerId: $this->stringValue($invoice['customer'] ?? null), + stripePaymentIntentId: $this->stringValue($invoice['payment_intent'] ?? null), + stripeSubscriptionId: $this->stringValue($invoice['subscription'] ?? null), + amount: $this->intValue($invoice['amount_paid'] ?? null), + currency: $this->stringValue($invoice['currency'] ?? null), + errorMessage: 'Invoice does not contain a monthly credit product line.', + payload: $invoice, + ); + } + } + + /** + * @param array $payload + */ + private function handleInvoicePaymentFailed(array $payload): void + { + $invoice = $payload['data']['object'] ?? []; + $invoiceId = $this->stringValue($invoice['id'] ?? null); + + if (! $invoiceId) { + return; + } + + foreach (($invoice['lines']['data'] ?? [null]) as $line) { + $priceId = is_array($line) ? $this->stringValue($line['price']['id'] ?? null) : null; + $amount = is_array($line) + ? $this->intValue($line['amount'] ?? $invoice['amount_due'] ?? null) + : $this->intValue($invoice['amount_due'] ?? null); + $product = $priceId + ? CreditProduct::query()->where('stripe_price_id', $priceId)->first() + : null; + + $this->transactions->recordInvoice( + user: $this->userFromCustomer($invoice['customer'] ?? null), + product: $product, + stripeInvoiceId: $invoiceId, + stripePriceId: $priceId, + status: \App\Enums\PaymentTransactionStatus::FAILED, + stripeEventId: $this->stringValue($payload['id'] ?? null), + stripeEventType: $this->stringValue($payload['type'] ?? null), + stripeCustomerId: $this->stringValue($invoice['customer'] ?? null), + stripePaymentIntentId: $this->stringValue($invoice['payment_intent'] ?? null), + stripeSubscriptionId: $this->stringValue($invoice['subscription'] ?? null), + amount: $amount, + currency: $this->stringValue($invoice['currency'] ?? null), + errorMessage: 'Invoice payment failed.', + payload: $invoice, + ); } } @@ -123,4 +342,14 @@ class GrantCreditsFromStripeWebhook ->where('type', $type) ->first(); } + + private function stringValue(mixed $value): ?string + { + return is_string($value) && $value !== '' ? $value : null; + } + + private function intValue(mixed $value): ?int + { + return is_numeric($value) ? (int) $value : null; + } } diff --git a/app/Models/CreditProduct.php b/app/Models/CreditProduct.php index 7286c67..b6f3b3b 100644 --- a/app/Models/CreditProduct.php +++ b/app/Models/CreditProduct.php @@ -32,6 +32,11 @@ class CreditProduct extends Model return $this->hasMany(CreditLedgerEntry::class); } + public function paymentTransactions(): HasMany + { + return $this->hasMany(PaymentTransaction::class); + } + public function scopeActive(Builder $query): Builder { return $query->where('is_active', true); diff --git a/app/Models/PaymentTransaction.php b/app/Models/PaymentTransaction.php new file mode 100644 index 0000000..8ff2072 --- /dev/null +++ b/app/Models/PaymentTransaction.php @@ -0,0 +1,67 @@ + */ + use HasFactory, HasUlids; + + protected $fillable = [ + 'user_id', + 'credit_product_id', + 'credit_ledger_entry_id', + 'type', + 'status', + 'stripe_event_id', + 'stripe_event_type', + 'stripe_customer_id', + 'stripe_checkout_session_id', + 'stripe_invoice_id', + 'stripe_payment_intent_id', + 'stripe_subscription_id', + 'stripe_price_id', + 'amount', + 'currency', + 'credits_expected', + 'credits_granted', + 'error_message', + 'payload', + 'processed_at', + ]; + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function creditProduct(): BelongsTo + { + return $this->belongsTo(CreditProduct::class); + } + + public function creditLedgerEntry(): BelongsTo + { + return $this->belongsTo(CreditLedgerEntry::class); + } + + protected function casts(): array + { + return [ + 'type' => PaymentTransactionType::class, + 'status' => PaymentTransactionStatus::class, + 'amount' => 'integer', + 'credits_expected' => 'integer', + 'credits_granted' => 'integer', + 'payload' => 'array', + 'processed_at' => 'datetime', + ]; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index c9e4436..d412d3c 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -124,6 +124,11 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, HasLocale return $this->hasMany(CreditLedgerEntry::class); } + public function paymentTransactions(): HasMany + { + return $this->hasMany(PaymentTransaction::class); + } + public function workouts(): HasMany { return $this->hasMany(WorkoutSessions::class); diff --git a/app/Services/PaymentTransactionRecorder.php b/app/Services/PaymentTransactionRecorder.php new file mode 100644 index 0000000..9fac269 --- /dev/null +++ b/app/Services/PaymentTransactionRecorder.php @@ -0,0 +1,101 @@ +|null $payload + */ + public function recordCheckoutSession( + ?User $user, + ?CreditProduct $product, + string $stripeCheckoutSessionId, + PaymentTransactionStatus $status = PaymentTransactionStatus::PENDING, + ?string $stripeEventId = null, + ?string $stripeEventType = null, + ?string $stripeCustomerId = null, + ?string $stripePaymentIntentId = null, + ?int $amount = null, + ?string $currency = null, + ?int $creditsExpected = null, + ?int $creditsGranted = null, + ?CreditLedgerEntry $ledgerEntry = null, + ?string $errorMessage = null, + ?array $payload = null, + ): PaymentTransaction { + return PaymentTransaction::query()->updateOrCreate([ + 'stripe_checkout_session_id' => $stripeCheckoutSessionId, + ], [ + 'user_id' => $user?->getKey(), + 'credit_product_id' => $product?->getKey(), + 'credit_ledger_entry_id' => $ledgerEntry?->getKey(), + 'type' => PaymentTransactionType::CHECKOUT, + 'status' => $status, + 'stripe_event_id' => $stripeEventId, + 'stripe_event_type' => $stripeEventType, + 'stripe_customer_id' => $stripeCustomerId, + 'stripe_payment_intent_id' => $stripePaymentIntentId, + 'amount' => $amount, + 'currency' => $currency, + 'credits_expected' => $creditsExpected ?? $product?->credits, + 'credits_granted' => $creditsGranted ?? 0, + 'error_message' => $errorMessage, + 'payload' => $payload, + 'processed_at' => $status === PaymentTransactionStatus::PENDING ? null : now(), + ]); + } + + /** + * @param array|null $payload + */ + public function recordInvoice( + ?User $user, + ?CreditProduct $product, + string $stripeInvoiceId, + ?string $stripePriceId, + PaymentTransactionStatus $status, + ?string $stripeEventId = null, + ?string $stripeEventType = null, + ?string $stripeCustomerId = null, + ?string $stripePaymentIntentId = null, + ?string $stripeSubscriptionId = null, + ?int $amount = null, + ?string $currency = null, + ?int $creditsExpected = null, + ?int $creditsGranted = null, + ?CreditLedgerEntry $ledgerEntry = null, + ?string $errorMessage = null, + ?array $payload = null, + ): PaymentTransaction { + return PaymentTransaction::query()->updateOrCreate([ + 'stripe_invoice_id' => $stripeInvoiceId, + 'stripe_price_id' => $stripePriceId, + ], [ + 'user_id' => $user?->getKey(), + 'credit_product_id' => $product?->getKey(), + 'credit_ledger_entry_id' => $ledgerEntry?->getKey(), + 'type' => PaymentTransactionType::SUBSCRIPTION_INVOICE, + 'status' => $status, + 'stripe_event_id' => $stripeEventId, + 'stripe_event_type' => $stripeEventType, + 'stripe_customer_id' => $stripeCustomerId, + 'stripe_payment_intent_id' => $stripePaymentIntentId, + 'stripe_subscription_id' => $stripeSubscriptionId, + 'amount' => $amount, + 'currency' => $currency, + 'credits_expected' => $creditsExpected ?? $product?->credits, + 'credits_granted' => $creditsGranted ?? 0, + 'error_message' => $errorMessage, + 'payload' => $payload, + 'processed_at' => now(), + ]); + } +} diff --git a/database/migrations/2026_05_27_110000_create_payment_transactions_table.php b/database/migrations/2026_05_27_110000_create_payment_transactions_table.php new file mode 100644 index 0000000..9006412 --- /dev/null +++ b/database/migrations/2026_05_27_110000_create_payment_transactions_table.php @@ -0,0 +1,44 @@ +ulid('id')->primary(); + $table->foreignUlid('user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignUlid('credit_product_id')->nullable()->constrained('credit_products')->nullOnDelete(); + $table->foreignUlid('credit_ledger_entry_id')->nullable()->constrained('credit_ledger_entries')->nullOnDelete(); + $table->string('type', 64); + $table->string('status', 64); + $table->string('stripe_event_id')->nullable()->index(); + $table->string('stripe_event_type')->nullable()->index(); + $table->string('stripe_customer_id')->nullable()->index(); + $table->string('stripe_checkout_session_id')->nullable()->index(); + $table->string('stripe_invoice_id')->nullable()->index(); + $table->string('stripe_payment_intent_id')->nullable()->index(); + $table->string('stripe_subscription_id')->nullable()->index(); + $table->string('stripe_price_id')->nullable()->index(); + $table->unsignedInteger('amount')->nullable(); + $table->string('currency', 3)->nullable(); + $table->unsignedInteger('credits_expected')->nullable(); + $table->unsignedInteger('credits_granted')->default(0); + $table->text('error_message')->nullable(); + $table->json('payload')->nullable(); + $table->timestamp('processed_at')->nullable(); + $table->timestamps(); + + $table->index(['status', 'created_at']); + $table->index(['type', 'status', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('payment_transactions'); + } +}; diff --git a/lang/en/admin.php b/lang/en/admin.php index 837418e..ca55041 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -98,6 +98,46 @@ return [ 'sync_stripe_success' => 'Stripe product synchronized.', ], ], + 'payment_transactions' => [ + 'navigation' => [ + 'label' => 'Payments', + 'singular' => 'Payment', + 'plural' => 'Payments', + ], + 'sections' => [ + 'summary' => 'Summary', + 'stripe' => 'Stripe', + 'error' => 'Error', + 'payload' => 'Stripe payload', + ], + 'fields' => [ + 'id' => 'ID', + 'user' => 'User', + 'credit_product' => 'Product', + 'type' => 'Type', + 'status' => 'Status', + 'amount' => 'Amount', + 'credits_expected' => 'Expected credits', + 'credits_granted' => 'Granted credits', + 'stripe_event_id' => 'Stripe event ID', + 'stripe_event_type' => 'Event', + 'stripe_customer_id' => 'Stripe customer ID', + 'stripe_checkout_session_id' => 'Stripe checkout session ID', + 'stripe_checkout_session_id_short' => 'Checkout session', + 'stripe_invoice_id' => 'Stripe invoice ID', + 'stripe_invoice_id_short' => 'Invoice', + 'stripe_payment_intent_id' => 'Stripe payment intent ID', + 'stripe_subscription_id' => 'Stripe subscription ID', + 'stripe_price_id' => 'Stripe price ID', + 'error_message' => 'Message', + 'payload' => 'Payload', + 'processed_at' => 'Processed at', + 'created_at' => 'Created at', + ], + 'placeholders' => [ + 'empty' => '-', + ], + ], 'moderation_cases' => [ 'navigation' => [ 'label' => 'Moderation', diff --git a/lang/en/enums.php b/lang/en/enums.php index ea4f0ab..ba8a1b3 100644 --- a/lang/en/enums.php +++ b/lang/en/enums.php @@ -61,6 +61,18 @@ return [ 'refund' => 'Refund', 'adjustment' => 'Adjustment', ], + 'payment_transaction_status' => [ + 'pending' => 'Pending', + 'paid' => 'Paid', + 'credited' => 'Credited', + 'failed' => 'Failed', + 'ignored' => 'Ignored', + 'error' => 'Error', + ], + 'payment_transaction_type' => [ + 'checkout' => 'Checkout', + 'subscription_invoice' => 'Subscription invoice', + ], 'weight_goal' => [ 'lose_weight' => 'Lose weight', 'maintain_weight' => 'Maintain weight', diff --git a/lang/fr/admin.php b/lang/fr/admin.php index f23884e..c10c40e 100644 --- a/lang/fr/admin.php +++ b/lang/fr/admin.php @@ -98,6 +98,46 @@ return [ 'sync_stripe_success' => 'Produit Stripe synchronisé.', ], ], + 'payment_transactions' => [ + 'navigation' => [ + 'label' => 'Paiements', + 'singular' => 'Paiement', + 'plural' => 'Paiements', + ], + 'sections' => [ + 'summary' => 'Résumé', + 'stripe' => 'Stripe', + 'error' => 'Erreur', + 'payload' => 'Payload Stripe', + ], + 'fields' => [ + 'id' => 'ID', + 'user' => 'Utilisateur', + 'credit_product' => 'Produit', + 'type' => 'Type', + 'status' => 'Statut', + 'amount' => 'Montant', + 'credits_expected' => 'Crédits attendus', + 'credits_granted' => 'Crédits crédités', + 'stripe_event_id' => 'Stripe event ID', + 'stripe_event_type' => 'Événement', + 'stripe_customer_id' => 'Stripe customer ID', + 'stripe_checkout_session_id' => 'Stripe checkout session ID', + 'stripe_checkout_session_id_short' => 'Checkout session', + 'stripe_invoice_id' => 'Stripe invoice ID', + 'stripe_invoice_id_short' => 'Invoice', + 'stripe_payment_intent_id' => 'Stripe payment intent ID', + 'stripe_subscription_id' => 'Stripe subscription ID', + 'stripe_price_id' => 'Stripe price ID', + 'error_message' => 'Message', + 'payload' => 'Payload', + 'processed_at' => 'Traité le', + 'created_at' => 'Créé le', + ], + 'placeholders' => [ + 'empty' => '-', + ], + ], 'moderation_cases' => [ 'navigation' => [ 'label' => 'Modération', diff --git a/lang/fr/enums.php b/lang/fr/enums.php index 8b199a3..3879f1e 100644 --- a/lang/fr/enums.php +++ b/lang/fr/enums.php @@ -61,6 +61,18 @@ return [ 'refund' => 'Remboursement', 'adjustment' => 'Ajustement', ], + 'payment_transaction_status' => [ + 'pending' => 'En attente', + 'paid' => 'Payé', + 'credited' => 'Crédité', + 'failed' => 'Échec', + 'ignored' => 'Ignoré', + 'error' => 'Erreur', + ], + 'payment_transaction_type' => [ + 'checkout' => 'Checkout', + 'subscription_invoice' => 'Facture abonnement', + ], 'weight_goal' => [ 'lose_weight' => 'Perdre du poids', 'maintain_weight' => 'Maintenir le poids',