From 3c1e17c2af9d6f8dbe27686d992aae6a8e066055 Mon Sep 17 00:00:00 2001 From: kbe Date: Wed, 17 Sep 2025 02:07:52 +0200 Subject: [PATCH] feat(payouts): implement promoter earnings viewing, request flow, and admin Stripe processing with webhooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add model methods for accurate net calculations (€0.50 + 1.5% fees), eligibility, refund handling Update promoter/payouts controller for index (pending events), create (eligibility checks) Integrate admin processing via Stripe::Transfer, webhook for status sync Enhance views: index pending cards, events/show preview/form Add comprehensive tests (models, controllers, service, integration); run migrations --- app/controllers/admin/payouts_controller.rb | 28 ++-- .../promoter/payouts_controller.rb | 51 ++++--- app/controllers/webhooks/stripe_controller.rb | 34 +++++ app/models/earning.rb | 38 +++++ app/models/event.rb | 20 +-- app/models/order.rb | 15 +- app/models/payout.rb | 40 ++++- app/models/ticket.rb | 10 ++ app/models/user.rb | 15 +- app/services/payout_service.rb | 27 ++-- .../events/_earnings_preview.html.erb | 47 ++++++ app/views/promoter/events/show.html.erb | 71 +-------- app/views/promoter/payouts/index.html.erb | 61 +++++++- app/views/promoter/payouts/show.html.erb | 40 ++--- config/routes.rb | 10 +- ...index_to_payouts_on_event_id_and_status.rb | 5 + ...6230002_add_index_to_earnings_on_status.rb | 5 + ...index_to_tickets_on_status_and_order_id.rb | 5 + db/schema.rb | 5 +- .../admin/payouts_controller_test.rb | 48 ++++++ .../promoter/payouts_controller_test.rb | 111 +++++++++++++- test/fixtures/events.yml | 40 +++++ test/fixtures/orders.yml | 22 ++- test/fixtures/ticket_types.yml | 10 ++ test/integration/payout_flow_test.rb | 58 ++++++++ test/models/earning_test.rb | 49 +++++++ test/models/event_test.rb | 138 ++++++++++++++++++ test/models/payout_test.rb | 109 ++++++++++++++ test/models/ticket_test.rb | 17 +++ test/models/user_test.rb | 43 ++++++ test/services/payout_service_test.rb | 72 +++++++++ 31 files changed, 1096 insertions(+), 148 deletions(-) create mode 100644 app/controllers/webhooks/stripe_controller.rb create mode 100644 app/views/promoter/events/_earnings_preview.html.erb create mode 100644 db/migrate/20250916230001_add_index_to_payouts_on_event_id_and_status.rb create mode 100644 db/migrate/20250916230002_add_index_to_earnings_on_status.rb create mode 100644 db/migrate/20250916230003_add_index_to_tickets_on_status_and_order_id.rb create mode 100644 test/controllers/admin/payouts_controller_test.rb create mode 100644 test/integration/payout_flow_test.rb create mode 100644 test/models/payout_test.rb create mode 100644 test/services/payout_service_test.rb diff --git a/app/controllers/admin/payouts_controller.rb b/app/controllers/admin/payouts_controller.rb index 5e903df..52ac330 100644 --- a/app/controllers/admin/payouts_controller.rb +++ b/app/controllers/admin/payouts_controller.rb @@ -3,19 +3,25 @@ class Admin::PayoutsController < ApplicationController before_action :ensure_admin! def index - @payouts = Payout.includes(:event, :user) - .order(created_at: :desc) - .page(params[:page]) + @payouts = Payout.pending.includes(:user, :event).order(created_at: :asc).page(params[:page]) end - def create + def show @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 + + 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 + else + redirect_to admin_payouts_path, alert: "Cannot process this payout." end end @@ -28,4 +34,4 @@ class Admin::PayoutsController < ApplicationController redirect_to dashboard_path, alert: "Access denied." end end -end \ No newline at end of file +end diff --git a/app/controllers/promoter/payouts_controller.rb b/app/controllers/promoter/payouts_controller.rb index 63d8d26..e360e32 100644 --- a/app/controllers/promoter/payouts_controller.rb +++ b/app/controllers/promoter/payouts_controller.rb @@ -1,54 +1,61 @@ class Promoter::PayoutsController < ApplicationController before_action :authenticate_user! before_action :ensure_promoter! - before_action :set_event, only: [:show, :create] + before_action :set_event, only: [ :create ] # List all payouts for the current promoter def index - @payouts = current_user.payouts - .includes(:event) - .order(created_at: :desc) - .page(params[:page]) + @payouts = current_user.payouts.completed.order(created_at: :desc).page(params[:page]) + + @eligible_events = current_user.events.eligible_for_payout.includes(:earnings).limit(5) + @total_pending_net = @eligible_events.sum(&:net_earnings_cents) + + @total_paid_out = current_user.payouts.completed.sum(&:net_amount_cents) + @total_pending = @total_pending_net + @total_payouts_count = current_user.payouts.count end # Show payout details def show - @payout = @event.payouts.find(params[:id]) + @payout = current_user.payouts.find(params[:id]) + @event = @payout.event end # Create a new payout request def create # 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." + unless @event.can_request_payout?(current_user) + redirect_to event_path(@event.slug, @event), alert: "Payout cannot be requested for this event." return end - # Calculate payout amount - total_earnings_cents = @event.total_earnings_cents - total_fees_cents = @event.total_fees_cents - net_earnings_cents = @event.net_earnings_cents + # Calculate payout amount using model methods + gross = @event.total_gross_cents + fees = @event.total_fees_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 + # Count orders using model scope + total_orders_count = @event.orders.paid.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 + amount_cents: gross, + fee_cents: fees, + total_orders_count: total_orders_count ) + # refunded_orders_count will be set by model callback if @payout.save # Update event payout status @event.update!(payout_status: :requested, payout_requested_at: Time.current) - + + # Log notification (mailer can be added later if needed) + Rails.logger.info "Payout request submitted: #{@payout.id} for event #{@event.id}" + redirect_to promoter_payout_path(@payout), notice: "Payout request submitted successfully." else - redirect_to promoter_event_path(@event), alert: "Failed to submit payout request: #{@payout.errors.full_messages.join(', ')}" + flash.now[:alert] = "Failed to submit payout request: #{@payout.errors.full_messages.join(', ')}" + render "new" end end @@ -63,4 +70,4 @@ class Promoter::PayoutsController < ApplicationController def set_event @event = current_user.events.find(params[:event_id]) end -end \ No newline at end of file +end diff --git a/app/controllers/webhooks/stripe_controller.rb b/app/controllers/webhooks/stripe_controller.rb new file mode 100644 index 0000000..bce5408 --- /dev/null +++ b/app/controllers/webhooks/stripe_controller.rb @@ -0,0 +1,34 @@ +class Webhooks::StripeController < ApplicationController + skip_before_action :verify_authenticity_token + + def create + payload = request.body.read + sig_header = request.env["HTTP_STRIPE_SIGNATURE"] + + begin + event = Stripe::Webhook.construct_event( + payload, sig_header, ENV["STRIPE_WEBHOOK_SECRET"] + ) + rescue Stripe::SignatureVerificationError => e + # Invalid signature + return head 400 + end + + case event["type"] + when "transfer.payout.succeeded" + payout_id = event.data.object.metadata["payout_id"] + payout = Payout.find(payout_id) + if payout && payout.processing? + payout.update!(status: :completed, stripe_payout_id: event.data.object.id) + end + when "transfer.payout.failed", "transfer.canceled" + payout_id = event.data.object.metadata["payout_id"] + payout = Payout.find(payout_id) + if payout + payout.update!(status: :failed) + end + end + + head 200 + end +end diff --git a/app/models/earning.rb b/app/models/earning.rb index 8974978..d993ff5 100644 --- a/app/models/earning.rb +++ b/app/models/earning.rb @@ -1,4 +1,22 @@ class Earning < ApplicationRecord + def self.create_from_order(order) + return unless order.paid? || order.completed? + + gross_cents = order.tickets.active.sum(:price_cents) + fee_cents = order.tickets.active.sum do |ticket| + 50 + (ticket.price_cents * 0.015).to_i + end + amount_cents = gross_cents - fee_cents + + create!( + event: order.event, + user: order.event.user, + order: order, + amount_cents: amount_cents, + fee_cents: fee_cents, + status: :pending + ) + end # === Relations === belongs_to :event belongs_to :user @@ -13,4 +31,24 @@ class Earning < ApplicationRecord 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 + + # Recalculate earning based on active tickets in the order + def recalculate! + return unless order.present? + + active_tickets = order.tickets.active + if active_tickets.empty? + update!(amount_cents: 0, fee_cents: 0) + else + gross_cents = active_tickets.sum(:price_cents) + fee_cents = active_tickets.sum do |ticket| + 50 + (ticket.price_cents * 0.015).to_i + end + update!(amount_cents: gross_cents - fee_cents, fee_cents: fee_cents) + end + end + + def recalculate_on_refund(order) + recalculate! + end end diff --git a/app/models/event.rb b/app/models/event.rb index 6b31856..cb97682 100755 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -66,24 +66,26 @@ class Event < ApplicationRecord # Scope for published events ordered by start time scope :upcoming, -> { published.where("start_time >= ?", Time.current).order(start_time: :asc) } + # Scope for events eligible for payout + scope :eligible_for_payout, -> { where("end_time <= ?", Time.current).joins(:earnings).group("events.id").having("SUM(earnings.amount_cents) > 0") } + # === Instance Methods === # Payout methods - def can_request_payout? - event_ended? && earnings.pending.any? && user.can_receive_payouts? - end - - def total_earnings_cents - # Only count earnings from non-refunded tickets - earnings.pending.sum(:amount_cents) + def total_gross_cents + tickets.active.sum(:price_cents) end def total_fees_cents - (total_earnings_cents * 0.1).to_i # 10% platform fee + earnings.pending.sum(:fee_cents) end def net_earnings_cents - total_earnings_cents - total_fees_cents + total_gross_cents - total_fees_cents + end + + def can_request_payout?(user = self.user) + event_ended? && (net_earnings_cents > 0) && user.is_professionnal? && payouts.pending.empty? end # Check if coordinates were successfully geocoded or are fallback coordinates diff --git a/app/models/order.rb b/app/models/order.rb index db4037e..87c0808 100644 --- a/app/models/order.rb +++ b/app/models/order.rb @@ -3,6 +3,16 @@ class Order < ApplicationRecord DRAFT_EXPIRY_TIME = 15.minutes MAX_PAYMENT_ATTEMPTS = 3 + # === Enums === + enum :status, { + draft: 0, + pending_payment: 1, + paid: 2, + completed: 3, + cancelled: 4, + expired: 5 + }, default: :draft + # === Associations === belongs_to :user belongs_to :event @@ -23,8 +33,9 @@ class Order < ApplicationRecord attr_accessor :stripe_invoice_id # === Scopes === - scope :draft, -> { where(status: "draft") } - scope :active, -> { where(status: %w[paid completed]) } + scope :draft, -> { where(status: :draft) } + scope :active, -> { where(status: [ :paid, :completed ]) } + scope :paid, -> { where(status: :paid) } scope :expired_drafts, -> { draft.where("expires_at < ?", Time.current) } scope :can_retry_payment, -> { draft.where("payment_attempts < ? AND expires_at > ?", diff --git a/app/models/payout.rb b/app/models/payout.rb index 3cca8f3..fb56796 100644 --- a/app/models/payout.rb +++ b/app/models/payout.rb @@ -12,18 +12,44 @@ class Payout < ApplicationRecord }, default: :pending # === Validations === - validates :amount_cents, presence: true, numericality: { greater_than_or_equal_to: 0 } + validates :amount_cents, presence: true, numericality: { greater_than: 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 + validate :unique_pending_event_id, if: :pending? + + validate :net_earnings_greater_than_zero, if: :pending? + + def net_earnings_greater_than_zero + if event.net_earnings_cents <= 0 + errors.add(:base, "net earnings must be greater than 0") + end + end + + validate :net_earnings_greater_than_zero, if: :pending? + + def net_earnings_greater_than_zero + if event.net_earnings_cents <= 0 + errors.add(:base, "net earnings must be greater than 0") + end + end + + def unique_pending_event_id + if Payout.pending.where(event_id: event_id).where.not(id: id).exists? + errors.add(:base, "only one pending payout allowed per event") + end + end # === Scopes === scope :completed, -> { where(status: :completed) } scope :pending, -> { where(status: :pending) } scope :processing, -> { where(status: :processing) } + # === Callbacks === + after_create :calculate_refunded_orders_count + # === Instance Methods === # Amount in euros (formatted) @@ -56,4 +82,14 @@ class Payout < ApplicationRecord service = PayoutService.new(self) service.process! end -end \ No newline at end of file + public + + # === Instance Methods === + + def calculate_refunded_orders_count + refunded_order_ids = event.tickets.where(status: "refunded").select(:order_id).distinct.pluck(:order_id) + paid_statuses = %w[paid completed] + count = event.orders.where(status: paid_statuses).where(id: refunded_order_ids).count + update_column(:refunded_orders_count, count) + end +end diff --git a/app/models/ticket.rb b/app/models/ticket.rb index 94b2508..dd7684d 100755 --- a/app/models/ticket.rb +++ b/app/models/ticket.rb @@ -22,6 +22,8 @@ class Ticket < ApplicationRecord before_validation :set_price_from_ticket_type, on: :create before_validation :generate_qr_code, on: :create + after_update :recalculate_earning_if_refunded, if: :saved_change_to_status? + # Generate PDF ticket def to_pdf TicketPdfGenerator.new(self).generate @@ -73,4 +75,12 @@ class Ticket < ApplicationRecord def draft? status == "draft" end + + private + + def recalculate_earning_if_refunded + if status == "refunded" + order.earning&.recalculate! + end + end end diff --git a/app/models/user.rb b/app/models/user.rb index b067037..faff7b5 100755 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -65,6 +65,19 @@ class User < ApplicationRecord end def can_receive_payouts? - has_stripe_account? && promoter? + stripe_connected_account_id.present? && stripe_connect_verified? + end + private + + def stripe_connect_verified? + return false unless stripe_connected_account_id.present? + + begin + account = Stripe::Account.retrieve(stripe_connected_account_id) + account.charges_enabled + rescue Stripe::StripeError => e + Rails.logger.error "Failed to verify Stripe account #{stripe_connected_account_id}: #{e.message}" + false + end end end diff --git a/app/services/payout_service.rb b/app/services/payout_service.rb index d407eb8..7125743 100644 --- a/app/services/payout_service.rb +++ b/app/services/payout_service.rb @@ -8,23 +8,32 @@ class PayoutService @payout.update!(status: :processing) - # Create Stripe payout begin - stripe_payout = Stripe::Payout.create({ - amount: @payout.amount_cents, - currency: 'eur', - destination: @payout.user.stripe_account_id, - description: "Payout for event: #{@payout.event.name}" - }) + 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.update!( status: :completed, - stripe_payout_id: stripe_payout.id + stripe_payout_id: transfer.id ) + + 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 end -end \ No newline at end of file + + private + + def update_earnings_status + @payout.event.earnings.where(status: 0).update_all(status: 1) # pending to paid + end +end diff --git a/app/views/promoter/events/_earnings_preview.html.erb b/app/views/promoter/events/_earnings_preview.html.erb new file mode 100644 index 0000000..d93da04 --- /dev/null +++ b/app/views/promoter/events/_earnings_preview.html.erb @@ -0,0 +1,47 @@ +<% if @event.can_request_payout? %> +
+

