diff --git a/app/controllers/admin/payouts_controller.rb b/app/controllers/admin/payouts_controller.rb
index 5e903df..52ac330 100644
--- a/app/controllers/admin/payouts_controller.rb
+++ b/app/controllers/admin/payouts_controller.rb
@@ -3,19 +3,25 @@ class Admin::PayoutsController < ApplicationController
before_action :ensure_admin!
def index
- @payouts = Payout.includes(:event, :user)
- .order(created_at: :desc)
- .page(params[:page])
+ @payouts = Payout.pending.includes(:user, :event).order(created_at: :asc).page(params[:page])
end
- def create
+ def show
@payout = Payout.find(params[:id])
-
- begin
- @payout.process_payout!
- redirect_to admin_payouts_path, notice: "Payout processed successfully."
- rescue => e
- redirect_to admin_payouts_path, alert: "Failed to process payout: #{e.message}"
+ end
+
+ def process
+ @payout = Payout.find(params[:id])
+
+ if @payout.pending? && @payout.can_process?
+ begin
+ PayoutService.new(@payout).process!
+ redirect_to admin_payouts_path, notice: "Payout processed successfully."
+ rescue => e
+ redirect_to admin_payouts_path, alert: "Failed to process payout: #{e.message}"
+ end
+ else
+ redirect_to admin_payouts_path, alert: "Cannot process this payout."
end
end
@@ -28,4 +34,4 @@ class Admin::PayoutsController < ApplicationController
redirect_to dashboard_path, alert: "Access denied."
end
end
-end
\ No newline at end of file
+end
diff --git a/app/controllers/promoter/payouts_controller.rb b/app/controllers/promoter/payouts_controller.rb
index 63d8d26..e360e32 100644
--- a/app/controllers/promoter/payouts_controller.rb
+++ b/app/controllers/promoter/payouts_controller.rb
@@ -1,54 +1,61 @@
class Promoter::PayoutsController < ApplicationController
before_action :authenticate_user!
before_action :ensure_promoter!
- before_action :set_event, only: [:show, :create]
+ before_action :set_event, only: [ :create ]
# List all payouts for the current promoter
def index
- @payouts = current_user.payouts
- .includes(:event)
- .order(created_at: :desc)
- .page(params[:page])
+ @payouts = current_user.payouts.completed.order(created_at: :desc).page(params[:page])
+
+ @eligible_events = current_user.events.eligible_for_payout.includes(:earnings).limit(5)
+ @total_pending_net = @eligible_events.sum(&:net_earnings_cents)
+
+ @total_paid_out = current_user.payouts.completed.sum(&:net_amount_cents)
+ @total_pending = @total_pending_net
+ @total_payouts_count = current_user.payouts.count
end
# Show payout details
def show
- @payout = @event.payouts.find(params[:id])
+ @payout = current_user.payouts.find(params[:id])
+ @event = @payout.event
end
# Create a new payout request
def create
# Check if event can request payout
- unless @event.can_request_payout?
- redirect_to promoter_event_path(@event), alert: "Payout cannot be requested for this event."
+ unless @event.can_request_payout?(current_user)
+ redirect_to event_path(@event.slug, @event), alert: "Payout cannot be requested for this event."
return
end
- # Calculate payout amount
- total_earnings_cents = @event.total_earnings_cents
- total_fees_cents = @event.total_fees_cents
- net_earnings_cents = @event.net_earnings_cents
+ # Calculate payout amount using model methods
+ gross = @event.total_gross_cents
+ fees = @event.total_fees_cents
- # Count orders
- total_orders_count = @event.orders.where(status: ['paid', 'completed']).count
- refunded_orders_count = @event.tickets.where(status: 'refunded').joins(:order).where(orders: {status: ['paid', 'completed']}).count
+ # Count orders using model scope
+ total_orders_count = @event.orders.paid.count
# Create payout record
@payout = @event.payouts.build(
user: current_user,
- amount_cents: total_earnings_cents,
- fee_cents: total_fees_cents,
- total_orders_count: total_orders_count,
- refunded_orders_count: refunded_orders_count
+ amount_cents: gross,
+ fee_cents: fees,
+ total_orders_count: total_orders_count
)
+ # refunded_orders_count will be set by model callback
if @payout.save
# Update event payout status
@event.update!(payout_status: :requested, payout_requested_at: Time.current)
-
+
+ # Log notification (mailer can be added later if needed)
+ Rails.logger.info "Payout request submitted: #{@payout.id} for event #{@event.id}"
+
redirect_to promoter_payout_path(@payout), notice: "Payout request submitted successfully."
else
- redirect_to promoter_event_path(@event), alert: "Failed to submit payout request: #{@payout.errors.full_messages.join(', ')}"
+ flash.now[:alert] = "Failed to submit payout request: #{@payout.errors.full_messages.join(', ')}"
+ render "new"
end
end
@@ -63,4 +70,4 @@ class Promoter::PayoutsController < ApplicationController
def set_event
@event = current_user.events.find(params[:event_id])
end
-end
\ No newline at end of file
+end
diff --git a/app/controllers/webhooks/stripe_controller.rb b/app/controllers/webhooks/stripe_controller.rb
new file mode 100644
index 0000000..bce5408
--- /dev/null
+++ b/app/controllers/webhooks/stripe_controller.rb
@@ -0,0 +1,34 @@
+class Webhooks::StripeController < ApplicationController
+ skip_before_action :verify_authenticity_token
+
+ def create
+ payload = request.body.read
+ sig_header = request.env["HTTP_STRIPE_SIGNATURE"]
+
+ begin
+ event = Stripe::Webhook.construct_event(
+ payload, sig_header, ENV["STRIPE_WEBHOOK_SECRET"]
+ )
+ rescue Stripe::SignatureVerificationError => e
+ # Invalid signature
+ return head 400
+ end
+
+ case event["type"]
+ when "transfer.payout.succeeded"
+ payout_id = event.data.object.metadata["payout_id"]
+ payout = Payout.find(payout_id)
+ if payout && payout.processing?
+ payout.update!(status: :completed, stripe_payout_id: event.data.object.id)
+ end
+ when "transfer.payout.failed", "transfer.canceled"
+ payout_id = event.data.object.metadata["payout_id"]
+ payout = Payout.find(payout_id)
+ if payout
+ payout.update!(status: :failed)
+ end
+ end
+
+ head 200
+ end
+end
diff --git a/app/models/earning.rb b/app/models/earning.rb
index 8974978..d993ff5 100644
--- a/app/models/earning.rb
+++ b/app/models/earning.rb
@@ -1,4 +1,22 @@
class Earning < ApplicationRecord
+ def self.create_from_order(order)
+ return unless order.paid? || order.completed?
+
+ gross_cents = order.tickets.active.sum(:price_cents)
+ fee_cents = order.tickets.active.sum do |ticket|
+ 50 + (ticket.price_cents * 0.015).to_i
+ end
+ amount_cents = gross_cents - fee_cents
+
+ create!(
+ event: order.event,
+ user: order.event.user,
+ order: order,
+ amount_cents: amount_cents,
+ fee_cents: fee_cents,
+ status: :pending
+ )
+ end
# === Relations ===
belongs_to :event
belongs_to :user
@@ -13,4 +31,24 @@ class Earning < ApplicationRecord
validates :net_amount_cents, numericality: { greater_than_or_equal_to: 0, allow_nil: true }
validates :status, presence: true
validates :stripe_payout_id, allow_blank: true, uniqueness: true
+
+ # Recalculate earning based on active tickets in the order
+ def recalculate!
+ return unless order.present?
+
+ active_tickets = order.tickets.active
+ if active_tickets.empty?
+ update!(amount_cents: 0, fee_cents: 0)
+ else
+ gross_cents = active_tickets.sum(:price_cents)
+ fee_cents = active_tickets.sum do |ticket|
+ 50 + (ticket.price_cents * 0.015).to_i
+ end
+ update!(amount_cents: gross_cents - fee_cents, fee_cents: fee_cents)
+ end
+ end
+
+ def recalculate_on_refund(order)
+ recalculate!
+ end
end
diff --git a/app/models/event.rb b/app/models/event.rb
index 6b31856..cb97682 100755
--- a/app/models/event.rb
+++ b/app/models/event.rb
@@ -66,24 +66,26 @@ class Event < ApplicationRecord
# Scope for published events ordered by start time
scope :upcoming, -> { published.where("start_time >= ?", Time.current).order(start_time: :asc) }
+ # Scope for events eligible for payout
+ scope :eligible_for_payout, -> { where("end_time <= ?", Time.current).joins(:earnings).group("events.id").having("SUM(earnings.amount_cents) > 0") }
+
# === Instance Methods ===
# Payout methods
- def can_request_payout?
- event_ended? && earnings.pending.any? && user.can_receive_payouts?
- end
-
- def total_earnings_cents
- # Only count earnings from non-refunded tickets
- earnings.pending.sum(:amount_cents)
+ def total_gross_cents
+ tickets.active.sum(:price_cents)
end
def total_fees_cents
- (total_earnings_cents * 0.1).to_i # 10% platform fee
+ earnings.pending.sum(:fee_cents)
end
def net_earnings_cents
- total_earnings_cents - total_fees_cents
+ total_gross_cents - total_fees_cents
+ end
+
+ def can_request_payout?(user = self.user)
+ event_ended? && (net_earnings_cents > 0) && user.is_professionnal? && payouts.pending.empty?
end
# Check if coordinates were successfully geocoded or are fallback coordinates
diff --git a/app/models/order.rb b/app/models/order.rb
index db4037e..87c0808 100644
--- a/app/models/order.rb
+++ b/app/models/order.rb
@@ -3,6 +3,16 @@ class Order < ApplicationRecord
DRAFT_EXPIRY_TIME = 15.minutes
MAX_PAYMENT_ATTEMPTS = 3
+ # === Enums ===
+ enum :status, {
+ draft: 0,
+ pending_payment: 1,
+ paid: 2,
+ completed: 3,
+ cancelled: 4,
+ expired: 5
+ }, default: :draft
+
# === Associations ===
belongs_to :user
belongs_to :event
@@ -23,8 +33,9 @@ class Order < ApplicationRecord
attr_accessor :stripe_invoice_id
# === Scopes ===
- scope :draft, -> { where(status: "draft") }
- scope :active, -> { where(status: %w[paid completed]) }
+ scope :draft, -> { where(status: :draft) }
+ scope :active, -> { where(status: [ :paid, :completed ]) }
+ scope :paid, -> { where(status: :paid) }
scope :expired_drafts, -> { draft.where("expires_at < ?", Time.current) }
scope :can_retry_payment, -> {
draft.where("payment_attempts < ? AND expires_at > ?",
diff --git a/app/models/payout.rb b/app/models/payout.rb
index 3cca8f3..fb56796 100644
--- a/app/models/payout.rb
+++ b/app/models/payout.rb
@@ -12,18 +12,44 @@ class Payout < ApplicationRecord
}, default: :pending
# === Validations ===
- validates :amount_cents, presence: true, numericality: { greater_than_or_equal_to: 0 }
+ validates :amount_cents, presence: true, numericality: { greater_than: 0 }
validates :fee_cents, presence: true, numericality: { greater_than_or_equal_to: 0 }
validates :status, presence: true
validates :total_orders_count, presence: true, numericality: { greater_than_or_equal_to: 0 }
validates :refunded_orders_count, presence: true, numericality: { greater_than_or_equal_to: 0 }
validates :stripe_payout_id, allow_blank: true, uniqueness: true
+ validate :unique_pending_event_id, if: :pending?
+
+ validate :net_earnings_greater_than_zero, if: :pending?
+
+ def net_earnings_greater_than_zero
+ if event.net_earnings_cents <= 0
+ errors.add(:base, "net earnings must be greater than 0")
+ end
+ end
+
+ validate :net_earnings_greater_than_zero, if: :pending?
+
+ def net_earnings_greater_than_zero
+ if event.net_earnings_cents <= 0
+ errors.add(:base, "net earnings must be greater than 0")
+ end
+ end
+
+ def unique_pending_event_id
+ if Payout.pending.where(event_id: event_id).where.not(id: id).exists?
+ errors.add(:base, "only one pending payout allowed per event")
+ end
+ end
# === Scopes ===
scope :completed, -> { where(status: :completed) }
scope :pending, -> { where(status: :pending) }
scope :processing, -> { where(status: :processing) }
+ # === Callbacks ===
+ after_create :calculate_refunded_orders_count
+
# === Instance Methods ===
# Amount in euros (formatted)
@@ -56,4 +82,14 @@ class Payout < ApplicationRecord
service = PayoutService.new(self)
service.process!
end
-end
\ No newline at end of file
+ public
+
+ # === Instance Methods ===
+
+ def calculate_refunded_orders_count
+ refunded_order_ids = event.tickets.where(status: "refunded").select(:order_id).distinct.pluck(:order_id)
+ paid_statuses = %w[paid completed]
+ count = event.orders.where(status: paid_statuses).where(id: refunded_order_ids).count
+ update_column(:refunded_orders_count, count)
+ end
+end
diff --git a/app/models/ticket.rb b/app/models/ticket.rb
index 94b2508..dd7684d 100755
--- a/app/models/ticket.rb
+++ b/app/models/ticket.rb
@@ -22,6 +22,8 @@ class Ticket < ApplicationRecord
before_validation :set_price_from_ticket_type, on: :create
before_validation :generate_qr_code, on: :create
+ after_update :recalculate_earning_if_refunded, if: :saved_change_to_status?
+
# Generate PDF ticket
def to_pdf
TicketPdfGenerator.new(self).generate
@@ -73,4 +75,12 @@ class Ticket < ApplicationRecord
def draft?
status == "draft"
end
+
+ private
+
+ def recalculate_earning_if_refunded
+ if status == "refunded"
+ order.earning&.recalculate!
+ end
+ end
end
diff --git a/app/models/user.rb b/app/models/user.rb
index b067037..faff7b5 100755
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -65,6 +65,19 @@ class User < ApplicationRecord
end
def can_receive_payouts?
- has_stripe_account? && promoter?
+ stripe_connected_account_id.present? && stripe_connect_verified?
+ end
+ private
+
+ def stripe_connect_verified?
+ return false unless stripe_connected_account_id.present?
+
+ begin
+ account = Stripe::Account.retrieve(stripe_connected_account_id)
+ account.charges_enabled
+ rescue Stripe::StripeError => e
+ Rails.logger.error "Failed to verify Stripe account #{stripe_connected_account_id}: #{e.message}"
+ false
+ end
end
end
diff --git a/app/services/payout_service.rb b/app/services/payout_service.rb
index d407eb8..7125743 100644
--- a/app/services/payout_service.rb
+++ b/app/services/payout_service.rb
@@ -8,23 +8,32 @@ class PayoutService
@payout.update!(status: :processing)
- # Create Stripe payout
begin
- stripe_payout = Stripe::Payout.create({
- amount: @payout.amount_cents,
- currency: 'eur',
- destination: @payout.user.stripe_account_id,
- description: "Payout for event: #{@payout.event.name}"
- })
+ net_amount = @payout.amount_cents - @payout.fee_cents
+ transfer = Stripe::Transfer.create({
+ amount: (net_amount / 100.0).to_i,
+ currency: "eur",
+ destination: @payout.user.stripe_connected_account_id,
+ description: "Payout for event #{@payout.event.name}",
+ metadata: { payout_id: @payout.id, event_id: @payout.event_id }
+ }, idempotency_key: SecureRandom.uuid)
@payout.update!(
status: :completed,
- stripe_payout_id: stripe_payout.id
+ stripe_payout_id: transfer.id
)
+
+ update_earnings_status
rescue Stripe::StripeError => e
@payout.update!(status: :failed)
Rails.logger.error "Stripe payout failed for payout #{@payout.id}: #{e.message}"
raise e
end
end
-end
\ No newline at end of file
+
+ private
+
+ def update_earnings_status
+ @payout.event.earnings.where(status: 0).update_all(status: 1) # pending to paid
+ end
+end
diff --git a/app/views/promoter/events/_earnings_preview.html.erb b/app/views/promoter/events/_earnings_preview.html.erb
new file mode 100644
index 0000000..d93da04
--- /dev/null
+++ b/app/views/promoter/events/_earnings_preview.html.erb
@@ -0,0 +1,47 @@
+<% if @event.can_request_payout? %>
+
+
Aperçu des Revenus
+
+
+
+
+
Revenus Bruts
+
+ <%= number_to_currency(@event.total_earnings_cents / 100.0, unit: '€', separator: ',', delimiter: '.') %>
+
+
+
+
+
+
Frais Plateforme
+
+ -<%= number_to_currency(@event.total_fees_cents / 100.0, unit: '€', separator: ',', delimiter: '.') %>
+
+
+
+
+
+
Revenus Nets
+
+ <%= number_to_currency(@event.net_earnings_cents / 100.0, unit: '€', separator: ',', delimiter: '.') %>
+
+
+
+
+ <% if @event.payout.present? %>
+ <%= link_to "Voir les Détails du Paiement", promoter_payout_path(@event.payout),
+ class: "inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md shadow-sm text-gray-700 bg-white hover:bg-gray-50" %>
+ <% else %>
+ <%= form_with model: Payout.new, url: promoter_payouts_path, local: true, class: "inline-block" do |f| %>
+ <%= f.hidden_field :event_id, value: @event.id %>
+ <%= f.submit "Demander le Paiement Maintenant",
+ class: "inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500",
+ data: { confirm: "Êtes-vous sûr de vouloir demander un paiement de #{number_to_currency(@event.net_earnings_cents / 100.0, unit: '€', separator: ',', delimiter: '.') } ? Cette action ne peut pas être annulée." } %>
+ <% end %>
+ <% end %>
+
+<% else %>
+
+
Non éligible à la demande de paiement. L'événement n'est peut-être pas terminé ou le compte Stripe n'est pas vérifié.
+
+<% end %>
diff --git a/app/views/promoter/events/show.html.erb b/app/views/promoter/events/show.html.erb
index 97a1e67..c9f97c2 100644
--- a/app/views/promoter/events/show.html.erb
+++ b/app/views/promoter/events/show.html.erb
@@ -277,7 +277,7 @@
Gérer les types de billets
<% end %>
-
+
<% if @event.sold_out? %>
<%= button_to mark_available_promoter_event_path(@event), method: :patch, class: "w-full inline-flex items-center justify-center px-4 py-3 bg-blue-50 text-blue-700 font-medium text-sm rounded-lg hover:bg-blue-100 transition-colors duration-200" do %>
@@ -289,76 +289,9 @@
Marquer comme complet
<% end %>
<% end %>
-
-
- <% if @event.event_ended? && @event.can_request_payout? %>
-
-
-
Paiement des Revenus
-
-
-
-
Revenus Bruts
-
€<%= @event.total_earnings_cents / 100.0 %>
-
+ <%= render 'earnings_preview' %>
-
-
Frais Plateforme
-
-€<%= @event.total_fees_cents / 100.0 %>
-
-
-
-
Revenus Nets
-
€<%= @event.net_earnings_cents / 100.0 %>
-
-
-
-
- <% if @event.payout_status != "not_requested" %>
-
-
- <% case @event.payout_status %>
- <% when "requested" %>
-
- Paiement Demandé
- <% when "processing" %>
-
- Paiement en Traitement
- <% when "completed" %>
-
- Paiement Complété
- <% when "failed" %>
-
- Paiement Échoué
- <% end %>
-
-
Votre demande de paiement est en cours de traitement. Vous recevrez un email quand elle sera terminée.
-
- <% end %>
-
-
- <% if @event.payout_status == "not_requested" %>
- <%= button_to promoter_payouts_path(event_id: @event.id), method: :post,
- data: { confirm: "Êtes-vous sûr de vouloir demander un paiement de €#{@event.net_earnings_cents / 100.0} ? Cette action ne peut pas être annulée." },
- class: "payout-action-button primary" do %>
-
- Demander le Paiement de €<%= @event.net_earnings_cents / 100.0 %>
- <% end %>
- <% elsif @event.payout_status == "failed" %>
- <%= button_to promoter_payouts_path(event_id: @event.id), method: :post,
- data: { confirm: "Êtes-vous sûr de vouloir demander un nouveau paiement de €#{@event.net_earnings_cents / 100.0} ?" },
- class: "payout-action-button warning" do %>
-
- Réessayer le Paiement
- <% end %>
- <% else %>
- <%= link_to "Voir les Détails du Paiement", promoter_payouts_path,
- class: "payout-action-button secondary" %>
- <% end %>
-
- <% end %>
-
<%= button_to promoter_event_path(@event), method: :delete,
data: { confirm: "Êtes-vous sûr de vouloir supprimer cet événement ? Cette action est irréversible." },
diff --git a/app/views/promoter/payouts/index.html.erb b/app/views/promoter/payouts/index.html.erb
index 64dc98b..2f989ec 100644
--- a/app/views/promoter/payouts/index.html.erb
+++ b/app/views/promoter/payouts/index.html.erb
@@ -53,6 +53,65 @@
<% end %>
+
+
+
Pending Earnings
+
+ <% if @total_pending_net && @total_pending_net > 0 %>
+
+
+
+
+
+
+
+
Total Pending Net
+
+ <%= number_to_currency(@total_pending_net / 100.0, unit: '€', separator: ',', delimiter: '.') %>
+
+
+
+
+
+ <% end %>
+
+ <% if @eligible_events.present? && @eligible_events.any? %>
+
+ <% @eligible_events.limit(5).each do |event| %>
+
+
+
+
+
+
+
<%= event.name %>
+
<%= event.start_time.strftime("%d %b %Y") %>
+
+
+
+
Gross: <%= number_to_currency(event.total_earnings_cents / 100.0, unit: '€', separator: ',', delimiter: '.') %>
+
Net: <%= number_to_currency(event.net_earnings_cents / 100.0, unit: '€', separator: ',', delimiter: '.') %>
+
+ <%= link_to "Request Payout", promoter_event_path(event),
+ class: "mt-4 w-full inline-flex justify-center items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" %>
+
+ <% end %>
+
+ <% if @eligible_events.size > 5 %>
+
+ <%= link_to "View All Eligible Events", promoter_events_path, class: "text-indigo-600 hover:text-indigo-500 text-sm font-medium" %>
+
+ <% end %>
+ <% else %>
+
+
+
No pending earnings
+
Check your events to see if any are eligible for payout requests.
+ <%= link_to "View My Events", promoter_events_path, class: "mt-4 inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md shadow-sm text-gray-700 bg-white hover:bg-gray-50" %>
+
+ <% end %>
+
+
<% if @payouts.any? %>
@@ -139,4 +198,4 @@
<%= link_to "View My Events", promoter_events_path, class: "inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" %>
<% end %>
-
\ No newline at end of file
+
diff --git a/app/views/promoter/payouts/show.html.erb b/app/views/promoter/payouts/show.html.erb
index 417afa6..59c5481 100644
--- a/app/views/promoter/payouts/show.html.erb
+++ b/app/views/promoter/payouts/show.html.erb
@@ -26,7 +26,7 @@
Requested
<%= @payout.created_at.strftime("%b %d, %Y") %>
-
+
<% if @payout.status == 'processing' %>
@@ -39,7 +39,7 @@
Processing
-
+
<% if @payout.status == 'completed' %>
@@ -59,17 +59,23 @@
Gross Amount
-
€<%= @payout.amount_euros %>
+
+ <%= number_to_currency(@payout.amount_euros, unit: '€', separator: ',', delimiter: '.') %>
+
-
+
Platform Fees
-
-€<%= @payout.fee_euros %>
+
+ -<%= number_to_currency(@payout.fee_euros, unit: '€', separator: ',', delimiter: '.') %>
+
-
+
Net Amount
-
€<%= @payout.net_amount_euros %>
+
+ <%= number_to_currency(@payout.net_amount_euros, unit: '€', separator: ',', delimiter: '.') %>
+
@@ -79,7 +85,7 @@
Payout Information
Details about this payout request
-
+
-
+
Status
@@ -123,37 +129,37 @@
<% end %>
-
+
Gross Amount
€<%= @payout.amount_euros %>
-
+
Platform Fees
-€<%= @payout.fee_euros %>
-
+
Net Amount
€<%= @payout.net_amount_euros %>
-
+
Total Orders
<%= @payout.total_orders_count %>
-
+
Refunded Orders
<%= @payout.refunded_orders_count %>
-
+
Requested Date
<%= @payout.created_at.strftime("%B %d, %Y at %I:%M %p") %>
-
+
<% if @payout.stripe_payout_id.present? %>
Stripe Payout ID
@@ -162,4 +168,4 @@
<% end %>
-
\ No newline at end of file
+
diff --git a/config/routes.rb b/config/routes.rb
index 65ff184..bc87dee 100755
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -1,6 +1,10 @@
Rails.application.routes.draw do
namespace :admin do
- resources :payouts, only: [ :index, :create ]
+ resources :payouts, only: [ :index, :show ] do
+ member do
+ post :process
+ end
+ end
end
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
@@ -78,7 +82,7 @@ Rails.application.routes.draw do
# === Promoter Routes ===
namespace :promoter do
- resources :payouts, only: [:index, :show, :create]
+ resources :payouts, only: [ :index, :show, :create ]
resources :events do
member do
patch :publish
@@ -115,4 +119,6 @@ Rails.application.routes.draw do
# resources :ticket_types, only: [ :index, :show, :create, :update, :destroy ]
end
end
+
+ post "/webhooks/stripe", to: "webhooks/stripe#create"
end
diff --git a/db/migrate/20250916230001_add_index_to_payouts_on_event_id_and_status.rb b/db/migrate/20250916230001_add_index_to_payouts_on_event_id_and_status.rb
new file mode 100644
index 0000000..4f48022
--- /dev/null
+++ b/db/migrate/20250916230001_add_index_to_payouts_on_event_id_and_status.rb
@@ -0,0 +1,5 @@
+class AddIndexToPayoutsOnEventIdAndStatus < ActiveRecord::Migration[7.1]
+ def change
+ add_index :payouts, [ :event_id, :status ]
+ end
+end
diff --git a/db/migrate/20250916230002_add_index_to_earnings_on_status.rb b/db/migrate/20250916230002_add_index_to_earnings_on_status.rb
new file mode 100644
index 0000000..d900f53
--- /dev/null
+++ b/db/migrate/20250916230002_add_index_to_earnings_on_status.rb
@@ -0,0 +1,5 @@
+class AddIndexToEarningsOnStatus < ActiveRecord::Migration[7.1]
+ def change
+ add_index :earnings, :status
+ end
+end
diff --git a/db/migrate/20250916230003_add_index_to_tickets_on_status_and_order_id.rb b/db/migrate/20250916230003_add_index_to_tickets_on_status_and_order_id.rb
new file mode 100644
index 0000000..2069675
--- /dev/null
+++ b/db/migrate/20250916230003_add_index_to_tickets_on_status_and_order_id.rb
@@ -0,0 +1,5 @@
+class AddIndexToTicketsOnStatusAndOrderId < ActiveRecord::Migration[7.1]
+ def change
+ add_index :tickets, [ :status, :order_id ]
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index e51fc7b..f7bdf85 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.0].define(version: 2025_09_16_221454) do
+ActiveRecord::Schema[8.0].define(version: 2025_09_16_230003) do
create_table "earnings", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
t.integer "amount_cents"
t.integer "fee_cents"
@@ -24,6 +24,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_16_221454) do
t.datetime "updated_at", null: false
t.index ["event_id"], name: "index_earnings_on_event_id"
t.index ["order_id"], name: "index_earnings_on_order_id"
+ t.index ["status"], name: "index_earnings_on_status"
t.index ["user_id"], name: "index_earnings_on_user_id"
end
@@ -81,6 +82,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_16_221454) do
t.bigint "event_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
+ t.index ["event_id", "status"], name: "index_payouts_on_event_id_and_status"
t.index ["event_id"], name: "index_payouts_on_event_id"
t.index ["status"], name: "index_payouts_on_status"
t.index ["stripe_payout_id"], name: "index_payouts_on_stripe_payout_id", unique: true
@@ -116,6 +118,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_16_221454) do
t.datetime "updated_at", null: false
t.index ["order_id"], name: "index_tickets_on_order_id"
t.index ["qr_code"], name: "index_tickets_on_qr_code", unique: true
+ t.index ["status", "order_id"], name: "index_tickets_on_status_and_order_id"
t.index ["ticket_type_id"], name: "index_tickets_on_ticket_type_id"
end
diff --git a/test/controllers/admin/payouts_controller_test.rb b/test/controllers/admin/payouts_controller_test.rb
new file mode 100644
index 0000000..49f5973
--- /dev/null
+++ b/test/controllers/admin/payouts_controller_test.rb
@@ -0,0 +1,48 @@
+require "test_helper"
+
+class Admin::PayoutsControllerTest < ActionDispatch::IntegrationTest
+ setup do
+ @admin_user = User.create!(email: "admin@example.com", password: "password123", password_confirmation: "password123", is_professionnal: true)
+ @admin_user.add_role :admin # Assume role system
+ @payout = payouts(:one)
+ end
+
+ test "process payout success for pending payout" do
+ sign_in @admin_user
+ @payout.update(status: :pending)
+
+ # Mock service
+ PayoutService.any_instance.expects(:process!).returns(true)
+
+ patch admin_payout_url(@payout)
+ assert_redirected_to admin_payout_path(@payout)
+ assert_flash :notice, /Payout processed successfully/
+ assert_equal :completed, @payout.reload.status
+ end
+
+ test "process payout failure for non-pending" do
+ sign_in @admin_user
+ @payout.update(status: :completed)
+
+ patch admin_payout_url(@payout)
+ assert_redirected_to admin_payout_path(@payout)
+ assert_flash :alert, /Payout not in pending status/
+ end
+
+ test "process payout service error" do
+ sign_in @admin_user
+ @payout.update(status: :pending)
+
+ PayoutService.any_instance.expects(:process!).raises(StandardError.new("Stripe error"))
+
+ patch admin_payout_url(@payout)
+ assert_redirected_to admin_payout_path(@payout)
+ assert_flash :alert, /Failed to process payout/
+ assert_equal :failed, @payout.reload.status
+ end
+
+ test "requires admin authentication" do
+ patch admin_payout_url(@payout)
+ assert_redirected_to new_user_session_path
+ end
+end
diff --git a/test/controllers/promoter/payouts_controller_test.rb b/test/controllers/promoter/payouts_controller_test.rb
index e95675c..9fbf256 100644
--- a/test/controllers/promoter/payouts_controller_test.rb
+++ b/test/controllers/promoter/payouts_controller_test.rb
@@ -46,9 +46,118 @@ class Promoter::PayoutsControllerTest < ActionDispatch::IntegrationTest
fee_cents: 100,
status: :pending
)
- assert_difference('Payout.count', 1) do
+ assert_difference("Payout.count", 1) do
post promoter_payouts_url, params: { event_id: @event.id }
end
assert_redirected_to promoter_payout_path(Payout.last)
end
+ # Comprehensive index test with data
+ test "index shows completed payouts, eligible events, and totals for promoter" do
+ sign_in @user
+ @user.update(is_professionnal: true)
+
+ # Create completed payouts for user
+ completed_payout = Payout.create!(user: @user, event: @event, amount_cents: 1000, fee_cents: 100, status: :completed)
+
+ # Create eligible event
+ eligible_event = Event.create!(name: "Eligible Event", slug: "eligible-event", description: "desc", venue_name: "v", venue_address: "a", latitude: 48.0, longitude: 2.0, start_time: 1.day.ago, end_time: 2.days.ago, user: @user, state: :published)
+ # Setup net >0 for eligible
+ earning = Earning.create!(event: eligible_event, user: @user, order: orders(:one), amount_cents: 900, fee_cents: 100, status: :pending)
+
+ get promoter_payouts_url
+ assert_response :success
+
+ assert_select "table#payouts tbody tr", count: 1 # completed payout
+ assert_select ".eligible-events li", count: 1 # eligible event
+ assert_match /Pending net earnings: €9.00/, @response.body # totals
+ assert_match /Total paid out: €10.00/, @response.body
+ end
+
+ test "index does not show for non-professional" do
+ sign_in @user
+ get promoter_payouts_url
+ assert_redirected_to root_path # or appropriate redirect
+ end
+
+ # Show test with access control
+ test "show renders payout details for own payout" do
+ sign_in @user
+ @user.update(is_professionnal: true)
+ payout = Payout.create!(user: @user, event: @event, amount_cents: 1000, fee_cents: 100, status: :completed)
+
+ get promoter_payout_url(payout)
+ assert_response :success
+ assert_match payout.amount.to_s, @response.body
+ end
+
+ test "show returns 404 for other user's payout" do
+ sign_in @user
+ @user.update(is_professionnal: true)
+ other_user = User.create!(email: "other@example.com", password: "password123", password_confirmation: "password123", is_professionnal: true)
+ other_payout = Payout.create!(user: other_user, event: @event, amount_cents: 2000, fee_cents: 200, status: :completed)
+
+ get promoter_payout_url(other_payout)
+ assert_response :not_found
+ end
+
+ # Expanded create test: success
+ test "create payout success for eligible event" do
+ sign_in @user
+ @user.update(is_professionnal: true)
+ @event.update(user: @user, end_time: 1.day.ago) # ended
+ # Setup net >0
+ earning = @event.earnings.create!(user: @user, order: orders(:paid_order), amount_cents: 900, fee_cents: 100, status: :pending)
+ # Ensure eligible
+ assert @event.can_request_payout?(@user)
+
+ assert_difference("Payout.count", 1) do
+ post promoter_payouts_url, params: { event_id: @event.id }
+ end
+ assert_redirected_to promoter_payout_path(Payout.last)
+ assert_flash :notice, /Payout requested successfully/
+ assert_equal :requested, @event.reload.payout_status # assume enum
+ payout = Payout.last
+ assert_equal @event.total_gross_cents, payout.amount_cents
+ assert_equal @event.total_fees_cents, payout.fee_cents
+ end
+
+ # Create failure: ineligible event
+ test "create payout fails for ineligible event" do
+ sign_in @user
+ @user.update(is_professionnal: true)
+ @event.update(user: @user, end_time: 1.day.from_now) # not ended
+ assert_not @event.can_request_payout?(@user)
+
+ assert_no_difference("Payout.count") do
+ post promoter_payouts_url, params: { event_id: @event.id }
+ end
+ assert_redirected_to event_path(@event)
+ assert_flash :alert, /Event not eligible for payout/
+ end
+
+ # Create failure: validation errors
+ test "create payout fails with validation errors" do
+ sign_in @user
+ @user.update(is_professionnal: true)
+ @event.update(user: @user, end_time: 1.day.ago)
+ # Setup net =0
+ assert_not @event.can_request_payout?(@user)
+
+ assert_no_difference("Payout.count") do
+ post promoter_payouts_url, params: { event_id: @event.id }
+ end
+ assert_response :success # renders new or show with errors
+ assert_template :new # or appropriate
+ assert_flash :alert, /Validation failed/
+ end
+
+ # Unauthorized create
+ test "create requires authentication and professional status" do
+ post promoter_payouts_url, params: { event_id: @event.id }
+ assert_redirected_to new_user_session_path
+
+ sign_in @user # non-professional
+ post promoter_payouts_url, params: { event_id: @event.id }
+ assert_redirected_to root_path # or deny access
+ end
end
diff --git a/test/fixtures/events.yml b/test/fixtures/events.yml
index 8d562ac..001f942 100755
--- a/test/fixtures/events.yml
+++ b/test/fixtures/events.yml
@@ -1,5 +1,19 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
+one:
+ name: Test Event
+ slug: test-event
+ description: This is a test event description that is long enough to meet validation requirements.
+ state: published
+ venue_name: Test Venue
+ venue_address: 123 Test Street
+ latitude: 48.8566
+ longitude: 2.3522
+ start_time: <%= 1.week.from_now %>
+ end_time: <%= 1.week.from_now + 4.hours %>
+ user: one
+ featured: false
+
concert_event:
name: Summer Concert
slug: summer-concert
@@ -25,3 +39,29 @@ winter_gala:
start_time: <%= 2.weeks.from_now %>
end_time: <%= 2.weeks.from_now + 6.hours %>
user: two
+
+another_event:
+ name: Another Event
+ slug: another-event
+ description: This is another test event description that is long enough to meet validation requirements.
+ state: published
+ venue_name: Another Venue
+ venue_address: 456 Test Street
+ latitude: 48.8566
+ longitude: 2.3522
+ start_time: <%= 1.week.ago %>
+ end_time: <%= 1.week.ago + 4.hours %>
+ user: one
+
+ineligible:
+ name: Ineligible Event
+ slug: ineligible-event
+ description: This is an ineligible test event description that is long enough to meet validation requirements.
+ state: draft
+ venue_name: Ineligible Venue
+ venue_address: 789 Test Street
+ latitude: 48.8566
+ longitude: 2.3522
+ start_time: <%= 1.week.from_now %>
+ end_time: <%= 1.week.from_now + 4.hours %>
+ user: one
diff --git a/test/fixtures/orders.yml b/test/fixtures/orders.yml
index 9832752..f606c50 100644
--- a/test/fixtures/orders.yml
+++ b/test/fixtures/orders.yml
@@ -1,3 +1,13 @@
+one:
+ user: one
+ event: concert_event
+ status: paid
+ total_amount_cents: 2500
+ payment_attempts: 1
+ expires_at: <%= 1.hour.from_now %>
+ created_at: <%= 1.hour.ago %>
+ updated_at: <%= 1.hour.ago %>
+
paid_order:
user: one
event: concert_event
@@ -26,4 +36,14 @@ expired_order:
payment_attempts: 1
expires_at: <%= 1.hour.ago %>
created_at: <%= 2.hours.ago %>
- updated_at: <%= 1.hour.ago %>
\ No newline at end of file
+ updated_at: <%= 1.hour.ago %>
+
+two:
+ user: two
+ event: winter_gala
+ status: expired
+ total_amount_cents: 5000
+ payment_attempts: 2
+ expires_at: <%= 2.hours.ago %>
+ created_at: <%= 3.hours.ago %>
+ updated_at: <%= 2.hours.ago %>
\ No newline at end of file
diff --git a/test/fixtures/ticket_types.yml b/test/fixtures/ticket_types.yml
index 6041d8b..05ebd25 100755
--- a/test/fixtures/ticket_types.yml
+++ b/test/fixtures/ticket_types.yml
@@ -1,5 +1,15 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
+one:
+ name: Standard
+ description: Standard ticket type
+ price_cents: 1000
+ quantity: 100
+ sale_start_at: <%= 1.day.ago %>
+ sale_end_at: <%= 1.day.from_now %>
+ event: concert_event
+ requires_id: false
+
standard:
name: General Admission
description: General admission ticket for the event
diff --git a/test/integration/payout_flow_test.rb b/test/integration/payout_flow_test.rb
new file mode 100644
index 0000000..1e23589
--- /dev/null
+++ b/test/integration/payout_flow_test.rb
@@ -0,0 +1,58 @@
+require "test_helper"
+
+class PayoutFlowTest < ActionDispatch::IntegrationTest
+ setup do
+ @promoter = User.create!(email: "promoter@example.com", password: "password123", password_confirmation: "password123", is_professionnal: true)
+ @buyer = User.create!(email: "buyer@example.com", password: "password123", password_confirmation: "password123")
+ sign_in @promoter
+ end
+
+ test "full payout flow with refund" do
+ # Create event and ticket type
+ event = Event.create!(name: "Test Event", slug: "test-event", description: "This is a test event description that meets the minimum length requirement of 10 characters.", venue_name: "Venue", venue_address: "Address", latitude: 48.0, longitude: 2.0, start_time: 1.day.ago, end_time: 1.hour.ago, user: @promoter, state: :published)
+ ticket_type = TicketType.create!(event: event, name: "Standard", price_cents: 1000, quantity: 10, sale_start_at: 2.days.ago, sale_end_at: Time.current)
+
+ # Buyer purchases ticket (mock Stripe)
+ sign_in @buyer
+ Stripe::Checkout::Session.expects(:create).returns(stub(id: "cs_test"))
+
+ post event_checkout_path(event), params: { cart: { ticket_types: { ticket_type.id => 1 } } }
+ session_id = assigns(:session_id)
+ # Assume payment success creates order and tickets
+ order = Order.last
+ ticket = Ticket.last
+ assert_equal "paid", order.status
+ assert_equal "active", ticket.status
+
+ # Earnings created
+ earning = Earning.last
+ assert_not_nil earning
+ assert_equal 900, earning.amount_cents
+
+ # Refund one ticket
+ sign_in @promoter
+ ticket.update!(status: "refunded")
+ earning.reload
+ assert_equal 0, earning.amount_cents # Recalculated
+
+ # Request payout
+ assert event.can_request_payout?(@promoter)
+ post promoter_payouts_path, params: { event_id: event.id }
+ payout = Payout.last
+ assert_equal :pending, payout.status
+
+ # Admin process
+ admin = User.create!(email: "admin@example.com", password: "password123", password_confirmation: "password123")
+ admin.add_role :admin
+ sign_in admin
+ Stripe::Transfer.expects(:create).returns(stub(id: "tr_success"))
+ patch admin_payout_path(payout)
+ payout.reload
+ assert_equal :completed, payout.status
+
+ # Webhook succeeds
+ post stripe_webhooks_path, params: { type: "payout.succeeded", data: { object: { id: "po_123" } } }, headers: { "Stripe-Signature" => "valid_sig" }
+ payout.reload
+ assert_equal :completed, payout.status # Confirmed
+ end
+end
diff --git a/test/models/earning_test.rb b/test/models/earning_test.rb
index ca41de0..1c1712a 100644
--- a/test/models/earning_test.rb
+++ b/test/models/earning_test.rb
@@ -83,4 +83,53 @@ class EarningTest < ActiveSupport::TestCase
assert_not_includes Earning.paid, pending_earning
assert_includes Earning.paid, paid_earning
end
+ # Payout-related tests
+
+ test "creation from order" do
+ user = users(:one)
+ event = events(:concert_event)
+ order = orders(:paid_order)
+ order.update!(status: "paid", total_amount_cents: 10000)
+
+ # Assume Earning.create_from_order(order) or callback creates earning
+ Earning.create_from_order(order)
+
+ earning = Earning.where(order: order).first
+ assert_not_nil earning
+ assert_equal 9000, earning.amount_cents # After fees: assume 10% fee or based on ticket
+ assert_equal 1000, earning.fee_cents
+ assert earning.pending?
+ end
+
+ test "recalculation on full refund" do
+ earning = earnings(:one)
+ earning.amount_cents = 1000
+ earning.fee_cents = 100
+ earning.save!
+
+ # Assume all tickets in order refunded
+ order = orders(:one)
+ order.tickets.each { |t| t.update!(status: "refunded") }
+
+ earning.recalculate_on_refund(order)
+
+ assert_equal 0, earning.amount_cents
+ assert earning.refunded? # Assume status update
+ end
+
+ test "recalculation on partial refund" do
+ earning = earnings(:one)
+ earning.amount_cents = 2000
+ earning.fee_cents = 200
+ earning.save!
+
+ order = orders(:one)
+ # Refund one ticket of 1000
+ order.tickets.first.update!(status: "refunded")
+
+ earning.recalculate_on_refund(order)
+
+ assert_equal 1000, earning.amount_cents # Half
+ assert_equal 100, earning.fee_cents # Half
+ end
end
diff --git a/test/models/event_test.rb b/test/models/event_test.rb
index 8249bd1..200d704 100755
--- a/test/models/event_test.rb
+++ b/test/models/event_test.rb
@@ -317,4 +317,142 @@ class EventTest < ActiveSupport::TestCase
# Check that ticket types were NOT duplicated
assert_equal 0, duplicated_event.ticket_types.count
end
+ # Payout-related tests
+
+ test "total_gross_cents returns sum of active tickets prices" do
+ event = events(:concert_event)
+ ticket1 = tickets(:one)
+ ticket1.status = "active"
+ ticket1.price_cents = 1000
+ ticket1.save!
+
+ ticket2 = Ticket.create!(order: orders(:one), ticket_type: ticket_types(:one), qr_code: "qr2", price_cents: 2000, status: "active", first_name: "Test", last_name: "User")
+ ticket2.event = event
+ ticket2.save!
+
+ assert_equal 3000, event.total_gross_cents
+ end
+
+ test "total_fees_cents returns sum of pending earnings fees" do
+ event = events(:concert_event)
+ earning1 = earnings(:one)
+ earning1.status = "pending"
+ earning1.fee_cents = 100
+ earning1.save!
+
+ earning2 = Earning.create!(event: event, user: users(:one), order: orders(:one), amount_cents: 2000, fee_cents: 200, status: "pending")
+
+ assert_equal 300, event.total_fees_cents
+ end
+
+ test "net_earnings_cents returns gross minus fees" do
+ event = events(:concert_event)
+ # Setup gross 5000, fees 500
+ ticket1 = tickets(:one)
+ ticket1.status = "active"
+ ticket1.price_cents = 2500
+ ticket1.save!
+
+ ticket2 = Ticket.create!(order: orders(:one), ticket_type: ticket_types(:one), qr_code: "qr3", price_cents: 2500, status: "active", first_name: "Test2", last_name: "User2")
+ ticket2.event = event
+ ticket2.save!
+
+ earning1 = earnings(:one)
+ earning1.status = "pending"
+ earning1.fee_cents = 250
+ earning1.save!
+
+ earning2 = Earning.create!(event: event, user: users(:one), order: orders(:one), amount_cents: 2500, fee_cents: 250, status: "pending")
+
+ assert_equal 4500, event.net_earnings_cents
+ end
+
+ test "can_request_payout? returns true for ended event with net >0, eligible user, no pending payout" do
+ event = events(:concert_event)
+ event.update!(end_time: 1.day.ago) # ended
+ # Setup net >0
+ ticket = Ticket.create!(order: orders(:one), ticket_type: ticket_types(:one), qr_code: "qr4", price_cents: 1000, status: "active", first_name: "Test", last_name: "User")
+ ticket.event = event
+ ticket.save!
+
+ earning = Earning.create!(event: event, user: users(:one), order: orders(:one), amount_cents: 900, fee_cents: 100, status: "pending")
+
+ user = users(:one)
+ user.update!(is_professionnal: true) # eligible
+
+ # No pending payout
+ assert event.can_request_payout?(user)
+ end
+
+ test "can_request_payout? returns false for not ended event" do
+ event = events(:concert_event)
+ event.update!(end_time: 1.day.from_now) # not ended
+ user = users(:one)
+ user.update!(is_professionnal: true)
+
+ assert_not event.can_request_payout?(user)
+ end
+
+ test "can_request_payout? returns false if net <=0" do
+ event = events(:concert_event)
+ event.update!(end_time: 1.day.ago)
+ user = users(:one)
+ user.update!(is_professionnal: true)
+
+ assert_not event.can_request_payout?(user)
+ end
+
+ test "can_request_payout? returns false for non-professional user" do
+ event = events(:concert_event)
+ event.update!(end_time: 1.day.ago)
+ # Setup net >0
+ ticket = Ticket.create!(order: orders(:one), ticket_type: ticket_types(:one), qr_code: "qr5", price_cents: 1000, status: "active", first_name: "Test", last_name: "User")
+ ticket.event = event
+ ticket.save!
+
+ earning = Earning.create!(event: event, user: users(:one), order: orders(:one), amount_cents: 900, fee_cents: 100, status: "pending")
+
+ user = users(:one)
+ # is_professionnal false by default
+
+ assert_not event.can_request_payout?(user)
+ end
+
+ test "can_request_payout? returns false if pending payout exists" do
+ event = events(:concert_event)
+ event.update!(end_time: 1.day.ago)
+ # Setup net >0
+ ticket = Ticket.create!(order: orders(:one), ticket_type: ticket_types(:one), qr_code: "qr6", price_cents: 1000, status: "active", first_name: "Test", last_name: "User")
+ ticket.event = event
+ ticket.save!
+
+ earning = Earning.create!(event: event, user: users(:one), order: orders(:one), amount_cents: 900, fee_cents: 100, status: "pending")
+
+ user = users(:one)
+ user.update!(is_professionnal: true)
+
+ Payout.create!(user: user, event: event, amount_cents: 800, fee_cents: 100, status: :pending)
+
+ assert_not event.can_request_payout?(user)
+ end
+
+ test "eligible_for_payout scope returns events with net>0, ended, professional user" do
+ user = users(:one)
+ user.update!(is_professionnal: true)
+
+ eligible = Event.create!(name: "Eligible", slug: "eligible", description: "desc", venue_name: "v", venue_address: "a", latitude: 48.0, longitude: 2.0, start_time: 1.day.ago, end_time: 2.days.ago, user: user, state: :published)
+ # Setup net >0
+ ticket = Ticket.create!(order: orders(:one), ticket_type: ticket_types(:one), qr_code: "qr7", price_cents: 1000, status: "active", first_name: "Test", last_name: "User")
+ ticket.event = eligible
+ ticket.save!
+
+ earning = Earning.create!(event: eligible, user: user, order: orders(:one), amount_cents: 900, fee_cents: 100, status: "pending")
+
+ ineligible = Event.create!(name: "Ineligible", slug: "ineligible", description: "desc", venue_name: "v", venue_address: "a", latitude: 48.0, longitude: 2.0, start_time: 1.day.from_now, end_time: 2.days.from_now, user: user, state: :published)
+ # net =0
+
+ eligible_events = Event.eligible_for_payout
+ assert_includes eligible_events, eligible
+ assert_not_includes eligible_events, ineligible
+ end
end
diff --git a/test/models/payout_test.rb b/test/models/payout_test.rb
new file mode 100644
index 0000000..e1cfde4
--- /dev/null
+++ b/test/models/payout_test.rb
@@ -0,0 +1,109 @@
+require "test_helper"
+
+class PayoutTest < ActiveSupport::TestCase
+ setup do
+ @payout = payouts(:one)
+ @user = users(:one)
+ @event = events(:concert_event)
+ end
+
+ test "should be valid" do
+ assert @payout.valid?
+ end
+
+ test "validations: amount_cents must be present and positive" do
+ @payout.amount_cents = nil
+ assert_not @payout.valid?
+ assert_includes @payout.errors[:amount_cents], "can't be blank"
+
+ @payout.amount_cents = 0
+ assert_not @payout.valid?
+ assert_includes @payout.errors[:amount_cents], "must be greater than 0"
+
+ @payout.amount_cents = -100
+ assert_not @payout.valid?
+ assert_includes @payout.errors[:amount_cents], "must be greater than 0"
+ end
+
+ test "validations: fee_cents must be present and non-negative" do
+ @payout.fee_cents = nil
+ assert_not @payout.valid?
+ assert_includes @payout.errors[:fee_cents], "can't be blank"
+
+ @payout.fee_cents = -100
+ assert_not @payout.valid?
+ assert_includes @payout.errors[:fee_cents], "must be greater than or equal to 0"
+ end
+
+ test "validations: net earnings must be greater than 0" do
+ # Assuming event.net_earnings_cents is a method that calculates >0
+ @event.earnings.create!(user: @user, order: orders(:one), amount_cents: 0, fee_cents: 0, status: :pending)
+ payout = Payout.new(user: @user, event: @event, amount_cents: 1000, fee_cents: 100)
+ assert_not payout.valid?
+ assert_includes payout.errors[:base], "net earnings must be greater than 0" # Custom validation message
+
+ @event.earnings.first.update(amount_cents: 2000)
+ assert payout.valid?
+ end
+
+ test "validations: only one pending payout per event" do
+ pending_payout = Payout.create!(user: @user, event: @event, amount_cents: 1000, fee_cents: 100, status: :pending)
+ assert pending_payout.valid?
+
+ duplicate = Payout.new(user: @user, event: @event, amount_cents: 1000, fee_cents: 100, status: :pending)
+ assert_not duplicate.valid?
+ assert_includes duplicate.errors[:base], "only one pending payout allowed per event"
+ end
+
+ test "net_amount_cents virtual attribute" do
+ @payout.amount_cents = 10000
+ @payout.fee_cents = 1000
+ assert_equal 9000, @payout.net_amount_cents
+ end
+
+ test "after_create callback sets refunded_orders_count" do
+ refund_count = @event.orders.refunded.count # Assuming orders have refunded status
+ payout = Payout.create!(user: @user, event: @event, amount_cents: 1000, fee_cents: 100)
+ assert_equal refund_count, payout.refunded_orders_count
+ end
+
+ test "associations: belongs to user" do
+ association = Payout.reflect_on_association(:user)
+ assert_equal :belongs_to, association.macro
+ end
+
+ test "associations: belongs to event" do
+ association = Payout.reflect_on_association(:event)
+ assert_equal :belongs_to, association.macro
+ end
+
+ test "status enum" do
+ assert_equal 0, Payout.statuses[:pending]
+ assert_equal 1, Payout.statuses[:processing]
+ assert_equal 2, Payout.statuses[:completed]
+ assert_equal 3, Payout.statuses[:failed]
+
+ @payout.status = :pending
+ assert @payout.pending?
+
+ @payout.status = :completed
+ assert @payout.completed?
+ end
+
+ test "pending scope" do
+ pending = Payout.create!(user: @user, event: @event, amount_cents: 1000, fee_cents: 100, status: :pending)
+ completed = Payout.create!(user: @user, event: @event, amount_cents: 2000, fee_cents: 200, status: :completed)
+
+ assert_includes Payout.pending, pending
+ assert_not_includes Payout.pending, completed
+ end
+
+ test "scope: eligible_for_payout" do
+ # Assuming this scope exists or test if needed
+ eligible_event = events(:another_event) # Setup with net >0, ended, etc.
+ ineligible = events(:ineligible)
+
+ eligible_payouts = Payout.eligible_for_payout
+ assert_includes eligible_payouts, eligible_event.payouts.first if eligible_event.can_request_payout?
+ end
+end
diff --git a/test/models/ticket_test.rb b/test/models/ticket_test.rb
index 2922d54..da1ef5c 100755
--- a/test/models/ticket_test.rb
+++ b/test/models/ticket_test.rb
@@ -367,4 +367,21 @@ class TicketTest < ActiveSupport::TestCase
)
assert ticket.save
end
+ # Payout-related tests
+
+ test "after_update callback triggers earning recalculation on refund status change" do
+ user = User.create!(email: "refund@example.com", password: "password123", password_confirmation: "password123")
+ event = Event.create!(name: "Refund Event", slug: "refund-event", description: "Valid description", venue_name: "v", venue_address: "a", latitude: 48.0, longitude: 2.0, start_time: 1.day.from_now, user: user, state: :published)
+ ticket_type = TicketType.create!(name: "Standard", price_cents: 1000, quantity: 1, sale_start_at: Time.current, sale_end_at: Time.current + 1.day, event: event)
+ order = Order.create!(user: user, event: event, status: "paid", total_amount_cents: 1000)
+ ticket = Ticket.create!(order: order, ticket_type: ticket_type, qr_code: "qr_refund", price_cents: 1000, status: "active", first_name: "Refund", last_name: "Test")
+ earning = Earning.create!(event: event, user: user, order: order, amount_cents: 900, fee_cents: 100, status: :pending)
+
+ # Mock the recalc method
+ earning.expects(:recalculate_on_refund).once
+
+ # Change status to refunded
+ ticket.status = "refunded"
+ ticket.save!
+ end
end
diff --git a/test/models/user_test.rb b/test/models/user_test.rb
index f10c5c5..fb4416a 100755
--- a/test/models/user_test.rb
+++ b/test/models/user_test.rb
@@ -92,4 +92,47 @@ class UserTest < ActiveSupport::TestCase
user.update!(onboarding_completed: true)
assert_not user.needs_onboarding?, "User with true onboarding_completed should not need onboarding"
end
+ # Payout-related tests
+
+ test "can_receive_payouts? returns true if stripe account id present and charges enabled" do
+ user = users(:one)
+ user.update!(stripe_connected_account_id: "acct_12345", is_professionnal: true)
+
+ # Mock Stripe API call
+ Stripe::Account.expects(:retrieve).with("acct_12345").returns(stub(charges_enabled: true))
+
+ assert user.can_receive_payouts?
+ end
+
+ test "can_receive_payouts? returns false if no stripe account id" do
+ user = users(:one)
+ user.update!(is_professionnal: true)
+
+ assert_not user.can_receive_payouts?
+ end
+
+ test "can_receive_payouts? returns false if not professional" do
+ user = users(:one)
+ user.update!(stripe_connected_account_id: "acct_12345")
+
+ assert_not user.can_receive_payouts?
+ end
+
+ test "can_receive_payouts? returns false if charges not enabled" do
+ user = users(:one)
+ user.update!(stripe_connected_account_id: "acct_12345", is_professionnal: true)
+
+ Stripe::Account.expects(:retrieve).with("acct_12345").returns(stub(charges_enabled: false))
+
+ assert_not user.can_receive_payouts?
+ end
+
+ test "can_receive_payouts? handles Stripe API error" do
+ user = users(:one)
+ user.update!(stripe_connected_account_id: "acct_invalid", is_professionnal: true)
+
+ Stripe::Account.expects(:retrieve).with("acct_invalid").raises(Stripe::InvalidRequestError.new("Account not found"))
+
+ assert_not user.can_receive_payouts?
+ end
end
diff --git a/test/services/payout_service_test.rb b/test/services/payout_service_test.rb
new file mode 100644
index 0000000..bb19050
--- /dev/null
+++ b/test/services/payout_service_test.rb
@@ -0,0 +1,72 @@
+
+require "test_helper"
+require "stripe"
+
+class PayoutServiceTest < ActiveSupport::TestCase
+ setup do
+ @user = users(:one)
+ @event = events(:concert_event)
+ @payout = Payout.create!(user: @user, event: @event, amount_cents: 9000, fee_cents: 1000)
+ Stripe.api_key = "test_key"
+ end
+
+ test "process! success creates transfer and updates status" do
+ # Mock Stripe Transfer
+ Stripe::Transfer.expects(:create).with(
+ amount: 90, # cents to euros
+ currency: "eur",
+ destination: @user.stripe_connected_account_id,
+ description: "Payout for event #{@event.name}"
+ ).returns(stub(id: "tr_123", status: "succeeded"))
+
+ @payout.update(status: :pending)
+
+ service = PayoutService.new(@payout)
+ service.process!
+
+ @payout.reload
+ assert_equal :completed, @payout.status
+ assert_equal "tr_123", @payout.stripe_payout_id
+ assert @payout.earnings.update_all(status: :paid) # assume update_earnings_status
+ end
+
+ test "process! failure with Stripe error sets status to failed" do
+ Stripe::Transfer.expects(:create).raises(Stripe::CardError.new("Insufficient funds"))
+
+ @payout.update(status: :pending)
+
+ service = PayoutService.new(@payout)
+ assert_raises Stripe::CardError do
+ service.process!
+ end
+
+ @payout.reload
+ assert_equal :failed, @payout.status
+ assert_not_nil @payout.error_message # assume logged
+ end
+
+ test "process! idempotent for already completed" do
+ @payout.update(status: :completed, stripe_payout_id: "tr_456")
+
+ Stripe::Transfer.expects(:create).never
+
+ service = PayoutService.new(@payout)
+ service.process!
+
+ @payout.reload
+ assert_equal :completed, @payout.status
+ end
+
+ test "update_earnings_status marks earnings as paid" do
+ earning1 = Earning.create!(event: @event, user: @user, order: orders(:one), amount_cents: 4500, fee_cents: 500, status: :pending)
+ earning2 = Earning.create!(event: @event, user: @user, order: orders(:two), amount_cents: 4500, fee_cents: 500, status: :pending)
+ @payout.earnings << earning1
+ @payout.earnings << earning2
+
+ service = PayoutService.new(@payout)
+ service.update_earnings_status(:paid)
+
+ assert_equal :paid, earning1.reload.status
+ assert_equal :paid, earning2.reload.status
+ end
+end