feat: implement payout system database schema and models

This commit is contained in:
kbe
2025-09-16 23:52:26 +02:00
parent e5ed1a34dd
commit 0399761fb3
23 changed files with 421 additions and 5 deletions

View File

@@ -0,0 +1,13 @@
class Admin::PayoutsController < ApplicationController
def index
end
def show
end
def new
end
def create
end
end

View File

@@ -0,0 +1,2 @@
module Admin::PayoutsHelper
end

16
app/models/earning.rb Normal file
View 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

View File

@@ -16,16 +16,26 @@ class Event < ApplicationRecord
sold_out: 3 sold_out: 3
}, default: :draft }, default: :draft
enum :payout_status, {
not_requested: 0,
requested: 1,
processing: 2,
completed: 3,
failed: 4
}, default: :not_requested
# === Relations === # === Relations ===
belongs_to :user belongs_to :user
has_many :ticket_types has_many :ticket_types
has_many :tickets, through: :ticket_types has_many :tickets, through: :ticket_types
has_many :orders has_many :orders
has_many :earnings, dependent: :destroy
# === Callbacks === # === Callbacks ===
before_validation :geocode_address, if: :should_geocode_address? before_validation :geocode_address, if: :should_geocode_address?
# Validations for Event attributes # === Validations ===
# Basic information # Basic information
validates :name, presence: true, length: { minimum: 3, maximum: 100 } validates :name, presence: true, length: { minimum: 3, maximum: 100 }
validates :slug, 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 === # === 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 # Check if coordinates were successfully geocoded or are fallback coordinates
def geocoding_successful? def geocoding_successful?
coordinates_look_valid? coordinates_look_valid?

View File

@@ -32,6 +32,7 @@ class Order < ApplicationRecord
} }
before_validation :set_expiry, on: :create before_validation :set_expiry, on: :create
after_update :create_earnings_if_paid, if: -> { saved_change_to_status? && status == "paid" }
# === Instance Methods === # === Instance Methods ===
@@ -162,4 +163,30 @@ class Order < ApplicationRecord
def draft? def draft?
status == "draft" status == "draft"
end 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 end

View File

@@ -23,6 +23,7 @@ class User < ApplicationRecord
has_many :events, dependent: :destroy has_many :events, dependent: :destroy
has_many :tickets, dependent: :destroy has_many :tickets, dependent: :destroy
has_many :orders, dependent: :destroy has_many :orders, dependent: :destroy
has_many :earnings, dependent: :destroy
# Validations - allow reasonable name lengths # Validations - allow reasonable name lengths
validates :last_name, length: { minimum: 2, maximum: 50, allow_blank: true } 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 # Alias for can_manage_events? to make views more semantic
can_manage_events? can_manage_events?
end 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 end

View 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

View File

@@ -0,0 +1,2 @@
<h1>Admin::Payouts#create</h1>
<p>Find me in app/views/admin/payouts/create.html.erb</p>

View File

@@ -0,0 +1,2 @@
<h1>Admin::Payouts#index</h1>
<p>Find me in app/views/admin/payouts/index.html.erb</p>

View File

@@ -0,0 +1,2 @@
<h1>Admin::Payouts#new</h1>
<p>Find me in app/views/admin/payouts/new.html.erb</p>

View File

@@ -0,0 +1,2 @@
<h1>Admin::Payouts#show</h1>
<p>Find me in app/views/admin/payouts/show.html.erb</p>

View File

@@ -1,4 +1,7 @@
Rails.application.routes.draw do Rails.application.routes.draw do
namespace :admin do
resources :payouts, only: [ :index, :create ]
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
# Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.

View File

@@ -52,6 +52,8 @@ class DeviseCreateUsers < ActiveRecord::Migration[8.0]
# Add onboarding check on user model # Add onboarding check on user model
t.boolean :onboarding_completed, default: false, null: false t.boolean :onboarding_completed, default: false, null: false
# add_column :users, :stripe_connected_account_id, :string
t.timestamps null: false t.timestamps null: false
end end

View File

@@ -0,0 +1,16 @@
class CreateEarnings < ActiveRecord::Migration[8.0]
def change
create_table :earnings do |t|
t.integer :amount_cents
t.integer :fee_cents
t.integer :status
t.string :stripe_payout_id
t.references :event, null: false, foreign_key: false, index: true
t.references :user, null: false, foreign_key: false, index: true
t.references :order, null: false, foreign_key: false, index: true
t.timestamps
end
end
end

