Add Billet entity, BilletDesign, ticket designer, CRUD billets, commissions

- Create Billet entity: name, position, priceHT, quantity (nullable=unlimited),
  isGeneratedBillet, hasDefinedExit, notBuyable, type (billet/reservation_brocante/vote),
  stripeProductId, description, picture (VichUploader), category (ManyToOne CASCADE)
- Create BilletDesign entity (OneToOne Event): accentColor, invitationTitle, invitationColor
- Billet CRUD: add/edit/delete with access control, Stripe product sync on connected account
- Billet reorder: drag & drop with position field, refactored sortable.js for both categories and billets
- Ticket designer tab (custom offer only): accent color, invitation title/color, live iframe preview
- A4 ticket preview: 4 zones (HG infos+billet, HD affiche, BG association, BD sortie+invitation), fake QR code SVG
- Commission calculator JS: live breakdown of E-Ticket fee, Stripe fee (1.5%+0.25EUR), net amount
- Sales recap on categories tab: qty sold, total HT, total commissions, total net
- DisableProfilerSubscriber: disable web profiler toolbar on preview iframe
- CSP: allow self in frame-src and frame-ancestors for preview iframe
- Flysystem: dedicated billets.storage for billet images
- Upload accept restricted to png/jpeg/webp/gif (no HEIC)
- Makefile: add force_sql_dev command
- CLAUDE.md: add rule to never modify existing migrations
- Consolidate all migrations into single Version20260321111125
- Tests: BilletTest (20), BilletDesignTest (6), DisableProfilerSubscriberTest (5),
  billet-designer.test.js (7), commission-calculator.test.js (7),
  AccountControllerTest billet CRUD tests (11)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-03-21 12:19:46 +01:00
parent c054e9913e
commit 179a0703f8
31 changed files with 2377 additions and 93 deletions

View File

