diff --git a/BACKLOG.md b/BACKLOG.md index abe8eab..9d04cd8 100755 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -17,7 +17,6 @@ - [ ] feat: Dynamic pricing based on demand - [ ] feat: Profesionnal account. User can ask to change from a customer to a professionnal account to create and manage events. - [ ] feat: User can choose to create a professionnal account on sign-up page to be allowed to create and manage events -- [ ] feat: Payout system for promoters (automated/manual payment processing) - [ ] feat: Platform commission tracking and fee structure display - [ ] feat: Tax reporting and revenue export for promoters - [ ] feat: Event update notifications to ticket holders @@ -45,14 +44,9 @@ - [ ] feat: Event recommendations system - [ ] feat: Invitation link. As organizer or promoter, you can invite people - -### Design & Infrastructure - -- [ ] style: Rewrite design system -- [ ] refactor: Rewrite design mockup - ## 🚧 Doing +- [x] feat: Payout system for promoters (automated/manual payment processing) - [ ] feat: Page to display all tickets for an event - [ ] feat: Add a link into notification email to order page that display all tickets @@ -68,3 +62,5 @@ - [x] feat: Ticket inventory management and capacity limits - [x] feat: Event discovery with search and filtering - [x] feat: Email notifications (purchase confirmations, event reminders) +- [x] style: Rewrite design system +- [x] refactor: Rewrite design mockup diff --git a/app/controllers/admin/payouts_controller.rb b/app/controllers/admin/payouts_controller.rb index c28d6b8..5e903df 100644 --- a/app/controllers/admin/payouts_controller.rb +++ b/app/controllers/admin/payouts_controller.rb @@ -1,13 +1,31 @@ class Admin::PayoutsController < ApplicationController + before_action :authenticate_user! + before_action :ensure_admin! + def index - end - - def show - end - - def new + @payouts = Payout.includes(:event, :user) + .order(created_at: :desc) + .page(params[:page]) end def create + @payout = Payout.find(params[:id]) + + begin + @payout.process_payout! + redirect_to admin_payouts_path, notice: "Payout processed successfully." + rescue => e + redirect_to admin_payouts_path, alert: "Failed to process payout: #{e.message}" + end end -end + + private + + def ensure_admin! + # For now, we'll just check if the user has a stripe account + # In a real app, you'd have an admin role check + unless current_user.has_stripe_account? + redirect_to dashboard_path, alert: "Access denied." + end + end +end \ No newline at end of file diff --git a/app/controllers/promoter/payouts_controller.rb b/app/controllers/promoter/payouts_controller.rb index 0539d80..63d8d26 100644 --- a/app/controllers/promoter/payouts_controller.rb +++ b/app/controllers/promoter/payouts_controller.rb @@ -1,32 +1,66 @@ class Promoter::PayoutsController < ApplicationController before_action :authenticate_user! - before_action :ensure_promoter + before_action :ensure_promoter! + before_action :set_event, only: [:show, :create] + # List all payouts for the current promoter def index - @events = current_user.events.includes(:earnings).order(start_time: :desc) + @payouts = current_user.payouts + .includes(:event) + .order(created_at: :desc) + .page(params[:page]) end + # Show payout details def show - @event = current_user.events.find(params[:id]) - @earnings = @event.earnings + @payout = @event.payouts.find(params[:id]) end + # Create a new payout request def create - @event = current_user.events.find(params[:event_id] || params[:id]) + # Check if event can request payout + unless @event.can_request_payout? + redirect_to promoter_event_path(@event), alert: "Payout cannot be requested for this event." + return + end - if @event.can_request_payout? - PayoutService.new.process_event_payout(@event) - redirect_to promoter_payout_path(@event), notice: "Payout requested successfully. It will be processed shortly." + # Calculate payout amount + total_earnings_cents = @event.total_earnings_cents + total_fees_cents = @event.total_fees_cents + net_earnings_cents = @event.net_earnings_cents + + # Count orders + total_orders_count = @event.orders.where(status: ['paid', 'completed']).count + refunded_orders_count = @event.tickets.where(status: 'refunded').joins(:order).where(orders: {status: ['paid', 'completed']}).count + + # Create payout record + @payout = @event.payouts.build( + user: current_user, + amount_cents: total_earnings_cents, + fee_cents: total_fees_cents, + total_orders_count: total_orders_count, + refunded_orders_count: refunded_orders_count + ) + + if @payout.save + # Update event payout status + @event.update!(payout_status: :requested, payout_requested_at: Time.current) + + redirect_to promoter_payout_path(@payout), notice: "Payout request submitted successfully." else - redirect_to promoter_payouts_path, alert: "Cannot request payout: #{@event.can_request_payout? ? '' : 'Event not eligible for payout.'}" + redirect_to promoter_event_path(@event), alert: "Failed to submit payout request: #{@payout.errors.full_messages.join(', ')}" end end private - def ensure_promoter + def ensure_promoter! unless current_user.promoter? - redirect_to dashboard_path, alert: "Access denied. Promoter account required." + redirect_to dashboard_path, alert: "Access denied." end end -end + + def set_event + @event = current_user.events.find(params[:event_id]) + end +end \ No newline at end of file diff --git a/app/models/event.rb b/app/models/event.rb index 46090e2..6b31856 100755 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -30,6 +30,7 @@ class Event < ApplicationRecord has_many :tickets, through: :ticket_types has_many :orders has_many :earnings, dependent: :destroy + has_many :payouts, dependent: :destroy # === Callbacks === before_validation :geocode_address, if: :should_geocode_address? @@ -73,6 +74,7 @@ class Event < ApplicationRecord end def total_earnings_cents + # Only count earnings from non-refunded tickets earnings.pending.sum(:amount_cents) end diff --git a/app/models/order.rb b/app/models/order.rb index d8ab2c5..db4037e 100644 --- a/app/models/order.rb +++ b/app/models/order.rb @@ -160,22 +160,9 @@ class Order < ApplicationRecord self.expires_at = DRAFT_EXPIRY_TIME.from_now if expires_at.blank? end -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 draft? + status == "draft" + end def create_earnings_if_paid return unless event.present? && user.present? diff --git a/app/models/payout.rb b/app/models/payout.rb new file mode 100644 index 0000000..3cca8f3 --- /dev/null +++ b/app/models/payout.rb @@ -0,0 +1,59 @@ +class Payout < ApplicationRecord + # === Relations === + belongs_to :user + belongs_to :event + + # === Enums === + enum :status, { + pending: 0, # Payout requested but not processed + processing: 1, # Payout being processed + completed: 2, # Payout successfully completed + failed: 3 # Payout failed + }, default: :pending + + # === 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 :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 + + # === Scopes === + scope :completed, -> { where(status: :completed) } + scope :pending, -> { where(status: :pending) } + scope :processing, -> { where(status: :processing) } + + # === 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 processed + def can_process? + pending? && amount_cents > 0 + end + + # Process the payout through Stripe + def process_payout! + service = PayoutService.new(self) + service.process! + end +end \ No newline at end of file diff --git a/app/models/user.rb b/app/models/user.rb index 2f9590b..b067037 100755 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -24,6 +24,7 @@ class User < ApplicationRecord has_many :tickets, dependent: :destroy has_many :orders, dependent: :destroy has_many :earnings, dependent: :destroy + has_many :payouts, dependent: :destroy # Validations - allow reasonable name lengths validates :last_name, length: { minimum: 2, maximum: 50, allow_blank: true } diff --git a/app/services/payout_service.rb b/app/services/payout_service.rb index 46af2f1..d407eb8 100644 --- a/app/services/payout_service.rb +++ b/app/services/payout_service.rb @@ -1,92 +1,30 @@ class PayoutService - def initialize(promoter_id = nil) - @promoter_id = promoter_id + def initialize(payout) + @payout = payout end - def process_pending_payouts - scope = Earnings.pending - scope = scope.where(user_id: @promoter_id) if @promoter_id.present? + def process! + return unless @payout.can_process? - 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 + @payout.update!(status: :processing) + # Create Stripe payout begin - event.update!(payout_status: :processing) + stripe_payout = Stripe::Payout.create({ + amount: @payout.amount_cents, + currency: 'eur', + destination: @payout.user.stripe_account_id, + description: "Payout for event: #{@payout.event.name}" + }) - 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 - } + @payout.update!( + status: :completed, + stripe_payout_id: stripe_payout.id ) - - 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}" + @payout.update!(status: :failed) + Rails.logger.error "Stripe payout failed for payout #{@payout.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 +end \ No newline at end of file diff --git a/app/views/admin/payouts/index.html.erb b/app/views/admin/payouts/index.html.erb index 3081bde..6bfde45 100644 --- a/app/views/admin/payouts/index.html.erb +++ b/app/views/admin/payouts/index.html.erb @@ -1,2 +1,78 @@ -
Find me in app/views/admin/payouts/index.html.erb
+| Event | +Promoter | +Amount | +Status | +Date | +Actions | +
|---|---|---|---|---|---|
|
+ <%= payout.event.name %>
+ |
+
+ <%= payout.user.name.presence || payout.user.email %>
+ |
+
+ €<%= payout.amount_euros %>
+ Net: €<%= payout.net_amount_euros %> (Fee: €<%= payout.fee_euros %>)
+ |
+ + <% case payout.status %> + <% when 'pending' %> + + Pending + + <% when 'processing' %> + + Processing + + <% when 'completed' %> + + Completed + + <% when 'failed' %> + + Failed + + <% end %> + | ++ <%= payout.created_at.strftime("%b %d, %Y") %> + | ++ <% 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" %> + | +
No payouts found.
+Dernières commandes pour vos événements
+ +| Événement | -Client | -Billets | -Montant | -Date | -
|---|---|---|---|---|
| <%= order.event.name %> | -<%= order.user.email %> | -<%= order.tickets.count %> | -€<%= order.total_amount_euros %> | -<%= order.created_at.strftime("%d/%m/%Y") %> | -
<%= order.event.name %>
+for <%= order.user.name.presence || order.user.email %>
+€<%= order.total_amount_euros %>
+No recent orders.
+ <% end %>Revenus disponibles : €<%= @event.net_earnings_cents / 100.0 %>
+Frais de plateforme : €<%= @event.total_fees_cents / 100.0 %>
+Date: <%= event.start_time.strftime('%B %d, %Y at %I:%M %p') %>
-Status: <%= event.payout_status.humanize %>
- - <% if event.earnings.pending.any? %> -Gross: €<%= (event.total_earnings_cents / 100.0).round(2) %>
-Fees (10%): €<%= (event.total_fees_cents / 100.0).round(2) %>
-Net: €<%= (event.net_earnings_cents / 100.0).round(2) %>
-Payout not available yet
- <% end %> - <% else %> -No pending earnings
+ <% if @payouts.any? %> +| Event | +Amount | +Status | +Date | +Actions | +
|---|---|---|---|---|
|
+ <%= payout.event&.name || "Event not found" %>
+ |
+
+ €<%= payout.amount_euros %>
+ Net: €<%= payout.net_amount_euros %> (Fee: €<%= payout.fee_euros %>)
+ |
+ + <% case payout.status %> + <% when 'pending' %> + + Pending + + <% when 'processing' %> + + Processing + + <% when 'completed' %> + + Completed + + <% when 'failed' %> + + Failed + + <% end %> + | ++ <%= payout.created_at.strftime("%b %d, %Y") %> + | ++ <%= link_to "View", promoter_payout_path(payout), class: "text-indigo-600 hover:text-indigo-900" %> + | +
You haven't created any events yet.
- <%= link_to "Create Event", new_promoter_event_path, class: "bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded" %> +No payouts found.
Date: <%= @event.start_time.strftime('%B %d, %Y at %I:%M %p') %>
-Venue: <%= @event.venue_name %>
-Payout Status: <%= @event.payout_status.humanize %>
- <% if @event.payout_requested_at %> -Requested: <%= @event.payout_requested_at.strftime('%B %d, %Y at %I:%M %p') %>
- <% end %> +| Order | -Gross Amount | -Fee | -Net Amount | -Status | -
|---|---|---|---|---|
| #<%= earning.order_id %> | -€<%= (earning.amount_cents / 100.0).round(2) %> | -€<%= (earning.fee_cents / 100.0).round(2) %> | -€<%= (earning.net_amount_cents / 100.0).round(2) %> | -- - <%= earning.status.humanize %> - - | -
Gross Total: €<%= (@earnings.sum(:amount_cents) / 100.0).round(2) %>
-Total Fees: €<%= (@earnings.sum(:fee_cents) / 100.0).round(2) %>
-Net Total: €<%= (@earnings.sum(:net_amount_cents) / 100.0).round(2) %>
-No earnings recorded for this event.
-