feat: historique paiement
This commit is contained in:
parent
51b50aa1d0
commit
a01387bb09
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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}'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in New Issue