@@ -1101,6 +1101,197 @@ class AccountControllerTest extends WebTestCase
self::assertResponseIsSuccessful();
}
public function testAddBilletPage(): void
{
$client = static::createClient();
$em = static::getContainer()->get(EntityManagerInterface::class);
$user = $this->createUser(['ROLE_ORGANIZER'], true);
$event = $this->createEvent($em, $user);
$category = $this->createCategory($em, $event);
$client->loginUser($user);
$client->request('GET', '/mon-compte/evenement/'.$event->getId().'/categorie/'.$category->getId().'/billet/ajouter');
self::assertResponseIsSuccessful();
}
public function testAddBilletSubmit(): void
{
$client = static::createClient();
$em = static::getContainer()->get(EntityManagerInterface::class);
$user = $this->createUser(['ROLE_ORGANIZER'], true);
$event = $this->createEvent($em, $user);
$category = $this->createCategory($em, $event);
$client->loginUser($user);
$client->request('POST', '/mon-compte/evenement/'.$event->getId().'/categorie/'.$category->getId().'/billet/ajouter', [
'name' => 'Entree VIP',
'price_ht' => '1500',
'is_generated_billet' => '1',
'description' => 'Acces backstage',
]);
self::assertResponseRedirects('/mon-compte/evenement/'.$event->getId().'/modifier?tab=categories');
$billet = $em->getRepository(\App\Entity\Billet::class)->findOneBy(['name' => 'Entree VIP']);
self::assertNotNull($billet);
self::assertSame(1500, $billet->getPriceHT());
self::assertTrue($billet->isGeneratedBillet());
self::assertSame('Acces backstage', $billet->getDescription());
}
public function testAddBilletDeniedForOtherUser(): void
{
$client = static::createClient();
$em = static::getContainer()->get(EntityManagerInterface::class);
$owner = $this->createUser(['ROLE_ORGANIZER'], true);
$other = $this->createUser(['ROLE_ORGANIZER'], true);
$event = $this->createEvent($em, $owner);
$category = $this->createCategory($em, $event);
$client->loginUser($other);
$client->request('GET', '/mon-compte/evenement/'.$event->getId().'/categorie/'.$category->getId().'/billet/ajouter');
self::assertResponseStatusCodeSame(403);
}
public function testAddBilletCategoryNotFound(): void
{
$client = static::createClient();
$em = static::getContainer()->get(EntityManagerInterface::class);
$user = $this->createUser(['ROLE_ORGANIZER'], true);
$event = $this->createEvent($em, $user);
$client->loginUser($user);
$client->request('GET', '/mon-compte/evenement/'.$event->getId().'/categorie/999999/billet/ajouter');
self::assertResponseStatusCodeSame(404);
}
public function testEditBilletPage(): void
{
$client = static::createClient();
$em = static::getContainer()->get(EntityManagerInterface::class);
$user = $this->createUser(['ROLE_ORGANIZER'], true);
$event = $this->createEvent($em, $user);
$category = $this->createCategory($em, $event);
$billet = $this->createBillet($em, $category);
$client->loginUser($user);
$client->request('GET', '/mon-compte/evenement/'.$event->getId().'/billet/'.$billet->getId().'/modifier');
self::assertResponseIsSuccessful();
}
public function testEditBilletSubmit(): void
{
$client = static::createClient();
$em = static::getContainer()->get(EntityManagerInterface::class);
$user = $this->createUser(['ROLE_ORGANIZER'], true);
$event = $this->createEvent($em, $user);
$category = $this->createCategory($em, $event);
$billet = $this->createBillet($em, $category);
$client->loginUser($user);
$client->request('POST', '/mon-compte/evenement/'.$event->getId().'/billet/'.$billet->getId().'/modifier', [
'name' => 'Entree Premium',
'price_ht' => '2500',
'is_generated_billet' => '1',
'description' => 'Acces VIP',
]);
self::assertResponseRedirects('/mon-compte/evenement/'.$event->getId().'/modifier?tab=categories');
$em->refresh($billet);
self::assertSame('Entree Premium', $billet->getName());
self::assertSame(2500, $billet->getPriceHT());
}
public function testEditBilletDeniedForOtherUser(): void
{
$client = static::createClient();
$em = static::getContainer()->get(EntityManagerInterface::class);
$owner = $this->createUser(['ROLE_ORGANIZER'], true);
$other = $this->createUser(['ROLE_ORGANIZER'], true);
$event = $this->createEvent($em, $owner);
$category = $this->createCategory($em, $event);
$billet = $this->createBillet($em, $category);
$client->loginUser($other);
$client->request('GET', '/mon-compte/evenement/'.$event->getId().'/billet/'.$billet->getId().'/modifier');
self::assertResponseStatusCodeSame(403);
}
public function testEditBilletNotFound(): void
{
$client = static::createClient();
$em = static::getContainer()->get(EntityManagerInterface::class);
$user = $this->createUser(['ROLE_ORGANIZER'], true);
$event = $this->createEvent($em, $user);
$client->loginUser($user);
$client->request('GET', '/mon-compte/evenement/'.$event->getId().'/billet/999999/modifier');
self::assertResponseStatusCodeSame(404);
}
public function testDeleteBillet(): void
{
$client = static::createClient();
$em = static::getContainer()->get(EntityManagerInterface::class);
$user = $this->createUser(['ROLE_ORGANIZER'], true);
$event = $this->createEvent($em, $user);
$category = $this->createCategory($em, $event);
$billet = $this->createBillet($em, $category);
$billetId = $billet->getId();
$client->loginUser($user);
$client->request('POST', '/mon-compte/evenement/'.$event->getId().'/billet/'.$billetId.'/supprimer');
self::assertResponseRedirects('/mon-compte/evenement/'.$event->getId().'/modifier?tab=categories');
self::assertNull($em->getRepository(\App\Entity\Billet::class)->find($billetId));
}
public function testDeleteBilletDeniedForOtherUser(): void
{
$client = static::createClient();
$em = static::getContainer()->get(EntityManagerInterface::class);
$owner = $this->createUser(['ROLE_ORGANIZER'], true);
$other = $this->createUser(['ROLE_ORGANIZER'], true);
$event = $this->createEvent($em, $owner);
$category = $this->createCategory($em, $event);
$billet = $this->createBillet($em, $category);
$client->loginUser($other);
$client->request('POST', '/mon-compte/evenement/'.$event->getId().'/billet/'.$billet->getId().'/supprimer');
self::assertResponseStatusCodeSame(403);
}
private function createBillet(EntityManagerInterface $em, \App\Entity\Category $category, string $name = 'Test Billet', int $priceHT = 1000): \App\Entity\Billet
{
$billet = new \App\Entity\Billet();
$billet->setName($name);
$billet->setCategory($category);
$billet->setPriceHT($priceHT);
$em->persist($billet);
$em->flush();
return $billet;
}
private function createEvent(EntityManagerInterface $em, User $user): \App\Entity\Event
{
$event = new \App\Entity\Event();

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Tests\Entity;
use App\Entity\BilletDesign;
use App\Entity\Event;
use PHPUnit\Framework\TestCase;
class BilletDesignTest extends TestCase
{
public function testDefaults(): void
{
$design = new BilletDesign();
self::assertNull($design->getId());
self::assertNull($design->getEvent());
self::assertSame('#4f46e5', $design->getAccentColor());
self::assertSame('Invitation', $design->getInvitationTitle());
self::assertSame('#d4a017', $design->getInvitationColor());
self::assertInstanceOf(\DateTimeImmutable::class, $design->getUpdatedAt());
}
public function testSetAndGetEvent(): void
{
$design = new BilletDesign();
$event = new Event();
$result = $design->setEvent($event);
self::assertSame($event, $design->getEvent());
self::assertSame($design, $result);
}
public function testSetAndGetAccentColor(): void
{
$design = new BilletDesign();
$result = $design->setAccentColor('#ff0000');
self::assertSame('#ff0000', $design->getAccentColor());
self::assertSame($design, $result);
}
public function testSetAndGetInvitationTitle(): void
{
$design = new BilletDesign();
$result = $design->setInvitationTitle('VIP Pass');
self::assertSame('VIP Pass', $design->getInvitationTitle());
self::assertSame($design, $result);
}
public function testSetAndGetInvitationColor(): void
{
$design = new BilletDesign();
$result = $design->setInvitationColor('#00ff00');
self::assertSame('#00ff00', $design->getInvitationColor());
self::assertSame($design, $result);
}
public function testSetAndGetUpdatedAt(): void
{
$design = new BilletDesign();
$date = new \DateTimeImmutable('2026-01-01');
$result = $design->setUpdatedAt($date);
self::assertSame($date, $design->getUpdatedAt());
self::assertSame($design, $result);
}
}

227
tests/Entity/BilletTest.php Normal file
View File

@@ -0,0 +1,227 @@
<?php
namespace App\Tests\Entity;
use App\Entity\Billet;
use App\Entity\Category;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\File\File;
class BilletTest extends TestCase
{
public function testNewBilletDefaults(): void
{
$billet = new Billet();
self::assertNull($billet->getId());
self::assertNull($billet->getName());
self::assertNull($billet->getCategory());
self::assertNull($billet->getDescription());
self::assertNull($billet->getPictureName());
self::assertNull($billet->getPictureFile());
self::assertNull($billet->getUpdatedAt());
self::assertSame(0, $billet->getPosition());
self::assertSame(0, $billet->getPriceHT());
self::assertNull($billet->getQuantity());
self::assertTrue($billet->isUnlimited());
self::assertTrue($billet->isGeneratedBillet());
self::assertFalse($billet->hasDefinedExit());
self::assertFalse($billet->isNotBuyable());
self::assertSame('billet', $billet->getType());
self::assertNull($billet->getStripeProductId());
self::assertInstanceOf(\DateTimeImmutable::class, $billet->getCreatedAt());
}
public function testSetAndGetCategory(): void
{
$billet = new Billet();
$category = new Category();
$result = $billet->setCategory($category);
self::assertSame($category, $billet->getCategory());
self::assertSame($billet, $result);
}
public function testSetAndGetName(): void
{
$billet = new Billet();
$result = $billet->setName('Entree VIP');
self::assertSame('Entree VIP', $billet->getName());
self::assertSame($billet, $result);
}
public function testSetAndGetPriceHT(): void
{
$billet = new Billet();
$result = $billet->setPriceHT(1500);
self::assertSame(1500, $billet->getPriceHT());
self::assertSame($billet, $result);
}
public function testGetPriceHTDecimal(): void
{
$billet = new Billet();
$billet->setPriceHT(1500);
self::assertSame(15.0, $billet->getPriceHTDecimal());
}
public function testGetPriceHTDecimalZero(): void
{
$billet = new Billet();
self::assertSame(0.0, $billet->getPriceHTDecimal());
}
public function testSetAndGetIsGeneratedBillet(): void
{
$billet = new Billet();
$result = $billet->setIsGeneratedBillet(false);
self::assertFalse($billet->isGeneratedBillet());
self::assertSame($billet, $result);
$billet->setIsGeneratedBillet(true);
self::assertTrue($billet->isGeneratedBillet());
}
public function testSetAndGetHasDefinedExit(): void
{
$billet = new Billet();
$result = $billet->setHasDefinedExit(true);
self::assertTrue($billet->hasDefinedExit());
self::assertSame($billet, $result);
$billet->setHasDefinedExit(false);
self::assertFalse($billet->hasDefinedExit());
}
public function testSetAndGetDescription(): void
{
$billet = new Billet();
$result = $billet->setDescription('Acces backstage inclus');
self::assertSame('Acces backstage inclus', $billet->getDescription());
self::assertSame($billet, $result);
}
public function testSetDescriptionNull(): void
{
$billet = new Billet();
$billet->setDescription('Test');
$billet->setDescription(null);
self::assertNull($billet->getDescription());
}
public function testSetAndGetPictureName(): void
{
$billet = new Billet();
$result = $billet->setPictureName('billet-vip.jpg');
self::assertSame('billet-vip.jpg', $billet->getPictureName());
self::assertSame($billet, $result);
}
public function testSetPictureFileUpdatesTimestamp(): void
{
$billet = new Billet();
self::assertNull($billet->getUpdatedAt());
$file = $this->createMock(File::class);
$result = $billet->setPictureFile($file);
self::assertSame($file, $billet->getPictureFile());
self::assertInstanceOf(\DateTimeImmutable::class, $billet->getUpdatedAt());
self::assertSame($billet, $result);
}
public function testSetAndGetPosition(): void
{
$billet = new Billet();
$result = $billet->setPosition(3);
self::assertSame(3, $billet->getPosition());
self::assertSame($billet, $result);
}
public function testSetAndGetQuantity(): void
{
$billet = new Billet();
$result = $billet->setQuantity(100);
self::assertSame(100, $billet->getQuantity());
self::assertFalse($billet->isUnlimited());
self::assertSame($billet, $result);
$billet->setQuantity(null);
self::assertNull($billet->getQuantity());
self::assertTrue($billet->isUnlimited());
}
public function testSetAndGetNotBuyable(): void
{
$billet = new Billet();
$result = $billet->setNotBuyable(true);
self::assertTrue($billet->isNotBuyable());
self::assertSame($billet, $result);
$billet->setNotBuyable(false);
self::assertFalse($billet->isNotBuyable());
}
public function testSetAndGetType(): void
{
$billet = new Billet();
$result = $billet->setType('reservation_brocante');
self::assertSame('reservation_brocante', $billet->getType());
self::assertSame($billet, $result);
$billet->setType('vote');
self::assertSame('vote', $billet->getType());
}
public function testSetAndGetStripeProductId(): void
{
$billet = new Billet();
$result = $billet->setStripeProductId('prod_abc123');
self::assertSame('prod_abc123', $billet->getStripeProductId());
self::assertSame($billet, $result);
$billet->setStripeProductId(null);
self::assertNull($billet->getStripeProductId());
}
public function testSetPictureFileNullDoesNotUpdateTimestamp(): void
{
$billet = new Billet();
$billet->setPictureFile(null);
self::assertNull($billet->getUpdatedAt());
self::assertNull($billet->getPictureFile());
}
public function testSetCategoryNull(): void
{
$billet = new Billet();
$billet->setCategory(null);
self::assertNull($billet->getCategory());
}
public function testGetCreatedAt(): void
{
$before = new \DateTimeImmutable();
$billet = new Billet();
$after = new \DateTimeImmutable();
self::assertGreaterThanOrEqual($before, $billet->getCreatedAt());
self::assertLessThanOrEqual($after, $billet->getCreatedAt());
}
}

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Tests\EventSubscriber;
use App\EventSubscriber\DisableProfilerSubscriber;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\HttpKernel\HttpKernelInterface;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\HttpKernel\Profiler\Profiler;
class DisableProfilerSubscriberTest extends TestCase
{
public function testSubscribedEvents(): void
{
$events = DisableProfilerSubscriber::getSubscribedEvents();
self::assertArrayHasKey(KernelEvents::RESPONSE, $events);
}
public function testDisablesProfilerOnBilletPreviewRoute(): void
{
$profiler = $this->createMock(Profiler::class);
$profiler->expects(self::once())->method('disable');
$subscriber = new DisableProfilerSubscriber($profiler);
$request = new Request();
$request->attributes->set('_route', 'app_account_event_billet_preview');
$kernel = $this->createMock(HttpKernelInterface::class);
$event = new ResponseEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST, new Response());
$subscriber->onKernelResponse($event);
}
public function testDoesNotDisableProfilerOnOtherRoutes(): void
{
$profiler = $this->createMock(Profiler::class);
$profiler->expects(self::never())->method('disable');
$subscriber = new DisableProfilerSubscriber($profiler);
$request = new Request();
$request->attributes->set('_route', 'app_home');
$kernel = $this->createMock(HttpKernelInterface::class);
$event = new ResponseEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST, new Response());
$subscriber->onKernelResponse($event);
}
public function testHandlesNullProfiler(): void
{
$subscriber = new DisableProfilerSubscriber(null);
$request = new Request();
$request->attributes->set('_route', 'app_account_event_billet_preview');
$kernel = $this->createMock(HttpKernelInterface::class);
$event = new ResponseEvent($kernel, $request, HttpKernelInterface::MAIN_REQUEST, new Response());
// No exception thrown
$subscriber->onKernelResponse($event);
self::assertTrue(true);
}
public function testIgnoresSubRequests(): void
{
$profiler = $this->createMock(Profiler::class);
$profiler->expects(self::never())->method('disable');
$subscriber = new DisableProfilerSubscriber($profiler);
$request = new Request();
$request->attributes->set('_route', 'app_account_event_billet_preview');
$kernel = $this->createMock(HttpKernelInterface::class);
$event = new ResponseEvent($kernel, $request, HttpKernelInterface::SUB_REQUEST, new Response());
$subscriber->onKernelResponse($event);
}
}

