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:
Serreau Jovann
2026-03-25 10:15:01 +01:00
parent 972e4b63d2
commit 531c7da051
7 changed files with 222 additions and 32 deletions

View 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');
}
}

View File

@@ -179,6 +179,23 @@ class OrderController extends AbstractController
return $this->json(['clientSecret' => $paymentIntent->client_secret]);
}
$hasDebt = ($organizer->getDebt() ?? 0) > 0;
if ($hasDebt) {
$paymentIntent = $stripeService->getClient()->paymentIntents->create([
'amount' => $order->getTotalHT(),
'currency' => 'eur',
'automatic_payment_methods' => ['enabled' => true],
'metadata' => [
'order_id' => $order->getId(),
'reference' => $order->getReference(),
'debt_organizer_id' => $organizer->getId(),
],
'receipt_email' => $order->getEmail(),
], [
'idempotency_key' => 'pi_'.$order->getReference(),
]);
} else {
$commissionRate = $organizer->getCommissionRate() ?? 0;
$applicationFee = (int) round($order->getTotalHT() * ($commissionRate / 100));
@@ -196,6 +213,7 @@ class OrderController extends AbstractController
'stripe_account' => $organizer->getStripeAccountId(),
'idempotency_key' => 'pi_'.$order->getReference(),
]);
}
$order->setStripeSessionId($paymentIntent->id);
$em->flush();
@@ -219,9 +237,6 @@ class OrderController extends AbstractController
throw $this->createNotFoundException();
}
$commissionRate = $organizer->getCommissionRate() ?? 0;
$applicationFee = (int) round($order->getTotalHT() * ($commissionRate / 100));
$lineItems = [];
foreach ($order->getItems() as $item) {
$lineItems[] = [
@@ -236,6 +251,29 @@ class OrderController extends AbstractController
];
}
$hasDebt = ($organizer->getDebt() ?? 0) > 0;
$successUrl = $this->generateUrl('app_order_success', ['id' => $order->getId()], \Symfony\Component\Routing\Generator\UrlGeneratorInterface::ABSOLUTE_URL).'?redirect_status=succeeded&payment_intent={CHECKOUT_SESSION_ID}';
$cancelUrl = $this->generateUrl('app_order_payment', ['id' => $order->getId()], \Symfony\Component\Routing\Generator\UrlGeneratorInterface::ABSOLUTE_URL);
if ($hasDebt) {
$session = $stripeService->getClient()->checkout->sessions->create([
'mode' => 'payment',
'line_items' => $lineItems,
'customer_email' => $order->getEmail(),
'payment_intent_data' => [
'metadata' => [
'order_id' => $order->getId(),
'reference' => $order->getReference(),
'debt_organizer_id' => $organizer->getId(),
],
],
'success_url' => $successUrl,
'cancel_url' => $cancelUrl,
]);
} else {
$commissionRate = $organizer->getCommissionRate() ?? 0;
$applicationFee = (int) round($order->getTotalHT() * ($commissionRate / 100));
$session = $stripeService->getClient()->checkout->sessions->create([
'mode' => 'payment',
'line_items' => $lineItems,
@@ -247,9 +285,10 @@ class OrderController extends AbstractController
'reference' => $order->getReference(),
],
],
'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' => $this->generateUrl('app_order_payment', ['id' => $order->getId()], \Symfony\Component\Routing\Generator\UrlGeneratorInterface::ABSOLUTE_URL),
'success_url' => $successUrl,
'cancel_url' => $cancelUrl,
], ['stripe_account' => $organizer->getStripeAccountId()]);
}
$order->setStripeSessionId($session->payment_intent);
$em->flush();

View File

@@ -52,6 +52,7 @@ class StripeWebhookController extends AbstractController
'payment_intent.succeeded' => $this->handlePaymentIntentSucceeded($event, $em, $billetOrderService),
'payment_intent.payment_failed' => $this->handlePaymentIntentFailed($event, $em, $mailerService, $audit),
'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),
'invoice.payment_failed' => $this->handleInvoicePaymentFailed($event, $em, $mailerService),
'customer.subscription.deleted' => $this->handleSubscriptionDeleted($event, $em, $mailerService),
@@ -156,6 +157,14 @@ class StripeWebhookController extends AbstractController
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->generateAndSendTickets($order);
$billetOrderService->notifyOrganizer($order);
@@ -212,6 +221,8 @@ class StripeWebhookController extends AbstractController
}
$amountRefunded = (int) ($charge->amount_refunded ?? 0);
$previouslyRefunded = $order->getRefundedAmount();
$newRefundAmount = $amountRefunded - $previouslyRefunded;
$amountTotal = $order->getTotalHT();
$order->setRefundedAmount($amountRefunded);
@@ -228,6 +239,11 @@ class StripeWebhookController extends AbstractController
$order->setStatus(BilletBuyer::STATUS_PARTIALLY_REFUNDED);
}
if ($newRefundAmount > 0) {
$organizer = $order->getEvent()->getAccount();
$organizer->addDebt($newRefundAmount);
}
$em->flush();
$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
{
$session = $event->data->object;

View File

@@ -130,6 +130,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\Column(length: 255, nullable: true)]
private ?string $billingStripeSubscriptionId = null;
#[ORM\Column(nullable: true)]
private ?int $debt = null;
#[ORM\ManyToOne(targetEntity: self::class)]
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
private ?self $parentOrganizer = null;
@@ -505,6 +508,32 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
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
{
return $this->parentOrganizer;

View File

@@ -30,6 +30,16 @@
{% 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">&#9888;</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, ',', ' ') }} &euro;</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' %}
<div class="card-brutal-error p-8 text-center mb-8">
<div class="text-4xl mb-4">&#9888;</div>

View 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 }} &euro;</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 }} &euro;</p>
</div>
<p>Contactez <a href="mailto:contact@e-cosplay.fr">contact@e-cosplay.fr</a> pour toute question.</p>
{% endblock %}

View File

@@ -211,6 +211,32 @@ class UserTest extends TestCase
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
{
$user = new User();