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,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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">⚠</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' %}
|
||||
<div class="card-brutal-error p-8 text-center mb-8">
|
||||
<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());
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user