Compare commits
2 Commits
feature/pr
...
1889ee7fb2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1889ee7fb2 | ||
|
|
3c1e17c2af |
@@ -1,26 +1,77 @@
|
|||||||
class Admin::PayoutsController < ApplicationController
|
class Admin::PayoutsController < ApplicationController
|
||||||
before_action :authenticate_user!
|
before_action :authenticate_user!
|
||||||
before_action :ensure_admin!
|
before_action :ensure_admin!
|
||||||
|
before_action :set_payout, only: [:show, :approve, :reject, :mark_processing, :mark_completed, :mark_failed]
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@payouts = Payout.includes(:event, :user)
|
@pending_payouts = Payout.pending.includes(:user, :event).order(created_at: :asc)
|
||||||
.order(created_at: :desc)
|
@approved_payouts = Payout.approved.includes(:user, :event).order(created_at: :asc)
|
||||||
.page(params[:page])
|
@processing_payouts = Payout.processing.includes(:user, :event).order(created_at: :asc)
|
||||||
|
@completed_payouts = Payout.completed.includes(:user, :event).order(created_at: :desc).limit(10)
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def show
|
||||||
@payout = Payout.find(params[:id])
|
@service = PayoutService.new(@payout)
|
||||||
|
@transfer_summary = @service.generate_transfer_summary
|
||||||
|
@banking_errors = @service.validate_banking_info
|
||||||
|
end
|
||||||
|
|
||||||
begin
|
def approve
|
||||||
@payout.process_payout!
|
if @payout.approve!(current_user)
|
||||||
redirect_to admin_payouts_path, notice: "Payout processed successfully."
|
redirect_to admin_payout_path(@payout), notice: "Payout approved successfully."
|
||||||
rescue => e
|
else
|
||||||
redirect_to admin_payouts_path, alert: "Failed to process payout: #{e.message}"
|
redirect_to admin_payout_path(@payout), alert: "Cannot approve this payout."
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def reject
|
||||||
|
reason = params[:rejection_reason].presence || "No reason provided"
|
||||||
|
if @payout.reject!(current_user, reason)
|
||||||
|
redirect_to admin_payouts_path, notice: "Payout rejected."
|
||||||
|
else
|
||||||
|
redirect_to admin_payout_path(@payout), alert: "Cannot reject this payout."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def mark_processing
|
||||||
|
transfer_reference = params[:bank_transfer_reference]
|
||||||
|
if @payout.mark_processing!(current_user, transfer_reference)
|
||||||
|
redirect_to admin_payout_path(@payout), notice: "Payout marked as processing."
|
||||||
|
else
|
||||||
|
redirect_to admin_payout_path(@payout), alert: "Cannot mark payout as processing."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def mark_completed
|
||||||
|
transfer_reference = params[:bank_transfer_reference]
|
||||||
|
if @payout.mark_completed!(current_user, transfer_reference)
|
||||||
|
redirect_to admin_payouts_path, notice: "Payout completed successfully."
|
||||||
|
else
|
||||||
|
redirect_to admin_payout_path(@payout), alert: "Cannot mark payout as completed."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def mark_failed
|
||||||
|
reason = params[:failure_reason].presence || "Transfer failed"
|
||||||
|
if @payout.mark_failed!(current_user, reason)
|
||||||
|
redirect_to admin_payouts_path, notice: "Payout marked as failed."
|
||||||
|
else
|
||||||
|
redirect_to admin_payout_path(@payout), alert: "Cannot mark payout as failed."
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Legacy method - redirect to new workflow
|
||||||
|
def process
|
||||||
|
@payout = Payout.find(params[:id])
|
||||||
|
redirect_to admin_payout_path(@payout), alert: "Use the new manual payout workflow."
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def set_payout
|
||||||
|
@payout = Payout.find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
def ensure_admin!
|
def ensure_admin!
|
||||||
# For now, we'll just check if the user has a stripe account
|
# For now, we'll just check if the user has a stripe account
|
||||||
# In a real app, you'd have an admin role check
|
# In a real app, you'd have an admin role check
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -17,12 +17,12 @@ class Event < ApplicationRecord
|
|||||||
}, default: :draft
|
}, default: :draft
|
||||||
|
|
||||||
enum :payout_status, {
|
enum :payout_status, {
|
||||||
not_requested: 0,
|
pending_request: 0,
|
||||||
requested: 1,
|
requested: 1,
|
||||||
processing: 2,
|
processing: 2,
|
||||||
completed: 3,
|
completed: 3,
|
||||||
failed: 4
|
failed: 4
|
||||||
}, default: :not_requested
|
}, default: :pending_request
|
||||||
|
|
||||||
# === Relations ===
|
# === Relations ===
|
||||||
belongs_to :user
|
belongs_to :user
|
||||||
@@ -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 > ?",
|
||||||
|
|||||||
@@ -2,27 +2,59 @@ class Payout < ApplicationRecord
|
|||||||
# === Relations ===
|
# === Relations ===
|
||||||
belongs_to :user
|
belongs_to :user
|
||||||
belongs_to :event
|
belongs_to :event
|
||||||
|
belongs_to :processed_by, class_name: 'User', optional: true
|
||||||
|
|
||||||
# === Enums ===
|
# === Enums ===
|
||||||
enum :status, {
|
enum :status, {
|
||||||
pending: 0, # Payout requested but not processed
|
pending: 0, # Payout requested but not reviewed
|
||||||
processing: 1, # Payout being processed
|
approved: 1, # Payout approved by admin, ready for transfer
|
||||||
completed: 2, # Payout successfully completed
|
processing: 2, # Payout being processed (bank transfer initiated)
|
||||||
failed: 3 # Payout failed
|
completed: 3, # Payout successfully completed
|
||||||
|
failed: 4, # Payout failed
|
||||||
|
rejected: 5 # Payout rejected by admin
|
||||||
}, 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 :approved, -> { where(status: :approved) }
|
||||||
scope :processing, -> { where(status: :processing) }
|
scope :processing, -> { where(status: :processing) }
|
||||||
|
scope :rejected, -> { where(status: :rejected) }
|
||||||
|
scope :failed, -> { where(status: :failed) }
|
||||||
|
|
||||||
|
# === Callbacks ===
|
||||||
|
after_create :calculate_refunded_orders_count
|
||||||
|
|
||||||
# === Instance Methods ===
|
# === Instance Methods ===
|
||||||
|
|
||||||
@@ -46,14 +78,83 @@ class Payout < ApplicationRecord
|
|||||||
net_amount_cents / 100.0
|
net_amount_cents / 100.0
|
||||||
end
|
end
|
||||||
|
|
||||||
# Check if payout can be processed
|
# Check if payout can be approved (was pending)
|
||||||
def can_process?
|
def can_approve?
|
||||||
pending? && amount_cents > 0
|
pending? && amount_cents > 0 && user.has_complete_banking_info?
|
||||||
end
|
end
|
||||||
|
|
||||||
# Process the payout through Stripe
|
# Check if payout can be manually processed (was approved)
|
||||||
def process_payout!
|
def can_process?
|
||||||
service = PayoutService.new(self)
|
approved? && amount_cents > 0
|
||||||
service.process!
|
end
|
||||||
|
|
||||||
|
# Check if payout can be rejected
|
||||||
|
def can_reject?
|
||||||
|
pending?
|
||||||
|
end
|
||||||
|
|
||||||
|
# Approve the payout for manual processing
|
||||||
|
def approve!(admin_user)
|
||||||
|
return false unless can_approve?
|
||||||
|
update!(
|
||||||
|
status: :approved,
|
||||||
|
processed_by: admin_user,
|
||||||
|
processed_at: Time.current
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Reject the payout with reason
|
||||||
|
def reject!(admin_user, reason)
|
||||||
|
return false unless can_reject?
|
||||||
|
update!(
|
||||||
|
status: :rejected,
|
||||||
|
processed_by: admin_user,
|
||||||
|
processed_at: Time.current,
|
||||||
|
rejection_reason: reason
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Mark as processing (bank transfer initiated)
|
||||||
|
def mark_processing!(admin_user, transfer_reference = nil)
|
||||||
|
return false unless can_process?
|
||||||
|
update!(
|
||||||
|
status: :processing,
|
||||||
|
processed_by: admin_user,
|
||||||
|
processed_at: Time.current,
|
||||||
|
bank_transfer_reference: transfer_reference
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Mark as completed (bank transfer confirmed)
|
||||||
|
def mark_completed!(admin_user, transfer_reference = nil)
|
||||||
|
return false unless processing?
|
||||||
|
update!(
|
||||||
|
status: :completed,
|
||||||
|
processed_by: admin_user,
|
||||||
|
processed_at: Time.current,
|
||||||
|
bank_transfer_reference: transfer_reference || bank_transfer_reference
|
||||||
|
)
|
||||||
|
update_earnings_status
|
||||||
|
end
|
||||||
|
|
||||||
|
# Mark as failed
|
||||||
|
def mark_failed!(admin_user, reason)
|
||||||
|
return false unless processing?
|
||||||
|
update!(
|
||||||
|
status: :failed,
|
||||||
|
processed_by: admin_user,
|
||||||
|
processed_at: Time.current,
|
||||||
|
rejection_reason: reason
|
||||||
|
)
|
||||||
|
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
|
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
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ class User < ApplicationRecord
|
|||||||
validates :first_name, length: { minimum: 2, maximum: 50, allow_blank: true }
|
validates :first_name, length: { minimum: 2, maximum: 50, allow_blank: true }
|
||||||
validates :company_name, length: { minimum: 2, maximum: 100, allow_blank: true }
|
validates :company_name, length: { minimum: 2, maximum: 100, allow_blank: true }
|
||||||
|
|
||||||
|
# Banking information validations
|
||||||
|
validates :iban, format: { with: /\A[A-Z]{2}[0-9]{2}[A-Z0-9]{4}[0-9]{7}([A-Z0-9]?){0,16}\z/, message: "must be a valid IBAN format" }, allow_blank: true
|
||||||
|
validates :bank_name, length: { minimum: 2, maximum: 100 }, allow_blank: true
|
||||||
|
validates :account_holder_name, length: { minimum: 2, maximum: 100 }, allow_blank: true
|
||||||
|
|
||||||
# Onboarding methods
|
# Onboarding methods
|
||||||
def needs_onboarding?
|
def needs_onboarding?
|
||||||
!onboarding_completed?
|
!onboarding_completed?
|
||||||
@@ -65,6 +70,29 @@ class User < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
def can_receive_payouts?
|
def can_receive_payouts?
|
||||||
has_stripe_account? && promoter?
|
has_complete_banking_info?
|
||||||
|
end
|
||||||
|
|
||||||
|
# Banking information methods
|
||||||
|
def has_complete_banking_info?
|
||||||
|
iban.present? && bank_name.present? && account_holder_name.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def banking_info_summary
|
||||||
|
return "No banking information" unless has_complete_banking_info?
|
||||||
|
"#{account_holder_name} - #{bank_name} - #{iban}"
|
||||||
|
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
|
||||||
|
|||||||
@@ -3,28 +3,52 @@ class PayoutService
|
|||||||
@payout = payout
|
@payout = payout
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Legacy method for backward compatibility - now redirects to manual workflow
|
||||||
def process!
|
def process!
|
||||||
return unless @payout.can_process?
|
Rails.logger.warn "PayoutService#process! called - manual processing required for payout #{@payout.id}"
|
||||||
|
raise "Automatic payout processing is disabled. Use manual workflow in admin interface."
|
||||||
|
end
|
||||||
|
|
||||||
@payout.update!(status: :processing)
|
# Generate payout summary for manual transfer
|
||||||
|
def generate_transfer_summary
|
||||||
|
return nil unless @payout.approved? || @payout.processing?
|
||||||
|
|
||||||
# Create Stripe payout
|
{
|
||||||
begin
|
payout_id: @payout.id,
|
||||||
stripe_payout = Stripe::Payout.create({
|
recipient: @payout.user.name,
|
||||||
amount: @payout.amount_cents,
|
account_holder: @payout.user.account_holder_name,
|
||||||
currency: 'eur',
|
bank_name: @payout.user.bank_name,
|
||||||
destination: @payout.user.stripe_account_id,
|
iban: @payout.user.iban,
|
||||||
description: "Payout for event: #{@payout.event.name}"
|
amount_euros: @payout.net_amount_euros,
|
||||||
})
|
description: "Payout for event: #{@payout.event.name}",
|
||||||
|
event_name: @payout.event.name,
|
||||||
|
event_date: @payout.event.date,
|
||||||
|
total_orders: @payout.total_orders_count,
|
||||||
|
refunded_orders: @payout.refunded_orders_count
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
@payout.update!(
|
# Validate banking information before processing
|
||||||
status: :completed,
|
def validate_banking_info
|
||||||
stripe_payout_id: stripe_payout.id
|
errors = []
|
||||||
)
|
user = @payout.user
|
||||||
rescue Stripe::StripeError => e
|
|
||||||
@payout.update!(status: :failed)
|
errors << "Missing IBAN" unless user.iban.present?
|
||||||
Rails.logger.error "Stripe payout failed for payout #{@payout.id}: #{e.message}"
|
errors << "Missing bank name" unless user.bank_name.present?
|
||||||
raise e
|
errors << "Missing account holder name" unless user.account_holder_name.present?
|
||||||
end
|
errors << "Invalid IBAN format" if user.iban.present? && !valid_iban?(user.iban)
|
||||||
|
|
||||||
|
errors
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def valid_iban?(iban)
|
||||||
|
# Basic IBAN validation (simplified)
|
||||||
|
iban.match?(/\A[A-Z]{2}[0-9]{2}[A-Z0-9]{4}[0-9]{7}([A-Z0-9]?){0,16}\z/)
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_earnings_status
|
||||||
|
@payout.event.earnings.where(status: 0).update_all(status: 1) # pending to paid
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
96
app/views/admin/payouts/_payout_table.html.erb
Normal file
96
app/views/admin/payouts/_payout_table.html.erb
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Event</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Promoter</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Banking Info</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Amount</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
|
||||||
|
<% if show_actions %>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||||
|
<% end %>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white divide-y divide-gray-200">
|
||||||
|
<% payouts.each do |payout| %>
|
||||||
|
<tr>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div class="text-sm font-medium text-gray-900"><%= payout.event.name %></div>
|
||||||
|
<div class="text-sm text-gray-500"><%= payout.event.date.strftime("%b %d, %Y") if payout.event.date %></div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div class="text-sm text-gray-900"><%= payout.user.name.presence || payout.user.email %></div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<% if payout.user.has_complete_banking_info? %>
|
||||||
|
<div class="text-sm text-gray-900">✅ Complete</div>
|
||||||
|
<div class="text-sm text-gray-500"><%= payout.user.bank_name %></div>
|
||||||
|
<% else %>
|
||||||
|
<div class="text-sm text-red-600">❌ Incomplete</div>
|
||||||
|
<div class="text-sm text-gray-500">Missing banking info</div>
|
||||||
|
<% end %>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div class="text-sm text-gray-900">€<%= payout.amount_euros %></div>
|
||||||
|
<div class="text-sm text-gray-500">Net: €<%= payout.net_amount_euros %></div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<% case payout.status %>
|
||||||
|
<% when 'pending' %>
|
||||||
|
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-yellow-100 text-yellow-800">
|
||||||
|
Pending Review
|
||||||
|
</span>
|
||||||
|
<% when 'approved' %>
|
||||||
|
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-blue-100 text-blue-800">
|
||||||
|
Approved
|
||||||
|
</span>
|
||||||
|
<% when 'processing' %>
|
||||||
|
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-indigo-100 text-indigo-800">
|
||||||
|
Processing
|
||||||
|
</span>
|
||||||
|
<% when 'completed' %>
|
||||||
|
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
|
||||||
|
Completed
|
||||||
|
</span>
|
||||||
|
<% when 'failed' %>
|
||||||
|
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800">
|
||||||
|
Failed
|
||||||
|
</span>
|
||||||
|
<% when 'rejected' %>
|
||||||
|
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-gray-100 text-gray-800">
|
||||||
|
Rejected
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
<%= payout.created_at.strftime("%b %d, %Y") %>
|
||||||
|
<% if payout.processed_at %>
|
||||||
|
<div class="text-xs text-gray-400">Processed: <%= payout.processed_at.strftime("%b %d") %></div>
|
||||||
|
<% end %>
|
||||||
|
</td>
|
||||||
|
<% if show_actions %>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||||
|
<%= link_to "View", admin_payout_path(payout), class: "text-indigo-600 hover:text-indigo-900" %>
|
||||||
|
<% case section %>
|
||||||
|
<% when 'pending' %>
|
||||||
|
<% if payout.can_approve? %>
|
||||||
|
<%= link_to "Approve", approve_admin_payout_path(payout), method: :post,
|
||||||
|
class: "text-green-600 hover:text-green-900 ml-2",
|
||||||
|
data: { confirm: "Approve this payout for transfer?" } %>
|
||||||
|
<% end %>
|
||||||
|
<% when 'approved' %>
|
||||||
|
<%= link_to "Start Transfer", mark_processing_admin_payout_path(payout), method: :post,
|
||||||
|
class: "text-blue-600 hover:text-blue-900 ml-2",
|
||||||
|
data: { confirm: "Mark as processing (transfer initiated)?" } %>
|
||||||
|
<% when 'processing' %>
|
||||||
|
<%= link_to "Complete", mark_completed_admin_payout_path(payout), method: :post,
|
||||||
|
class: "text-green-600 hover:text-green-900 ml-2",
|
||||||
|
data: { confirm: "Mark transfer as completed?" } %>
|
||||||
|
<% end %>
|
||||||
|
</td>
|
||||||
|
<% end %>
|
||||||
|
</tr>
|
||||||
|
<% end %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
@@ -1,76 +1,49 @@
|
|||||||
<div class="container mx-auto px-4 py-8">
|
<div class="container mx-auto px-4 py-8">
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex justify-between items-center mb-6">
|
||||||
<h1 class="text-3xl font-bold text-gray-900">Admin Payouts</h1>
|
<h1 class="text-3xl font-bold text-gray-900">Manual Payout Administration</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% if @payouts.any? %>
|
<!-- Pending Payouts - Require Review -->
|
||||||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
<% if @pending_payouts.any? %>
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
<div class="mb-8">
|
||||||
<thead class="bg-gray-50">
|
<h2 class="text-xl font-semibold text-gray-900 mb-4">📋 Pending Review (<%= @pending_payouts.count %>)</h2>
|
||||||
<tr>
|
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Event</th>
|
<%= render partial: 'payout_table', locals: { payouts: @pending_payouts, show_actions: true, section: 'pending' } %>
|
||||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Promoter</th>
|
|
||||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Amount</th>
|
|
||||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
|
||||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
|
|
||||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="bg-white divide-y divide-gray-200">
|
|
||||||
<% @payouts.each do |payout| %>
|
|
||||||
<tr>
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div class="text-sm font-medium text-gray-900"><%= payout.event.name %></div>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div class="text-sm text-gray-900"><%= payout.user.name.presence || payout.user.email %></div>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div class="text-sm text-gray-900">€<%= payout.amount_euros %></div>
|
|
||||||
<div class="text-sm text-gray-500">Net: €<%= payout.net_amount_euros %> (Fee: €<%= payout.fee_euros %>)</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
|
||||||
<% case payout.status %>
|
|
||||||
<% when 'pending' %>
|
|
||||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-yellow-100 text-yellow-800">
|
|
||||||
Pending
|
|
||||||
</span>
|
|
||||||
<% when 'processing' %>
|
|
||||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-blue-100 text-blue-800">
|
|
||||||
Processing
|
|
||||||
</span>
|
|
||||||
<% when 'completed' %>
|
|
||||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
|
|
||||||
Completed
|
|
||||||
</span>
|
|
||||||
<% when 'failed' %>
|
|
||||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800">
|
|
||||||
Failed
|
|
||||||
</span>
|
|
||||||
<% end %>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
|
||||||
<%= payout.created_at.strftime("%b %d, %Y") %>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
|
||||||
<% if payout.can_process? %>
|
|
||||||
<%= button_to "Process", admin_payout_path(payout), method: :post,
|
|
||||||
class: "text-indigo-600 hover:text-indigo-900 bg-indigo-100 hover:bg-indigo-200 px-3 py-1 rounded" %>
|
|
||||||
<% end %>
|
|
||||||
<%= link_to "View", promoter_payout_path(payout), class: "text-indigo-600 hover:text-indigo-900 ml-2" %>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<% end %>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<% if @payouts.respond_to?(:total_pages) %>
|
|
||||||
<div class="mt-6">
|
|
||||||
<%= paginate @payouts %>
|
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
</div>
|
||||||
<% else %>
|
<% end %>
|
||||||
|
|
||||||
|
<!-- Approved Payouts - Ready for Transfer -->
|
||||||
|
<% if @approved_payouts.any? %>
|
||||||
|
<div class="mb-8">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 mb-4">✅ Approved - Ready for Transfer (<%= @approved_payouts.count %>)</h2>
|
||||||
|
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||||
|
<%= render partial: 'payout_table', locals: { payouts: @approved_payouts, show_actions: true, section: 'approved' } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- Processing Payouts - Transfer Initiated -->
|
||||||
|
<% if @processing_payouts.any? %>
|
||||||
|
<div class="mb-8">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 mb-4">🔄 Processing - Transfer in Progress (<%= @processing_payouts.count %>)</h2>
|
||||||
|
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||||
|
<%= render partial: 'payout_table', locals: { payouts: @processing_payouts, show_actions: true, section: 'processing' } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<!-- Recent Completed Payouts -->
|
||||||
|
<% if @completed_payouts.any? %>
|
||||||
|
<div class="mb-8">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 mb-4">✨ Recently Completed</h2>
|
||||||
|
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||||
|
<%= render partial: 'payout_table', locals: { payouts: @completed_payouts, show_actions: false, section: 'completed' } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% if @pending_payouts.empty? && @approved_payouts.empty? && @processing_payouts.empty? && @completed_payouts.empty? %>
|
||||||
<div class="bg-white rounded-lg shadow p-6 text-center">
|
<div class="bg-white rounded-lg shadow p-6 text-center">
|
||||||
<p class="text-gray-500">No payouts found.</p>
|
<p class="text-gray-500">No payouts found.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,2 +1,208 @@
|
|||||||
<h1>Admin::Payouts#show</h1>
|
<div class="container mx-auto px-4 py-8">
|
||||||
<p>Find me in app/views/admin/payouts/show.html.erb</p>
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900">Payout Details #<%= @payout.id %></h1>
|
||||||
|
<%= link_to "← Back to Payouts", admin_payouts_path, class: "text-indigo-600 hover:text-indigo-900" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
|
<!-- Payout Information -->
|
||||||
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 mb-4">Payout Information</h2>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-500">Status</label>
|
||||||
|
<% case @payout.status %>
|
||||||
|
<% when 'pending' %>
|
||||||
|
<span class="px-3 py-1 text-sm font-semibold rounded-full bg-yellow-100 text-yellow-800">
|
||||||
|
Pending Review
|
||||||
|
</span>
|
||||||
|
<% when 'approved' %>
|
||||||
|
<span class="px-3 py-1 text-sm font-semibold rounded-full bg-blue-100 text-blue-800">
|
||||||
|
Approved - Ready for Transfer
|
||||||
|
</span>
|
||||||
|
<% when 'processing' %>
|
||||||
|
<span class="px-3 py-1 text-sm font-semibold rounded-full bg-indigo-100 text-indigo-800">
|
||||||
|
Processing
|
||||||
|
</span>
|
||||||
|
<% when 'completed' %>
|
||||||
|
<span class="px-3 py-1 text-sm font-semibold rounded-full bg-green-100 text-green-800">
|
||||||
|
Completed
|
||||||
|
</span>
|
||||||
|
<% when 'failed' %>
|
||||||
|
<span class="px-3 py-1 text-sm font-semibold rounded-full bg-red-100 text-red-800">
|
||||||
|
Failed
|
||||||
|
</span>
|
||||||
|
<% when 'rejected' %>
|
||||||
|
<span class="px-3 py-1 text-sm font-semibold rounded-full bg-gray-100 text-gray-800">
|
||||||
|
Rejected
|
||||||
|
</span>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-500">Event</label>
|
||||||
|
<p class="text-gray-900"><%= @payout.event.name %></p>
|
||||||
|
<p class="text-sm text-gray-500"><%= @payout.event.date.strftime("%B %d, %Y") if @payout.event.date %></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-500">Promoter</label>
|
||||||
|
<p class="text-gray-900"><%= @payout.user.name.presence || @payout.user.email %></p>
|
||||||
|
<p class="text-sm text-gray-500"><%= @payout.user.email %></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-500">Gross Amount</label>
|
||||||
|
<p class="text-lg font-semibold text-gray-900">€<%= @payout.amount_euros %></p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-500">Platform Fee</label>
|
||||||
|
<p class="text-lg font-semibold text-gray-900">€<%= @payout.fee_euros %></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-500">Net Amount (To Transfer)</label>
|
||||||
|
<p class="text-2xl font-bold text-green-600">€<%= @payout.net_amount_euros %></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-500">Total Orders</label>
|
||||||
|
<p class="text-gray-900"><%= @payout.total_orders_count %></p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-500">Refunded Orders</label>
|
||||||
|
<p class="text-gray-900"><%= @payout.refunded_orders_count %></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-500">Requested</label>
|
||||||
|
<p class="text-gray-900"><%= @payout.created_at.strftime("%B %d, %Y at %I:%M %p") %></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if @payout.processed_at %>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-500">Processed</label>
|
||||||
|
<p class="text-gray-900"><%= @payout.processed_at.strftime("%B %d, %Y at %I:%M %p") %></p>
|
||||||
|
<% if @payout.processed_by %>
|
||||||
|
<p class="text-sm text-gray-500">by <%= @payout.processed_by.name.presence || @payout.processed_by.email %></p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% if @payout.bank_transfer_reference.present? %>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-500">Transfer Reference</label>
|
||||||
|
<p class="text-gray-900 font-mono"><%= @payout.bank_transfer_reference %></p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% if @payout.rejection_reason.present? %>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-500">Rejection/Failure Reason</label>
|
||||||
|
<p class="text-red-600"><%= @payout.rejection_reason %></p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Banking Information -->
|
||||||
|
<div class="bg-white rounded-lg shadow p-6">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 mb-4">Banking Information</h2>
|
||||||
|
|
||||||
|
<% if @banking_errors.any? %>
|
||||||
|
<div class="mb-4 p-4 bg-red-50 border border-red-200 rounded-md">
|
||||||
|
<h3 class="text-sm font-medium text-red-800">Banking Information Issues:</h3>
|
||||||
|
<ul class="mt-2 text-sm text-red-700">
|
||||||
|
<% @banking_errors.each do |error| %>
|
||||||
|
<li>• <%= error %></li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% if @transfer_summary %>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-500">Account Holder</label>
|
||||||
|
<p class="text-gray-900"><%= @transfer_summary[:account_holder] %></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-500">Bank Name</label>
|
||||||
|
<p class="text-gray-900"><%= @transfer_summary[:bank_name] %></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-500">IBAN</label>
|
||||||
|
<p class="text-gray-900 font-mono"><%= @transfer_summary[:iban] %></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4 bg-blue-50 border border-blue-200 rounded-md">
|
||||||
|
<h3 class="text-sm font-medium text-blue-800">Transfer Instructions</h3>
|
||||||
|
<div class="mt-2 text-sm text-blue-700">
|
||||||
|
<p><strong>Amount:</strong> €<%= @transfer_summary[:amount_euros] %></p>
|
||||||
|
<p><strong>Reference:</strong> Payout #<%= @transfer_summary[:payout_id] %> - <%= @transfer_summary[:event_name] %></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<div class="text-center text-gray-500 py-8">
|
||||||
|
<p>Banking information not available for display.</p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="mt-8 bg-white rounded-lg shadow p-6">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 mb-4">Actions</h2>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-4">
|
||||||
|
<% if @payout.can_approve? %>
|
||||||
|
<%= button_to "✅ Approve Payout", approve_admin_payout_path(@payout), method: :post,
|
||||||
|
class: "bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-md font-medium",
|
||||||
|
data: { confirm: "Approve this payout for manual bank transfer?" } %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% if @payout.can_reject? %>
|
||||||
|
<%= form_with url: reject_admin_payout_path(@payout), method: :post, local: true, class: "flex gap-2" do |form| %>
|
||||||
|
<%= form.text_field :rejection_reason, placeholder: "Rejection reason...", required: true,
|
||||||
|
class: "border border-gray-300 rounded-md px-3 py-2" %>
|
||||||
|
<%= form.submit "❌ Reject", class: "bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-md font-medium",
|
||||||
|
data: { confirm: "Reject this payout?" } %>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% if @payout.can_process? %>
|
||||||
|
<%= form_with url: mark_processing_admin_payout_path(@payout), method: :post, local: true, class: "flex gap-2" do |form| %>
|
||||||
|
<%= form.text_field :bank_transfer_reference, placeholder: "Transfer reference (optional)",
|
||||||
|
class: "border border-gray-300 rounded-md px-3 py-2" %>
|
||||||
|
<%= form.submit "🔄 Mark as Processing", class: "bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md font-medium",
|
||||||
|
data: { confirm: "Mark as processing (bank transfer initiated)?" } %>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% if @payout.processing? %>
|
||||||
|
<%= form_with url: mark_completed_admin_payout_path(@payout), method: :post, local: true, class: "flex gap-2" do |form| %>
|
||||||
|
<%= form.text_field :bank_transfer_reference, placeholder: "Final transfer reference",
|
||||||
|
value: @payout.bank_transfer_reference,
|
||||||
|
class: "border border-gray-300 rounded-md px-3 py-2" %>
|
||||||
|
<%= form.submit "✅ Mark as Completed", class: "bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-md font-medium",
|
||||||
|
data: { confirm: "Confirm transfer completion?" } %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= form_with url: mark_failed_admin_payout_path(@payout), method: :post, local: true, class: "flex gap-2" do |form| %>
|
||||||
|
<%= form.text_field :failure_reason, placeholder: "Failure reason...", required: true,
|
||||||
|
class: "border border-gray-300 rounded-md px-3 py-2" %>
|
||||||
|
<%= form.submit "❌ Mark as Failed", class: "bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-md font-medium",
|
||||||
|
data: { confirm: "Mark transfer as failed?" } %>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|||||||
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,15 @@
|
|||||||
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 # Legacy route
|
||||||
|
post :approve
|
||||||
|
post :reject
|
||||||
|
post :mark_processing
|
||||||
|
post :mark_completed
|
||||||
|
post :mark_failed
|
||||||
|
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 +87,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 +124,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
|
||||||
|
|||||||
@@ -55,6 +55,11 @@ class DeviseCreateUsers < ActiveRecord::Migration[8.0]
|
|||||||
# Link user to Stripe account for promoter payout
|
# Link user to Stripe account for promoter payout
|
||||||
t.string :stripe_connected_account_id
|
t.string :stripe_connected_account_id
|
||||||
|
|
||||||
|
# Banking information for manual payouts
|
||||||
|
t.string :iban
|
||||||
|
t.string :bank_name
|
||||||
|
t.string :account_holder_name
|
||||||
|
|
||||||
t.timestamps null: false
|
t.timestamps null: false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ class CreateEvents < ActiveRecord::Migration[8.0]
|
|||||||
t.boolean :allow_booking_during_event, default: false, null: false
|
t.boolean :allow_booking_during_event, default: false, null: false
|
||||||
|
|
||||||
# Payout fields
|
# Payout fields
|
||||||
t.integer :payout_status
|
t.integer :payout_status, default: 0, null: false
|
||||||
t.datetime :payout_requested_at
|
t.datetime :payout_requested_at
|
||||||
|
|
||||||
t.timestamps
|
t.timestamps
|
||||||
|
|||||||
@@ -17,5 +17,6 @@ class CreateTickets < ActiveRecord::Migration[8.0]
|
|||||||
end
|
end
|
||||||
|
|
||||||
add_index :tickets, :qr_code, unique: true
|
add_index :tickets, :qr_code, unique: true
|
||||||
|
add_index :tickets, [:status, :order_id]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -14,5 +14,7 @@ class CreateEarnings < ActiveRecord::Migration[8.0]
|
|||||||
|
|
||||||
t.timestamps
|
t.timestamps
|
||||||
end
|
end
|
||||||
|
|
||||||
|
add_index :earnings, :status
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -11,10 +11,17 @@ class CreatePayouts < ActiveRecord::Migration[8.0]
|
|||||||
t.references :user, null: false, foreign_key: false
|
t.references :user, null: false, foreign_key: false
|
||||||
t.references :event, null: false, foreign_key: false
|
t.references :event, null: false, foreign_key: false
|
||||||
|
|
||||||
|
# Manual processing fields
|
||||||
|
t.references :processed_by, null: true, foreign_key: { to_table: :users }
|
||||||
|
t.datetime :processed_at
|
||||||
|
t.text :rejection_reason
|
||||||
|
t.string :bank_transfer_reference
|
||||||
|
|
||||||
t.timestamps
|
t.timestamps
|
||||||
end
|
end
|
||||||
|
|
||||||
add_index :payouts, :status
|
add_index :payouts, :status
|
||||||
add_index :payouts, :stripe_payout_id, unique: true
|
add_index :payouts, :stripe_payout_id, unique: true
|
||||||
|
add_index :payouts, [:event_id, :status]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
21
db/schema.rb
generated
21
db/schema.rb
generated
@@ -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
|
||||||
|
|
||||||
@@ -41,11 +42,11 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_16_221454) do
|
|||||||
t.decimal "longitude", precision: 10, scale: 6, null: false
|
t.decimal "longitude", precision: 10, scale: 6, null: false
|
||||||
t.boolean "featured", default: false, null: false
|
t.boolean "featured", default: false, null: false
|
||||||
t.bigint "user_id", null: false
|
t.bigint "user_id", null: false
|
||||||
t.boolean "allow_booking_during_event", default: false, null: false
|
|
||||||
t.integer "payout_status"
|
|
||||||
t.datetime "payout_requested_at"
|
|
||||||
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.boolean "allow_booking_during_event", default: false, null: false
|
||||||
|
t.integer "payout_status", default: 0, null: false
|
||||||
|
t.datetime "payout_requested_at"
|
||||||
t.index ["featured"], name: "index_events_on_featured"
|
t.index ["featured"], name: "index_events_on_featured"
|
||||||
t.index ["latitude", "longitude"], name: "index_events_on_latitude_and_longitude"
|
t.index ["latitude", "longitude"], name: "index_events_on_latitude_and_longitude"
|
||||||
t.index ["payout_status"], name: "index_events_on_payout_status"
|
t.index ["payout_status"], name: "index_events_on_payout_status"
|
||||||
@@ -81,7 +82,13 @@ 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.bigint "processed_by_id"
|
||||||
|
t.datetime "processed_at"
|
||||||
|
t.text "rejection_reason"
|
||||||
|
t.string "bank_transfer_reference"
|
||||||
|
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 ["processed_by_id"], name: "index_payouts_on_processed_by_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
|
||||||
t.index ["user_id"], name: "index_payouts_on_user_id"
|
t.index ["user_id"], name: "index_payouts_on_user_id"
|
||||||
@@ -116,6 +123,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
|
||||||
|
|
||||||
@@ -135,11 +143,14 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_16_221454) do
|
|||||||
t.string "company_website"
|
t.string "company_website"
|
||||||
t.string "stripe_customer_id"
|
t.string "stripe_customer_id"
|
||||||
t.boolean "onboarding_completed", default: false, null: false
|
t.boolean "onboarding_completed", default: false, null: false
|
||||||
t.string "stripe_connected_account_id"
|
|
||||||
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.string "iban"
|
||||||
|
t.string "bank_name"
|
||||||
|
t.string "account_holder_name"
|
||||||
t.index ["email"], name: "index_users_on_email", unique: true
|
t.index ["email"], name: "index_users_on_email", unique: true
|
||||||
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
|
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
|
||||||
t.index ["stripe_connected_account_id"], name: "index_users_on_stripe_connected_account_id", unique: true
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
add_foreign_key "payouts", "users", column: "processed_by_id"
|
||||||
end
|
end
|
||||||
|
|||||||
157
docs/manual-payout-workflow.md
Normal file
157
docs/manual-payout-workflow.md
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
# Manual Payout Workflow
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document describes the manual payout system implemented to replace Stripe Global Payouts, which is not available in France. The system allows promoters to request payouts for their events, and administrators to process these requests manually through bank transfers.
|
||||||
|
|
||||||
|
## Workflow Steps
|
||||||
|
|
||||||
|
### 1. Promoter Requests Payout
|
||||||
|
- Promoters can request payouts for ended events through the existing interface
|
||||||
|
- The system validates that banking information is complete before allowing requests
|
||||||
|
- Payout status is set to `pending`
|
||||||
|
|
||||||
|
### 2. Admin Review (Pending → Approved/Rejected)
|
||||||
|
**Admin Actions Available:**
|
||||||
|
- **Approve**: If all information is correct and banking details are valid
|
||||||
|
- **Reject**: If there are issues (missing info, invalid details, policy violations)
|
||||||
|
|
||||||
|
**What Admins Check:**
|
||||||
|
- Banking information completeness (IBAN, bank name, account holder)
|
||||||
|
- Event validity and earnings calculation
|
||||||
|
- Promoter eligibility
|
||||||
|
|
||||||
|
### 3. Manual Bank Transfer (Approved → Processing)
|
||||||
|
**Admin Actions:**
|
||||||
|
- **Mark as Processing**: When bank transfer is initiated
|
||||||
|
- Optional: Add transfer reference number
|
||||||
|
- Admin manually processes SEPA transfer through their banking system
|
||||||
|
|
||||||
|
### 4. Transfer Completion (Processing → Completed/Failed)
|
||||||
|
**Admin Actions:**
|
||||||
|
- **Mark as Completed**: When transfer is confirmed successful
|
||||||
|
- **Mark as Failed**: If transfer fails or is rejected by bank
|
||||||
|
- Update transfer reference if needed
|
||||||
|
|
||||||
|
## Banking Information Requirements
|
||||||
|
|
||||||
|
### For Promoters
|
||||||
|
Users must provide:
|
||||||
|
- **IBAN**: Valid IBAN format (validated by regex)
|
||||||
|
- **Bank Name**: Name of the banking institution
|
||||||
|
- **Account Holder Name**: Full name matching bank account
|
||||||
|
|
||||||
|
### IBAN Validation
|
||||||
|
- Basic format validation implemented
|
||||||
|
- Supports standard European IBAN format
|
||||||
|
- Regex: `/\A[A-Z]{2}[0-9]{2}[A-Z0-9]{4}[0-9]{7}([A-Z0-9]?){0,16}\z/`
|
||||||
|
|
||||||
|
## Database Schema Changes
|
||||||
|
|
||||||
|
### New User Fields
|
||||||
|
```ruby
|
||||||
|
add_column :users, :iban, :string
|
||||||
|
add_column :users, :bank_name, :string
|
||||||
|
add_column :users, :account_holder_name, :string
|
||||||
|
```
|
||||||
|
|
||||||
|
### New Payout Fields
|
||||||
|
```ruby
|
||||||
|
add_reference :payouts, :processed_by, foreign_key: { to_table: :users }
|
||||||
|
add_column :payouts, :processed_at, :datetime
|
||||||
|
add_column :payouts, :rejection_reason, :text
|
||||||
|
add_column :payouts, :bank_transfer_reference, :string
|
||||||
|
```
|
||||||
|
|
||||||
|
### Updated Payout Statuses
|
||||||
|
```ruby
|
||||||
|
enum :status, {
|
||||||
|
pending: 0, # Payout requested but not reviewed
|
||||||
|
approved: 1, # Payout approved by admin, ready for transfer
|
||||||
|
processing: 2, # Payout being processed (bank transfer initiated)
|
||||||
|
completed: 3, # Payout successfully completed
|
||||||
|
failed: 4, # Payout failed
|
||||||
|
rejected: 5 # Payout rejected by admin
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Admin Interface
|
||||||
|
|
||||||
|
### Dashboard Sections
|
||||||
|
1. **Pending Review**: New requests requiring admin approval/rejection
|
||||||
|
2. **Approved**: Ready for manual bank transfer
|
||||||
|
3. **Processing**: Transfers in progress
|
||||||
|
4. **Recently Completed**: Completed transfers for reference
|
||||||
|
|
||||||
|
### Transfer Information Display
|
||||||
|
- Promoter banking details
|
||||||
|
- Transfer amount and reference
|
||||||
|
- Event information
|
||||||
|
- Validation warnings for incomplete banking info
|
||||||
|
|
||||||
|
## Security & Audit
|
||||||
|
|
||||||
|
### Audit Trail
|
||||||
|
- All status changes tracked with timestamp
|
||||||
|
- Admin user recorded for each action
|
||||||
|
- Transfer references stored for bank reconciliation
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
- Banking information validated before approval
|
||||||
|
- IBAN format checking
|
||||||
|
- Complete information required before processing
|
||||||
|
|
||||||
|
## Migration from Stripe
|
||||||
|
|
||||||
|
### Immediate Changes
|
||||||
|
- ✅ Stripe Transfer functionality disabled
|
||||||
|
- ✅ Manual workflow implemented
|
||||||
|
- ✅ Banking information collection added
|
||||||
|
- ✅ Admin interface updated
|
||||||
|
|
||||||
|
### Legacy Support
|
||||||
|
- Original `PayoutService#process!` method redirects to manual workflow
|
||||||
|
- Existing payout request flow preserved for promoters
|
||||||
|
- Database backward compatible
|
||||||
|
|
||||||
|
## Usage Instructions
|
||||||
|
|
||||||
|
### For Administrators
|
||||||
|
1. Access admin payout dashboard at `/admin/payouts`
|
||||||
|
2. Review pending payouts for approval
|
||||||
|
3. For approved payouts, initiate bank transfers manually
|
||||||
|
4. Update payout status as transfers progress
|
||||||
|
5. Mark as completed when transfer is confirmed
|
||||||
|
|
||||||
|
### For Promoters
|
||||||
|
1. Ensure banking information is complete in profile
|
||||||
|
2. Request payouts for ended events as before
|
||||||
|
3. Monitor payout status through existing interface
|
||||||
|
4. Banking information must be valid IBAN format
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### Common Issues
|
||||||
|
- **Incomplete Banking Info**: Prevents approval until resolved
|
||||||
|
- **Invalid IBAN**: Validation error displayed to admin
|
||||||
|
- **Transfer Failures**: Can be marked as failed with reason
|
||||||
|
|
||||||
|
### Recovery
|
||||||
|
- Failed payouts can be retried after fixing issues
|
||||||
|
- Rejected payouts require new requests
|
||||||
|
- Banking information can be updated by promoters
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
### Potential Improvements
|
||||||
|
1. Integration with banking APIs for automated transfers
|
||||||
|
2. Enhanced IBAN validation with checksum verification
|
||||||
|
3. Email notifications for status changes
|
||||||
|
4. Bulk transfer processing
|
||||||
|
5. Advanced reporting and reconciliation tools
|
||||||
|
|
||||||
|
### France-Specific Considerations
|
||||||
|
1. SEPA transfer compliance
|
||||||
|
2. Tax reporting requirements
|
||||||
|
3. AML/KYC documentation
|
||||||
|
4. Banking regulation compliance
|
||||||
112
docs/payout-system-analysis.md
Normal file
112
docs/payout-system-analysis.md
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
# Payout System Analysis Report
|
||||||
|
|
||||||
|
## Current Implementation Overview
|
||||||
|
|
||||||
|
The current payout system implemented on the `feat/stripe-global-payouts` branch uses **Stripe Connect** with **Stripe Transfers** to automatically process payouts to promoters. This implementation is **not compatible with France** as it relies on Stripe Global Payouts functionality.
|
||||||
|
|
||||||
|
## Architecture Analysis
|
||||||
|
|
||||||
|
### Core Components
|
||||||
|
|
||||||
|
1. **Models**
|
||||||
|
- `Payout`: Tracks payout requests with statuses (pending, processing, completed, failed)
|
||||||
|
- `User`: Contains `stripe_connected_account_id` for Stripe Connect integration
|
||||||
|
- `Event`: Has payout eligibility and earnings calculation methods
|
||||||
|
- `Earning`: Tracks individual order earnings (referenced in docs but may not be fully implemented)
|
||||||
|
|
||||||
|
2. **Services**
|
||||||
|
- `PayoutService` (`app/services/payout_service.rb:13-19`): Processes payouts via `Stripe::Transfer.create`
|
||||||
|
- `StripeConnectService`: Manages Stripe Connect account setup
|
||||||
|
|
||||||
|
3. **Controllers**
|
||||||
|
- `Promoter::PayoutsController`: Handles promoter payout requests and viewing
|
||||||
|
- `Admin::PayoutsController`: Handles admin payout processing
|
||||||
|
|
||||||
|
### Current Payout Flow
|
||||||
|
|
||||||
|
1. **Promoter Request**: Promoter requests payout for ended event via `Promoter::PayoutsController#create`
|
||||||
|
2. **Admin Processing**: Admin processes payout via `Admin::PayoutsController#process`
|
||||||
|
3. **Stripe Transfer**: `PayoutService` creates `Stripe::Transfer` to promoter's connected account
|
||||||
|
4. **Status Update**: Payout status updated to completed/failed based on Stripe response
|
||||||
|
|
||||||
|
### Key Issues for France
|
||||||
|
|
||||||
|
1. **Stripe Global Payouts Dependency**: The system uses `Stripe::Transfer.create` with `destination: stripe_connected_account_id` which requires Stripe Global Payouts
|
||||||
|
2. **Stripe Connect Requirement**: Users must have verified Stripe Connect accounts (`stripe_connected_account_id`)
|
||||||
|
3. **Automatic Processing**: System assumes automated Stripe processing capability
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
### Payouts Table
|
||||||
|
- `amount_cents`: Gross payout amount
|
||||||
|
- `fee_cents`: Platform fees
|
||||||
|
- `status`: Enum (pending, processing, completed, failed)
|
||||||
|
- `stripe_payout_id`: Stripe transfer ID
|
||||||
|
- `total_orders_count`: Number of orders included
|
||||||
|
- `refunded_orders_count`: Number of refunded orders
|
||||||
|
- `user_id`: Promoter receiving payout
|
||||||
|
- `event_id`: Event for which payout is requested
|
||||||
|
|
||||||
|
### Users Table (Relevant Fields)
|
||||||
|
- `stripe_connected_account_id`: Stripe Connect account ID
|
||||||
|
- `is_professionnal`: Required for event management
|
||||||
|
|
||||||
|
## Compliance and Legal Considerations
|
||||||
|
|
||||||
|
### France-Specific Issues
|
||||||
|
1. **Stripe Global Payouts**: Not available in France as of current analysis
|
||||||
|
2. **Banking Regulations**: May require different approach for cross-border transfers
|
||||||
|
3. **Tax Reporting**: Manual payouts may require additional documentation
|
||||||
|
|
||||||
|
### Alternative Approaches Needed
|
||||||
|
1. **Manual Bank Transfers**: Admin-initiated SEPA transfers
|
||||||
|
2. **Payout Request System**: Promoters request, admins approve and process manually
|
||||||
|
3. **Documentation**: Enhanced record-keeping for manual transfers
|
||||||
|
|
||||||
|
## Recommendations
|
||||||
|
|
||||||
|
### Immediate Actions Required
|
||||||
|
1. **Disable Automatic Processing**: Remove Stripe Transfer functionality
|
||||||
|
2. **Implement Manual Workflow**: Create admin interface for manual payout processing
|
||||||
|
3. **Add Banking Information**: Collect IBAN/SWIFT details from promoters
|
||||||
|
4. **Update Status Flow**: Modify payout statuses for manual processing
|
||||||
|
|
||||||
|
### Proposed Manual Payout System
|
||||||
|
1. **Request Phase**: Promoters submit payout requests (existing functionality can be kept)
|
||||||
|
2. **Review Phase**: Admins review and approve requests
|
||||||
|
3. **Processing Phase**: Admins mark as "processing" and initiate bank transfer
|
||||||
|
4. **Completion Phase**: Admins confirm transfer completion manually
|
||||||
|
|
||||||
|
## Migration Strategy
|
||||||
|
|
||||||
|
### Phase 1: Immediate Fix
|
||||||
|
- Disable automatic Stripe processing
|
||||||
|
- Add manual processing interface for admins
|
||||||
|
- Update payout status workflow
|
||||||
|
|
||||||
|
### Phase 2: Enhanced Manual System
|
||||||
|
- Add banking information collection
|
||||||
|
- Implement approval workflow
|
||||||
|
- Add transfer documentation features
|
||||||
|
|
||||||
|
### Phase 3: Potential Automation
|
||||||
|
- Investigate France-compatible payment providers
|
||||||
|
- Implement API-based bank transfers if available
|
||||||
|
- Maintain manual fallback option
|
||||||
|
|
||||||
|
## Technical Debt
|
||||||
|
|
||||||
|
### Files Requiring Updates
|
||||||
|
- `app/services/payout_service.rb`: Remove Stripe Transfer logic
|
||||||
|
- `app/controllers/admin/payouts_controller.rb`: Add manual processing actions
|
||||||
|
- `app/models/user.rb`: Add banking information fields
|
||||||
|
- Database migrations: Add IBAN/banking fields to users table
|
||||||
|
|
||||||
|
### Testing Impact
|
||||||
|
- Update `test/services/payout_service_test.rb`
|
||||||
|
- Modify controller tests for manual workflow
|
||||||
|
- Add integration tests for manual payout flow
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
The current Stripe Global Payouts implementation is not viable for France operations. A manual payout system must be implemented immediately to handle promoter payments through traditional banking methods while maintaining audit trails and proper documentation.
|
||||||
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