View File

@@ -0,0 +1,96 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { initBilletDesigner } from '../../assets/modules/billet-designer.js'
describe('initBilletDesigner', () => {
beforeEach(() => {
document.body.innerHTML = ''
})
it('does nothing without designer element', () => {
expect(() => initBilletDesigner()).not.toThrow()
})
it('does nothing without preview url', () => {
document.body.innerHTML = '<div id="billet-designer"></div>'
expect(() => initBilletDesigner()).not.toThrow()
})
it('does nothing without iframe', () => {
document.body.innerHTML = '<div id="billet-designer" data-preview-url="/preview"></div>'
expect(() => initBilletDesigner()).not.toThrow()
})
it('reloads iframe on color input change', () => {
document.body.innerHTML = `
<div id="billet-designer" data-preview-url="/preview">
<input type="color" name="bg_color" value="#ffffff">
<iframe id="billet-preview-frame" src="/preview"></iframe>
</div>
`
initBilletDesigner()
const input = document.querySelector('input[name="bg_color"]')
input.value = '#ff0000'
input.dispatchEvent(new Event('input', { bubbles: true }))
const iframe = document.getElementById('billet-preview-frame')
expect(iframe.src).toContain('bg_color=%23ff0000')
})
it('reloads iframe on checkbox change', () => {
document.body.innerHTML = `
<div id="billet-designer" data-preview-url="/preview">
<input type="checkbox" name="show_logo" checked>
<iframe id="billet-preview-frame" src="/preview"></iframe>
</div>
`
initBilletDesigner()
const checkbox = document.querySelector('input[name="show_logo"]')
checkbox.checked = false
checkbox.dispatchEvent(new Event('change', { bubbles: true }))
const iframe = document.getElementById('billet-preview-frame')
expect(iframe.src).toContain('show_logo=0')
})
it('includes all inputs in preview url', () => {
document.body.innerHTML = `
<div id="billet-designer" data-preview-url="/preview">
<input type="color" name="bg_color" value="#ffffff">
<input type="color" name="text_color" value="#111111">
<input type="checkbox" name="show_logo" checked>
<iframe id="billet-preview-frame" src="/preview"></iframe>
</div>
`
initBilletDesigner()
const input = document.querySelector('input[name="bg_color"]')
input.dispatchEvent(new Event('input', { bubbles: true }))
const iframe = document.getElementById('billet-preview-frame')
expect(iframe.src).toContain('bg_color=%23ffffff')
expect(iframe.src).toContain('text_color=%23111111')
expect(iframe.src).toContain('show_logo=1')
})
it('reloads on reload button click', () => {
document.body.innerHTML = `
<div id="billet-designer" data-preview-url="/preview">
<input type="color" name="bg_color" value="#aabbcc">
<iframe id="billet-preview-frame" src="/preview"></iframe>
<button id="billet-reload-preview"></button>
</div>
`
initBilletDesigner()
document.getElementById('billet-reload-preview').click()
const iframe = document.getElementById('billet-preview-frame')
expect(iframe.src).toContain('bg_color=%23aabbcc')
})
})