View File

@@ -0,0 +1,6 @@
class AddPayoutFieldsToEvents < ActiveRecord::Migration[8.0]
def change
add_column :events, :payout_requested_at, :datetime
add_column :events, :payout_status, :integer
end
end

View File

@@ -0,0 +1,5 @@
class AddNetAmountToEarnings < ActiveRecord::Migration[8.0]
def change
add_column :earnings, :net_amount_cents, :integer
end
end

View File

@@ -0,0 +1,6 @@
class AddIndexToStripeConnectedAccountIdOnUsers < ActiveRecord::Migration[8.0]
def change
add_column :stripe_connected_account_id_on_users, :stripe_connected_account_id, :string
add_index :stripe_connected_account_id_on_users, :stripe_connected_account_id
end
end

View File

@@ -0,0 +1,11 @@
class UpdatePayoutStatusOnEvents < ActiveRecord::Migration[8.0]
def up
change_column_default :events, :payout_status, from: nil, to: 0
add_index :events, :payout_status
end
def down
change_column_default :events, :payout_status, from: 0, to: nil
remove_index :events, :payout_status
end
end

23
db/schema.rb generated
View File

@@ -10,7 +10,23 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_09_11_063815) do ActiveRecord::Schema[8.0].define(version: 2025_09_16_215119) do
create_table "earnings", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
t.integer "amount_cents"
t.integer "fee_cents"
t.integer "status"
t.string "stripe_payout_id"
t.bigint "event_id", null: false
t.bigint "user_id", null: false
t.bigint "order_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "net_amount_cents"
t.index ["event_id"], name: "index_earnings_on_event_id"
t.index ["order_id"], name: "index_earnings_on_order_id"
t.index ["user_id"], name: "index_earnings_on_user_id"
end
create_table "events", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t| create_table "events", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
t.string "name", null: false t.string "name", null: false
t.string "slug", null: false t.string "slug", null: false
@@ -25,9 +41,11 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_11_063815) 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.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.datetime "payout_requested_at"
t.integer "payout_status"
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 ["state"], name: "index_events_on_state" t.index ["state"], name: "index_events_on_state"
@@ -101,6 +119,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_11_063815) do
t.boolean "onboarding_completed", default: false, null: false t.boolean "onboarding_completed", default: false, 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.string "stripe_connected_account_id"
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
end end

24
docs/promoter-payouts.md Normal file
View File

@@ -0,0 +1,24 @@
# Promoter Payouts Architecture
## Overview
To handle promoter payouts in the Rails app (where promoters are users creating events), track all order payments in the database for auditing and fee calculation. Save payments (e.g., via Stripe webhooks) and apply platform fees per order processed—e.g., promoter gets 90% of ticket revenue minus your fee, stored in a new `earnings` table linked to events/orders.
## Recommended Architecture
### 1. Models & DB
- Add `has_many :earnings, dependent: :destroy` to `Event` and `User` models.
- Create `Earnings` model: `belongs_to :event, :user; fields: amount_cents (Decimal), fee_cents (Decimal), status (enum: pending/paid), stripe_payout_id (String), order_id (ref)`.
- On order payment success (in your Stripe webhook or after_create callback on Order), create Earnings record: `earnings = event.earnings.create!(amount_cents: total_revenue_cents * 0.9, fee_cents: total_revenue_cents * 0.1, status: :pending, order: order)`.
### 2. Payout Processing
- Use Stripe Connect (setup promoter Stripe accounts via `account_links` in user onboarding).
- Create a `PayoutService`: Batch pending earnings per promoter, transfer via `Stripe::Transfer.create` to their connected account, update status to `:paid`.
- Run via cron job (e.g., in `lib/tasks/payouts.rake`) or admin-triggered job.
### 3. Admin Dashboard for Due Payouts
- Add admin routes: `resources :admin, only: [] do; resources :payouts; end` in `config/routes.rb`.
- Controller: `Admin::PayoutsController` with `index` action querying `Earnings.pending.where(user_id: params[:promoter_id]).group_by(&:user).sum(:amount_cents)`.
- View: Table showing promoter name, total due, unpaid earnings list; button to trigger payout.
- Use Pundit or CanCanCan for admin-only access (add `is_admin?` to User).
This ensures transparency, scalability, and easy auditing. Start by migrating the Earnings model: `rails g model Earnings event:references user:references order:references amount_cents:decimal fee_cents:decimal status:integer stripe_payout_id:string`. Test with Stripe test mode.

