feat: historique paiement
Laravel CI-CD / Tests Unitaires (push) Successful in 1m30s Details
Laravel CI-CD / Deploy with Kamal (push) Successful in 1m58s Details

This commit is contained in:
Leon Morival 2026-05-27 11:37:29 +02:00
parent 51b50aa1d0
commit a01387bb09
18 changed files with 920 additions and 13 deletions

View File

@ -0,0 +1,39 @@
<?php
namespace App\Enums;
use Filament\Support\Contracts\HasColor;
use Filament\Support\Contracts\HasLabel;
enum PaymentTransactionStatus: string implements HasColor, HasLabel
{
case PENDING = 'pending';
case PAID = 'paid';
case CREDITED = 'credited';
case FAILED = 'failed';
case IGNORED = 'ignored';
case ERROR = 'error';
public function getLabel(): string
{
return match ($this) {
self::PENDING => __('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',
};
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\Enums;
use Filament\Support\Contracts\HasColor;
use Filament\Support\Contracts\HasLabel;
enum PaymentTransactionType: string implements HasColor, HasLabel
{
case CHECKOUT = 'checkout';
case SUBSCRIPTION_INVOICE = 'subscription_invoice';
public function getLabel(): string
{
return match ($this) {
self::CHECKOUT => __('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',
};
}
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\PaymentTransactions\Pages;
use App\Filament\Resources\PaymentTransactions\PaymentTransactionResource;
use Filament\Resources\Pages\ListRecords;
class ListPaymentTransactions extends ListRecords
{
protected static string $resource = PaymentTransactionResource::class;
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Filament\Resources\PaymentTransactions\Pages;
use App\Filament\Resources\PaymentTransactions\PaymentTransactionResource;
use Filament\Resources\Pages\ViewRecord;
class ViewPaymentTransaction extends ViewRecord
{
protected static string $resource = PaymentTransactionResource::class;
}

View File

@ -0,0 +1,61 @@
<?php
namespace App\Filament\Resources\PaymentTransactions;
use App\Filament\Resources\PaymentTransactions\Pages\ListPaymentTransactions;
use App\Filament\Resources\PaymentTransactions\Pages\ViewPaymentTransaction;
use App\Filament\Resources\PaymentTransactions\Schemas\PaymentTransactionInfolist;
use App\Filament\Resources\PaymentTransactions\Tables\PaymentTransactionsTable;
use App\Models\PaymentTransaction;
use BackedEnum;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Filament\Tables\Table;
class PaymentTransactionResource extends Resource
{
protected static ?string $model = PaymentTransaction::class;
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedReceiptPercent;
protected static ?string $recordTitleAttribute = 'id';
public static function getNavigationLabel(): string
{
return __('admin.payment_transactions.navigation.label');
}
public static function getModelLabel(): string
{
return __('admin.payment_transactions.navigation.singular');
}
public static function getPluralModelLabel(): string
{
return __('admin.payment_transactions.navigation.plural');
}
public static function canCreate(): bool
{
return false;
}
public static function infolist(Schema $schema): Schema
{
return PaymentTransactionInfolist::configure($schema);
}
public static function table(Table $table): Table
{
return PaymentTransactionsTable::configure($table);
}
public static function getPages(): array
{
return [
'index' => ListPaymentTransactions::route('/'),
'view' => ViewPaymentTransaction::route('/{record}'),
];
}
}

View File

@ -0,0 +1,100 @@
<?php
namespace App\Filament\Resources\PaymentTransactions\Schemas;
use Filament\Infolists\Components\TextEntry;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Schema;
class PaymentTransactionInfolist
{
public static function configure(Schema $schema): Schema
{
return $schema
->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(),
]),
]);
}
}

View File

@ -0,0 +1,88 @@
<?php
namespace App\Filament\Resources\PaymentTransactions\Tables;
use App\Enums\PaymentTransactionStatus;
use App\Enums\PaymentTransactionType;
use Filament\Actions\ViewAction;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
class PaymentTransactionsTable
{
public static function configure(Table $table): Table
{
return $table
->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(),
]);
}
}

View File

@ -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,

View File

@ -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<string, mixed>} $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<string, mixed> $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<string, mixed> $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;
}
}

View File

@ -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);

View File

@ -0,0 +1,67 @@
<?php
namespace App\Models;
use App\Enums\PaymentTransactionStatus;
use App\Enums\PaymentTransactionType;
use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class PaymentTransaction extends Model
{
/** @use HasFactory<\Database\Factories\PaymentTransactionFactory> */
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',
];
}
}

View File

@ -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);

View File

@ -0,0 +1,101 @@
<?php
namespace App\Services;
use App\Enums\PaymentTransactionStatus;
use App\Enums\PaymentTransactionType;
use App\Models\CreditLedgerEntry;
use App\Models\CreditProduct;
use App\Models\PaymentTransaction;
use App\Models\User;
class PaymentTransactionRecorder
{
/**
* @param array<string, mixed>|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<string, mixed>|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(),
]);
}
}

View File

@ -0,0 +1,44 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('payment_transactions', function (Blueprint $table): void {
$table->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');
}
};

View File

@ -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',

View File

@ -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',

View File

@ -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',

View File

@ -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',