- Replace Stripe automatic payouts with manual admin-processed bank transfers - Add banking information fields (IBAN, bank name, account holder) to User model - Implement manual payout workflow: pending → approved → processing → completed - Add comprehensive admin interface for payout review and processing - Update Payout model with manual processing fields and workflow methods - Add transfer reference tracking and rejection/failure handling - Consolidate all migration fragments into clean "create" migrations - Add comprehensive documentation for manual payout workflow - Fix Event payout_status enum definition and database column issues This addresses France's lack of Stripe Global Payouts support by implementing a complete manual bank transfer workflow while maintaining audit trails and proper admin controls. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
161 lines
4.5 KiB
Ruby
161 lines
4.5 KiB
Ruby
class Payout < ApplicationRecord
|
|
# === Relations ===
|
|
belongs_to :user
|
|
belongs_to :event
|
|
belongs_to :processed_by, class_name: 'User', optional: true
|
|
|
|
# === Enums ===
|
|
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
|
|
}, default: :pending
|
|
|
|
# === Validations ===
|
|
validates :amount_cents, presence: true, numericality: { greater_than: 0 }
|
|
validates :fee_cents, presence: true, numericality: { greater_than_or_equal_to: 0 }
|
|
validates :status, presence: true
|
|
validates :total_orders_count, presence: true, numericality: { greater_than_or_equal_to: 0 }
|
|
validates :refunded_orders_count, presence: true, numericality: { greater_than_or_equal_to: 0 }
|
|
validates :stripe_payout_id, allow_blank: true, uniqueness: true
|
|
validate :unique_pending_event_id, if: :pending?
|
|
|
|
validate :net_earnings_greater_than_zero, if: :pending?
|
|
|
|
def net_earnings_greater_than_zero
|
|
if event.net_earnings_cents <= 0
|
|
errors.add(:base, "net earnings must be greater than 0")
|
|
end
|
|
end
|
|
|
|
validate :net_earnings_greater_than_zero, if: :pending?
|
|
|
|
def net_earnings_greater_than_zero
|
|
if event.net_earnings_cents <= 0
|
|
errors.add(:base, "net earnings must be greater than 0")
|
|
end
|
|
end
|
|
|
|
def unique_pending_event_id
|
|
if Payout.pending.where(event_id: event_id).where.not(id: id).exists?
|
|
errors.add(:base, "only one pending payout allowed per event")
|
|
end
|
|
end
|
|
|
|
# === Scopes ===
|
|
scope :completed, -> { where(status: :completed) }
|
|
scope :pending, -> { where(status: :pending) }
|
|
scope :approved, -> { where(status: :approved) }
|
|
scope :processing, -> { where(status: :processing) }
|
|
scope :rejected, -> { where(status: :rejected) }
|
|
scope :failed, -> { where(status: :failed) }
|
|
|
|
# === Callbacks ===
|
|
after_create :calculate_refunded_orders_count
|
|
|
|
# === Instance Methods ===
|
|
|
|
# Amount in euros (formatted)
|
|
def amount_euros
|
|
amount_cents / 100.0
|
|
end
|
|
|
|
# Fee in euros (formatted)
|
|
def fee_euros
|
|
fee_cents / 100.0
|
|
end
|
|
|
|
# Net amount after fees
|
|
def net_amount_cents
|
|
amount_cents - fee_cents
|
|
end
|
|
|
|
# Net amount in euros
|
|
def net_amount_euros
|
|
net_amount_cents / 100.0
|
|
end
|
|
|
|
# Check if payout can be approved (was pending)
|
|
def can_approve?
|
|
pending? && amount_cents > 0 && user.has_complete_banking_info?
|
|
end
|
|
|
|
# Check if payout can be manually processed (was approved)
|
|
def can_process?
|
|
approved? && amount_cents > 0
|
|
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
|