View File

@@ -0,0 +1,88 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { initCommissionCalculator } from '../../assets/modules/commission-calculator.js'
describe('initCommissionCalculator', () => {
beforeEach(() => {
document.body.innerHTML = ''
})
it('does nothing without calculator element', () => {
expect(() => initCommissionCalculator()).not.toThrow()
})
it('does nothing without price input', () => {
document.body.innerHTML = '<div id="commission-calculator" data-eticket-rate="5" data-stripe-rate="1.5" data-stripe-fixed="0.25"></div>'
expect(() => initCommissionCalculator()).not.toThrow()
})
function setupCalculator(eticketRate = '5', price = '') {
document.body.innerHTML = `
<input type="number" id="billet_price" value="${price}">
<div id="commission-calculator" data-eticket-rate="${eticketRate}" data-stripe-rate="1.5" data-stripe-fixed="0.25">
<span id="calc-price"></span>
<span id="calc-eticket"></span>
<span id="calc-stripe"></span>
<span id="calc-total"></span>
<span id="calc-net"></span>
</div>
`
initCommissionCalculator()
}
it('shows zero values when price is empty', () => {
setupCalculator('5', '')
expect(document.getElementById('calc-price').textContent).toBe('0,00 \u20AC')
expect(document.getElementById('calc-net').textContent).toBe('0,00 \u20AC')
})
it('calculates commissions for 10 EUR with 5% eticket rate', () => {
setupCalculator('5', '10')
// E-Ticket: 10 * 5% = 0.50
// Stripe: 10 * 1.5% + 0.25 = 0.40
// Total: 0.90
// Net: 9.10
expect(document.getElementById('calc-price').textContent).toBe('10,00 \u20AC')
expect(document.getElementById('calc-eticket').textContent).toBe('- 0,50 \u20AC')
expect(document.getElementById('calc-stripe').textContent).toBe('- 0,40 \u20AC')
expect(document.getElementById('calc-total').textContent).toBe('- 0,90 \u20AC')
expect(document.getElementById('calc-net').textContent).toBe('9,10 \u20AC')
})
it('calculates with 0% eticket rate', () => {
setupCalculator('0', '20')
// E-Ticket: 0
// Stripe: 20 * 1.5% + 0.25 = 0.55
// Net: 19.45
expect(document.getElementById('calc-eticket').textContent).toBe('- 0,00 \u20AC')
expect(document.getElementById('calc-stripe').textContent).toBe('- 0,55 \u20AC')
expect(document.getElementById('calc-net').textContent).toBe('19,45 \u20AC')
})
it('updates on input event', () => {
setupCalculator('5', '0')
const input = document.getElementById('billet_price')
input.value = '15'
input.dispatchEvent(new Event('input', { bubbles: true }))
// E-Ticket: 15 * 5% = 0.75
// Stripe: 15 * 1.5% + 0.25 = 0.475 → 0.48
// Total: 1.225 → 1.23 (but floating point...)
expect(document.getElementById('calc-price').textContent).toBe('15,00 \u20AC')
expect(document.getElementById('calc-eticket').textContent).toBe('- 0,75 \u20AC')
const net = document.getElementById('calc-net').textContent
expect(net).toContain('\u20AC')
})
it('net is never negative', () => {
setupCalculator('99', '0.01')
// With 99% commission the net would be very low or negative after stripe
const net = document.getElementById('calc-net').textContent
expect(net).toBe('0,00 \u20AC')
})
})