feat(payouts): implement promoter earnings viewing, request flow, and admin Stripe processing with webhooks
Add model methods for accurate net calculations (€0.50 + 1.5% fees), eligibility, refund handling Update promoter/payouts controller for index (pending events), create (eligibility checks) Integrate admin processing via Stripe::Transfer, webhook for status sync Enhance views: index pending cards, events/show preview/form Add comprehensive tests (models, controllers, service, integration); run migrations
This commit is contained in:
@@ -3,20 +3,26 @@ 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])
|
||||
end
|
||||
|
||||
def process
|
||||
@payout = Payout.find(params[:id])
|
||||
|
||||
if @payout.pending? && @payout.can_process?
|
||||
begin
|
||||
@payout.process_payout!
|
||||
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
|
||||
|
||||
private
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
34
app/controllers/webhooks/stripe_controller.rb
Normal file
34
app/controllers/webhooks/stripe_controller.rb
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 > ?",
|
||||
|
||||
@@ -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
|
||||
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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
private
|
||||
|
||||
def update_earnings_status
|
||||
@payout.event.earnings.where(status: 0).update_all(status: 1) # pending to paid
|
||||
end
|
||||
end
|
||||
47
app/views/promoter/events/_earnings_preview.html.erb
Normal file
47
app/views/promoter/events/_earnings_preview.html.erb
Normal file
@@ -0,0 +1,47 @@
|
||||
<% if @event.can_request_payout? %>
|
||||
<div class="space-y-4">
|
||||
<h4 class="text-lg font-medium text-gray-900">Aperçu des Revenus</h4>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-6">
|
||||
<!-- Gross -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<p class="text-sm font-medium text-gray-500">Revenus Bruts</p>
|
||||
<p class="text-2xl font-bold text-gray-900">
|
||||
<%= number_to_currency(@event.total_earnings_cents / 100.0, unit: '€', separator: ',', delimiter: '.') %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Fees -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<p class="text-sm font-medium text-gray-500">Frais Plateforme</p>
|
||||
<p class="text-2xl font-bold text-gray-900">
|
||||
-<%= number_to_currency(@event.total_fees_cents / 100.0, unit: '€', separator: ',', delimiter: '.') %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Net -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<p class="text-sm font-medium text-gray-500">Revenus Nets</p>
|
||||
<p class="text-2xl font-bold text-gray-900">
|
||||
<%= number_to_currency(@event.net_earnings_cents / 100.0, unit: '€', separator: ',', delimiter: '.') %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% 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 %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="bg-yellow-50 border border-yellow-200 rounded-xl p-4">
|
||||
<p class="text-sm text-yellow-800">Non éligible à la demande de paiement. L'événement n'est peut-être pas terminé ou le compte Stripe n'est pas vérifié.</p>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -290,74 +290,7 @@
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<!-- Payout section -->
|
||||
<% if @event.event_ended? && @event.can_request_payout? %>
|
||||
<hr class="border-gray-200">
|
||||
<div class="space-y-4">
|
||||
<h4 class="text-lg font-medium text-gray-900">Paiement des Revenus</h4>
|
||||
|
||||
<!-- Earnings Summary -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<p class="text-sm text-gray-500">Revenus Bruts</p>
|
||||
<p class="text-lg font-bold text-gray-900">€<%= @event.total_earnings_cents / 100.0 %></p>
|
||||
</div>
|
||||
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<p class="text-sm text-gray-500">Frais Plateforme</p>
|
||||
<p class="text-lg font-bold text-gray-900">-€<%= @event.total_fees_cents / 100.0 %></p>
|
||||
</div>
|
||||
|
||||
<div class="payout-summary-card">
|
||||
<p class="payout-summary-label">Revenus Nets</p>
|
||||
<p class="payout-summary-amount">€<%= @event.net_earnings_cents / 100.0 %></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payout Status -->
|
||||
<% if @event.payout_status != "not_requested" %>
|
||||
<div class="bg-blue-50 rounded-lg p-4 border border-blue-200">
|
||||
<div class="flex items-center">
|
||||
<% case @event.payout_status %>
|
||||
<% when "requested" %>
|
||||
<i data-lucide="clock" class="w-5 h-5 text-blue-500 mr-2"></i>
|
||||
<span class="font-medium text-blue-800">Paiement Demandé</span>
|
||||
<% when "processing" %>
|
||||
<i data-lucide="refresh-cw" class="w-5 h-5 text-blue-500 mr-2"></i>
|
||||
<span class="font-medium text-blue-800">Paiement en Traitement</span>
|
||||
<% when "completed" %>
|
||||
<i data-lucide="check-circle" class="w-5 h-5 text-green-500 mr-2"></i>
|
||||
<span class="font-medium text-green-800">Paiement Complété</span>
|
||||
<% when "failed" %>
|
||||
<i data-lucide="x-circle" class="w-5 h-5 text-red-500 mr-2"></i>
|
||||
<span class="font-medium text-red-800">Paiement Échoué</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 mt-1">Votre demande de paiement est en cours de traitement. Vous recevrez un email quand elle sera terminée.</p>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Payout Action -->
|
||||
<% 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 %>
|
||||
<i data-lucide="dollar-sign" class="w-5 h-5 mr-2"></i>
|
||||
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 %>
|
||||
<i data-lucide="refresh-ccw" class="w-5 h-5 mr-2"></i>
|
||||
Réessayer le Paiement
|
||||
<% end %>
|
||||
<% else %>
|
||||
<%= link_to "Voir les Détails du Paiement", promoter_payouts_path,
|
||||
class: "payout-action-button secondary" %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= render 'earnings_preview' %>
|
||||
|
||||
<hr class="border-gray-200">
|
||||
<%= button_to promoter_event_path(@event), method: :delete,
|
||||
|
||||
@@ -53,6 +53,65 @@
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<!-- Pending Earnings Section -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-8">
|
||||
<h2 class="text-lg font-medium text-gray-900 mb-4">Pending Earnings</h2>
|
||||
|
||||
<% if @total_pending_net && @total_pending_net > 0 %>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="p-2 bg-yellow-100 rounded-lg">
|
||||
<i data-lucide="dollar-sign" class="w-6 h-6 text-yellow-600"></i>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<p class="text-sm font-medium text-gray-500">Total Pending Net</p>
|
||||
<p class="text-2xl font-bold text-gray-900">
|
||||
<%= number_to_currency(@total_pending_net / 100.0, unit: '€', separator: ',', delimiter: '.') %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if @eligible_events.present? && @eligible_events.any? %>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<% @eligible_events.limit(5).each do |event| %>
|
||||
<div class="bg-white p-6 rounded-lg shadow border border-gray-200">
|
||||
<div class="flex items-center mb-3">
|
||||
<div class="flex-shrink-0 h-10 w-10 bg-gradient-to-br from-purple-500 to-pink-500 rounded-lg flex items-center justify-center">
|
||||
<i data-lucide="calendar" class="h-5 w-5 text-white"></i>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<h3 class="text-lg font-bold text-gray-900"><%= event.name %></h3>
|
||||
<p class="text-sm text-gray-500"><%= event.start_time.strftime("%d %b %Y") %></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2 text-sm">
|
||||
<p><span class="font-medium">Gross:</span> <%= number_to_currency(event.total_earnings_cents / 100.0, unit: '€', separator: ',', delimiter: '.') %></p>
|
||||
<p><span class="font-medium">Net:</span> <%= number_to_currency(event.net_earnings_cents / 100.0, unit: '€', separator: ',', delimiter: '.') %></p>
|
||||
</div>
|
||||
<%= 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" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% if @eligible_events.size > 5 %>
|
||||
<div class="text-center mt-4">
|
||||
<%= link_to "View All Eligible Events", promoter_events_path, class: "text-indigo-600 hover:text-indigo-500 text-sm font-medium" %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<div class="text-center py-12">
|
||||
<i data-lucide="inbox" class="mx-auto h-12 w-12 text-gray-400 mb-4"></i>
|
||||
<h3 class="text-lg font-medium text-gray-900 mb-2">No pending earnings</h3>
|
||||
<p class="text-gray-500">Check your events to see if any are eligible for payout requests.</p>
|
||||
<%= 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" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Payouts Table -->
|
||||
<% if @payouts.any? %>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
|
||||
@@ -59,17 +59,23 @@
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<p class="text-sm font-medium text-gray-500">Gross Amount</p>
|
||||
<p class="mt-1 text-2xl font-bold text-gray-900">€<%= @payout.amount_euros %></p>
|
||||
<p class="mt-1 text-2xl font-bold text-gray-900">
|
||||
<%= number_to_currency(@payout.amount_euros, unit: '€', separator: ',', delimiter: '.') %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<p class="text-sm font-medium text-gray-500">Platform Fees</p>
|
||||
<p class="mt-1 text-2xl font-bold text-gray-900">-€<%= @payout.fee_euros %></p>
|
||||
<p class="mt-1 text-2xl font-bold text-gray-900">
|
||||
-<%= number_to_currency(@payout.fee_euros, unit: '€', separator: ',', delimiter: '.') %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="payout-summary-card">
|
||||
<p class="payout-summary-label">Net Amount</p>
|
||||
<p class="payout-summary-amount">€<%= @payout.net_amount_euros %></p>
|
||||
<p class="payout-summary-amount">
|
||||
<%= number_to_currency(@payout.net_amount_euros, unit: '€', separator: ',', delimiter: '.') %>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddIndexToPayoutsOnEventIdAndStatus < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
add_index :payouts, [ :event_id, :status ]
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddIndexToEarningsOnStatus < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
add_index :earnings, :status
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddIndexToTicketsOnStatusAndOrderId < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
add_index :tickets, [ :status, :order_id ]
|
||||
end
|
||||
end
|
||||
5
db/schema.rb
generated
5
db/schema.rb
generated
@@ -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
|
||||
|
||||
|
||||
48
test/controllers/admin/payouts_controller_test.rb
Normal file
48
test/controllers/admin/payouts_controller_test.rb
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
40
test/fixtures/events.yml
vendored
40
test/fixtures/events.yml
vendored
@@ -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
|
||||
|
||||
20
test/fixtures/orders.yml
vendored
20
test/fixtures/orders.yml
vendored
@@ -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
|
||||
@@ -27,3 +37,13 @@ expired_order:
|
||||
expires_at: <%= 1.hour.ago %>
|
||||
created_at: <%= 2.hours.ago %>
|
||||
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 %>
|
||||
10
test/fixtures/ticket_types.yml
vendored
10
test/fixtures/ticket_types.yml
vendored
@@ -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
|
||||
|
||||
58
test/integration/payout_flow_test.rb
Normal file
58
test/integration/payout_flow_test.rb
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
109
test/models/payout_test.rb
Normal file
109
test/models/payout_test.rb
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
72
test/services/payout_service_test.rb
Normal file
72
test/services/payout_service_test.rb
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user