Aperçu des Revenus

+ +
+ +
+

Revenus Bruts

+

+ <%= number_to_currency(@event.total_earnings_cents / 100.0, unit: '€', separator: ',', delimiter: '.') %> +

+
+ + +
+

Frais Plateforme

+

+ -<%= number_to_currency(@event.total_fees_cents / 100.0, unit: '€', separator: ',', delimiter: '.') %> +

+
+ + +
+

Revenus Nets

+

+ <%= number_to_currency(@event.net_earnings_cents / 100.0, unit: '€', separator: ',', delimiter: '.') %> +

+
+
+ + <% if @event.payout.present? %> + <%= link_to "Voir les Détails du Paiement", promoter_payout_path(@event.payout), + class: "inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md shadow-sm text-gray-700 bg-white hover:bg-gray-50" %> + <% else %> + <%= form_with model: Payout.new, url: promoter_payouts_path, local: true, class: "inline-block" do |f| %> + <%= f.hidden_field :event_id, value: @event.id %> + <%= f.submit "Demander le Paiement Maintenant", + class: "inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500", + data: { confirm: "Êtes-vous sûr de vouloir demander un paiement de #{number_to_currency(@event.net_earnings_cents / 100.0, unit: '€', separator: ',', delimiter: '.') } ? Cette action ne peut pas être annulée." } %> + <% end %> + <% end %> +
+<% else %> +
+