7
lib/tasks/payouts.rake Normal file
View File

@@ -0,0 +1,7 @@
namespace :payouts do
desc "Process all pending promoter payouts"
task process: :environment do
PayoutService.new.process_pending_payouts
puts "Pending payouts processed."
end
end

19
test/fixtures/earnings.yml vendored Normal file
View File

@@ -0,0 +1,19 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
one:
event: concert_event
user: users(one)
order: paid_order
amount_cents: 9000 # €90.00
fee_cents: 1000 # €10.00
status: pending
stripe_payout_id:
two:
event: winter_gala
user: users(two)
order: expired_order
amount_cents: 4500 # €45.00
fee_cents: 500 # €5.00
status: paid
stripe_payout_id: payout_123

View File

@@ -0,0 +1,86 @@
require "test_helper"
class EarningTest < ActiveSupport::TestCase
setup do
@user = users(:one) || User.create!(email: "test@example.com", password: "password")
@event = events(:concert_event) || Event.create!(name: "Test Event", slug: "test-event", description: "Description", venue_name: "Venue", venue_address: "Address", latitude: 48.8566, longitude: 2.3522, start_time: Time.current, user: @user)
@order = orders(:paid_order) || Order.create!(user: @user, event: @event, status: "paid", total_amount_cents: 10000)
@earning = Earning.create!(event: @event, user: @user, order: @order, amount_cents: 9000, fee_cents: 1000, status: :pending)
end
test "valid earning" do
assert @earning.valid?
end
test "amount_cents must be present and non-negative" do
@earning.amount_cents = nil
assert_not @earning.valid?
assert_includes @earning.errors[:amount_cents], "can't be blank"
@earning.amount_cents = -1
assert_not @earning.valid?
assert_includes @earning.errors[:amount_cents], "must be greater than or equal to 0"
end
test "fee_cents must be present and non-negative" do
@earning.fee_cents = nil
assert_not @earning.valid?
assert_includes @earning.errors[:fee_cents], "can't be blank"
@earning.fee_cents = -1
assert_not @earning.valid?
assert_includes @earning.errors[:fee_cents], "must be greater than or equal to 0"
end
test "status must be present" do
@earning.status = nil
assert_not @earning.valid?
assert_includes @earning.errors[:status], "can't be blank"
end
test "stripe_payout_id must be unique if present" do
@earning.stripe_payout_id = "test_payout"
@earning.save!
duplicate = @earning.dup
duplicate.stripe_payout_id = "test_payout"
assert_not duplicate.valid?
assert_includes duplicate.errors[:stripe_payout_id], "has already been taken"
end
test "belongs to associations" do
assert_instance_of Event, @earning.event
assert_instance_of User, @earning.user
assert_instance_of Order, @earning.order
end
test "status enum" do
assert_equal 0, Earning.statuses[:pending]
assert_equal 1, Earning.statuses[:paid]
assert @earning.pending?
assert_not @earning.paid?
@earning.status = :paid
@earning.save!
assert @earning.paid?
assert_not @earning.pending?
end
test "pending scope from enum" do
pending_earning = Earning.create!(event: @event, user: @user, order: @order, amount_cents: 9000, fee_cents: 1000, status: :pending)
paid_earning = Earning.create!(event: @event, user: @user, order: @order, amount_cents: 4500, fee_cents: 500, status: :paid)
assert_includes Earning.pending, pending_earning
assert_not_includes Earning.pending, paid_earning
end
test "paid scope from enum" do
pending_earning = Earning.create!(event: @event, user: @user, order: @order, amount_cents: 9000, fee_cents: 1000, status: :pending)
paid_earning = Earning.create!(event: @event, user: @user, order: @order, amount_cents: 4500, fee_cents: 500, status: :paid)
assert_not_includes Earning.paid, pending_earning
assert_includes Earning.paid, paid_earning
end
end