diff --git a/app/controllers/orders_controller.rb b/app/controllers/orders_controller.rb index b0787fa..7ce04a8 100644 --- a/app/controllers/orders_controller.rb +++ b/app/controllers/orders_controller.rb @@ -293,18 +293,7 @@ class OrdersController < ApplicationController } end - # Add service fee as a separate line item - line_items << { - price_data: { - currency: "eur", - product_data: { - name: "Frais de service", - description: "Frais de traitement de la commande" - }, - unit_amount: 100 # 1€ in cents - }, - quantity: 1 - } + # No service fee added to customer; deducted from promoter payout Stripe::Checkout::Session.create( payment_method_types: [ "card" ], diff --git a/app/controllers/promoter/events_controller.rb b/app/controllers/promoter/events_controller.rb index 170fa13..31e2ada 100644 --- a/app/controllers/promoter/events_controller.rb +++ b/app/controllers/promoter/events_controller.rb @@ -5,7 +5,7 @@ class Promoter::EventsController < ApplicationController before_action :authenticate_user! before_action :ensure_can_manage_events! - before_action :set_event, only: [ :show, :edit, :update, :destroy, :publish, :unpublish, :cancel, :mark_sold_out ] + before_action :set_event, only: [ :show, :edit, :update, :destroy, :publish, :unpublish, :cancel, :mark_sold_out, :duplicate ] # Display all events for the current promoter def index @@ -93,6 +93,18 @@ class Promoter::EventsController < ApplicationController end end + # Duplicate an event and all its ticket types + def duplicate + clone_ticket_types = params[:clone_ticket_types] == "true" + @new_event = @event.duplicate(clone_ticket_types: clone_ticket_types) + + if @new_event + redirect_to edit_promoter_event_path(@new_event), notice: "Événement dupliqué avec succès! Vous pouvez maintenant modifier les détails de l'événement copié." + else + redirect_to promoter_event_path(@event), alert: "Erreur lors de la duplication de l'événement." + end + end + private def ensure_can_manage_events! diff --git a/app/javascript/controllers/countdown_controller.js b/app/javascript/controllers/countdown_controller.js new file mode 100644 index 0000000..17c542f --- /dev/null +++ b/app/javascript/controllers/countdown_controller.js @@ -0,0 +1,71 @@ +import { Controller } from "@hotwired/stimulus" + +// Countdown controller for displaying remaining time until order expiration +export default class extends Controller { + static values = { + expiresAt: String, // ISO timestamp when the order expires + orderId: Number // Order ID for identification + } + + connect() { + // Parse the expiration timestamp + this.expirationTime = new Date(this.expiresAtValue).getTime() + + // Find the countdown element + this.countdownElement = this.element.querySelector('.countdown-timer') + + if (this.countdownElement && !isNaN(this.expirationTime)) { + // Start the countdown + this.updateCountdown() + this.timer = setInterval(() => this.updateCountdown(), 1000) + } + } + + disconnect() { + // Clean up the interval when the controller disconnects + if (this.timer) { + clearInterval(this.timer) + } + } + + updateCountdown() { + const now = new Date().getTime() + const distance = this.expirationTime - now + + // If the countdown is finished + if (distance < 0) { + this.countdownElement.innerHTML = "EXPIRÉ" + this.countdownElement.classList.add("text-red-600", "font-bold") + this.countdownElement.classList.remove("text-orange-600") + + // Add a more urgent visual indicator + this.element.classList.add("bg-red-50", "border-red-200") + this.element.classList.remove("bg-orange-50", "border-orange-200") + + // Stop the timer + if (this.timer) { + clearInterval(this.timer) + } + return + } + + // Calculate time components + const seconds = Math.floor(distance / 1000) + + // Display the result + this.countdownElement.innerHTML = `${seconds} secondes` + + // Add urgency styling when time is running low + if (seconds < 60) { + this.countdownElement.classList.add("text-red-600", "font-bold") + this.countdownElement.classList.remove("text-orange-600") + + // Add background warning for extra urgency + this.element.classList.add("bg-red-50", "border-red-200") + this.element.classList.remove("bg-orange-50", "border-orange-200") + } else if (seconds < 300) { // Less than 5 minutes + this.countdownElement.classList.add("text-orange-600", "font-bold") + this.element.classList.add("bg-orange-50", "border-orange-200") + } + } +} \ No newline at end of file diff --git a/app/javascript/controllers/event_duplication_controller.js b/app/javascript/controllers/event_duplication_controller.js new file mode 100644 index 0000000..3895c52 --- /dev/null +++ b/app/javascript/controllers/event_duplication_controller.js @@ -0,0 +1,53 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["cloneTicketTypes"] + static values = { + duplicateUrl: String + } + + connect() { + // Get modal element from the document + this.modalElement = document.querySelector('[data-event-duplication-target="modal"]') + } + + open() { + this.modalElement.classList.remove('hidden') + document.body.classList.add('overflow-hidden') + } + + close() { + this.modalElement.classList.add('hidden') + document.body.classList.remove('overflow-hidden') + } + + duplicate() { + const cloneTicketTypes = this.cloneTicketTypesTarget.checked + + // Create form data + const formData = new FormData() + formData.append('clone_ticket_types', cloneTicketTypes) + formData.append('authenticity_token', document.querySelector('meta[name="csrf-token"]').getAttribute('content')) + + // Send request to duplicate endpoint + fetch(this.duplicateUrlValue, { + method: 'POST', + body: formData, + headers: { + 'X-Requested-With': 'XMLHttpRequest' + } + }) + .then(response => { + if (response.redirected) { + window.location.href = response.url + } else { + return response.json() + } + }) + .catch(error => { + console.error('Error:', error) + alert('Erreur lors de la duplication de l\'événement.') + this.close() + }) + } +} \ No newline at end of file diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js index 8eee735..f69d4f3 100755 --- a/app/javascript/controllers/index.js +++ b/app/javascript/controllers/index.js @@ -24,3 +24,9 @@ application.register("qr-code", QrCodeController); import EventFormController from "./event_form_controller"; application.register("event-form", EventFormController); + +import CountdownController from "./countdown_controller"; +application.register("countdown", CountdownController); + +import EventDuplicationController from "./event_duplication_controller"; +application.register("event-duplication", EventDuplicationController); diff --git a/app/models/event.rb b/app/models/event.rb index 35bb3a2..674770c 100755 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -102,6 +102,35 @@ class Event < ApplicationRecord !!allow_booking_during_event end + # Duplicate an event with all its ticket types + def duplicate(clone_ticket_types: true) + # Duplicate the event + new_event = self.dup + new_event.name = "Copie de #{name}" + new_event.slug = "#{slug}-copy-#{Time.current.to_i}" + new_event.state = :draft + new_event.created_at = Time.current + new_event.updated_at = Time.current + + Event.transaction do + if new_event.save + # Duplicate all ticket types if requested + if clone_ticket_types + ticket_types.each do |ticket_type| + new_ticket_type = ticket_type.dup + new_ticket_type.event = new_event + new_ticket_type.save! + end + end + new_event + else + nil + end + end + rescue + nil + end + private # Determine if we should perform server-side geocoding diff --git a/app/models/order.rb b/app/models/order.rb index e3e17a9..666a6a3 100644 --- a/app/models/order.rb +++ b/app/models/order.rb @@ -88,11 +88,32 @@ class Order < ApplicationRecord end end - # Calculate total from tickets plus 1€ service fee + # Calculate total from ticket prices only (platform fee deducted from promoter payout) def calculate_total! ticket_total = tickets.sum(:price_cents) - fee_cents = 100 # 1€ in cents - update!(total_amount_cents: ticket_total + fee_cents) + update!(total_amount_cents: ticket_total) + end + + # Platform fee: €0.50 fixed + 1.5% of ticket price, per ticket + def platform_fee_cents + tickets.sum do |ticket| + fixed_fee = 50 # €0.50 in cents + percentage_fee = (ticket.price_cents * 0.015).to_i + fixed_fee + percentage_fee + end + end + + # Promoter payout amount after platform fee deduction + def promoter_payout_cents + total_amount_cents - platform_fee_cents + end + + def platform_fee_euros + platform_fee_cents / 100.0 + end + + def promoter_payout_euros + promoter_payout_cents / 100.0 end # Create Stripe invoice for accounting records diff --git a/app/services/stripe_invoice_service.rb b/app/services/stripe_invoice_service.rb index d97c9ee..803993a 100644 --- a/app/services/stripe_invoice_service.rb +++ b/app/services/stripe_invoice_service.rb @@ -166,19 +166,7 @@ class StripeInvoiceService }) end - # Add service fee line item - service_fee_cents = 100 # 1€ service fee - Stripe::InvoiceItem.create({ - customer: customer.id, - invoice: invoice.id, - amount: service_fee_cents, - currency: "eur", - description: "Frais de service - Frais de traitement de la commande", - metadata: { - item_type: "service_fee", - amount_cents: service_fee_cents - } - }) + # No service fee on customer invoice; platform fee deducted from promoter payout end def build_line_item_description(ticket_type, tickets) diff --git a/app/views/orders/checkout.html.erb b/app/views/orders/checkout.html.erb index 33f666c..0a34c38 100644 --- a/app/views/orders/checkout.html.erb +++ b/app/views/orders/checkout.html.erb @@ -50,7 +50,7 @@