Non éligible à la demande de paiement. L'événement n'est peut-être pas terminé ou le compte Stripe n'est pas vérifié.

+
+<% end %> diff --git a/app/views/promoter/events/show.html.erb b/app/views/promoter/events/show.html.erb index 97a1e67..c9f97c2 100644 --- a/app/views/promoter/events/show.html.erb +++ b/app/views/promoter/events/show.html.erb @@ -277,7 +277,7 @@ Gérer les types de billets <% end %> - + <% if @event.sold_out? %> <%= button_to mark_available_promoter_event_path(@event), method: :patch, class: "w-full inline-flex items-center justify-center px-4 py-3 bg-blue-50 text-blue-700 font-medium text-sm rounded-lg hover:bg-blue-100 transition-colors duration-200" do %> @@ -289,76 +289,9 @@ Marquer comme complet <% end %> <% end %> - - - <% if @event.event_ended? && @event.can_request_payout? %> -
-
-

Paiement des Revenus

- -
-
-

Revenus Bruts

-

€<%= @event.total_earnings_cents / 100.0 %>

-
+ <%= render 'earnings_preview' %> -
-

Frais Plateforme

-

-€<%= @event.total_fees_cents / 100.0 %>

-
- -
-

Revenus Nets

-

€<%= @event.net_earnings_cents / 100.0 %>

-
-
- - - <% if @event.payout_status != "not_requested" %> -
-
- <% case @event.payout_status %> - <% when "requested" %> - - Paiement Demandé - <% when "processing" %> - - Paiement en Traitement - <% when "completed" %> - - Paiement Complété - <% when "failed" %> - - Paiement Échoué - <% end %> -
-

Votre demande de paiement est en cours de traitement. Vous recevrez un email quand elle sera terminée.

-
- <% end %> - - - <% if @event.payout_status == "not_requested" %> - <%= button_to promoter_payouts_path(event_id: @event.id), method: :post, - data: { confirm: "Êtes-vous sûr de vouloir demander un paiement de €#{@event.net_earnings_cents / 100.0} ? Cette action ne peut pas être annulée." }, - class: "payout-action-button primary" do %> - - Demander le Paiement de €<%= @event.net_earnings_cents / 100.0 %> - <% end %> - <% elsif @event.payout_status == "failed" %> - <%= button_to promoter_payouts_path(event_id: @event.id), method: :post, - data: { confirm: "Êtes-vous sûr de vouloir demander un nouveau paiement de €#{@event.net_earnings_cents / 100.0} ?" }, - class: "payout-action-button warning" do %> - - Réessayer le Paiement - <% end %> - <% else %> - <%= link_to "Voir les Détails du Paiement", promoter_payouts_path, - class: "payout-action-button secondary" %> - <% end %> -
- <% end %> -
<%= button_to promoter_event_path(@event), method: :delete, data: { confirm: "Êtes-vous sûr de vouloir supprimer cet événement ? Cette action est irréversible." }, diff --git a/app/views/promoter/payouts/index.html.erb b/app/views/promoter/payouts/index.html.erb index 64dc98b..2f989ec 100644 --- a/app/views/promoter/payouts/index.html.erb +++ b/app/views/promoter/payouts/index.html.erb @@ -53,6 +53,65 @@ <% end %> + +
+

Pending Earnings

+ + <% if @total_pending_net && @total_pending_net > 0 %> +
+
+
+
+ +
+
+

Total Pending Net

+

+ <%= number_to_currency(@total_pending_net / 100.0, unit: '€', separator: ',', delimiter: '.') %> +

+
+
+
+
+ <% end %> + + <% if @eligible_events.present? && @eligible_events.any? %> +
+ <% @eligible_events.limit(5).each do |event| %> +
+
+
+ +
+
+

<%= event.name %>

+

<%= event.start_time.strftime("%d %b %Y") %>

+
+
+
+

Gross: <%= number_to_currency(event.total_earnings_cents / 100.0, unit: '€', separator: ',', delimiter: '.') %>

+

Net: <%= number_to_currency(event.net_earnings_cents / 100.0, unit: '€', separator: ',', delimiter: '.') %>

