feat: implement payout system database schema and models
This commit is contained in:
13
app/controllers/admin/payouts_controller.rb
Normal file
13
app/controllers/admin/payouts_controller.rb
Normal file
@@ -0,0 +1,13 @@
|
||||
class Admin::PayoutsController < ApplicationController
|
||||
def index
|
||||
end
|
||||
|
||||
def show
|
||||
end
|
||||
|
||||
def new
|
||||
end
|
||||
|
||||
def create
|
||||
end
|
||||
end
|
||||
2
app/helpers/admin/payouts_helper.rb
Normal file
2
app/helpers/admin/payouts_helper.rb
Normal file
@@ -0,0 +1,2 @@
|
||||
module Admin::PayoutsHelper
|
||||
end
|
||||
16
app/models/earning.rb
Normal file
16
app/models/earning.rb
Normal file
@@ -0,0 +1,16 @@
|
||||
class Earning < ApplicationRecord
|
||||
# === Relations ===
|
||||
belongs_to :event
|
||||
belongs_to :user
|
||||
belongs_to :order
|
||||
|
||||
# === Enums ===
|
||||
enum :status, { pending: 0, paid: 1 }
|
||||
|
||||
# === Validations ===
|
||||
validates :amount_cents, presence: true, numericality: { greater_than_or_equal_to: 0 }
|
||||
validates :fee_cents, presence: true, numericality: { greater_than_or_equal_to: 0 }
|
||||
validates :net_amount_cents, numericality: { greater_than_or_equal_to: 0, allow_nil: true }
|
||||
validates :status, presence: true
|
||||
validates :stripe_payout_id, allow_blank: true, uniqueness: true
|
||||
end
|
||||
@@ -16,16 +16,26 @@ class Event < ApplicationRecord
|
||||
sold_out: 3
|
||||
}, default: :draft
|
||||
|
||||
enum :payout_status, {
|
||||
not_requested: 0,
|
||||
requested: 1,
|
||||
processing: 2,
|
||||
completed: 3,
|
||||
failed: 4
|
||||
}, default: :not_requested
|
||||
|
||||
# === Relations ===
|
||||
belongs_to :user
|
||||
has_many :ticket_types
|
||||
has_many :tickets, through: :ticket_types
|
||||
has_many :orders
|
||||
has_many :earnings, dependent: :destroy
|
||||
|
||||
# === Callbacks ===
|
||||
before_validation :geocode_address, if: :should_geocode_address?
|
||||
|
||||
# Validations for Event attributes
|
||||
# === Validations ===
|
||||
|
||||
# Basic information
|
||||
validates :name, presence: true, length: { minimum: 3, maximum: 100 }
|
||||
validates :slug, presence: true, length: { minimum: 3, maximum: 100 }
|
||||
@@ -57,6 +67,32 @@ class Event < ApplicationRecord
|
||||
|
||||
# === Instance Methods ===
|
||||
|
||||
# Payout status enum
|
||||
enum :payout_status, {
|
||||
not_requested: 0,
|
||||
requested: 1,
|
||||
processing: 2,
|
||||
completed: 3,
|
||||
failed: 4
|
||||
}, default: :not_requested
|
||||
|
||||
# Payout methods
|
||||
def can_request_payout?
|
||||
event_ended? && earnings.pending.any? && user.can_receive_payouts?
|
||||
end
|
||||
|
||||
def total_earnings_cents
|
||||
earnings.pending.sum(:amount_cents)
|
||||
end
|
||||
|
||||
def total_fees_cents
|
||||
(total_earnings_cents * 0.1).to_i # 10% platform fee
|
||||
end
|
||||
|
||||
def net_earnings_cents
|
||||
total_earnings_cents - total_fees_cents
|
||||
end
|
||||
|
||||
# Check if coordinates were successfully geocoded or are fallback coordinates
|
||||
def geocoding_successful?
|
||||
coordinates_look_valid?
|
||||
|
||||
@@ -32,6 +32,7 @@ class Order < ApplicationRecord
|
||||
}
|
||||
|
||||
before_validation :set_expiry, on: :create
|
||||
after_update :create_earnings_if_paid, if: -> { saved_change_to_status? && status == "paid" }
|
||||
|
||||
# === Instance Methods ===
|
||||
|
||||
@@ -159,7 +160,33 @@ class Order < ApplicationRecord
|
||||
self.expires_at = DRAFT_EXPIRY_TIME.from_now if expires_at.blank?
|
||||
end
|
||||
|
||||
def draft?
|
||||
status == "draft"
|
||||
def draft?
|
||||
status == "draft"
|
||||
end
|
||||
|
||||
def create_earnings_if_paid
|
||||
return unless event.present? && user.present?
|
||||
return if event.earnings.exists?(order_id: id)
|
||||
|
||||
event.earnings.create!(
|
||||
user: user,
|
||||
order: self,
|
||||
amount_cents: promoter_payout_cents,
|
||||
fee_cents: platform_fee_cents,
|
||||
status: :pending
|
||||
)
|
||||
end
|
||||
|
||||
def create_earnings_if_paid
|
||||
return unless event.present? && user.present?
|
||||
return if event.earnings.exists?(order_id: id)
|
||||
|
||||
event.earnings.create!(
|
||||
user: user,
|
||||
order: self,
|
||||
amount_cents: promoter_payout_cents,
|
||||
fee_cents: platform_fee_cents,
|
||||
status: :pending
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -23,6 +23,7 @@ class User < ApplicationRecord
|
||||
has_many :events, dependent: :destroy
|
||||
has_many :tickets, dependent: :destroy
|
||||
has_many :orders, dependent: :destroy
|
||||
has_many :earnings, dependent: :destroy
|
||||
|
||||
# Validations - allow reasonable name lengths
|
||||
validates :last_name, length: { minimum: 2, maximum: 50, allow_blank: true }
|
||||
@@ -48,4 +49,21 @@ class User < ApplicationRecord
|
||||
# Alias for can_manage_events? to make views more semantic
|
||||
can_manage_events?
|
||||
end
|
||||
|
||||
def name
|
||||
[ first_name, last_name ].compact.join(" ").strip
|
||||
end
|
||||
|
||||
# Stripe Connect methods
|
||||
def stripe_account_id
|
||||
stripe_connected_account_id
|
||||
end
|
||||
|
||||
def has_stripe_account?
|
||||
stripe_connected_account_id.present?
|
||||
end
|
||||
|
||||
def can_receive_payouts?
|
||||
has_stripe_account? && promoter?
|
||||
end
|
||||
end
|
||||
|
||||
92
app/services/payout_service.rb
Normal file
92
app/services/payout_service.rb
Normal file
@@ -0,0 +1,92 @@
|
||||
class PayoutService
|
||||
def initialize(promoter_id = nil)
|
||||
@promoter_id = promoter_id
|
||||
end
|
||||
|
||||
def process_pending_payouts
|
||||
scope = Earnings.pending
|
||||
scope = scope.where(user_id: @promoter_id) if @promoter_id.present?
|
||||
|
||||
scope.includes(:user, :order, :event).group_by(&:user_id).each do |user_id, earnings|
|
||||
process_payout_for_user(user_id, earnings)
|
||||
end
|
||||
end
|
||||
|
||||
def process_event_payout(event)
|
||||
return unless event.can_request_payout?
|
||||
|
||||
earnings = event.earnings.pending
|
||||
total_cents = earnings.sum(:amount_cents)
|
||||
fees_cents = event.total_fees_cents
|
||||
net_cents = total_cents - fees_cents
|
||||
|
||||
return if net_cents <= 0
|
||||
|
||||
begin
|
||||
event.update!(payout_status: :processing)
|
||||
|
||||
transfer = Stripe::Transfer.create(
|
||||
amount: net_cents / 100,
|
||||
currency: "eur",
|
||||
destination: event.user.stripe_account_id,
|
||||
description: "Payout for event: #{event.name}",
|
||||
metadata: {
|
||||
event_id: event.id,
|
||||
promoter_id: event.user_id,
|
||||
gross_amount: total_cents,
|
||||
fees: fees_cents,
|
||||
net_amount: net_cents
|
||||
}
|
||||
)
|
||||
|
||||
earnings.update_all(
|
||||
status: :paid,
|
||||
fee_cents: fees_cents,
|
||||
net_amount_cents: net_cents,
|
||||
stripe_payout_id: transfer.id
|
||||
)
|
||||
|
||||
event.update!(
|
||||
payout_status: :completed,
|
||||
payout_requested_at: Time.current
|
||||
)
|
||||
|
||||
Rails.logger.info "Processed event payout #{transfer.id} for event #{event.id}: €#{net_cents / 100.0}"
|
||||
rescue Stripe::StripeError => e
|
||||
event.update!(payout_status: :failed)
|
||||
Rails.logger.error "Payout failed for event #{event.id}: #{e.message}"
|
||||
raise e
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_payout_for_user(user_id, earnings)
|
||||
user = User.find(user_id)
|
||||
return unless user.stripe_account_id.present?
|
||||
|
||||
total_amount_cents = earnings.sum(:amount_cents)
|
||||
|
||||
begin
|
||||
transfer = Stripe::Transfer.create(
|
||||
amount: total_amount_cents / 100,
|
||||
currency: "eur",
|
||||
destination: user.stripe_account_id,
|
||||
description: "Payout for promoter #{user_id} - Total: €#{total_amount_cents / 100.0}",
|
||||
metadata: {
|
||||
promoter_id: user_id,
|
||||
earnings_ids: earnings.map(&:id).join(",")
|
||||
}
|
||||
)
|
||||
|
||||
earnings.update_all(
|
||||
status: :paid,
|
||||
stripe_payout_id: transfer.id
|
||||
)
|
||||
|
||||
Rails.logger.info "Processed payout #{transfer.id} for promoter #{user_id}: €#{total_amount_cents / 100.0}"
|
||||
rescue Stripe::StripeError => e
|
||||
Rails.logger.error "Failed to process payout for promoter #{user_id}: #{e.message}"
|
||||
end
|
||||
end
|
||||
end
|
||||
2
app/views/admin/payouts/create.html.erb
Normal file
2
app/views/admin/payouts/create.html.erb
Normal file
@@ -0,0 +1,2 @@
|
||||
<h1>Admin::Payouts#create</h1>
|
||||
<p>Find me in app/views/admin/payouts/create.html.erb</p>
|
||||
2
app/views/admin/payouts/index.html.erb
Normal file
2
app/views/admin/payouts/index.html.erb
Normal file
@@ -0,0 +1,2 @@
|
||||
<h1>Admin::Payouts#index</h1>
|
||||
<p>Find me in app/views/admin/payouts/index.html.erb</p>
|
||||
2
app/views/admin/payouts/new.html.erb
Normal file
2
app/views/admin/payouts/new.html.erb
Normal file
@@ -0,0 +1,2 @@
|
||||
<h1>Admin::Payouts#new</h1>
|
||||
<p>Find me in app/views/admin/payouts/new.html.erb</p>
|
||||
2
app/views/admin/payouts/show.html.erb
Normal file
2
app/views/admin/payouts/show.html.erb
Normal file
@@ -0,0 +1,2 @@
|
||||
<h1>Admin::Payouts#show</h1>
|
||||
<p>Find me in app/views/admin/payouts/show.html.erb</p>
|
||||
Reference in New Issue
Block a user