Merge pull request 'feat/free-ticket' (#2) from feat/free-ticket into develop
All checks were successful
Ruby on Rails Test / rails-test (push) Successful in 1m56s
All checks were successful
Ruby on Rails Test / rails-test (push) Successful in 1m56s
Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
@@ -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" ],
|
||||
|
||||
@@ -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!
|
||||
|
||||
71
app/javascript/controllers/countdown_controller.js
Normal file
71
app/javascript/controllers/countdown_controller.js
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
53
app/javascript/controllers/event_duplication_controller.js
Normal file
53
app/javascript/controllers/event_duplication_controller.js
Normal file
@@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -100,16 +100,8 @@
|
||||
</div>
|
||||
|
||||
<!-- Order Total -->
|
||||
<div class="border-t border-gray-200 pt-6">
|
||||
<div class=" pt-12">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600">Sous-total</span>
|
||||
<span class="text-gray-900"><%= @order.total_amount_euros - 1.0 %>€</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600">Frais de service</span>
|
||||
<span class="text-gray-900">1.00€</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-lg pt-2 border-t border-gray-200">
|
||||
<span class="font-medium text-gray-900">Total</span>
|
||||
<span class="font-bold text-2xl text-purple-600"><%= @order.total_amount_euros %>€</span>
|
||||
|
||||
@@ -119,12 +119,6 @@
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 text-right"><%= "%.2f" % (tickets.count * ticket_type.price_cents / 100.0) %>€</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">Frais de service</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 text-right">1</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 text-right">1.00€</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 text-right">1.00€</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tfoot class="bg-gray-50">
|
||||
<tr>
|
||||
|
||||
@@ -135,10 +135,6 @@
|
||||
<span class="text-gray-600">Sous-total</span>
|
||||
<span class="text-gray-900"><%= @order.total_amount_euros - 1.0 %>€</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600">Frais de service</span>
|
||||
<span class="text-gray-900">1.00€</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-lg pt-2 border-t border-gray-200">
|
||||
<span class="font-medium text-gray-900">Total à payer</span>
|
||||
<span class="font-bold text-2xl text-red-600">
|
||||
|
||||
@@ -124,16 +124,8 @@
|
||||
</div>
|
||||
|
||||
<!-- Total -->
|
||||
<div class="border-t border-gray-200 pt-6 mt-6">
|
||||
<div class="mt-6">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600">Sous-total</span>
|
||||
<span class="text-gray-900"><%= @order.total_amount_euros - 1.0 %>€</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600">Frais de service</span>
|
||||
<span class="text-gray-900">1.00€</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-lg pt-2 border-t border-gray-200">
|
||||
<span class="font-medium text-gray-900">Total payé</span>
|
||||
<span class="font-bold text-2xl text-green-600">
|
||||
@@ -205,7 +197,7 @@
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
|
||||
</svg>
|
||||
Retour au Tableau de Bord
|
||||
Retour au tableau de bord
|
||||
</div>
|
||||
<% 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 @@
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
Voir l'Événement Complet
|
||||
Voir la fiche de l'événement
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -96,22 +96,12 @@
|
||||
|
||||
<!-- Total -->
|
||||
<div class="border-t border-gray-200 pt-6 mt-6">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600">Sous-total</span>
|
||||
<span class="text-gray-900"><%= @order.total_amount_euros - 1.0 %>€</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-600">Frais de service</span>
|
||||
<span class="text-gray-900">1.00€</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-lg pt-2 border-t border-gray-200">
|
||||
<span class="font-medium text-gray-900">Total <%= @order.status == 'paid' || @order.status == 'completed' ? 'payé' : 'à payer' %></span>
|
||||
<span class="font-bold text-2xl <%= @order.status == 'paid' || @order.status == 'completed' ? 'text-green-600' : 'text-purple-600' %>">
|
||||
<%= @order.total_amount_euros %>€
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- View Invoice -->
|
||||
|
||||
@@ -129,7 +129,10 @@
|
||||
<% @promoter_events.each do |event| %>
|
||||
<div class="border border-gray-200 rounded-xl p-4 hover:shadow-md transition-shadow">
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<h4 class="font-semibold text-gray-900 text-sm"><%= event.name %></h4>
|
||||
<%= link_to promoter_event_path(event) do %>
|
||||
<h4 class="font-semibold text-gray-900 text-sm"><%= event.name %></h4>
|
||||
<% end %>
|
||||
|
||||
<span class="text-xs px-2 py-1 rounded-full <%= event.state == 'published' ? 'bg-green-100 text-green-800' : event.state == 'draft' ? 'bg-yellow-100 text-yellow-800' : 'bg-red-100 text-red-800' %>">
|
||||
<%= event.state.humanize %>
|
||||
</span>
|
||||
@@ -246,7 +249,12 @@
|
||||
Tentatives: <%= order.payment_attempts %>/3
|
||||
</div>
|
||||
<% if order.expiring_soon? %>
|
||||
<span class="text-orange-600 font-medium">⚠️ Expire dans <%= time_ago_in_words(order.expires_at) %></span>
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-orange-50 border border-orange-200 text-orange-600"
|
||||
data-controller="countdown"
|
||||
data-countdown-expires-at-value="<%= order.expires_at.iso8601 %>"
|
||||
data-countdown-order-id-value="<%= order.id %>">
|
||||
⚠️ Expire dans <span class="countdown-timer ml-1 font-bold"></span>
|
||||
</span>
|
||||
<% else %>
|
||||
<span class="text-gray-500">Expire dans <%= time_ago_in_words(order.expires_at) %></span>
|
||||
<% end %>
|
||||
@@ -385,6 +393,7 @@
|
||||
<% @upcoming_preview_events.each do |event| %>
|
||||
<div class="bg-gray-50 rounded-xl p-4 hover:shadow-md transition-shadow">
|
||||
<h4 class="font-medium text-gray-900 mb-2 text-base"><%= event.name %></h4>
|
||||
|
||||
<div class="text-sm text-gray-600 space-y-1">
|
||||
<div class="flex items-center">
|
||||
<i data-lucide="calendar" class="w-4 h-4 mr-2"></i>
|
||||
|
||||
@@ -107,22 +107,6 @@
|
||||
<div class="w-5 h-5 border-2 border-purple-200 border-t-purple-600 rounded-full animate-spin"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Location Actions -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button type="button" data-action="click->event-form#getCurrentLocation" data-event-form-target="getCurrentLocationBtn" class="inline-flex items-center px-3 py-2 text-xs font-medium text-white bg-green-600 rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<span data-event-form-target="getCurrentLocationIcon">
|
||||
<i data-lucide="map-pin" class="w-3 h-3 mr-1"></i>
|
||||
</span>
|
||||
<span data-event-form-target="getCurrentLocationText">Ma position</span>
|
||||
</button>
|
||||
<button type="button" data-action="click->event-form#previewLocation" data-event-form-target="previewLocationBtn" class="inline-flex items-center px-3 py-2 text-xs font-medium text-purple-700 bg-purple-50 border border-purple-200 rounded-lg hover:bg-purple-100 transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<span data-event-form-target="previewLocationIcon">
|
||||
<i data-lucide="map" class="w-3 h-3 mr-1"></i>
|
||||
</span>
|
||||
<span data-event-form-target="previewLocationText">Prévisualiser</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mt-2 text-sm text-gray-500">
|
||||
@@ -144,11 +128,24 @@
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-6">Options</h3>
|
||||
|
||||
<div class="flex items-center">
|
||||
<%= 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" %>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center">
|
||||
<%= 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" %>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500">Les événements mis en avant apparaissent en premier sur la page d'accueil.</p>
|
||||
|
||||
<div class="flex items-start">
|
||||
<%= 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" %>
|
||||
<div class="ml-2">
|
||||
<%= form.label :allow_booking_during_event, "Autoriser la réservation pendant l'événement", class: "text-sm text-gray-700 font-medium" %>
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-500">Les événements mis en avant apparaissent en premier sur la page d'accueil.</p>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
@@ -166,4 +163,3 @@
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<% content_for(:title, @event.name) %>
|
||||
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div data-controller="event-duplication" data-event-duplication-duplicate-url-value="<%= duplicate_promoter_event_path(@event) %>">
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
|
||||
<!-- Breadcrumb -->
|
||||
<%= render 'components/breadcrumb', crumbs: [
|
||||
@@ -39,6 +40,11 @@
|
||||
Modifier
|
||||
<% end %>
|
||||
|
||||
<button type="button" data-action="click->event-duplication#open" class="w-full sm:w-auto inline-flex items-center justify-center px-4 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors duration-200">
|
||||
<i data-lucide="copy" class="w-4 h-4 mr-2"></i>
|
||||
Dupliquer
|
||||
</button>
|
||||
|
||||
<% 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 @@
|
||||
</div>
|
||||
<div class="flex-shrink-0">
|
||||
<%= 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 <i data-lucide="external-link" class="w-4 h-4 inline ml-1"></i>
|
||||
Voir la fiche publique <i data-lucide="external-link" class="w-4 h-4 inline ml-1"></i>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -282,4 +288,52 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
<div data-event-duplication-target="modal" class="hidden relative z-50" aria-labelledby="modal-title" role="dialog" aria-modal="true">
|
||||
<!-- Background backdrop, show/hide based on modal state -->
|
||||
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"></div>
|
||||
|
||||
<div class="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div class="flex min-h-full items-end justify-center p-4 text-center sm:items-center sm:p-0">
|
||||
<!-- Modal container -->
|
||||
<div class="relative transform overflow-hidden rounded-lg bg-white text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg">
|
||||
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<div class="sm:flex sm:items-start">
|
||||
<div class="mx-auto flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-blue-100 sm:mx-0 sm:h-10 sm:w-10">
|
||||
<i data-lucide="copy" class="h-6 w-6 text-blue-600"></i>
|
||||
</div>
|
||||
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
||||
<h3 class="text-lg font-medium leading-6 text-gray-900" id="modal-title">
|
||||
Dupliquer l'événement
|
||||
</h3>
|
||||
<div class="mt-2">
|
||||
<p class="text-sm text-gray-500">
|
||||
Choisissez les options de duplication pour "<%= @event.name %>".
|
||||
</p>
|
||||
|
||||
<div class="mt-4">
|
||||
<div class="flex items-center">
|
||||
<input data-event-duplication-target="cloneTicketTypes" id="cloneTicketTypes" type="checkbox" class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded" checked>
|
||||
<label for="cloneTicketTypes" class="ml-2 block text-sm text-gray-900">
|
||||
Dupliquer également les types de billets (<%= @event.ticket_types.count %> type(s))
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6">
|
||||
<button type="button" data-action="click->event-duplication#duplicate" class="inline-flex w-full justify-center rounded-md border border-transparent bg-blue-600 px-4 py-2 text-base font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 sm:ml-3 sm:w-auto sm:text-sm">
|
||||
Dupliquer
|
||||
</button>
|
||||
<button type="button" data-action="click->event-duplication#close" class="mt-3 inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm">
|
||||
Annuler
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
<% content_for(:title, "#{@ticket_type.name} - #{@event.name}") %>
|
||||
|
||||
<div class="container py-8">
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Breadcrumb -->
|
||||
<%= 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 }
|
||||
] %>
|
||||
|
||||
<!-- Header with actions -->
|
||||
<div class="mb-8">
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
|
||||
@@ -81,6 +81,7 @@ Rails.application.routes.draw do
|
||||
patch :unpublish
|
||||
patch :cancel
|
||||
patch :mark_sold_out
|
||||
post :duplicate
|
||||
end
|
||||
|
||||
# Nested ticket types routes
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
- Eventbrite keeps: €1.85 (3.7%)
|
||||
- Promoter receives: €48.15
|
||||
|
||||
**Ticketmaster:**
|
||||
|
||||
- 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)**:
|
||||
|
||||
- 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
|
||||
@@ -310,45 +348,49 @@ Our main competitor charges a simple €1 flat fee per order. Here's how our mod
|
||||
**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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) ===
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user