+
+ <%= link_to "Request Payout", promoter_event_path(event), + class: "mt-4 w-full inline-flex justify-center items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" %> +
+ <% end %> +
+ <% if @eligible_events.size > 5 %> +
+ <%= link_to "View All Eligible Events", promoter_events_path, class: "text-indigo-600 hover:text-indigo-500 text-sm font-medium" %> +
+ <% end %> + <% else %> +
+ +

No pending earnings

+

Check your events to see if any are eligible for payout requests.

+ <%= link_to "View My Events", promoter_events_path, class: "mt-4 inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md shadow-sm text-gray-700 bg-white hover:bg-gray-50" %> +
+ <% end %> +
+ <% if @payouts.any? %>
@@ -139,4 +198,4 @@ <%= link_to "View My Events", promoter_events_path, class: "inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" %>
<% end %> - \ No newline at end of file + diff --git a/app/views/promoter/payouts/show.html.erb b/app/views/promoter/payouts/show.html.erb index 417afa6..59c5481 100644 --- a/app/views/promoter/payouts/show.html.erb +++ b/app/views/promoter/payouts/show.html.erb @@ -26,7 +26,7 @@

Requested

<%= @payout.created_at.strftime("%b %d, %Y") %>

- +
<% if @payout.status == 'processing' %> @@ -39,7 +39,7 @@

Processing

- +
<% if @payout.status == 'completed' %> @@ -59,17 +59,23 @@

Gross Amount

-

€<%= @payout.amount_euros %>

+

+ <%= number_to_currency(@payout.amount_euros, unit: '€', separator: ',', delimiter: '.') %> +

- +

Platform Fees

-

-€<%= @payout.fee_euros %>

+

+ -<%= number_to_currency(@payout.fee_euros, unit: '€', separator: ',', delimiter: '.') %> +

- +

Net Amount

-

€<%= @payout.net_amount_euros %>

+

+ <%= number_to_currency(@payout.net_amount_euros, unit: '€', separator: ',', delimiter: '.') %> +

@@ -79,7 +85,7 @@

Payout Information

Details about this payout request

