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!
|
before_action :ensure_admin!
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@payouts = Payout.includes(:event, :user)
|
@payouts = Payout.pending.includes(:user, :event).order(created_at: :asc).page(params[:page])
|
||||||
.order(created_at: :desc)
|
|
||||||
.page(params[:page])
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def show
|
||||||
|
@payout = Payout.find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def process
|
||||||
@payout = Payout.find(params[:id])
|
@payout = Payout.find(params[:id])
|
||||||
|
|
||||||
|
if @payout.pending? && @payout.can_process?
|
||||||
begin
|
begin
|
||||||
@payout.process_payout!
|
PayoutService.new(@payout).process!
|
||||||
redirect_to admin_payouts_path, notice: "Payout processed successfully."
|
redirect_to admin_payouts_path, notice: "Payout processed successfully."
|
||||||
rescue => e
|
rescue => e
|
||||||
redirect_to admin_payouts_path, alert: "Failed to process payout: #{e.message}"
|
redirect_to admin_payouts_path, alert: "Failed to process payout: #{e.message}"
|
||||||
end
|
end
|
||||||
|
else
|
||||||
|
redirect_to admin_payouts_path, alert: "Cannot process this payout."
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||
@@ -1,54 +1,61 @@
|
|||||||
class Promoter::PayoutsController < ApplicationController
|
class Promoter::PayoutsController < ApplicationController
|
||||||
before_action :authenticate_user!
|
before_action :authenticate_user!
|
||||||
before_action :ensure_promoter!
|
before_action :ensure_promoter!
|
||||||
before_action :set_event, only: [:show, :create]
|
before_action :set_event, only: [ :create ]
|
||||||
|
|
||||||
# List all payouts for the current promoter
|
# List all payouts for the current promoter
|
||||||
def index
|
def index
|
||||||
@payouts = current_user.payouts
|
@payouts = current_user.payouts.completed.order(created_at: :desc).page(params[:page])
|
||||||
.includes(:event)
|
|
||||||
.order(created_at: :desc)
|
@eligible_events = current_user.events.eligible_for_payout.includes(:earnings).limit(5)
|
||||||
.page(params[:page])
|
@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
|
end
|
||||||
|
|
||||||
# Show payout details
|
# Show payout details
|
||||||
def show
|
def show
|
||||||
@payout = @event.payouts.find(params[:id])
|
@payout = current_user.payouts.find(params[:id])
|
||||||
|
@event = @payout.event
|
||||||
end
|
end
|
||||||
|
|
||||||
# Create a new payout request
|
# Create a new payout request
|
||||||
def create
|
def create
|
||||||
# Check if event can request payout
|
# Check if event can request payout
|
||||||
unless @event.can_request_payout?
|
unless @event.can_request_payout?(current_user)
|
||||||
redirect_to promoter_event_path(@event), alert: "Payout cannot be requested for this event."
|
redirect_to event_path(@event.slug, @event), alert: "Payout cannot be requested for this event."
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
# Calculate payout amount
|
# Calculate payout amount using model methods
|
||||||
total_earnings_cents = @event.total_earnings_cents
|
gross = @event.total_gross_cents
|
||||||
total_fees_cents = @event.total_fees_cents
|
fees = @event.total_fees_cents
|
||||||
net_earnings_cents = @event.net_earnings_cents
|
|
||||||
|
|
||||||
# Count orders
|
# Count orders using model scope
|
||||||
total_orders_count = @event.orders.where(status: ['paid', 'completed']).count
|
total_orders_count = @event.orders.paid.count
|
||||||
refunded_orders_count = @event.tickets.where(status: 'refunded').joins(:order).where(orders: {status: ['paid', 'completed']}).count
|
|
||||||
|
|
||||||
# Create payout record
|
# Create payout record
|
||||||
@payout = @event.payouts.build(
|
@payout = @event.payouts.build(
|
||||||
user: current_user,
|
user: current_user,
|
||||||
amount_cents: total_earnings_cents,
|
amount_cents: gross,
|
||||||
fee_cents: total_fees_cents,
|
fee_cents: fees,
|
||||||
total_orders_count: total_orders_count,
|
total_orders_count: total_orders_count
|
||||||
refunded_orders_count: refunded_orders_count
|
|
||||||
)
|
)
|
||||||
|
# refunded_orders_count will be set by model callback
|
||||||
|
|
||||||
if @payout.save
|
if @payout.save
|
||||||
# Update event payout status
|
# Update event payout status
|
||||||
@event.update!(payout_status: :requested, payout_requested_at: Time.current)
|
@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."
|
redirect_to promoter_payout_path(@payout), notice: "Payout request submitted successfully."
|
||||||
else
|
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
|
||||||
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
|
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 ===
|
# === Relations ===
|
||||||
belongs_to :event
|
belongs_to :event
|
||||||
belongs_to :user
|
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 :net_amount_cents, numericality: { greater_than_or_equal_to: 0, allow_nil: true }
|
||||||
validates :status, presence: true
|
validates :status, presence: true
|
||||||
validates :stripe_payout_id, allow_blank: true, uniqueness: 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
|
end
|
||||||
|
|||||||
@@ -66,24 +66,26 @@ class Event < ApplicationRecord
|
|||||||
# Scope for published events ordered by start time
|
# Scope for published events ordered by start time
|
||||||
scope :upcoming, -> { published.where("start_time >= ?", Time.current).order(start_time: :asc) }
|
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 ===
|
# === Instance Methods ===
|
||||||
|
|
||||||
# Payout methods
|
# Payout methods
|
||||||
def can_request_payout?
|
def total_gross_cents
|
||||||
event_ended? && earnings.pending.any? && user.can_receive_payouts?
|
tickets.active.sum(:price_cents)
|
||||||
end
|
|
||||||
|
|
||||||
def total_earnings_cents
|
|
||||||
# Only count earnings from non-refunded tickets
|
|
||||||
earnings.pending.sum(:amount_cents)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def total_fees_cents
|
def total_fees_cents
|
||||||
(total_earnings_cents * 0.1).to_i # 10% platform fee
|
earnings.pending.sum(:fee_cents)
|
||||||
end
|
end
|
||||||
|
|
||||||
def net_earnings_cents
|
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
|
end
|
||||||
|
|
||||||
# Check if coordinates were successfully geocoded or are fallback coordinates
|
# Check if coordinates were successfully geocoded or are fallback coordinates
|
||||||
|
|||||||
@@ -3,6 +3,16 @@ class Order < ApplicationRecord
|
|||||||
DRAFT_EXPIRY_TIME = 15.minutes
|
DRAFT_EXPIRY_TIME = 15.minutes
|
||||||
MAX_PAYMENT_ATTEMPTS = 3
|
MAX_PAYMENT_ATTEMPTS = 3
|
||||||
|
|
||||||
|
# === Enums ===
|
||||||
|
enum :status, {
|
||||||
|
draft: 0,
|
||||||
|
pending_payment: 1,
|
||||||
|
paid: 2,
|
||||||
|
completed: 3,
|
||||||
|
cancelled: 4,
|
||||||
|
expired: 5
|
||||||
|
}, default: :draft
|
||||||
|
|
||||||
# === Associations ===
|
# === Associations ===
|
||||||
belongs_to :user
|
belongs_to :user
|
||||||
belongs_to :event
|
belongs_to :event
|
||||||
@@ -23,8 +33,9 @@ class Order < ApplicationRecord
|
|||||||
attr_accessor :stripe_invoice_id
|
attr_accessor :stripe_invoice_id
|
||||||
|
|
||||||
# === Scopes ===
|
# === Scopes ===
|
||||||
scope :draft, -> { where(status: "draft") }
|
scope :draft, -> { where(status: :draft) }
|
||||||
scope :active, -> { where(status: %w[paid completed]) }
|
scope :active, -> { where(status: [ :paid, :completed ]) }
|
||||||
|
scope :paid, -> { where(status: :paid) }
|
||||||
scope :expired_drafts, -> { draft.where("expires_at < ?", Time.current) }
|
scope :expired_drafts, -> { draft.where("expires_at < ?", Time.current) }
|
||||||
scope :can_retry_payment, -> {
|
scope :can_retry_payment, -> {
|
||||||
draft.where("payment_attempts < ? AND expires_at > ?",
|
draft.where("payment_attempts < ? AND expires_at > ?",
|
||||||
|
|||||||
@@ -12,18 +12,44 @@ class Payout < ApplicationRecord
|
|||||||
}, default: :pending
|
}, default: :pending
|
||||||
|
|
||||||
# === Validations ===
|
# === 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 :fee_cents, presence: true, numericality: { greater_than_or_equal_to: 0 }
|
||||||
validates :status, presence: true
|
validates :status, presence: true
|
||||||
validates :total_orders_count, presence: true, numericality: { greater_than_or_equal_to: 0 }
|
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 :refunded_orders_count, presence: true, numericality: { greater_than_or_equal_to: 0 }
|
||||||
validates :stripe_payout_id, allow_blank: true, uniqueness: true
|
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 ===
|
# === Scopes ===
|
||||||
scope :completed, -> { where(status: :completed) }
|
scope :completed, -> { where(status: :completed) }
|
||||||
scope :pending, -> { where(status: :pending) }
|
scope :pending, -> { where(status: :pending) }
|
||||||
scope :processing, -> { where(status: :processing) }
|
scope :processing, -> { where(status: :processing) }
|
||||||
|
|
||||||
|
# === Callbacks ===
|
||||||
|
after_create :calculate_refunded_orders_count
|
||||||
|
|
||||||
# === Instance Methods ===
|
# === Instance Methods ===
|
||||||
|
|
||||||
# Amount in euros (formatted)
|
# Amount in euros (formatted)
|
||||||
@@ -56,4 +82,14 @@ class Payout < ApplicationRecord
|
|||||||
service = PayoutService.new(self)
|
service = PayoutService.new(self)
|
||||||
service.process!
|
service.process!
|
||||||
end
|
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
|
end
|
||||||
@@ -22,6 +22,8 @@ class Ticket < ApplicationRecord
|
|||||||
before_validation :set_price_from_ticket_type, on: :create
|
before_validation :set_price_from_ticket_type, on: :create
|
||||||
before_validation :generate_qr_code, on: :create
|
before_validation :generate_qr_code, on: :create
|
||||||
|
|
||||||
|
after_update :recalculate_earning_if_refunded, if: :saved_change_to_status?
|
||||||
|
|
||||||
# Generate PDF ticket
|
# Generate PDF ticket
|
||||||
def to_pdf
|
def to_pdf
|
||||||
TicketPdfGenerator.new(self).generate
|
TicketPdfGenerator.new(self).generate
|
||||||
@@ -73,4 +75,12 @@ class Ticket < ApplicationRecord
|
|||||||
def draft?
|
def draft?
|
||||||
status == "draft"
|
status == "draft"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def recalculate_earning_if_refunded
|
||||||
|
if status == "refunded"
|
||||||
|
order.earning&.recalculate!
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -65,6 +65,19 @@ class User < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
def can_receive_payouts?
|
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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -8,23 +8,32 @@ class PayoutService
|
|||||||
|
|
||||||
@payout.update!(status: :processing)
|
@payout.update!(status: :processing)
|
||||||
|
|
||||||
# Create Stripe payout
|
|
||||||
begin
|
begin
|
||||||
stripe_payout = Stripe::Payout.create({
|
net_amount = @payout.amount_cents - @payout.fee_cents
|
||||||
amount: @payout.amount_cents,
|
transfer = Stripe::Transfer.create({
|
||||||
currency: 'eur',
|
amount: (net_amount / 100.0).to_i,
|
||||||
destination: @payout.user.stripe_account_id,
|
currency: "eur",
|
||||||
description: "Payout for event: #{@payout.event.name}"
|
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!(
|
@payout.update!(
|
||||||
status: :completed,
|
status: :completed,
|
||||||
stripe_payout_id: stripe_payout.id
|
stripe_payout_id: transfer.id
|
||||||
)
|
)
|
||||||
|
|
||||||
|
update_earnings_status
|
||||||
rescue Stripe::StripeError => e
|
rescue Stripe::StripeError => e
|
||||||
@payout.update!(status: :failed)
|
@payout.update!(status: :failed)
|
||||||
Rails.logger.error "Stripe payout failed for payout #{@payout.id}: #{e.message}"
|
Rails.logger.error "Stripe payout failed for payout #{@payout.id}: #{e.message}"
|
||||||
raise e
|
raise e
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def update_earnings_status
|
||||||
|
@payout.event.earnings.where(status: 0).update_all(status: 1) # pending to paid
|
||||||
|
end
|
||||||
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 %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<!-- Payout section -->
|
<%= render 'earnings_preview' %>
|
||||||
<% 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 %>
|
|
||||||
|
|
||||||
<hr class="border-gray-200">
|
<hr class="border-gray-200">
|
||||||
<%= button_to promoter_event_path(@event), method: :delete,
|
<%= button_to promoter_event_path(@event), method: :delete,
|
||||||
|
|||||||
@@ -53,6 +53,65 @@
|
|||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% 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 -->
|
<!-- Payouts Table -->
|
||||||
<% if @payouts.any? %>
|
<% if @payouts.any? %>
|
||||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
<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="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">
|
<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="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>
|
||||||
|
|
||||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
<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="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>
|
||||||
|
|
||||||
<div class="payout-summary-card">
|
<div class="payout-summary-card">
|
||||||
<p class="payout-summary-label">Net Amount</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
Rails.application.routes.draw do
|
Rails.application.routes.draw do
|
||||||
namespace :admin do
|
namespace :admin do
|
||||||
resources :payouts, only: [ :index, :create ]
|
resources :payouts, only: [ :index, :show ] do
|
||||||
|
member do
|
||||||
|
post :process
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
|
# 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 ===
|
# === Promoter Routes ===
|
||||||
namespace :promoter do
|
namespace :promoter do
|
||||||
resources :payouts, only: [:index, :show, :create]
|
resources :payouts, only: [ :index, :show, :create ]
|
||||||
resources :events do
|
resources :events do
|
||||||
member do
|
member do
|
||||||
patch :publish
|
patch :publish
|
||||||
@@ -115,4 +119,6 @@ Rails.application.routes.draw do
|
|||||||
# resources :ticket_types, only: [ :index, :show, :create, :update, :destroy ]
|
# resources :ticket_types, only: [ :index, :show, :create, :update, :destroy ]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
post "/webhooks/stripe", to: "webhooks/stripe#create"
|
||||||
end
|
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.
|
# 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|
|
create_table "earnings", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
|
||||||
t.integer "amount_cents"
|
t.integer "amount_cents"
|
||||||
t.integer "fee_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.datetime "updated_at", null: false
|
||||||
t.index ["event_id"], name: "index_earnings_on_event_id"
|
t.index ["event_id"], name: "index_earnings_on_event_id"
|
||||||
t.index ["order_id"], name: "index_earnings_on_order_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"
|
t.index ["user_id"], name: "index_earnings_on_user_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -81,6 +82,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_16_221454) do
|
|||||||
t.bigint "event_id", null: false
|
t.bigint "event_id", null: false
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_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 ["event_id"], name: "index_payouts_on_event_id"
|
||||||
t.index ["status"], name: "index_payouts_on_status"
|
t.index ["status"], name: "index_payouts_on_status"
|
||||||
t.index ["stripe_payout_id"], name: "index_payouts_on_stripe_payout_id", unique: true
|
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.datetime "updated_at", null: false
|
||||||
t.index ["order_id"], name: "index_tickets_on_order_id"
|
t.index ["order_id"], name: "index_tickets_on_order_id"
|
||||||
t.index ["qr_code"], name: "index_tickets_on_qr_code", unique: true
|
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"
|
t.index ["ticket_type_id"], name: "index_tickets_on_ticket_type_id"
|
||||||
end
|
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,
|
fee_cents: 100,
|
||||||
status: :pending
|
status: :pending
|
||||||
)
|
)
|
||||||
assert_difference('Payout.count', 1) do
|
assert_difference("Payout.count", 1) do
|
||||||
post promoter_payouts_url, params: { event_id: @event.id }
|
post promoter_payouts_url, params: { event_id: @event.id }
|
||||||
end
|
end
|
||||||
assert_redirected_to promoter_payout_path(Payout.last)
|
assert_redirected_to promoter_payout_path(Payout.last)
|
||||||
end
|
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
|
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
|
# 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:
|
concert_event:
|
||||||
name: Summer Concert
|
name: Summer Concert
|
||||||
slug: summer-concert
|
slug: summer-concert
|
||||||
@@ -25,3 +39,29 @@ winter_gala:
|
|||||||
start_time: <%= 2.weeks.from_now %>
|
start_time: <%= 2.weeks.from_now %>
|
||||||
end_time: <%= 2.weeks.from_now + 6.hours %>
|
end_time: <%= 2.weeks.from_now + 6.hours %>
|
||||||
user: two
|
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:
|
paid_order:
|
||||||
user: one
|
user: one
|
||||||
event: concert_event
|
event: concert_event
|
||||||
@@ -27,3 +37,13 @@ expired_order:
|
|||||||
expires_at: <%= 1.hour.ago %>
|
expires_at: <%= 1.hour.ago %>
|
||||||
created_at: <%= 2.hours.ago %>
|
created_at: <%= 2.hours.ago %>
|
||||||
updated_at: <%= 1.hour.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
|
# 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:
|
standard:
|
||||||
name: General Admission
|
name: General Admission
|
||||||
description: General admission ticket for the event
|
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_not_includes Earning.paid, pending_earning
|
||||||
assert_includes Earning.paid, paid_earning
|
assert_includes Earning.paid, paid_earning
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -317,4 +317,142 @@ class EventTest < ActiveSupport::TestCase
|
|||||||
# Check that ticket types were NOT duplicated
|
# Check that ticket types were NOT duplicated
|
||||||
assert_equal 0, duplicated_event.ticket_types.count
|
assert_equal 0, duplicated_event.ticket_types.count
|
||||||
end
|
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
|
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
|
assert ticket.save
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -92,4 +92,47 @@ class UserTest < ActiveSupport::TestCase
|
|||||||
user.update!(onboarding_completed: true)
|
user.update!(onboarding_completed: true)
|
||||||
assert_not user.needs_onboarding?, "User with true onboarding_completed should not need onboarding"
|
assert_not user.needs_onboarding?, "User with true onboarding_completed should not need onboarding"
|
||||||
end
|
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
|
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