Add debt system: track refunds/disputes, redirect payments until cleared
- Add nullable debt field to User entity with addDebt/reduceDebt helpers - On refund webhook: add refunded amount to organizer debt - On dispute webhook (charge.dispute.created): add disputed amount to debt - OrderController: if organizer has debt > 0, payment goes to main Stripe account instead of connected account, debt reduced on payment success - Display debt amount on organizer dashboard with warning message - Add dispute notification email template - Migration for debt column on user table Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
26
migrations/Version20260325090632.php
Normal file
26
migrations/Version20260325090632.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
final class Version20260325090632 extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return 'Add debt field to user table for refund/dispute tracking';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE "user" ADD debt INT DEFAULT NULL');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
$this->addSql('ALTER TABLE "user" DROP debt');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -179,23 +179,41 @@ class OrderController extends AbstractController
|
|||||||
return $this->json(['clientSecret' => $paymentIntent->client_secret]);
|
return $this->json(['clientSecret' => $paymentIntent->client_secret]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$commissionRate = $organizer->getCommissionRate() ?? 0;
|
$hasDebt = ($organizer->getDebt() ?? 0) > 0;
|
||||||
$applicationFee = (int) round($order->getTotalHT() * ($commissionRate / 100));
|
|
||||||
|
|
||||||
$paymentIntent = $stripeService->getClient()->paymentIntents->create([
|
if ($hasDebt) {
|
||||||
'amount' => $order->getTotalHT(),
|
$paymentIntent = $stripeService->getClient()->paymentIntents->create([
|
||||||
'currency' => 'eur',
|
'amount' => $order->getTotalHT(),
|
||||||
'automatic_payment_methods' => ['enabled' => true],
|
'currency' => 'eur',
|
||||||
'application_fee_amount' => $applicationFee,
|
'automatic_payment_methods' => ['enabled' => true],
|
||||||
'metadata' => [
|
'metadata' => [
|
||||||
'order_id' => $order->getId(),
|
'order_id' => $order->getId(),
|
||||||
'reference' => $order->getReference(),
|
'reference' => $order->getReference(),
|
||||||
],
|
'debt_organizer_id' => $organizer->getId(),
|
||||||
'receipt_email' => $order->getEmail(),
|
],
|
||||||
], [
|
'receipt_email' => $order->getEmail(),
|
||||||
'stripe_account' => $organizer->getStripeAccountId(),
|
], [
|
||||||
'idempotency_key' => 'pi_'.$order->getReference(),
|
'idempotency_key' => 'pi_'.$order->getReference(),
|
||||||
]);
|
]);
|
||||||
|
} else {
|
||||||
|
$commissionRate = $organizer->getCommissionRate() ?? 0;
|
||||||
|
$applicationFee = (int) round($order->getTotalHT() * ($commissionRate / 100));
|
||||||
|
|
||||||
|
$paymentIntent = $stripeService->getClient()->paymentIntents->create([
|
||||||
|
'amount' => $order->getTotalHT(),
|
||||||
|
'currency' => 'eur',
|
||||||
|
'automatic_payment_methods' => ['enabled' => true],
|
||||||
|
'application_fee_amount' => $applicationFee,
|
||||||
|
'metadata' => [
|
||||||
|
'order_id' => $order->getId(),
|
||||||
|
'reference' => $order->getReference(),
|
||||||
|
],
|
||||||
|
'receipt_email' => $order->getEmail(),
|
||||||
|
], [
|
||||||
|
'stripe_account' => $organizer->getStripeAccountId(),
|
||||||
|
'idempotency_key' => 'pi_'.$order->getReference(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
$order->setStripeSessionId($paymentIntent->id);
|
$order->setStripeSessionId($paymentIntent->id);
|
||||||
$em->flush();
|
$em->flush();
|
||||||
@@ -219,9 +237,6 @@ class OrderController extends AbstractController
|
|||||||
throw $this->createNotFoundException();
|
throw $this->createNotFoundException();
|
||||||
}
|
}
|
||||||
|
|
||||||
$commissionRate = $organizer->getCommissionRate() ?? 0;
|
|
||||||
$applicationFee = (int) round($order->getTotalHT() * ($commissionRate / 100));
|
|
||||||
|
|
||||||
$lineItems = [];
|
$lineItems = [];
|
||||||
foreach ($order->getItems() as $item) {
|
foreach ($order->getItems() as $item) {
|
||||||
$lineItems[] = [
|
$lineItems[] = [
|
||||||
@@ -236,20 +251,44 @@ class OrderController extends AbstractController
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
$session = $stripeService->getClient()->checkout->sessions->create([
|
$hasDebt = ($organizer->getDebt() ?? 0) > 0;
|
||||||
'mode' => 'payment',
|
$successUrl = $this->generateUrl('app_order_success', ['id' => $order->getId()], \Symfony\Component\Routing\Generator\UrlGeneratorInterface::ABSOLUTE_URL).'?redirect_status=succeeded&payment_intent={CHECKOUT_SESSION_ID}';
|
||||||
'line_items' => $lineItems,
|
$cancelUrl = $this->generateUrl('app_order_payment', ['id' => $order->getId()], \Symfony\Component\Routing\Generator\UrlGeneratorInterface::ABSOLUTE_URL);
|
||||||
'customer_email' => $order->getEmail(),
|
|
||||||
'payment_intent_data' => [
|
if ($hasDebt) {
|
||||||
'application_fee_amount' => $applicationFee,
|
$session = $stripeService->getClient()->checkout->sessions->create([
|
||||||
'metadata' => [
|
'mode' => 'payment',
|
||||||
'order_id' => $order->getId(),
|
'line_items' => $lineItems,
|
||||||
'reference' => $order->getReference(),
|
'customer_email' => $order->getEmail(),
|
||||||
|
'payment_intent_data' => [
|
||||||
|
'metadata' => [
|
||||||
|
'order_id' => $order->getId(),
|
||||||
|
'reference' => $order->getReference(),
|
||||||
|
'debt_organizer_id' => $organizer->getId(),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
],
|
'success_url' => $successUrl,
|
||||||
'success_url' => $this->generateUrl('app_order_success', ['id' => $order->getId()], \Symfony\Component\Routing\Generator\UrlGeneratorInterface::ABSOLUTE_URL).'?redirect_status=succeeded&payment_intent={CHECKOUT_SESSION_ID}',
|
'cancel_url' => $cancelUrl,
|
||||||
'cancel_url' => $this->generateUrl('app_order_payment', ['id' => $order->getId()], \Symfony\Component\Routing\Generator\UrlGeneratorInterface::ABSOLUTE_URL),
|
]);
|
||||||
], ['stripe_account' => $organizer->getStripeAccountId()]);
|
} else {
|
||||||
|
$commissionRate = $organizer->getCommissionRate() ?? 0;
|
||||||
|
$applicationFee = (int) round($order->getTotalHT() * ($commissionRate / 100));
|
||||||
|
|
||||||
|
$session = $stripeService->getClient()->checkout->sessions->create([
|
||||||
|
'mode' => 'payment',
|
||||||
|
'line_items' => $lineItems,
|
||||||
|
'customer_email' => $order->getEmail(),
|
||||||
|
'payment_intent_data' => [
|
||||||
|
'application_fee_amount' => $applicationFee,
|
||||||
|
'metadata' => [
|
||||||
|
'order_id' => $order->getId(),
|
||||||
|
'reference' => $order->getReference(),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'success_url' => $successUrl,
|
||||||
|
'cancel_url' => $cancelUrl,
|
||||||
|
], ['stripe_account' => $organizer->getStripeAccountId()]);
|
||||||
|
}
|
||||||
|
|
||||||
$order->setStripeSessionId($session->payment_intent);
|
$order->setStripeSessionId($session->payment_intent);
|
||||||
$em->flush();
|
$em->flush();
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ class StripeWebhookController extends AbstractController
|
|||||||
'payment_intent.succeeded' => $this->handlePaymentIntentSucceeded($event, $em, $billetOrderService),
|
'payment_intent.succeeded' => $this->handlePaymentIntentSucceeded($event, $em, $billetOrderService),
|
||||||
'payment_intent.payment_failed' => $this->handlePaymentIntentFailed($event, $em, $mailerService, $audit),
|
'payment_intent.payment_failed' => $this->handlePaymentIntentFailed($event, $em, $mailerService, $audit),
|
||||||
'charge.refunded' => $this->handleChargeRefunded($event, $em, $mailerService, $audit, $billetOrderService),
|
'charge.refunded' => $this->handleChargeRefunded($event, $em, $mailerService, $audit, $billetOrderService),
|
||||||
|
'charge.dispute.created' => $this->handleDisputeCreated($event, $em, $mailerService),
|
||||||
'checkout.session.completed' => $this->handleCheckoutSessionCompleted($event, $em, $mailerService),
|
'checkout.session.completed' => $this->handleCheckoutSessionCompleted($event, $em, $mailerService),
|
||||||
'invoice.payment_failed' => $this->handleInvoicePaymentFailed($event, $em, $mailerService),
|
'invoice.payment_failed' => $this->handleInvoicePaymentFailed($event, $em, $mailerService),
|
||||||
'customer.subscription.deleted' => $this->handleSubscriptionDeleted($event, $em, $mailerService),
|
'customer.subscription.deleted' => $this->handleSubscriptionDeleted($event, $em, $mailerService),
|
||||||
@@ -156,6 +157,14 @@ class StripeWebhookController extends AbstractController
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$debtOrganizerId = $paymentIntent->metadata->debt_organizer_id ?? null;
|
||||||
|
if ($debtOrganizerId) {
|
||||||
|
$organizer = $em->getRepository(User::class)->find((int) $debtOrganizerId);
|
||||||
|
if ($organizer) {
|
||||||
|
$organizer->reduceDebt($paymentIntent->amount ?? 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$billetOrderService->generateOrderTickets($order);
|
$billetOrderService->generateOrderTickets($order);
|
||||||
$billetOrderService->generateAndSendTickets($order);
|
$billetOrderService->generateAndSendTickets($order);
|
||||||
$billetOrderService->notifyOrganizer($order);
|
$billetOrderService->notifyOrganizer($order);
|
||||||
@@ -212,6 +221,8 @@ class StripeWebhookController extends AbstractController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$amountRefunded = (int) ($charge->amount_refunded ?? 0);
|
$amountRefunded = (int) ($charge->amount_refunded ?? 0);
|
||||||
|
$previouslyRefunded = $order->getRefundedAmount();
|
||||||
|
$newRefundAmount = $amountRefunded - $previouslyRefunded;
|
||||||
$amountTotal = $order->getTotalHT();
|
$amountTotal = $order->getTotalHT();
|
||||||
$order->setRefundedAmount($amountRefunded);
|
$order->setRefundedAmount($amountRefunded);
|
||||||
|
|
||||||
@@ -228,6 +239,11 @@ class StripeWebhookController extends AbstractController
|
|||||||
$order->setStatus(BilletBuyer::STATUS_PARTIALLY_REFUNDED);
|
$order->setStatus(BilletBuyer::STATUS_PARTIALLY_REFUNDED);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($newRefundAmount > 0) {
|
||||||
|
$organizer = $order->getEvent()->getAccount();
|
||||||
|
$organizer->addDebt($newRefundAmount);
|
||||||
|
}
|
||||||
|
|
||||||
$em->flush();
|
$em->flush();
|
||||||
|
|
||||||
$actionLabel = $isFullRefund ? 'remboursee' : 'partiellement remboursee';
|
$actionLabel = $isFullRefund ? 'remboursee' : 'partiellement remboursee';
|
||||||
@@ -319,6 +335,37 @@ class StripeWebhookController extends AbstractController
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function handleDisputeCreated(\Stripe\Event $event, EntityManagerInterface $em, MailerService $mailerService): void
|
||||||
|
{
|
||||||
|
$dispute = $event->data->object;
|
||||||
|
$paymentIntentId = $dispute->payment_intent ?? null;
|
||||||
|
$amount = (int) ($dispute->amount ?? 0);
|
||||||
|
|
||||||
|
if (!$paymentIntentId || 0 === $amount) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$order = $em->getRepository(BilletBuyer::class)->findOneBy(['stripeSessionId' => $paymentIntentId]);
|
||||||
|
if (!$order) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$organizer = $order->getEvent()->getAccount();
|
||||||
|
$organizer->addDebt($amount);
|
||||||
|
$em->flush();
|
||||||
|
|
||||||
|
$mailerService->sendEmail(
|
||||||
|
$organizer->getEmail(),
|
||||||
|
'Litige recu - E-Ticket',
|
||||||
|
$this->renderView('email/dispute_created.html.twig', [
|
||||||
|
'firstName' => $organizer->getFirstName(),
|
||||||
|
'amount' => number_format($amount / 100, 2, ',', ' '),
|
||||||
|
'orderNumber' => $order->getOrderNumber(),
|
||||||
|
'debt' => number_format(($organizer->getDebt() ?? 0) / 100, 2, ',', ' '),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private function handleCheckoutSessionCompleted(\Stripe\Event $event, EntityManagerInterface $em, MailerService $mailerService): void
|
private function handleCheckoutSessionCompleted(\Stripe\Event $event, EntityManagerInterface $em, MailerService $mailerService): void
|
||||||
{
|
{
|
||||||
$session = $event->data->object;
|
$session = $event->data->object;
|
||||||
|
|||||||
@@ -130,6 +130,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
#[ORM\Column(length: 255, nullable: true)]
|
#[ORM\Column(length: 255, nullable: true)]
|
||||||
private ?string $billingStripeSubscriptionId = null;
|
private ?string $billingStripeSubscriptionId = null;
|
||||||
|
|
||||||
|
#[ORM\Column(nullable: true)]
|
||||||
|
private ?int $debt = null;
|
||||||
|
|
||||||
#[ORM\ManyToOne(targetEntity: self::class)]
|
#[ORM\ManyToOne(targetEntity: self::class)]
|
||||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||||
private ?self $parentOrganizer = null;
|
private ?self $parentOrganizer = null;
|
||||||
@@ -505,6 +508,32 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getDebt(): ?int
|
||||||
|
{
|
||||||
|
return $this->debt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setDebt(?int $debt): static
|
||||||
|
{
|
||||||
|
$this->debt = $debt;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function addDebt(int $amount): static
|
||||||
|
{
|
||||||
|
$this->debt = ($this->debt ?? 0) + $amount;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reduceDebt(int $amount): static
|
||||||
|
{
|
||||||
|
$this->debt = max(0, ($this->debt ?? 0) - $amount);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
public function getParentOrganizer(): ?self
|
public function getParentOrganizer(): ?self
|
||||||
{
|
{
|
||||||
return $this->parentOrganizer;
|
return $this->parentOrganizer;
|
||||||
|
|||||||
@@ -30,6 +30,16 @@
|
|||||||
|
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|
||||||
|
{% if isOrganizer and app.user.debt is not null and app.user.debt > 0 %}
|
||||||
|
<div class="card-brutal-error p-6 mb-8">
|
||||||
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
<span class="text-2xl">⚠</span>
|
||||||
|
<h2 class="text-sm font-black uppercase tracking-widest text-red-800">Dette en cours</h2>
|
||||||
|
</div>
|
||||||
|
<p class="font-bold text-gray-700 text-sm">Suite a des remboursements ou litiges, votre compte presente une dette de <strong class="text-red-700">{{ (app.user.debt / 100)|number_format(2, ',', ' ') }} €</strong>. Les paiements de vos clients seront retenus jusqu'a l'apurement complet de cette dette.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if isOrganizer and app.user.billing and app.user.billingState != 'good' %}
|
{% if isOrganizer and app.user.billing and app.user.billingState != 'good' %}
|
||||||
<div class="card-brutal-error p-8 text-center mb-8">
|
<div class="card-brutal-error p-8 text-center mb-8">
|
||||||
<div class="text-4xl mb-4">⚠</div>
|
<div class="text-4xl mb-4">⚠</div>
|
||||||
|
|||||||
13
templates/email/dispute_created.html.twig
Normal file
13
templates/email/dispute_created.html.twig
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{% extends 'email/base.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Litige recu{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h2>Bonjour {{ firstName }},</h2>
|
||||||
|
<p>Un litige de <strong>{{ amount }} €</strong> a ete ouvert sur la commande <strong>{{ orderNumber }}</strong>.</p>
|
||||||
|
<p>Ce montant a ete ajoute a votre dette. Les paiements de vos clients seront retenus jusqu'a l'apurement complet.</p>
|
||||||
|
<div style="background:#fef2f2;border-left:4px solid #dc2626;padding:12px 16px;margin:16px 0;">
|
||||||
|
<p style="font-weight:700;font-size:13px;color:#991b1b;margin:0;">Dette totale : {{ debt }} €</p>
|
||||||
|
</div>
|
||||||
|
<p>Contactez <a href="mailto:contact@e-cosplay.fr">contact@e-cosplay.fr</a> pour toute question.</p>
|
||||||
|
{% endblock %}
|
||||||
@@ -211,6 +211,32 @@ class UserTest extends TestCase
|
|||||||
self::assertSame('suspendu', $user->getBillingState());
|
self::assertSame('suspendu', $user->getBillingState());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testDebtFields(): void
|
||||||
|
{
|
||||||
|
$user = new User();
|
||||||
|
|
||||||
|
self::assertNull($user->getDebt());
|
||||||
|
|
||||||
|
$result = $user->setDebt(5000);
|
||||||
|
self::assertSame($user, $result);
|
||||||
|
self::assertSame(5000, $user->getDebt());
|
||||||
|
|
||||||
|
$user->addDebt(2000);
|
||||||
|
self::assertSame(7000, $user->getDebt());
|
||||||
|
|
||||||
|
$user->reduceDebt(3000);
|
||||||
|
self::assertSame(4000, $user->getDebt());
|
||||||
|
|
||||||
|
$user->reduceDebt(10000);
|
||||||
|
self::assertSame(0, $user->getDebt());
|
||||||
|
|
||||||
|
$user->setDebt(null);
|
||||||
|
self::assertNull($user->getDebt());
|
||||||
|
|
||||||
|
$user->addDebt(1000);
|
||||||
|
self::assertSame(1000, $user->getDebt());
|
||||||
|
}
|
||||||
|
|
||||||
public function testEmailVerificationFields(): void
|
public function testEmailVerificationFields(): void
|
||||||
{
|
{
|
||||||
$user = new User();
|
$user = new User();
|
||||||
|
|||||||
Reference in New Issue
Block a user