diff --git a/app/controllers/admin/payouts_controller.rb b/app/controllers/admin/payouts_controller.rb new file mode 100644 index 0000000..c28d6b8 --- /dev/null +++ b/app/controllers/admin/payouts_controller.rb @@ -0,0 +1,13 @@ +class Admin::PayoutsController < ApplicationController + def index + end + + def show + end + + def new + end + + def create + end +end diff --git a/app/helpers/admin/payouts_helper.rb b/app/helpers/admin/payouts_helper.rb new file mode 100644 index 0000000..66303e5 --- /dev/null +++ b/app/helpers/admin/payouts_helper.rb @@ -0,0 +1,2 @@ +module Admin::PayoutsHelper +end diff --git a/app/models/earning.rb b/app/models/earning.rb new file mode 100644 index 0000000..8974978 --- /dev/null +++ b/app/models/earning.rb @@ -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 diff --git a/app/models/event.rb b/app/models/event.rb index 2fa5daa..ad59b2e 100755 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -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? diff --git a/app/models/order.rb b/app/models/order.rb index 3c84bc7..d8ab2c5 100644 --- a/app/models/order.rb +++ b/app/models/order.rb @@ -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 diff --git a/app/models/user.rb b/app/models/user.rb index 46c1bd5..2f9590b 100755 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -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 diff --git a/app/services/payout_service.rb b/app/services/payout_service.rb new file mode 100644 index 0000000..46af2f1 --- /dev/null +++ b/app/services/payout_service.rb @@ -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 diff --git a/app/views/admin/payouts/create.html.erb b/app/views/admin/payouts/create.html.erb new file mode 100644 index 0000000..68821c3 --- /dev/null +++ b/app/views/admin/payouts/create.html.erb @@ -0,0 +1,2 @@ +

Admin::Payouts#create

+

Find me in app/views/admin/payouts/create.html.erb

diff --git a/app/views/admin/payouts/index.html.erb b/app/views/admin/payouts/index.html.erb new file mode 100644 index 0000000..3081bde --- /dev/null +++ b/app/views/admin/payouts/index.html.erb @@ -0,0 +1,2 @@ +

Admin::Payouts#index

+

Find me in app/views/admin/payouts/index.html.erb

diff --git a/app/views/admin/payouts/new.html.erb b/app/views/admin/payouts/new.html.erb new file mode 100644 index 0000000..19039f8 --- /dev/null +++ b/app/views/admin/payouts/new.html.erb @@ -0,0 +1,2 @@ +

Admin::Payouts#new

+

Find me in app/views/admin/payouts/new.html.erb

diff --git a/app/views/admin/payouts/show.html.erb b/app/views/admin/payouts/show.html.erb new file mode 100644 index 0000000..0c14797 --- /dev/null +++ b/app/views/admin/payouts/show.html.erb @@ -0,0 +1,2 @@ +

Admin::Payouts#show

+

Find me in app/views/admin/payouts/show.html.erb

diff --git a/config/routes.rb b/config/routes.rb index ce1b6b6..86ab812 100755 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,4 +1,7 @@ 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 # Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500. diff --git a/db/migrate/20250816145933_devise_create_users.rb b/db/migrate/20250816145933_devise_create_users.rb index 0ce256a..9044049 100755 --- a/db/migrate/20250816145933_devise_create_users.rb +++ b/db/migrate/20250816145933_devise_create_users.rb @@ -52,6 +52,8 @@ class DeviseCreateUsers < ActiveRecord::Migration[8.0] # Add onboarding check on user model t.boolean :onboarding_completed, default: false, null: false + # add_column :users, :stripe_connected_account_id, :string + t.timestamps null: false end diff --git a/db/migrate/20250916212717_create_earnings.rb b/db/migrate/20250916212717_create_earnings.rb new file mode 100644 index 0000000..f7bd26d --- /dev/null +++ b/db/migrate/20250916212717_create_earnings.rb @@ -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 diff --git a/db/migrate/20250916215118_add_payout_fields_to_events.rb b/db/migrate/20250916215118_add_payout_fields_to_events.rb new file mode 100644 index 0000000..d97a37e --- /dev/null +++ b/db/migrate/20250916215118_add_payout_fields_to_events.rb @@ -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 diff --git a/db/migrate/20250916215119_add_net_amount_to_earnings.rb b/db/migrate/20250916215119_add_net_amount_to_earnings.rb new file mode 100644 index 0000000..acef14c --- /dev/null +++ b/db/migrate/20250916215119_add_net_amount_to_earnings.rb @@ -0,0 +1,5 @@ +class AddNetAmountToEarnings < ActiveRecord::Migration[8.0] + def change + add_column :earnings, :net_amount_cents, :integer + end +end diff --git a/db/migrate/20250916215129_add_index_to_stripe_connected_account_id_on_users.rb b/db/migrate/20250916215129_add_index_to_stripe_connected_account_id_on_users.rb new file mode 100644 index 0000000..c2bd3ba --- /dev/null +++ b/db/migrate/20250916215129_add_index_to_stripe_connected_account_id_on_users.rb @@ -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 diff --git a/db/migrate/20250916215130_update_payout_status_on_events.rb b/db/migrate/20250916215130_update_payout_status_on_events.rb new file mode 100644 index 0000000..dc34dc6 --- /dev/null +++ b/db/migrate/20250916215130_update_payout_status_on_events.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 08ebc4a..956d390 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,23 @@ # # 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| t.string "name", 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.boolean "featured", default: false, 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 "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 ["latitude", "longitude"], name: "index_events_on_latitude_and_longitude" 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.datetime "created_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 ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true end diff --git a/docs/promoter-payouts.md b/docs/promoter-payouts.md new file mode 100644 index 0000000..7390496 --- /dev/null +++ b/docs/promoter-payouts.md @@ -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. \ No newline at end of file diff --git a/lib/tasks/payouts.rake b/lib/tasks/payouts.rake new file mode 100644 index 0000000..f235398 --- /dev/null +++ b/lib/tasks/payouts.rake @@ -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 diff --git a/test/fixtures/earnings.yml b/test/fixtures/earnings.yml new file mode 100644 index 0000000..8b80703 --- /dev/null +++ b/test/fixtures/earnings.yml @@ -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 \ No newline at end of file diff --git a/test/models/earning_test.rb b/test/models/earning_test.rb new file mode 100644 index 0000000..ca41de0 --- /dev/null +++ b/test/models/earning_test.rb @@ -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