Nouvelle tentative de paiement

- Tentative <%= @order.payment_attempts + 1 %> sur <%= @order.class::MAX_PAYMENT_ATTEMPTS %>. + Tentative <%= @order.payment_attempts + 1 %> sur <%= @order.class::MAX_PAYMENT_ATTEMPTS %>. <% if @order.payment_attempts >= @order.class::MAX_PAYMENT_ATTEMPTS - 1 %> Dernière tentative avant expiration ! <% end %> @@ -79,7 +79,7 @@

Récapitulatif de votre commande

- + <% @tickets.each do |ticket| %>
@@ -100,16 +100,8 @@
-
+
-
- Sous-total - <%= @order.total_amount_euros - 1.0 %>€ -
-
- Frais de service - 1.00€ -
Total <%= @order.total_amount_euros %>€ @@ -139,8 +131,8 @@
-
`; - + // Redirect to Stripe const sessionId = checkoutButton.dataset.sessionId; console.log('Redirecting to Stripe with session ID:', sessionId); @@ -290,4 +282,4 @@
-
\ No newline at end of file + diff --git a/app/views/orders/invoice.html.erb b/app/views/orders/invoice.html.erb index 910d7a6..dfb3f44 100644 --- a/app/views/orders/invoice.html.erb +++ b/app/views/orders/invoice.html.erb @@ -119,12 +119,6 @@ <%= "%.2f" % (tickets.count * ticket_type.price_cents / 100.0) %>€ <% end %> - - Frais de service - 1 - 1.00€ - 1.00€ - diff --git a/app/views/orders/payment_cancel.html.erb b/app/views/orders/payment_cancel.html.erb index 3e1d97f..952e5e3 100644 --- a/app/views/orders/payment_cancel.html.erb +++ b/app/views/orders/payment_cancel.html.erb @@ -135,10 +135,6 @@ Sous-total <%= @order.total_amount_euros - 1.0 %>€ -
- Frais de service - 1.00€ -
Total à payer @@ -195,4 +191,4 @@
- \ No newline at end of file + diff --git a/app/views/orders/payment_success.html.erb b/app/views/orders/payment_success.html.erb index bfc8f48..207f58d 100644 --- a/app/views/orders/payment_success.html.erb +++ b/app/views/orders/payment_success.html.erb @@ -124,16 +124,8 @@ -
+
-
- Sous-total - <%= @order.total_amount_euros - 1.0 %>€ -
-
- Frais de service - 1.00€ -
Total payé @@ -205,7 +197,7 @@ - Retour au Tableau de Bord + Retour au tableau de bord
<% end %> <%= link_to event_path(@order.event.slug, @order.event), class: "block w-full text-center py-3 px-4 border border-gray-300 rounded-lg text-gray-700 hover:bg-gray-50 transition-colors" do %> @@ -213,7 +205,7 @@ - Voir l'Événement Complet + Voir la fiche de l'événement
<% end %>
diff --git a/app/views/orders/show.html.erb b/app/views/orders/show.html.erb index 6950b66..7f106fe 100644 --- a/app/views/orders/show.html.erb +++ b/app/views/orders/show.html.erb @@ -96,22 +96,12 @@
-
-
- Sous-total - <%= @order.total_amount_euros - 1.0 %>€ -
-
- Frais de service - 1.00€ -
Total <%= @order.status == 'paid' || @order.status == 'completed' ? 'payé' : 'à payer' %> <%= @order.total_amount_euros %>€
-
diff --git a/app/views/pages/dashboard.html.erb b/app/views/pages/dashboard.html.erb index e7d195d..39e0612 100755 --- a/app/views/pages/dashboard.html.erb +++ b/app/views/pages/dashboard.html.erb @@ -129,7 +129,10 @@ <% @promoter_events.each do |event| %>
-

<%= event.name %>

+ <%= link_to promoter_event_path(event) do %> +

<%= event.name %>

+ <% end %> + <%= event.state.humanize %> @@ -246,7 +249,12 @@ Tentatives: <%= order.payment_attempts %>/3
<% if order.expiring_soon? %> - ⚠️ Expire dans <%= time_ago_in_words(order.expires_at) %> + + ⚠️ Expire dans + <% else %> Expire dans <%= time_ago_in_words(order.expires_at) %> <% end %> @@ -385,6 +393,7 @@ <% @upcoming_preview_events.each do |event| %>

<%= event.name %>

+
diff --git a/app/views/promoter/events/new.html.erb b/app/views/promoter/events/new.html.erb index a3bd96b..c7dcde1 100644 --- a/app/views/promoter/events/new.html.erb +++ b/app/views/promoter/events/new.html.erb @@ -40,13 +40,13 @@

Informations générales

- +
<%= form.label :name, "Nom de l'événement", class: "block text-sm font-medium text-gray-700 mb-2" %> <%= form.text_field :name, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "Ex: Soirée d'ouverture", data: { "event-form-target": "name", action: "input->event-form#generateSlug" } %>
- +
<%= form.label :slug, "Slug (URL)", class: "block text-sm font-medium text-gray-700 mb-2" %> <%= form.text_field :slug, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "soiree-ouverture", data: { "event-form-target": "slug" } %> @@ -69,13 +69,13 @@

Date et heure

- +
<%= form.label :start_time, "Date et heure de début", class: "block text-sm font-medium text-gray-700 mb-2" %> <%= form.datetime_local_field :start_time, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" %>
- +
<%= form.label :end_time, "Date et heure de fin", class: "block text-sm font-medium text-gray-700 mb-2" %> <%= form.datetime_local_field :end_time, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" %> @@ -86,55 +86,39 @@

Lieu de l'événement

- +
- +
<%= form.label :venue_name, "Nom du lieu", class: "block text-sm font-medium text-gray-700 mb-2" %> <%= form.text_field :venue_name, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "Ex: Le Grand Rex" %>
- +
<%= form.label :venue_address, "Adresse complète", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.text_field :venue_address, class: "w-full px-4 py-2 pr-12 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "Ex: 1 Boulevard Poissonnière, 75002 Paris", data: { "event-form-target": "address", action: "input->event-form#addressChanged" } %> - +
- - -
- - -
- +

Les coordonnées GPS seront automatiquement calculées à partir de cette adresse.

- + <%= form.hidden_field :latitude, data: { "event-form-target": "latitude" } %> <%= form.hidden_field :longitude, data: { "event-form-target": "longitude" } %> - +
@@ -143,12 +127,25 @@

Options

- -
- <%= form.check_box :featured, class: "h-4 w-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500" %> - <%= form.label :featured, "Mettre en avant sur la page d'accueil", class: "ml-2 text-sm text-gray-700" %> + +
+
+ <%= form.check_box :featured, class: "h-4 w-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500" %> + <%= form.label :featured, "Mettre en avant sur la page d'accueil", class: "ml-2 text-sm text-gray-700" %> +
+

Les événements mis en avant apparaissent en premier sur la page d'accueil.

+ +
+ <%= form.check_box :allow_booking_during_event, class: "h-4 w-4 text-purple-600 border-gray-300 rounded focus:ring-purple-500 mt-1" %> +
+ <%= form.label :allow_booking_during_event, "Autoriser la réservation pendant l'événement", class: "text-sm text-gray-700 font-medium" %> +

+ Si activé, les participants pourront acheter des billets même après le début de l'événement. + Si désactivé, la vente de billets s'arrêtera automatiquement à l'heure de début. +

+
+
-

Les événements mis en avant apparaissent en premier sur la page d'accueil.

@@ -158,7 +155,7 @@ Annuler <% end %>
- +
<%= form.submit "Créer en brouillon", class: "inline-flex items-center px-6 py-3 bg-gray-600 text-white font-medium rounded-lg hover:bg-gray-700 transition-colors duration-200" %>
@@ -166,4 +163,3 @@ <% end %>
- diff --git a/app/views/promoter/events/show.html.erb b/app/views/promoter/events/show.html.erb index b87da97..fe95cb4 100644 --- a/app/views/promoter/events/show.html.erb +++ b/app/views/promoter/events/show.html.erb @@ -1,6 +1,7 @@ <% content_for(:title, @event.name) %> -
+
+
<%= render 'components/breadcrumb', crumbs: [ @@ -39,6 +40,11 @@ Modifier <% end %> + + <% if @event.draft? %> <% if @event.ticket_types.blank? %> <%= button_to publish_promoter_event_path(@event), method: :patch, disabled: true, class: "w-full sm:w-auto inline-flex items-center justify-center px-4 py-2 bg-gray-400 text-white font-medium rounded-lg cursor-not-allowed transition-colors duration-200", title: "Vous devez créer au moins un type de billet avant de publier" do %> @@ -107,7 +113,7 @@
<%= link_to event_path(@event.slug, @event), target: "_blank", class: "text-green-600 hover:text-green-800 font-medium text-sm whitespace-nowrap" do %> - Voir publiquement + Voir la fiche publique <% end %>
@@ -282,4 +288,52 @@
+ + +
diff --git a/app/views/promoter/ticket_types/show.html.erb b/app/views/promoter/ticket_types/show.html.erb index 82e0e40..fea82b3 100644 --- a/app/views/promoter/ticket_types/show.html.erb +++ b/app/views/promoter/ticket_types/show.html.erb @@ -1,6 +1,15 @@ <% content_for(:title, "#{@ticket_type.name} - #{@event.name}") %> -
+
+ + <%= render 'components/breadcrumb', crumbs: [ + { name: 'Dashboard', path: dashboard_path }, + { name: 'Événements', path: promoter_events_path }, + { name: @event.name, path: promoter_event_path(@event) }, + { name: 'Types de billets', path: promoter_event_ticket_types_path(@event) }, + { name: @ticket_type.name, path: nil } + ] %> +
diff --git a/config/routes.rb b/config/routes.rb index b152192..d4bf3be 100755 --- a/config/routes.rb +++ b/config/routes.rb @@ -81,6 +81,7 @@ Rails.application.routes.draw do patch :unpublish patch :cancel patch :mark_sold_out + post :duplicate end # Nested ticket types routes diff --git a/docs/pricing-model-recommendations.md b/docs/pricing-model-recommendations.md index d90bee3..cffe862 100644 --- a/docs/pricing-model-recommendations.md +++ b/docs/pricing-model-recommendations.md @@ -3,28 +3,32 @@ ## Current Problem Analysis ### Current Model: €1 Fixed Fee Per Order + - **Revenue**: €1.00 per order (regardless of ticket price) - **Stripe Costs**: 1.15% + €0.25 per transaction - **Result**: Losing money on higher-priced tickets ### Financial Impact Examples + | Ticket Price | Current Revenue | Stripe Fees | Net Profit/Loss | -|--------------|----------------|-------------|-----------------| -| €20 | €1.00 | €0.48 | **+€0.52** ✅ | -| €50 | €1.00 | €0.83 | **+€0.17** ⚠️ | -| €100 | €1.00 | €1.40 | **-€0.40** ❌ | -| €200 | €1.00 | €2.55 | **-€1.55** ❌ | +| ------------ | --------------- | ----------- | --------------- | +| €20 | €1.00 | €0.48 | **+€0.52** ✅ | +| €50 | €1.00 | €0.83 | **+€0.17** ⚠️ | +| €100 | €1.00 | €1.40 | **-€0.40** ❌ | +| €200 | €1.00 | €2.55 | **-€1.55** ❌ | **Critical Issue**: We lose money on any ticket above €65 (€1.00 - (€65 × 1.15% + €0.25) = -€0.00) ## Recommended Pricing Models ### Model 1: Percentage-Based (Recommended) + ``` Platform Fee = 3-5% of ticket price ``` **Advantages:** + - Always profitable regardless of ticket price - Scales naturally with event value - Simple for promoters to understand @@ -33,17 +37,19 @@ Platform Fee = 3-5% of ticket price **Financial Impact:** | Ticket Price | 3.5% Fee | Stripe Costs | Net Profit | |--------------|----------|--------------|------------| -| €20 | €0.70 | €0.48 | €0.22 | -| €50 | €1.75 | €0.83 | €0.92 | -| €100 | €3.50 | €1.40 | €2.10 | -| €200 | €7.00 | €2.55 | €4.45 | +| €20 | €0.70 | €0.48 | €0.22 | +| €50 | €1.75 | €0.83 | €0.92 | +| €100 | €3.50 | €1.40 | €2.10 | +| €200 | €7.00 | €2.55 | €4.45 | ### Model 2: Hybrid Model (Best) + ``` Platform Fee = €1.50 + 2% of ticket price ``` **Advantages:** + - Higher base fee covers Stripe fixed costs - Percentage component scales with value - Better margins on low-priced tickets @@ -52,12 +58,13 @@ Platform Fee = €1.50 + 2% of ticket price **Financial Impact:** | Ticket Price | Hybrid Fee | Stripe Costs | Net Profit | |--------------|------------|--------------|------------| -| €20 | €1.90 | €0.48 | €1.42 | -| €50 | €2.50 | €0.83 | €1.67 | -| €100 | €3.50 | €1.40 | €2.10 | -| €200 | €5.50 | €2.55 | €2.95 | +| €20 | €1.90 | €0.48 | €1.42 | +| €50 | €2.50 | €0.83 | €1.67 | +| €100 | €3.50 | €1.40 | €2.10 | +| €200 | €5.50 | €2.55 | €2.95 | ### Model 3: Tiered Flat Fees + ``` €0-25: €1.50 fee €26-75: €2.50 fee @@ -66,32 +73,36 @@ Platform Fee = €1.50 + 2% of ticket price ``` **Advantages:** + - Predictable fees for promoters - Simple pricing tiers - Better than current model **Disadvantages:** + - Less scalable than percentage - Requires tier management ## Industry Benchmarks -| Platform | Fee Structure | Effective Rate (€50 ticket) | -|-------------|----------------------------|------------------------------| -| Eventbrite | 3.7% + €0.59 | €2.44 | -| Universe | 2.5% + €0.49 | €1.74 | -| Ticketfly | 4% + €0.99 | €2.99 | -| **Recommended** | **3.5%** | **€1.75** | +| Platform | Fee Structure | Effective Rate (€50 ticket) | +| --------------- | ------------- | --------------------------- | +| Eventbrite | 3.7% + €0.59 | €2.44 | +| Universe | 2.5% + €0.49 | €1.74 | +| Ticketfly | 4% + €0.99 | €2.99 | +| **Recommended** | **3.5%** | **€1.75** | ## Implementation Strategy ### Phase 1: Immediate Implementation (Week 1-2) + 1. **Switch to 3.5% percentage model** for new events 2. **Grandfathering**: Existing published events keep €1 pricing 3. **Communication**: Notify promoters 1 week before change 4. **Update pricing display** on event creation and checkout pages ### Phase 2: Optimization (Month 2-3) + 1. **A/B Test different rates**: 3%, 3.5%, 4% 2. **Monitor metrics**: - Promoter signup rate @@ -101,6 +112,7 @@ Platform Fee = €1.50 + 2% of ticket price 3. **Adjust based on data** ### Phase 3: Premium Tiers (Month 4-6) + 1. **Volume discounts** for high-performing promoters: - Standard: 3.5% - Bronze (€5K+ monthly): 3% @@ -112,6 +124,7 @@ Platform Fee = €1.50 + 2% of ticket price ## Revenue Projections ### Current State (€1 fixed) + - Average ticket price: €35 - Monthly orders: 1,000 - Monthly revenue: €1,000 @@ -119,11 +132,13 @@ Platform Fee = €1.50 + 2% of ticket price - **Net monthly profit: €372** ### With 3.5% Model + - Monthly revenue: €1,225 (3.5% × €35 × 1,000) - Monthly Stripe costs: €628 - **Net monthly profit: €597** (+60% increase) ### With Growth (3.5% model + 20% more events) + - Monthly orders: 1,200 (20% growth from competitive pricing) - Monthly revenue: €1,470 - **Net monthly profit: €842** (+126% increase) @@ -131,6 +146,7 @@ Platform Fee = €1.50 + 2% of ticket price ## Technical Implementation ### Database Changes + ```ruby # Add to events table add_column :events, :commission_rate, :decimal, precision: 5, scale: 4, default: 0.035 @@ -140,6 +156,7 @@ add_column :users, :commission_tier, :string, default: 'standard' ``` ### Fee Calculation Logic + ```ruby class Order < ApplicationRecord def calculate_platform_fee @@ -148,7 +165,7 @@ class Order < ApplicationRecord base_amount = total_amount_cents / 100.0 (base_amount * commission_rate * 100).to_i # return in cents end - + def platform_fee_euros calculate_platform_fee / 100.0 end @@ -156,6 +173,7 @@ end ``` ### Promoter Dashboard Updates + - Show fee breakdown on event creation - Display projected fees during ticket setup - Add revenue vs. fees analytics @@ -164,9 +182,11 @@ end ## Communication Plan ### Email to Existing Promoters + **Subject**: "Important Pricing Update - New Fair Fee Structure" **Key Points**: + - Current model loses money on higher-priced tickets - New model ensures platform sustainability - Better features and support with improved revenue @@ -174,6 +194,7 @@ end - Competitive with industry standards ### Website Updates + - Update pricing page with clear fee calculator - Add FAQ about fee structure - Transparency about what fees cover (development, support, payment processing) @@ -181,11 +202,13 @@ end ## Risk Mitigation ### Potential Issues + 1. **Promoter backlash**: Higher fees on expensive tickets 2. **Competitor advantage**: Other platforms with lower fees 3. **Reduced event creation**: Promoters may create fewer events ### Mitigation Strategies + 1. **Value communication**: Emphasize platform improvements and reliability 2. **Competitive analysis**: Position as "fair and sustainable" vs. competitors 3. **Volume incentives**: Quick path to reduced rates for active promoters @@ -194,12 +217,14 @@ end ## Success Metrics ### Financial KPIs + - Monthly recurring revenue growth - Average revenue per transaction - Profit margin improvement - Customer acquisition cost vs. lifetime value ### Product KPIs + - Promoter retention rate - New event creation volume - Average ticket prices @@ -221,6 +246,7 @@ The current €1 fixed fee model is financially unsustainable and actually costs ### Current Approach vs Industry Standard **Current Model (Not Recommended)**: + ``` Customer pays: €50 + €1 fee = €51 Promoter receives: €50 @@ -228,6 +254,7 @@ Platform keeps: €1 ``` **Industry Standard (Recommended)**: + ``` Customer pays: €50 (clean price) Platform keeps: €1.75 (3.5% of €50) @@ -237,18 +264,21 @@ Promoter receives: €48.25 ### How Major Platforms Handle Fees **Eventbrite:** + - Promoter sets: €50 ticket -- Customer pays: €50 +- Customer pays: €50 - Eventbrite keeps: €1.85 (3.7%) - Promoter receives: €48.15 **Ticketmaster:** -- Promoter sets: €50 ticket + +- Promoter sets: €50 ticket - Customer pays: €50 - Ticketmaster keeps: €5-7.50 (10-15%) - Promoter receives: €42.50-45 **Universe (by Ticketmaster):** + - Promoter sets: €50 ticket - Customer pays: €50 - Universe keeps: €1.74 (2.5% + €0.49) @@ -257,16 +287,19 @@ Promoter receives: €48.25 ### Why Deducting from Payout is Better #### 1. Customer Experience + - **Price transparency**: Customer sees exactly what they expect to pay - **No surprise fees**: Reduces cart abandonment - **Competitive pricing**: Easier to compare with other events #### 2. Promoter Benefits + - **Marketing simplicity**: Can advertise clean prices - **Psychological pricing**: €50 sounds better than €51.75 - **Competitive advantage**: Not adding extra fees to customer #### 3. Platform Benefits + - **Higher conversion rates**: No fee-shock at checkout - **Better promoter adoption**: Easier to sell to event organizers - **Industry standard**: Follows established practices @@ -274,14 +307,17 @@ Promoter receives: €48.25 ### Psychological Impact **Adding Fees to Customer (Current)**: + - Customer thinks: "€50 ticket... oh wait, €51.75 total" 😤 - Cart abandonment risk -**Deducting from Payout (Recommended)**: +**Deducting from Payout (Recommended)**: + - Customer thinks: "€50 ticket, €50 total" 😊 - Smooth purchase experience ### Promoter Dashboard Display + ``` Ticket Price: €50.00 Platform Fee (3.5%): -€1.75 @@ -289,10 +325,12 @@ Your Earnings per Ticket: €48.25 ``` ### Communication to Promoters + **Before:** "Platform charges €1 per order to customers" **After:** "Set your desired revenue per ticket, we handle the rest" **Example:** + - Promoter wants €48.25 net per ticket - They should set ticket price at €50 - Customer pays €50, promoter gets €48.25 @@ -306,52 +344,56 @@ Our main competitor charges a simple €1 flat fee per order. Here's how our mod ### Hybrid Model vs Competitor Analysis -**Hybrid Model**: €1.50 + 2% of ticket price +**Hybrid Model**: €1.50 + 2% of ticket price **Competitor**: €1.00 flat fee | Ticket Price | Competitor Fee | Hybrid Fee | Difference | Competitive Position | -|--------------|----------------|------------|------------|---------------------| -| €10 | €1.00 | €1.70 | +€0.70 | More expensive | -| €25 | €1.00 | €2.00 | +€1.00 | More expensive | -| €50 | €1.00 | €2.50 | +€1.50 | More expensive | +| ------------ | -------------- | ---------- | ---------- | -------------------- | +| €10 | €1.00 | €1.70 | +€0.70 | More expensive | +| €25 | €1.00 | €2.00 | +€1.00 | More expensive | +| €50 | €1.00 | €2.50 | +€1.50 | More expensive | | **€75** | **€1.00** | **€3.00** | **+€2.00** | **Break-even point** | -| €100 | €1.00 | €3.50 | +€2.50 | Much more expensive | +| €100 | €1.00 | €3.50 | +€2.50 | Much more expensive | ### Alternative Competitive Models #### Option 1: Low-End Competitive Model + ``` -Platform Fee = €0.50 + 1.5% of ticket price + Platform Fee = €0.50 + 1.5% of ticket price ``` -| Ticket Price | Competitor Fee | Our Fee | Difference | Position | -|--------------|----------------|---------|------------|----------| -| €10 | €1.00 | €0.65 | **-€0.35** | ✅ **Cheaper** | -| €25 | €1.00 | €0.88 | **-€0.12** | ✅ **Cheaper** | +| Ticket Price | Competitor Fee | Our Fee | Difference | Position | +| ------------ | -------------- | ------- | ---------- | ------------------ | +| €10 | €1.00 | €0.65 | **-€0.35** | ✅ **Cheaper** | +| €25 | €1.00 | €0.88 | **-€0.12** | ✅ **Cheaper** | | €50 | €1.00 | €1.25 | +€0.25 | ⚠️ Slightly higher | -| €100 | €1.00 | €2.00 | +€1.00 | More expensive | +| €100 | €1.00 | €2.00 | +€1.00 | More expensive | #### Option 2: Modified Hybrid Model + ``` Platform Fee = €0.75 + 2.5% of ticket price ``` -| Ticket Price | Competitor Fee | Our Fee | Difference | Position | -|--------------|----------------|---------|------------|----------| -| €10 | €1.00 | €1.00 | **Equal** | ✅ Competitive | -| €25 | €1.00 | €1.38 | +€0.38 | ⚠️ Slightly higher | -| €40 | €1.00 | €1.75 | +€0.75 | **Break-even** | +| Ticket Price | Competitor Fee | Our Fee | Difference | Position | +| ------------ | -------------- | ------- | ---------- | ------------------- | +| €10 | €1.00 | €1.00 | **Equal** | ✅ Competitive | +| €25 | €1.00 | €1.38 | +€0.38 | ⚠️ Slightly higher | +| €40 | €1.00 | €1.75 | +€0.75 | **Break-even** | | €75 | €1.00 | €2.63 | +€1.63 | Much more expensive | ### Competitive Strategy Recommendations #### 1. Value Differentiation Approach + Since we'll be more expensive on higher-priced tickets, focus on premium positioning: **Value Proposition:** + - "We're not the cheapest, we're the most complete" - Advanced analytics dashboard -- Real-time sales tracking +- Real-time sales tracking - Professional promoter tools - Superior customer support - Platform reliability and sustainability @@ -359,12 +401,14 @@ Since we'll be more expensive on higher-priced tickets, focus on premium positio #### 2. Market Segmentation Strategy **Target Market Positioning:** + - **Competitor**: Best for small, simple events (€10-30 tickets) - **Us**: Best for professional events (€40+ tickets) with serious promoters #### 3. Hybrid Competitive Approach **Tiered Offering:** + - **Basic Plan**: Match competitor at €1 flat fee (limited features) - **Professional Plan**: Hybrid model with premium features - **Enterprise Plan**: Custom pricing with full feature set @@ -372,6 +416,7 @@ Since we'll be more expensive on higher-priced tickets, focus on premium positio #### 4. Volume-Based Competitive Response **Free Tier Strategy:** + - First 3 events per month at competitor's €1 rate - Volume discounts for high-activity promoters - Loyalty rewards for long-term customers @@ -381,15 +426,18 @@ Since we'll be more expensive on higher-priced tickets, focus on premium positio #### "Choose Your Business Model" Campaign **For Simple Events (Under €40):** + - "Need basic ticketing? Our competitor works fine" - "Pay €1 flat fee for simple events" **For Professional Events (€40+):** + - "Serious about your business? You need serious tools" - "Fair percentage-based pricing" - "Advanced analytics, marketing tools, priority support" #### Brand Positioning Statement + **"We're the Shopify of Events - Built for Growth"** This positions us as the premium option for serious promoters while acknowledging the competitor's advantage on small events. @@ -399,12 +447,14 @@ This positions us as the premium option for serious promoters while acknowledgin Given the competitive landscape, we recommend **Option 1** (€0.50 + 1.5%): **Advantages:** + - Competitive on low-priced tickets - Still profitable at all price points - Better positioning against main competitor - Appeals to both small and large event organizers **Financial Impact:** + - Lower fees on tickets under €33 - Competitive fees on tickets €33-66 -- Premium pricing on high-value tickets justified by features \ No newline at end of file +- Premium pricing on high-value tickets justified by features diff --git a/test/controllers/promoter/events_controller_test.rb b/test/controllers/promoter/events_controller_test.rb index 2a6928a..1bf2f01 100644 --- a/test/controllers/promoter/events_controller_test.rb +++ b/test/controllers/promoter/events_controller_test.rb @@ -26,5 +26,103 @@ class Promoter::EventsControllerTest < ActionDispatch::IntegrationTest assert_not_includes assigns(:events), other_event end + test "should duplicate an event with ticket types" do + sign_in @promoter + + # Create ticket types for the event + ticket_type1 = TicketType.create!( + name: "Standard Ticket", + description: "A standard ticket for the event with all the basic access", + price_cents: 2000, + quantity: 100, + sale_start_at: 1.day.ago, + sale_end_at: @event.start_time - 1.hour, + event: @event + ) + + ticket_type2 = TicketType.create!( + name: "VIP Ticket", + description: "A VIP ticket for the event with special access", + price_cents: 5000, + quantity: 50, + sale_start_at: 1.day.ago, + sale_end_at: @event.start_time - 1.hour, + event: @event + ) + + # Verify that ticket types were created successfully + assert ticket_type1.valid? + assert ticket_type2.valid? + + # Duplicate the event + assert_difference("Event.count", 1) do + post duplicate_promoter_event_path(@event), params: { clone_ticket_types: "true" } + end + + # Check that the new event was created + assert_redirected_to edit_promoter_event_path(Event.last) + assert_equal "Événement dupliqué avec succès! Vous pouvez maintenant modifier les détails de l'événement copié.", flash[:notice] + + # Check that the new event has the correct attributes + new_event = Event.last + assert_equal "Copie de #{@event.name}", new_event.name + assert_equal "draft", new_event.state + assert_equal @event.venue_name, new_event.venue_name + assert_equal @event.venue_address, new_event.venue_address + + # Check that ticket types were duplicated + assert_equal 2, new_event.ticket_types.count + assert_equal "Standard Ticket", new_event.ticket_types.find_by(name: "Standard Ticket").name + assert_equal "VIP Ticket", new_event.ticket_types.find_by(name: "VIP Ticket").name + end + + test "should duplicate an event without ticket types" do + sign_in @promoter + + # Create ticket types for the event + ticket_type1 = TicketType.create!( + name: "Standard Ticket", + description: "A standard ticket for the event with all the basic access", + price_cents: 2000, + quantity: 100, + sale_start_at: 1.day.ago, + sale_end_at: @event.start_time - 1.hour, + event: @event + ) + + ticket_type2 = TicketType.create!( + name: "VIP Ticket", + description: "A VIP ticket for the event with special access", + price_cents: 5000, + quantity: 50, + sale_start_at: 1.day.ago, + sale_end_at: @event.start_time - 1.hour, + event: @event + ) + + # Verify that ticket types were created successfully + assert ticket_type1.valid? + assert ticket_type2.valid? + + # Duplicate the event without ticket types + assert_difference("Event.count", 1) do + post duplicate_promoter_event_path(@event), params: { clone_ticket_types: "false" } + end + + # Check that the new event was created + assert_redirected_to edit_promoter_event_path(Event.last) + assert_equal "Événement dupliqué avec succès! Vous pouvez maintenant modifier les détails de l'événement copié.", flash[:notice] + + # Check that the new event has the correct attributes + new_event = Event.last + assert_equal "Copie de #{@event.name}", new_event.name + assert_equal "draft", new_event.state + assert_equal @event.venue_name, new_event.venue_name + assert_equal @event.venue_address, new_event.venue_address + + # Check that ticket types were NOT duplicated + assert_equal 0, new_event.ticket_types.count + end + # Add tests for new, create, etc. as needed end diff --git a/test/models/event_test.rb b/test/models/event_test.rb index 0f94404..8249bd1 100755 --- a/test/models/event_test.rb +++ b/test/models/event_test.rb @@ -271,4 +271,50 @@ class EventTest < ActiveSupport::TestCase event = Event.new assert_not event.allow_booking_during_event? end + + test "should duplicate event with ticket types" do + user = User.create!(email: "test@example.com", password: "password123", password_confirmation: "password123") + event = Event.create!(name: "Original Event", slug: "original", description: "A description that is sufficiently long", venue_name: "v", venue_address: "a", user: user, latitude: 48.0, longitude: 2.0, start_time: 1.week.from_now, state: :published) + + # Create ticket types + ticket_type1 = TicketType.create!(name: "Standard", description: "A standard ticket for the event", price_cents: 2000, quantity: 100, sale_start_at: 1.day.ago, sale_end_at: event.start_time - 1.hour, event: event) + ticket_type2 = TicketType.create!(name: "VIP", description: "A VIP ticket for the event", price_cents: 5000, quantity: 50, sale_start_at: 1.day.ago, sale_end_at: event.start_time - 1.hour, event: event) + + # Duplicate the event + duplicated_event = event.duplicate + + # Check that duplication was successful + assert_not_nil duplicated_event + assert_equal "Copie de #{event.name}", duplicated_event.name + assert_equal "draft", duplicated_event.state + assert_equal event.venue_name, duplicated_event.venue_name + assert_equal event.venue_address, duplicated_event.venue_address + + # Check that ticket types were duplicated + assert_equal 2, duplicated_event.ticket_types.count + assert_equal "Standard", duplicated_event.ticket_types.find_by(name: "Standard").name + assert_equal "VIP", duplicated_event.ticket_types.find_by(name: "VIP").name + end + + test "should duplicate event without ticket types" do + user = User.create!(email: "test@example.com", password: "password123", password_confirmation: "password123") + event = Event.create!(name: "Original Event", slug: "original", description: "A description that is sufficiently long", venue_name: "v", venue_address: "a", user: user, latitude: 48.0, longitude: 2.0, start_time: 1.week.from_now, state: :published) + + # Create ticket types + ticket_type1 = TicketType.create!(name: "Standard", description: "A standard ticket for the event", price_cents: 2000, quantity: 100, sale_start_at: 1.day.ago, sale_end_at: event.start_time - 1.hour, event: event) + ticket_type2 = TicketType.create!(name: "VIP", description: "A VIP ticket for the event", price_cents: 5000, quantity: 50, sale_start_at: 1.day.ago, sale_end_at: event.start_time - 1.hour, event: event) + + # Duplicate the event without ticket types + duplicated_event = event.duplicate(clone_ticket_types: false) + + # Check that duplication was successful + assert_not_nil duplicated_event + assert_equal "Copie de #{event.name}", duplicated_event.name + assert_equal "draft", duplicated_event.state + assert_equal event.venue_name, duplicated_event.venue_name + assert_equal event.venue_address, duplicated_event.venue_address + + # Check that ticket types were NOT duplicated + assert_equal 0, duplicated_event.ticket_types.count + end end diff --git a/test/models/order_test.rb b/test/models/order_test.rb index 40c7e16..6db2124 100644 --- a/test/models/order_test.rb +++ b/test/models/order_test.rb @@ -469,7 +469,7 @@ class OrderTest < ActiveSupport::TestCase assert_equal "active", ticket2.status end - test "calculate_total! should sum ticket prices plus 1€ service fee" do + test "calculate_total! should sum ticket prices only (platform fee deducted from promoter payout)" do order = Order.create!( user: @user, event: @event, total_amount_cents: 0, status: "draft", payment_attempts: 0 @@ -506,7 +506,80 @@ class OrderTest < ActiveSupport::TestCase order.calculate_total! order.reload - assert_equal 3100, order.total_amount_cents # 2 tickets * 1500 cents + 100 cents (1€ fee) + assert_equal 3000, order.total_amount_cents # 2 tickets * 1500 cents (no service fee added to customer) + end + + test "platform_fee_cents should calculate €0.50 + 1.5% per ticket" do + order = Order.create!( + user: @user, event: @event, total_amount_cents: 0, + status: "draft", payment_attempts: 0 + ) + + ticket_type1 = TicketType.create!( + name: "Cheap Ticket", + description: "Cheap ticket type", + price_cents: 1000, # €10 + quantity: 10, + sale_start_at: Time.current, + sale_end_at: Time.current + 1.day, + requires_id: false, + event: @event + ) + + ticket_type2 = TicketType.create!( + name: "Expensive Ticket", + description: "Expensive ticket type", + price_cents: 5000, # €50 + quantity: 10, + sale_start_at: Time.current, + sale_end_at: Time.current + 1.day, + requires_id: false, + event: @event + ) + + ticket1 = Ticket.create!(order: order, ticket_type: ticket_type1, status: "draft", first_name: "John", last_name: "Doe") + ticket2 = Ticket.create!(order: order, ticket_type: ticket_type2, status: "draft", first_name: "Jane", last_name: "Doe") + + expected_fee = (50 + (1000 * 0.015).to_i) + (50 + (5000 * 0.015).to_i) # 50+15 + 50+75 = 190 + assert_equal 190, order.platform_fee_cents + end + + test "promoter_payout_cents should be total minus platform fee" do + order = Order.create!( + user: @user, event: @event, total_amount_cents: 3000, + status: "paid", payment_attempts: 0 + ) + + ticket_type = TicketType.create!( + name: "Test Ticket", + description: "Test ticket", + price_cents: 1500, + quantity: 10, + sale_start_at: Time.current, + sale_end_at: Time.current + 1.day, + requires_id: false, + event: @event + ) + + Ticket.create!(order: order, ticket_type: ticket_type, status: "active", first_name: "John", last_name: "Doe") + Ticket.create!(order: order, ticket_type: ticket_type, status: "active", first_name: "Jane", last_name: "Doe") + + order.calculate_total! # Should still be 3000 + expected_payout = 3000 - (50 + (1500 * 0.015).to_i) * 2 # 3000 - (50+22.5≈22)*2 = 3000 - 144 = 2856 + assert_equal 2856, order.promoter_payout_cents + end + + test "platform_fee_euros should convert cents to euros" do + order = Order.new(total_amount_cents: 0) + # Assuming one €10 ticket: 50 + 150 = 200 cents = €2.00 + def order.platform_fee_cents; 200; end + assert_equal 2.0, order.platform_fee_euros + end + + test "promoter_payout_euros should convert cents to euros" do + order = Order.new(total_amount_cents: 10000) + def order.platform_fee_cents; 500; end + assert_equal 95.0, order.promoter_payout_euros end # === Stripe Integration Tests (Mock) === diff --git a/test/services/stripe_invoice_service_test.rb b/test/services/stripe_invoice_service_test.rb index c89f6eb..60e914e 100644 --- a/test/services/stripe_invoice_service_test.rb +++ b/test/services/stripe_invoice_service_test.rb @@ -151,7 +151,7 @@ class StripeInvoiceServiceTest < ActiveSupport::TestCase mock_invoice.stubs(:finalize_invoice).returns(mock_invoice) mock_invoice.expects(:pay) Stripe::Invoice.expects(:create).returns(mock_invoice) - Stripe::InvoiceItem.expects(:create).twice # Once for tickets, once for service fee + Stripe::InvoiceItem.expects(:create).once # Only for tickets, no service fee result = @service.create_post_payment_invoice assert_not_nil result @@ -173,7 +173,7 @@ class StripeInvoiceServiceTest < ActiveSupport::TestCase mock_invoice.stubs(:finalize_invoice).returns(mock_invoice) mock_invoice.expects(:pay) Stripe::Invoice.expects(:create).returns(mock_invoice) - Stripe::InvoiceItem.expects(:create).twice # Once for tickets, once for service fee + Stripe::InvoiceItem.expects(:create).once # Only for tickets, no service fee result = @service.create_post_payment_invoice assert_not_nil result @@ -261,7 +261,7 @@ class StripeInvoiceServiceTest < ActiveSupport::TestCase mock_invoice.expects(:pay) Stripe::Invoice.expects(:create).with(expected_invoice_data).returns(mock_invoice) - Stripe::InvoiceItem.expects(:create).twice # Once for tickets, once for service fee + Stripe::InvoiceItem.expects(:create).once # Only for tickets, no service fee result = @service.create_post_payment_invoice assert_not_nil result @@ -300,7 +300,7 @@ class StripeInvoiceServiceTest < ActiveSupport::TestCase }) Stripe::Invoice.expects(:create).returns(mock_invoice) - Stripe::InvoiceItem.expects(:create).twice # Once for tickets, once for service fee + Stripe::InvoiceItem.expects(:create).once # Only for tickets, no service fee mock_invoice.expects(:finalize_invoice).returns(mock_finalized_invoice) result = @service.create_post_payment_invoice