- +
Event
@@ -95,7 +101,7 @@
- +
Status
@@ -123,37 +129,37 @@ <% end %>
- +
Gross Amount
€<%= @payout.amount_euros %>
- +
Platform Fees
-€<%= @payout.fee_euros %>
- +
Net Amount
€<%= @payout.net_amount_euros %>
- +
Total Orders
<%= @payout.total_orders_count %>
- +
Refunded Orders
<%= @payout.refunded_orders_count %>
- +
Requested Date
<%= @payout.created_at.strftime("%B %d, %Y at %I:%M %p") %>
- + <% if @payout.stripe_payout_id.present? %>
Stripe Payout ID
@@ -162,4 +168,4 @@ <% end %>
- \ No newline at end of file + diff --git a/config/routes.rb b/config/routes.rb index 65ff184..bc87dee 100755 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,6 +1,10 @@ Rails.application.routes.draw do namespace :admin do - resources :payouts, only: [ :index, :create ] + resources :payouts, only: [ :index, :show ] do + member do + post :process + end + end end # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html @@ -78,7 +82,7 @@ Rails.application.routes.draw do # === Promoter Routes === namespace :promoter do - resources :payouts, only: [:index, :show, :create] + resources :payouts, only: [ :index, :show, :create ] resources :events do member do patch :publish @@ -115,4 +119,6 @@ Rails.application.routes.draw do # resources :ticket_types, only: [ :index, :show, :create, :update, :destroy ] end end + + post "/webhooks/stripe", to: "webhooks/stripe#create" end diff --git a/db/migrate/20250916230001_add_index_to_payouts_on_event_id_and_status.rb b/db/migrate/20250916230001_add_index_to_payouts_on_event_id_and_status.rb new file mode 100644 index 0000000..4f48022 --- /dev/null +++ b/db/migrate/20250916230001_add_index_to_payouts_on_event_id_and_status.rb @@ -0,0 +1,5 @@ +class AddIndexToPayoutsOnEventIdAndStatus < ActiveRecord::Migration[7.1] + def change + add_index :payouts, [ :event_id, :status ] + end +end diff --git a/db/migrate/20250916230002_add_index_to_earnings_on_status.rb b/db/migrate/20250916230002_add_index_to_earnings_on_status.rb new file mode 100644 index 0000000..d900f53 --- /dev/null +++ b/db/migrate/20250916230002_add_index_to_earnings_on_status.rb @@ -0,0 +1,5 @@ +class AddIndexToEarningsOnStatus < ActiveRecord::Migration[7.1] + def change + add_index :earnings, :status + end +end diff --git a/db/migrate/20250916230003_add_index_to_tickets_on_status_and_order_id.rb b/db/migrate/20250916230003_add_index_to_tickets_on_status_and_order_id.rb new file mode 100644 index 0000000..2069675 --- /dev/null +++ b/db/migrate/20250916230003_add_index_to_tickets_on_status_and_order_id.rb @@ -0,0 +1,5 @@ +class AddIndexToTicketsOnStatusAndOrderId < ActiveRecord::Migration[7.1] + def change + add_index :tickets, [ :status, :order_id ] + end +end diff --git a/db/schema.rb b/db/schema.rb index e51fc7b..f7bdf85 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_09_16_221454) do +ActiveRecord::Schema[8.0].define(version: 2025_09_16_230003) do create_table "earnings", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t| t.integer "amount_cents" t.integer "fee_cents" @@ -24,6 +24,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_16_221454) do t.datetime "updated_at", null: false t.index ["event_id"], name: "index_earnings_on_event_id" t.index ["order_id"], name: "index_earnings_on_order_id" + t.index ["status"], name: "index_earnings_on_status" t.index ["user_id"], name: "index_earnings_on_user_id" end @@ -81,6 +82,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_16_221454) do t.bigint "event_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.index ["event_id", "status"], name: "index_payouts_on_event_id_and_status" t.index ["event_id"], name: "index_payouts_on_event_id" t.index ["status"], name: "index_payouts_on_status" t.index ["stripe_payout_id"], name: "index_payouts_on_stripe_payout_id", unique: true @@ -116,6 +118,7 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_16_221454) do t.datetime "updated_at", null: false t.index ["order_id"], name: "index_tickets_on_order_id" t.index ["qr_code"], name: "index_tickets_on_qr_code", unique: true + t.index ["status", "order_id"], name: "index_tickets_on_status_and_order_id" t.index ["ticket_type_id"], name: "index_tickets_on_ticket_type_id" end diff --git a/test/controllers/admin/payouts_controller_test.rb b/test/controllers/admin/payouts_controller_test.rb new file mode 100644 index 0000000..49f5973 --- /dev/null +++ b/test/controllers/admin/payouts_controller_test.rb @@ -0,0 +1,48 @@ +require "test_helper" + +class Admin::PayoutsControllerTest < ActionDispatch::IntegrationTest + setup do + @admin_user = User.create!(email: "admin@example.com", password: "password123", password_confirmation: "password123", is_professionnal: true) + @admin_user.add_role :admin # Assume role system + @payout = payouts(:one) + end + + test "process payout success for pending payout" do + sign_in @admin_user + @payout.update(status: :pending) + + # Mock service + PayoutService.any_instance.expects(:process!).returns(true) + + patch admin_payout_url(@payout) + assert_redirected_to admin_payout_path(@payout) + assert_flash :notice, /Payout processed successfully/ + assert_equal :completed, @payout.reload.status + end + + test "process payout failure for non-pending" do + sign_in @admin_user + @payout.update(status: :completed) + + patch admin_payout_url(@payout) + assert_redirected_to admin_payout_path(@payout) + assert_flash :alert, /Payout not in pending status/ + end + + test "process payout service error" do + sign_in @admin_user + @payout.update(status: :pending) + + PayoutService.any_instance.expects(:process!).raises(StandardError.new("Stripe error")) + + patch admin_payout_url(@payout) + assert_redirected_to admin_payout_path(@payout) + assert_flash :alert, /Failed to process payout/ + assert_equal :failed, @payout.reload.status + end + + test "requires admin authentication" do + patch admin_payout_url(@payout) + assert_redirected_to new_user_session_path + end +end diff --git a/test/controllers/promoter/payouts_controller_test.rb b/test/controllers/promoter/payouts_controller_test.rb index e95675c..9fbf256 100644 --- a/test/controllers/promoter/payouts_controller_test.rb +++ b/test/controllers/promoter/payouts_controller_test.rb @@ -46,9 +46,118 @@ class Promoter::PayoutsControllerTest < ActionDispatch::IntegrationTest fee_cents: 100, status: :pending ) - assert_difference('Payout.count', 1) do + assert_difference("Payout.count", 1) do post promoter_payouts_url, params: { event_id: @event.id } end assert_redirected_to promoter_payout_path(Payout.last) end + # Comprehensive index test with data + test "index shows completed payouts, eligible events, and totals for promoter" do + sign_in @user + @user.update(is_professionnal: true) + + # Create completed payouts for user + completed_payout = Payout.create!(user: @user, event: @event, amount_cents: 1000, fee_cents: 100, status: :completed) + + # Create eligible event + eligible_event = Event.create!(name: "Eligible Event", slug: "eligible-event", description: "desc", venue_name: "v", venue_address: "a", latitude: 48.0, longitude: 2.0, start_time: 1.day.ago, end_time: 2.days.ago, user: @user, state: :published) + # Setup net >0 for eligible + earning = Earning.create!(event: eligible_event, user: @user, order: orders(:one), amount_cents: 900, fee_cents: 100, status: :pending) + + get promoter_payouts_url + assert_response :success + + assert_select "table#payouts tbody tr", count: 1 # completed payout + assert_select ".eligible-events li", count: 1 # eligible event + assert_match /Pending net earnings: €9.00/, @response.body # totals + assert_match /Total paid out: €10.00/, @response.body + end + + test "index does not show for non-professional" do + sign_in @user + get promoter_payouts_url + assert_redirected_to root_path # or appropriate redirect + end + + # Show test with access control + test "show renders payout details for own payout" do + sign_in @user + @user.update(is_professionnal: true) + payout = Payout.create!(user: @user, event: @event, amount_cents: 1000, fee_cents: 100, status: :completed) + + get promoter_payout_url(payout) + assert_response :success + assert_match payout.amount.to_s, @response.body + end + + test "show returns 404 for other user's payout" do + sign_in @user + @user.update(is_professionnal: true) + other_user = User.create!(email: "other@example.com", password: "password123", password_confirmation: "password123", is_professionnal: true) + other_payout = Payout.create!(user: other_user, event: @event, amount_cents: 2000, fee_cents: 200, status: :completed) + + get promoter_payout_url(other_payout) + assert_response :not_found + end + + # Expanded create test: success + test "create payout success for eligible event" do + sign_in @user + @user.update(is_professionnal: true) + @event.update(user: @user, end_time: 1.day.ago) # ended + # Setup net >0 + earning = @event.earnings.create!(user: @user, order: orders(:paid_order), amount_cents: 900, fee_cents: 100, status: :pending) + # Ensure eligible + assert @event.can_request_payout?(@user) + + assert_difference("Payout.count", 1) do + post promoter_payouts_url, params: { event_id: @event.id } + end + assert_redirected_to promoter_payout_path(Payout.last) + assert_flash :notice, /Payout requested successfully/ + assert_equal :requested, @event.reload.payout_status # assume enum + payout = Payout.last + assert_equal @event.total_gross_cents, payout.amount_cents + assert_equal @event.total_fees_cents, payout.fee_cents + end + + # Create failure: ineligible event + test "create payout fails for ineligible event" do + sign_in @user + @user.update(is_professionnal: true) + @event.update(user: @user, end_time: 1.day.from_now) # not ended + assert_not @event.can_request_payout?(@user) + + assert_no_difference("Payout.count") do + post promoter_payouts_url, params: { event_id: @event.id } + end + assert_redirected_to event_path(@event) + assert_flash :alert, /Event not eligible for payout/ + end + + # Create failure: validation errors + test "create payout fails with validation errors" do + sign_in @user + @user.update(is_professionnal: true) + @event.update(user: @user, end_time: 1.day.ago) + # Setup net =0 + assert_not @event.can_request_payout?(@user) + + assert_no_difference("Payout.count") do + post promoter_payouts_url, params: { event_id: @event.id } + end + assert_response :success # renders new or show with errors + assert_template :new # or appropriate + assert_flash :alert, /Validation failed/ + end + + # Unauthorized create + test "create requires authentication and professional status" do + post promoter_payouts_url, params: { event_id: @event.id } + assert_redirected_to new_user_session_path + + sign_in @user # non-professional + post promoter_payouts_url, params: { event_id: @event.id } + assert_redirected_to root_path # or deny access + end end diff --git a/test/fixtures/events.yml b/test/fixtures/events.yml index 8d562ac..001f942 100755 --- a/test/fixtures/events.yml +++ b/test/fixtures/events.yml @@ -1,5 +1,19 @@ # Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html +one: + name: Test Event + slug: test-event + description: This is a test event description that is long enough to meet validation requirements. + state: published + venue_name: Test Venue + venue_address: 123 Test Street + latitude: 48.8566 + longitude: 2.3522 + start_time: <%= 1.week.from_now %> + end_time: <%= 1.week.from_now + 4.hours %> + user: one + featured: false + concert_event: name: Summer Concert slug: summer-concert @@ -25,3 +39,29 @@ winter_gala: start_time: <%= 2.weeks.from_now %> end_time: <%= 2.weeks.from_now + 6.hours %> user: two + +another_event: + name: Another Event + slug: another-event + description: This is another test event description that is long enough to meet validation requirements. + state: published + venue_name: Another Venue + venue_address: 456 Test Street + latitude: 48.8566 + longitude: 2.3522 + start_time: <%= 1.week.ago %> + end_time: <%= 1.week.ago + 4.hours %> + user: one + +ineligible: + name: Ineligible Event + slug: ineligible-event + description: This is an ineligible test event description that is long enough to meet validation requirements. + state: draft + venue_name: Ineligible Venue + venue_address: 789 Test Street + latitude: 48.8566 + longitude: 2.3522 + start_time: <%= 1.week.from_now %> + end_time: <%= 1.week.from_now + 4.hours %> + user: one diff --git a/test/fixtures/orders.yml b/test/fixtures/orders.yml index 9832752..f606c50 100644 --- a/test/fixtures/orders.yml +++ b/test/fixtures/orders.yml @@ -1,3 +1,13 @@ +one: + user: one + event: concert_event + status: paid + total_amount_cents: 2500 + payment_attempts: 1 + expires_at: <%= 1.hour.from_now %> + created_at: <%= 1.hour.ago %> + updated_at: <%= 1.hour.ago %> + paid_order: user: one event: concert_event @@ -26,4 +36,14 @@ expired_order: payment_attempts: 1 expires_at: <%= 1.hour.ago %> created_at: <%= 2.hours.ago %> - updated_at: <%= 1.hour.ago %> \ No newline at end of file + updated_at: <%= 1.hour.ago %> + +two: + user: two + event: winter_gala + status: expired + total_amount_cents: 5000 + payment_attempts: 2 + expires_at: <%= 2.hours.ago %> + created_at: <%= 3.hours.ago %> + updated_at: <%= 2.hours.ago %> \ No newline at end of file diff --git a/test/fixtures/ticket_types.yml b/test/fixtures/ticket_types.yml index 6041d8b..05ebd25 100755 --- a/test/fixtures/ticket_types.yml +++ b/test/fixtures/ticket_types.yml @@ -1,5 +1,15 @@ # Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html +one: + name: Standard + description: Standard ticket type + price_cents: 1000 + quantity: 100 + sale_start_at: <%= 1.day.ago %> + sale_end_at: <%= 1.day.from_now %> + event: concert_event + requires_id: false + standard: name: General Admission description: General admission ticket for the event diff --git a/test/integration/payout_flow_test.rb b/test/integration/payout_flow_test.rb new file mode 100644 index 0000000..1e23589 --- /dev/null +++ b/test/integration/payout_flow_test.rb @@ -0,0 +1,58 @@ +require "test_helper" + +class PayoutFlowTest < ActionDispatch::IntegrationTest + setup do + @promoter = User.create!(email: "promoter@example.com", password: "password123", password_confirmation: "password123", is_professionnal: true) + @buyer = User.create!(email: "buyer@example.com", password: "password123", password_confirmation: "password123") + sign_in @promoter + end + + test "full payout flow with refund" do + # Create event and ticket type + event = Event.create!(name: "Test Event", slug: "test-event", description: "This is a test event description that meets the minimum length requirement of 10 characters.", venue_name: "Venue", venue_address: "Address", latitude: 48.0, longitude: 2.0, start_time: 1.day.ago, end_time: 1.hour.ago, user: @promoter, state: :published) + ticket_type = TicketType.create!(event: event, name: "Standard", price_cents: 1000, quantity: 10, sale_start_at: 2.days.ago, sale_end_at: Time.current) + + # Buyer purchases ticket (mock Stripe) + sign_in @buyer + Stripe::Checkout::Session.expects(:create).returns(stub(id: "cs_test")) + + post event_checkout_path(event), params: { cart: { ticket_types: { ticket_type.id => 1 } } } + session_id = assigns(:session_id) + # Assume payment success creates order and tickets + order = Order.last + ticket = Ticket.last + assert_equal "paid", order.status + assert_equal "active", ticket.status + + # Earnings created + earning = Earning.last + assert_not_nil earning + assert_equal 900, earning.amount_cents + + # Refund one ticket + sign_in @promoter + ticket.update!(status: "refunded") + earning.reload + assert_equal 0, earning.amount_cents # Recalculated + + # Request payout + assert event.can_request_payout?(@promoter) + post promoter_payouts_path, params: { event_id: event.id } + payout = Payout.last + assert_equal :pending, payout.status + + # Admin process + admin = User.create!(email: "admin@example.com", password: "password123", password_confirmation: "password123") + admin.add_role :admin + sign_in admin + Stripe::Transfer.expects(:create).returns(stub(id: "tr_success")) + patch admin_payout_path(payout) + payout.reload + assert_equal :completed, payout.status + + # Webhook succeeds + post stripe_webhooks_path, params: { type: "payout.succeeded", data: { object: { id: "po_123" } } }, headers: { "Stripe-Signature" => "valid_sig" } + payout.reload + assert_equal :completed, payout.status # Confirmed + end +end diff --git a/test/models/earning_test.rb b/test/models/earning_test.rb index ca41de0..1c1712a 100644 --- a/test/models/earning_test.rb +++ b/test/models/earning_test.rb @@ -83,4 +83,53 @@ class EarningTest < ActiveSupport::TestCase assert_not_includes Earning.paid, pending_earning assert_includes Earning.paid, paid_earning end + # Payout-related tests + + test "creation from order" do + user = users(:one) + event = events(:concert_event) + order = orders(:paid_order) + order.update!(status: "paid", total_amount_cents: 10000) + + # Assume Earning.create_from_order(order) or callback creates earning + Earning.create_from_order(order) + + earning = Earning.where(order: order).first + assert_not_nil earning + assert_equal 9000, earning.amount_cents # After fees: assume 10% fee or based on ticket + assert_equal 1000, earning.fee_cents + assert earning.pending? + end + + test "recalculation on full refund" do + earning = earnings(:one) + earning.amount_cents = 1000 + earning.fee_cents = 100 + earning.save! + + # Assume all tickets in order refunded + order = orders(:one) + order.tickets.each { |t| t.update!(status: "refunded") } + + earning.recalculate_on_refund(order) + + assert_equal 0, earning.amount_cents + assert earning.refunded? # Assume status update + end + + test "recalculation on partial refund" do + earning = earnings(:one) + earning.amount_cents = 2000 + earning.fee_cents = 200 + earning.save! + + order = orders(:one) + # Refund one ticket of 1000 + order.tickets.first.update!(status: "refunded") + + earning.recalculate_on_refund(order) + + assert_equal 1000, earning.amount_cents # Half + assert_equal 100, earning.fee_cents # Half + end end diff --git a/test/models/event_test.rb b/test/models/event_test.rb index 8249bd1..200d704 100755 --- a/test/models/event_test.rb +++ b/test/models/event_test.rb @@ -317,4 +317,142 @@ class EventTest < ActiveSupport::TestCase # Check that ticket types were NOT duplicated assert_equal 0, duplicated_event.ticket_types.count end + # Payout-related tests + + test "total_gross_cents returns sum of active tickets prices" do + event = events(:concert_event) + ticket1 = tickets(:one) + ticket1.status = "active" + ticket1.price_cents = 1000 + ticket1.save! + + ticket2 = Ticket.create!(order: orders(:one), ticket_type: ticket_types(:one), qr_code: "qr2", price_cents: 2000, status: "active", first_name: "Test", last_name: "User") + ticket2.event = event + ticket2.save! + + assert_equal 3000, event.total_gross_cents + end + + test "total_fees_cents returns sum of pending earnings fees" do + event = events(:concert_event) + earning1 = earnings(:one) + earning1.status = "pending" + earning1.fee_cents = 100 + earning1.save! + + earning2 = Earning.create!(event: event, user: users(:one), order: orders(:one), amount_cents: 2000, fee_cents: 200, status: "pending") + + assert_equal 300, event.total_fees_cents + end + + test "net_earnings_cents returns gross minus fees" do + event = events(:concert_event) + # Setup gross 5000, fees 500 + ticket1 = tickets(:one) + ticket1.status = "active" + ticket1.price_cents = 2500 + ticket1.save! + + ticket2 = Ticket.create!(order: orders(:one), ticket_type: ticket_types(:one), qr_code: "qr3", price_cents: 2500, status: "active", first_name: "Test2", last_name: "User2") + ticket2.event = event + ticket2.save! + + earning1 = earnings(:one) + earning1.status = "pending" + earning1.fee_cents = 250 + earning1.save! + + earning2 = Earning.create!(event: event, user: users(:one), order: orders(:one), amount_cents: 2500, fee_cents: 250, status: "pending") + + assert_equal 4500, event.net_earnings_cents + end + + test "can_request_payout? returns true for ended event with net >0, eligible user, no pending payout" do + event = events(:concert_event) + event.update!(end_time: 1.day.ago) # ended + # Setup net >0 + ticket = Ticket.create!(order: orders(:one), ticket_type: ticket_types(:one), qr_code: "qr4", price_cents: 1000, status: "active", first_name: "Test", last_name: "User") + ticket.event = event + ticket.save! + + earning = Earning.create!(event: event, user: users(:one), order: orders(:one), amount_cents: 900, fee_cents: 100, status: "pending") + + user = users(:one) + user.update!(is_professionnal: true) # eligible + + # No pending payout + assert event.can_request_payout?(user) + end + + test "can_request_payout? returns false for not ended event" do + event = events(:concert_event) + event.update!(end_time: 1.day.from_now) # not ended + user = users(:one) + user.update!(is_professionnal: true) + + assert_not event.can_request_payout?(user) + end + + test "can_request_payout? returns false if net <=0" do + event = events(:concert_event) + event.update!(end_time: 1.day.ago) + user = users(:one) + user.update!(is_professionnal: true) + + assert_not event.can_request_payout?(user) + end + + test "can_request_payout? returns false for non-professional user" do + event = events(:concert_event) + event.update!(end_time: 1.day.ago) + # Setup net >0 + ticket = Ticket.create!(order: orders(:one), ticket_type: ticket_types(:one), qr_code: "qr5", price_cents: 1000, status: "active", first_name: "Test", last_name: "User") + ticket.event = event + ticket.save! + + earning = Earning.create!(event: event, user: users(:one), order: orders(:one), amount_cents: 900, fee_cents: 100, status: "pending") + + user = users(:one) + # is_professionnal false by default + + assert_not event.can_request_payout?(user) + end + + test "can_request_payout? returns false if pending payout exists" do + event = events(:concert_event) + event.update!(end_time: 1.day.ago) + # Setup net >0 + ticket = Ticket.create!(order: orders(:one), ticket_type: ticket_types(:one), qr_code: "qr6", price_cents: 1000, status: "active", first_name: "Test", last_name: "User") + ticket.event = event + ticket.save! + + earning = Earning.create!(event: event, user: users(:one), order: orders(:one), amount_cents: 900, fee_cents: 100, status: "pending") + + user = users(:one) + user.update!(is_professionnal: true) + + Payout.create!(user: user, event: event, amount_cents: 800, fee_cents: 100, status: :pending) + + assert_not event.can_request_payout?(user) + end + + test "eligible_for_payout scope returns events with net>0, ended, professional user" do + user = users(:one) + user.update!(is_professionnal: true) + + eligible = Event.create!(name: "Eligible", slug: "eligible", description: "desc", venue_name: "v", venue_address: "a", latitude: 48.0, longitude: 2.0, start_time: 1.day.ago, end_time: 2.days.ago, user: user, state: :published) + # Setup net >0 + ticket = Ticket.create!(order: orders(:one), ticket_type: ticket_types(:one), qr_code: "qr7", price_cents: 1000, status: "active", first_name: "Test", last_name: "User") + ticket.event = eligible + ticket.save! + + earning = Earning.create!(event: eligible, user: user, order: orders(:one), amount_cents: 900, fee_cents: 100, status: "pending") + + ineligible = Event.create!(name: "Ineligible", slug: "ineligible", description: "desc", venue_name: "v", venue_address: "a", latitude: 48.0, longitude: 2.0, start_time: 1.day.from_now, end_time: 2.days.from_now, user: user, state: :published) + # net =0 + + eligible_events = Event.eligible_for_payout + assert_includes eligible_events, eligible + assert_not_includes eligible_events, ineligible + end end diff --git a/test/models/payout_test.rb b/test/models/payout_test.rb new file mode 100644 index 0000000..e1cfde4 --- /dev/null +++ b/test/models/payout_test.rb @@ -0,0 +1,109 @@ +require "test_helper" + +class PayoutTest < ActiveSupport::TestCase + setup do + @payout = payouts(:one) + @user = users(:one) + @event = events(:concert_event) + end + + test "should be valid" do + assert @payout.valid? + end + + test "validations: amount_cents must be present and positive" do + @payout.amount_cents = nil + assert_not @payout.valid? + assert_includes @payout.errors[:amount_cents], "can't be blank" + + @payout.amount_cents = 0 + assert_not @payout.valid? + assert_includes @payout.errors[:amount_cents], "must be greater than 0" + + @payout.amount_cents = -100 + assert_not @payout.valid? + assert_includes @payout.errors[:amount_cents], "must be greater than 0" + end + + test "validations: fee_cents must be present and non-negative" do + @payout.fee_cents = nil + assert_not @payout.valid? + assert_includes @payout.errors[:fee_cents], "can't be blank" + + @payout.fee_cents = -100 + assert_not @payout.valid? + assert_includes @payout.errors[:fee_cents], "must be greater than or equal to 0" + end + + test "validations: net earnings must be greater than 0" do + # Assuming event.net_earnings_cents is a method that calculates >0 + @event.earnings.create!(user: @user, order: orders(:one), amount_cents: 0, fee_cents: 0, status: :pending) + payout = Payout.new(user: @user, event: @event, amount_cents: 1000, fee_cents: 100) + assert_not payout.valid? + assert_includes payout.errors[:base], "net earnings must be greater than 0" # Custom validation message + + @event.earnings.first.update(amount_cents: 2000) + assert payout.valid? + end + + test "validations: only one pending payout per event" do + pending_payout = Payout.create!(user: @user, event: @event, amount_cents: 1000, fee_cents: 100, status: :pending) + assert pending_payout.valid? + + duplicate = Payout.new(user: @user, event: @event, amount_cents: 1000, fee_cents: 100, status: :pending) + assert_not duplicate.valid? + assert_includes duplicate.errors[:base], "only one pending payout allowed per event" + end + + test "net_amount_cents virtual attribute" do + @payout.amount_cents = 10000 + @payout.fee_cents = 1000 + assert_equal 9000, @payout.net_amount_cents + end + + test "after_create callback sets refunded_orders_count" do + refund_count = @event.orders.refunded.count # Assuming orders have refunded status + payout = Payout.create!(user: @user, event: @event, amount_cents: 1000, fee_cents: 100) + assert_equal refund_count, payout.refunded_orders_count + end + + test "associations: belongs to user" do + association = Payout.reflect_on_association(:user) + assert_equal :belongs_to, association.macro + end + + test "associations: belongs to event" do + association = Payout.reflect_on_association(:event) + assert_equal :belongs_to, association.macro + end + + test "status enum" do + assert_equal 0, Payout.statuses[:pending] + assert_equal 1, Payout.statuses[:processing] + assert_equal 2, Payout.statuses[:completed] + assert_equal 3, Payout.statuses[:failed] + + @payout.status = :pending + assert @payout.pending? + + @payout.status = :completed + assert @payout.completed? + end + + test "pending scope" do + pending = Payout.create!(user: @user, event: @event, amount_cents: 1000, fee_cents: 100, status: :pending) + completed = Payout.create!(user: @user, event: @event, amount_cents: 2000, fee_cents: 200, status: :completed) + + assert_includes Payout.pending, pending + assert_not_includes Payout.pending, completed + end + + test "scope: eligible_for_payout" do + # Assuming this scope exists or test if needed + eligible_event = events(:another_event) # Setup with net >0, ended, etc. + ineligible = events(:ineligible) + + eligible_payouts = Payout.eligible_for_payout + assert_includes eligible_payouts, eligible_event.payouts.first if eligible_event.can_request_payout? + end +end diff --git a/test/models/ticket_test.rb b/test/models/ticket_test.rb index 2922d54..da1ef5c 100755 --- a/test/models/ticket_test.rb +++ b/test/models/ticket_test.rb @@ -367,4 +367,21 @@ class TicketTest < ActiveSupport::TestCase ) assert ticket.save end + # Payout-related tests + + test "after_update callback triggers earning recalculation on refund status change" do + user = User.create!(email: "refund@example.com", password: "password123", password_confirmation: "password123") + event = Event.create!(name: "Refund Event", slug: "refund-event", description: "Valid description", venue_name: "v", venue_address: "a", latitude: 48.0, longitude: 2.0, start_time: 1.day.from_now, user: user, state: :published) + ticket_type = TicketType.create!(name: "Standard", price_cents: 1000, quantity: 1, sale_start_at: Time.current, sale_end_at: Time.current + 1.day, event: event) + order = Order.create!(user: user, event: event, status: "paid", total_amount_cents: 1000) + ticket = Ticket.create!(order: order, ticket_type: ticket_type, qr_code: "qr_refund", price_cents: 1000, status: "active", first_name: "Refund", last_name: "Test") + earning = Earning.create!(event: event, user: user, order: order, amount_cents: 900, fee_cents: 100, status: :pending) + + # Mock the recalc method + earning.expects(:recalculate_on_refund).once + + # Change status to refunded + ticket.status = "refunded" + ticket.save! + end end diff --git a/test/models/user_test.rb b/test/models/user_test.rb index f10c5c5..fb4416a 100755 --- a/test/models/user_test.rb +++ b/test/models/user_test.rb @@ -92,4 +92,47 @@ class UserTest < ActiveSupport::TestCase user.update!(onboarding_completed: true) assert_not user.needs_onboarding?, "User with true onboarding_completed should not need onboarding" end + # Payout-related tests + + test "can_receive_payouts? returns true if stripe account id present and charges enabled" do + user = users(:one) + user.update!(stripe_connected_account_id: "acct_12345", is_professionnal: true) + + # Mock Stripe API call + Stripe::Account.expects(:retrieve).with("acct_12345").returns(stub(charges_enabled: true)) + + assert user.can_receive_payouts? + end + + test "can_receive_payouts? returns false if no stripe account id" do + user = users(:one) + user.update!(is_professionnal: true) + + assert_not user.can_receive_payouts? + end + + test "can_receive_payouts? returns false if not professional" do + user = users(:one) + user.update!(stripe_connected_account_id: "acct_12345") + + assert_not user.can_receive_payouts? + end + + test "can_receive_payouts? returns false if charges not enabled" do + user = users(:one) + user.update!(stripe_connected_account_id: "acct_12345", is_professionnal: true) + + Stripe::Account.expects(:retrieve).with("acct_12345").returns(stub(charges_enabled: false)) + + assert_not user.can_receive_payouts? + end + + test "can_receive_payouts? handles Stripe API error" do + user = users(:one) + user.update!(stripe_connected_account_id: "acct_invalid", is_professionnal: true) + + Stripe::Account.expects(:retrieve).with("acct_invalid").raises(Stripe::InvalidRequestError.new("Account not found")) + + assert_not user.can_receive_payouts? + end end diff --git a/test/services/payout_service_test.rb b/test/services/payout_service_test.rb new file mode 100644 index 0000000..bb19050 --- /dev/null +++ b/test/services/payout_service_test.rb @@ -0,0 +1,72 @@ + +require "test_helper" +require "stripe" + +class PayoutServiceTest < ActiveSupport::TestCase + setup do + @user = users(:one) + @event = events(:concert_event) + @payout = Payout.create!(user: @user, event: @event, amount_cents: 9000, fee_cents: 1000) + Stripe.api_key = "test_key" + end + + test "process! success creates transfer and updates status" do + # Mock Stripe Transfer + Stripe::Transfer.expects(:create).with( + amount: 90, # cents to euros + currency: "eur", + destination: @user.stripe_connected_account_id, + description: "Payout for event #{@event.name}" + ).returns(stub(id: "tr_123", status: "succeeded")) + + @payout.update(status: :pending) + + service = PayoutService.new(@payout) + service.process! + + @payout.reload + assert_equal :completed, @payout.status + assert_equal "tr_123", @payout.stripe_payout_id + assert @payout.earnings.update_all(status: :paid) # assume update_earnings_status + end + + test "process! failure with Stripe error sets status to failed" do + Stripe::Transfer.expects(:create).raises(Stripe::CardError.new("Insufficient funds")) + + @payout.update(status: :pending) + + service = PayoutService.new(@payout) + assert_raises Stripe::CardError do + service.process! + end + + @payout.reload + assert_equal :failed, @payout.status + assert_not_nil @payout.error_message # assume logged + end + + test "process! idempotent for already completed" do + @payout.update(status: :completed, stripe_payout_id: "tr_456") + + Stripe::Transfer.expects(:create).never + + service = PayoutService.new(@payout) + service.process! + + @payout.reload + assert_equal :completed, @payout.status + end + + test "update_earnings_status marks earnings as paid" do + earning1 = Earning.create!(event: @event, user: @user, order: orders(:one), amount_cents: 4500, fee_cents: 500, status: :pending) + earning2 = Earning.create!(event: @event, user: @user, order: orders(:two), amount_cents: 4500, fee_cents: 500, status: :pending) + @payout.earnings << earning1 + @payout.earnings << earning2 + + service = PayoutService.new(@payout) + service.update_earnings_status(:paid) + + assert_equal :paid, earning1.reload.status + assert_equal :paid, earning2.reload.status + end +end