diff --git a/app/controllers/admin/payouts_controller.rb b/app/controllers/admin/payouts_controller.rb index 52ac330..80240b8 100644 --- a/app/controllers/admin/payouts_controller.rb +++ b/app/controllers/admin/payouts_controller.rb @@ -1,32 +1,77 @@ class Admin::PayoutsController < ApplicationController before_action :authenticate_user! before_action :ensure_admin! + before_action :set_payout, only: [:show, :approve, :reject, :mark_processing, :mark_completed, :mark_failed] def index - @payouts = Payout.pending.includes(:user, :event).order(created_at: :asc).page(params[:page]) + @pending_payouts = Payout.pending.includes(:user, :event).order(created_at: :asc) + @approved_payouts = Payout.approved.includes(:user, :event).order(created_at: :asc) + @processing_payouts = Payout.processing.includes(:user, :event).order(created_at: :asc) + @completed_payouts = Payout.completed.includes(:user, :event).order(created_at: :desc).limit(10) end def show - @payout = Payout.find(params[:id]) + @service = PayoutService.new(@payout) + @transfer_summary = @service.generate_transfer_summary + @banking_errors = @service.validate_banking_info end - def process - @payout = Payout.find(params[:id]) - - if @payout.pending? && @payout.can_process? - begin - PayoutService.new(@payout).process! - redirect_to admin_payouts_path, notice: "Payout processed successfully." - rescue => e - redirect_to admin_payouts_path, alert: "Failed to process payout: #{e.message}" - end + def approve + if @payout.approve!(current_user) + redirect_to admin_payout_path(@payout), notice: "Payout approved successfully." else - redirect_to admin_payouts_path, alert: "Cannot process this payout." + redirect_to admin_payout_path(@payout), alert: "Cannot approve this payout." end end + def reject + reason = params[:rejection_reason].presence || "No reason provided" + if @payout.reject!(current_user, reason) + redirect_to admin_payouts_path, notice: "Payout rejected." + else + redirect_to admin_payout_path(@payout), alert: "Cannot reject this payout." + end + end + + def mark_processing + transfer_reference = params[:bank_transfer_reference] + if @payout.mark_processing!(current_user, transfer_reference) + redirect_to admin_payout_path(@payout), notice: "Payout marked as processing." + else + redirect_to admin_payout_path(@payout), alert: "Cannot mark payout as processing." + end + end + + def mark_completed + transfer_reference = params[:bank_transfer_reference] + if @payout.mark_completed!(current_user, transfer_reference) + redirect_to admin_payouts_path, notice: "Payout completed successfully." + else + redirect_to admin_payout_path(@payout), alert: "Cannot mark payout as completed." + end + end + + def mark_failed + reason = params[:failure_reason].presence || "Transfer failed" + if @payout.mark_failed!(current_user, reason) + redirect_to admin_payouts_path, notice: "Payout marked as failed." + else + redirect_to admin_payout_path(@payout), alert: "Cannot mark payout as failed." + end + end + + # Legacy method - redirect to new workflow + def process + @payout = Payout.find(params[:id]) + redirect_to admin_payout_path(@payout), alert: "Use the new manual payout workflow." + end + private + def set_payout + @payout = Payout.find(params[:id]) + end + 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 diff --git a/app/models/event.rb b/app/models/event.rb index cb97682..837b172 100755 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -17,12 +17,12 @@ class Event < ApplicationRecord }, default: :draft enum :payout_status, { - not_requested: 0, + pending_request: 0, requested: 1, processing: 2, completed: 3, failed: 4 - }, default: :not_requested + }, default: :pending_request # === Relations === belongs_to :user diff --git a/app/models/payout.rb b/app/models/payout.rb index fb56796..444bc99 100644 --- a/app/models/payout.rb +++ b/app/models/payout.rb @@ -2,13 +2,16 @@ 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 processed - processing: 1, # Payout being processed - completed: 2, # Payout successfully completed - failed: 3 # Payout failed + 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 === @@ -45,7 +48,10 @@ class Payout < ApplicationRecord # === 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 @@ -72,15 +78,74 @@ class Payout < ApplicationRecord net_amount_cents / 100.0 end - # Check if payout can be processed - def can_process? - pending? && amount_cents > 0 + # Check if payout can be approved (was pending) + def can_approve? + pending? && amount_cents > 0 && user.has_complete_banking_info? end - # Process the payout through Stripe - def process_payout! - service = PayoutService.new(self) - service.process! + # 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 diff --git a/app/models/user.rb b/app/models/user.rb index faff7b5..fee2493 100755 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -31,6 +31,11 @@ class User < ApplicationRecord validates :first_name, length: { minimum: 2, maximum: 50, allow_blank: true } validates :company_name, length: { minimum: 2, maximum: 100, allow_blank: true } + # Banking information validations + validates :iban, format: { with: /\A[A-Z]{2}[0-9]{2}[A-Z0-9]{4}[0-9]{7}([A-Z0-9]?){0,16}\z/, message: "must be a valid IBAN format" }, allow_blank: true + validates :bank_name, length: { minimum: 2, maximum: 100 }, allow_blank: true + validates :account_holder_name, length: { minimum: 2, maximum: 100 }, allow_blank: true + # Onboarding methods def needs_onboarding? !onboarding_completed? @@ -65,7 +70,17 @@ class User < ApplicationRecord end def can_receive_payouts? - stripe_connected_account_id.present? && stripe_connect_verified? + has_complete_banking_info? + end + + # Banking information methods + def has_complete_banking_info? + iban.present? && bank_name.present? && account_holder_name.present? + end + + def banking_info_summary + return "No banking information" unless has_complete_banking_info? + "#{account_holder_name} - #{bank_name} - #{iban}" end private diff --git a/app/services/payout_service.rb b/app/services/payout_service.rb index 7125743..ffc36b7 100644 --- a/app/services/payout_service.rb +++ b/app/services/payout_service.rb @@ -3,36 +3,51 @@ class PayoutService @payout = payout end + # Legacy method for backward compatibility - now redirects to manual workflow def process! - return unless @payout.can_process? + Rails.logger.warn "PayoutService#process! called - manual processing required for payout #{@payout.id}" + raise "Automatic payout processing is disabled. Use manual workflow in admin interface." + end - @payout.update!(status: :processing) + # Generate payout summary for manual transfer + def generate_transfer_summary + return nil unless @payout.approved? || @payout.processing? - begin - net_amount = @payout.amount_cents - @payout.fee_cents - transfer = Stripe::Transfer.create({ - amount: (net_amount / 100.0).to_i, - currency: "eur", - destination: @payout.user.stripe_connected_account_id, - description: "Payout for event #{@payout.event.name}", - metadata: { payout_id: @payout.id, event_id: @payout.event_id } - }, idempotency_key: SecureRandom.uuid) + { + payout_id: @payout.id, + recipient: @payout.user.name, + account_holder: @payout.user.account_holder_name, + bank_name: @payout.user.bank_name, + iban: @payout.user.iban, + amount_euros: @payout.net_amount_euros, + description: "Payout for event: #{@payout.event.name}", + event_name: @payout.event.name, + event_date: @payout.event.date, + total_orders: @payout.total_orders_count, + refunded_orders: @payout.refunded_orders_count + } + end - @payout.update!( - status: :completed, - stripe_payout_id: transfer.id - ) + # Validate banking information before processing + def validate_banking_info + errors = [] + user = @payout.user - update_earnings_status - rescue Stripe::StripeError => e - @payout.update!(status: :failed) - Rails.logger.error "Stripe payout failed for payout #{@payout.id}: #{e.message}" - raise e - end + errors << "Missing IBAN" unless user.iban.present? + errors << "Missing bank name" unless user.bank_name.present? + errors << "Missing account holder name" unless user.account_holder_name.present? + errors << "Invalid IBAN format" if user.iban.present? && !valid_iban?(user.iban) + + errors end private + def valid_iban?(iban) + # Basic IBAN validation (simplified) + iban.match?(/\A[A-Z]{2}[0-9]{2}[A-Z0-9]{4}[0-9]{7}([A-Z0-9]?){0,16}\z/) + end + def update_earnings_status @payout.event.earnings.where(status: 0).update_all(status: 1) # pending to paid end diff --git a/app/views/admin/payouts/_payout_table.html.erb b/app/views/admin/payouts/_payout_table.html.erb new file mode 100644 index 0000000..a4176da --- /dev/null +++ b/app/views/admin/payouts/_payout_table.html.erb @@ -0,0 +1,96 @@ +
| Event | +Promoter | +Banking Info | +Amount | +Status | +Date | + <% if show_actions %> +Actions | + <% end %> +
|---|---|---|---|---|---|---|
|
+ <%= payout.event.name %>
+ <%= payout.event.date.strftime("%b %d, %Y") if payout.event.date %>
+ |
+
+ <%= payout.user.name.presence || payout.user.email %>
+ |
+
+ <% if payout.user.has_complete_banking_info? %>
+ ✅ Complete
+ <%= payout.user.bank_name %>
+ <% else %>
+ ❌ Incomplete
+ Missing banking info
+ <% end %>
+ |
+
+ €<%= payout.amount_euros %>
+ Net: €<%= payout.net_amount_euros %>
+ |
+ + <% case payout.status %> + <% when 'pending' %> + + Pending Review + + <% when 'approved' %> + + Approved + + <% when 'processing' %> + + Processing + + <% when 'completed' %> + + Completed + + <% when 'failed' %> + + Failed + + <% when 'rejected' %> + + Rejected + + <% end %> + | +
+ <%= payout.created_at.strftime("%b %d, %Y") %>
+ <% if payout.processed_at %>
+ Processed: <%= payout.processed_at.strftime("%b %d") %>
+ <% end %>
+ |
+ <% if show_actions %>
+ + <%= link_to "View", admin_payout_path(payout), class: "text-indigo-600 hover:text-indigo-900" %> + <% case section %> + <% when 'pending' %> + <% if payout.can_approve? %> + <%= link_to "Approve", approve_admin_payout_path(payout), method: :post, + class: "text-green-600 hover:text-green-900 ml-2", + data: { confirm: "Approve this payout for transfer?" } %> + <% end %> + <% when 'approved' %> + <%= link_to "Start Transfer", mark_processing_admin_payout_path(payout), method: :post, + class: "text-blue-600 hover:text-blue-900 ml-2", + data: { confirm: "Mark as processing (transfer initiated)?" } %> + <% when 'processing' %> + <%= link_to "Complete", mark_completed_admin_payout_path(payout), method: :post, + class: "text-green-600 hover:text-green-900 ml-2", + data: { confirm: "Mark transfer as completed?" } %> + <% end %> + | + <% end %> +
| 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.
Find me in app/views/admin/payouts/show.html.erb
+<%= @payout.event.name %>
+<%= @payout.event.date.strftime("%B %d, %Y") if @payout.event.date %>
+<%= @payout.user.name.presence || @payout.user.email %>
+<%= @payout.user.email %>
+€<%= @payout.amount_euros %>
+€<%= @payout.fee_euros %>
+€<%= @payout.net_amount_euros %>
+<%= @payout.total_orders_count %>
+<%= @payout.refunded_orders_count %>
+<%= @payout.created_at.strftime("%B %d, %Y at %I:%M %p") %>
+<%= @payout.processed_at.strftime("%B %d, %Y at %I:%M %p") %>
+ <% if @payout.processed_by %> +by <%= @payout.processed_by.name.presence || @payout.processed_by.email %>
+ <% end %> +<%= @payout.bank_transfer_reference %>
+<%= @payout.rejection_reason %>
+<%= @transfer_summary[:account_holder] %>
+<%= @transfer_summary[:bank_name] %>
+<%= @transfer_summary[:iban] %>
+Amount: €<%= @transfer_summary[:amount_euros] %>
+Reference: Payout #<%= @transfer_summary[:payout_id] %> - <%= @transfer_summary[:event_name] %>
+Banking information not available for display.
+