$maxAmount) { throw new \InvalidArgumentException( "Le montant doit être compris entre {$minAmount} et {$maxAmount} EUR.", ); } $existing = PaymentTransaction::query() ->where('idempotency_key', $idempotencyKey) ->first(); if ($existing !== null) { return $existing; } $provider = config('laverie.payment.default_provider', 'simulated'); if ($provider === 'stripe' && ! $this->stripePaymentService->isEnabled()) { throw new \RuntimeException('Stripe n\'est pas configuré.'); } return DB::transaction(function () use ($user, $amount, $idempotencyKey, $returnUrl, $provider) { $payment = PaymentTransaction::query()->create([ 'uuid' => (string) Str::uuid(), 'user_id' => $user->id, 'provider' => $provider, 'provider_payment_id' => null, 'amount' => $amount, 'currency' => 'EUR', 'status' => 'initiated', 'idempotency_key' => $idempotencyKey, 'return_url' => $returnUrl, ]); if ($provider === 'stripe') { try { $intent = $this->stripePaymentService->createPaymentIntent($user, $payment, $amount); } catch (ApiErrorException $e) { throw new \RuntimeException('Impossible de créer le paiement Stripe.', 0, $e); } $payment->update([ 'provider_payment_id' => $intent->id, 'status' => 'pending', 'raw_payload' => [ 'client_secret' => $intent->client_secret, ], ]); } else { $payment->update([ 'provider_payment_id' => 'sim-'.Str::random(16), ]); } $this->auditService->log($user, 'payment.initiated', $payment, null, $payment->fresh()->toArray()); return $payment->fresh(); }); } public function confirmTopUp(string $paymentUuid): PaymentTransaction { return DB::transaction(function () use ($paymentUuid) { $payment = PaymentTransaction::query() ->where('uuid', $paymentUuid) ->lockForUpdate() ->firstOrFail(); if ($payment->status === 'succeeded') { return $payment; } if (! in_array($payment->status, ['initiated', 'pending'], true)) { throw new \RuntimeException("Paiement non confirmable (statut : {$payment->status})."); } if ($payment->provider === 'stripe') { if (blank($payment->provider_payment_id)) { throw new \RuntimeException('Paiement Stripe introuvable.'); } try { $intent = $this->stripePaymentService->retrievePaymentIntent($payment->provider_payment_id); } catch (ApiErrorException $e) { throw new \RuntimeException('Impossible de vérifier le paiement Stripe.', 0, $e); } if (! $this->stripePaymentService->isPaymentSucceeded($intent)) { throw new \RuntimeException('Le paiement Stripe n\'est pas encore confirmé.'); } $payment->update([ 'raw_payload' => array_merge($payment->raw_payload ?? [], [ 'stripe' => $this->stripePaymentService->summarizePaymentIntent($intent), ]), ]); } return $this->markPaymentSucceeded($payment->fresh()); }); } public function handleWebhook(string $provider, array $payload): void { $providerPaymentId = $payload['provider_payment_id'] ?? $payload['payment_id'] ?? null; $idempotencyKey = $payload['idempotency_key'] ?? null; $status = $payload['status'] ?? 'succeeded'; if ($providerPaymentId === null && $idempotencyKey === null) { throw new \InvalidArgumentException('Webhook paiement invalide.'); } if ($status === 'ignored') { return; } DB::transaction(function () use ($provider, $payload, $providerPaymentId, $idempotencyKey, $status) { $query = PaymentTransaction::query()->lockForUpdate(); if ($idempotencyKey !== null) { $payment = $query->where('idempotency_key', $idempotencyKey)->first(); } else { $payment = $query ->where('provider', $provider) ->where('provider_payment_id', $providerPaymentId) ->first(); } if ($payment === null) { return; } if ($payment->status === 'succeeded') { return; } $payment->update([ 'raw_payload' => array_merge($payment->raw_payload ?? [], [ 'webhook' => $payload, ]), ]); if ($status === 'succeeded') { $this->markPaymentSucceeded($payment); } elseif (in_array($status, ['failed', 'cancelled', 'refunded'], true)) { $payment->update(['status' => $status]); } }); } public function handleStripeWebhook(string $payload, string $signature): void { $parsed = $this->stripePaymentService->parseWebhook($payload, $signature); if ($parsed['status'] === 'ignored') { return; } $this->handleWebhook('stripe', [ 'provider_payment_id' => $parsed['provider_payment_id'], 'status' => $parsed['status'], 'payload' => $parsed['payload'], ]); } private function markPaymentSucceeded(PaymentTransaction $payment): PaymentTransaction { if ($payment->status === 'succeeded') { return $payment; } $payment->update(['status' => 'succeeded']); $this->walletService->credit( $payment->user, (float) $payment->amount, PaymentTransaction::class, $payment->id, "payment-credit:{$payment->uuid}", $this->buildWalletCreditMetadata($payment), ); $this->auditService->log( $payment->user, 'payment.confirmed', $payment, null, $payment->fresh()->toArray(), ); return $payment->fresh(); } /** * @return array */ private function buildWalletCreditMetadata(PaymentTransaction $payment): array { $metadata = [ 'operation' => 'top_up', 'provider' => $payment->provider, 'label' => match ($payment->provider) { 'stripe' => 'Rechargement par carte', 'simulated' => 'Rechargement (simulation)', default => 'Rechargement portefeuille', }, 'payment_uuid' => $payment->uuid, 'provider_payment_id' => $payment->provider_payment_id, 'currency' => $payment->currency, 'amount' => (float) $payment->amount, ]; $raw = $payment->raw_payload ?? []; if ($payment->provider === 'stripe') { $metadata['stripe'] = $raw['stripe'] ?? [ 'payment_intent_id' => $payment->provider_payment_id, 'status' => 'succeeded', ]; } return $metadata; } }