diff --git a/AGENT.md b/AGENT.md index 6a2fdb1..9a88397 100755 --- a/AGENT.md +++ b/AGENT.md @@ -12,22 +12,30 @@ This document provides technical details for AI agents working on the Aperonight - **Devise Integration**: Complete authentication system with registration, login, password reset - **Relationships**: Users can create events and purchase tickets - **Validations**: Email format, password strength, optional name fields +- **Promoter System**: Professional accounts can create and manage events with Stripe integration #### 2. Event System (`app/models/event.rb`) - **States**: `draft`, `published`, `canceled`, `sold_out` with enum management - **Geographic Data**: Latitude/longitude for venue mapping - **Relationships**: Belongs to user, has many ticket types and tickets through ticket types - **Scopes**: Featured events, published events, upcoming events with proper ordering +- **Payout Management**: Event-level payout tracking and status management #### 3. Ticket Management - **TicketType** (`app/models/ticket_type.rb`): Defines ticket categories with pricing, quantity, sale periods - **Ticket** (`app/models/ticket.rb`): Individual tickets with unique QR codes, status tracking, price storage +- **Order System** (`app/models/order.rb`): Groups tickets into orders with payment status tracking #### 4. Payment Processing (`app/controllers/events_controller.rb`) - **Stripe Integration**: Complete checkout session creation and payment confirmation - **Session Management**: Proper handling of payment success/failure with ticket generation - **Security**: Authentication required, cart validation, availability checking +#### 5. Financial System +- **Earnings** (`app/models/earning.rb`): Tracks revenue from paid orders, excluding refunded tickets +- **Payouts** (`app/models/payout.rb`): Manages promoter payout requests and processing +- **Platform Fees**: €0.50 fixed fee + 1.5% of ticket price, per ticket + ### Database Schema Key Points ```sql @@ -38,6 +46,8 @@ CREATE TABLE users ( encrypted_password varchar(255) NOT NULL, first_name varchar(255), last_name varchar(255), + is_professionnal boolean DEFAULT false, -- Professional account flag + stripe_connected_account_id varchar(255), -- Stripe Connect account for payouts -- Devise fields: confirmation, reset tokens, etc. ); @@ -55,6 +65,8 @@ CREATE TABLE events ( start_time datetime NOT NULL, end_time datetime, state integer DEFAULT 0, -- enum: draft=0, published=1, canceled=2, sold_out=3 + payout_status integer, -- enum: not_requested=0, requested=1, processing=2, completed=3, failed=4 + payout_requested_at datetime, featured boolean DEFAULT false, image varchar(500) ); @@ -73,14 +85,53 @@ CREATE TABLE ticket_types ( minimum_age integer ); +-- Orders group tickets and track payment status +CREATE TABLE orders ( + id bigint PRIMARY KEY, + user_id bigint REFERENCES users(id), + event_id bigint REFERENCES events(id), + status varchar(255) DEFAULT 'draft', -- draft, pending_payment, paid, completed, cancelled, expired + total_amount_cents integer DEFAULT 0, + payment_attempts integer DEFAULT 0, + expires_at datetime, + last_payment_attempt_at datetime +); + -- Individual tickets with QR codes CREATE TABLE tickets ( id bigint PRIMARY KEY, - user_id bigint REFERENCES users(id), + order_id bigint REFERENCES orders(id), ticket_type_id bigint REFERENCES ticket_types(id), qr_code varchar(255) UNIQUE NOT NULL, price_cents integer NOT NULL, - status varchar(255) DEFAULT 'active' -- active, used, expired, refunded + status varchar(255) DEFAULT 'active', -- draft, active, used, expired, refunded + first_name varchar(255), + last_name varchar(255) +); + +-- Earnings track revenue from paid orders +CREATE TABLE earnings ( + id bigint PRIMARY KEY, + event_id bigint REFERENCES events(id), + user_id bigint REFERENCES users(id), + order_id bigint REFERENCES orders(id), + amount_cents integer, -- Promoter payout amount (after fees) + fee_cents integer, -- Platform fees + status integer DEFAULT 0, -- enum: pending=0, paid=1 + stripe_payout_id varchar(255) +); + +-- Payouts track promoter payout requests +CREATE TABLE payouts ( + id bigint PRIMARY KEY, + user_id bigint REFERENCES users(id), + event_id bigint REFERENCES events(id), + amount_cents integer NOT NULL, -- Gross amount + fee_cents integer NOT NULL DEFAULT 0, -- Platform fees + status integer DEFAULT 0, -- enum: pending=0, processing=1, completed=2, failed=3 + stripe_payout_id varchar(255), + total_orders_count integer DEFAULT 0, + refunded_orders_count integer DEFAULT 0 ); ``` @@ -137,6 +188,7 @@ session = Stripe::Checkout::Session.create({ 2. **Ticket Creation**: Generate tickets based on order items from metadata 3. **QR Code Generation**: Automatic unique QR code creation via model callbacks 4. **Success Page**: Display tickets with download links +5. **Earnings Creation**: Automatically creates earnings records for promoter payout tracking ### 3. PDF Ticket Generation (`app/services/ticket_pdf_generator.rb`) @@ -174,6 +226,61 @@ end - **Session Storage**: Preserves cart when redirecting to login - **Dynamic Updates**: Real-time cart total and ticket count updates +## 🔄 Application Workflows + +### 1. User Registration & Onboarding +1. User registers with email/password +2. Completes onboarding process to set up profile +3. Can browse and purchase tickets as a customer + +### 2. Promoter Account Setup +1. User requests professional account status +2. Connects Stripe account for payment processing +3. Can create and manage events + +### 3. Event Creation & Management +1. Promoter creates event in draft state +2. Adds ticket types with pricing and quantities +3. Publishes event to make it publicly available +4. Manages event status (publish/unpublish/cancel) + +### 4. Ticket Purchase Flow +1. User adds tickets to cart +2. Proceeds to checkout with Stripe +3. Payment processing through Stripe +4. Order and ticket creation upon successful payment +5. Email confirmation sent to user +6. Automatic earnings record creation for promoter + +### 5. Financial Workflows + +#### Platform Fee Structure +- **Fixed Fee**: €0.50 per ticket +- **Percentage Fee**: 1.5% of ticket price per ticket +- **Calculation Example**: + - 1 ticket at €20.00: €0.50 + (€20.00 × 1.5%) = €0.50 + €0.30 = €0.80 total fees + - 3 tickets at €25.00 each: (3 × €0.50) + (3 × €25.00 × 1.5%) = €1.50 + €1.13 = €2.63 total fees + +#### Earnings Tracking +1. When order is marked as paid, earnings record is automatically created +2. Earnings amount = Total ticket sales - Platform fees +3. Only non-refunded tickets are counted in earnings +4. Earnings remain in "pending" status until payout is requested + +#### Payout Request Process +1. Event ends (current time >= event end_time) +2. Promoter requests payout through event management interface +3. System calculates total earnings for the event (excluding refunded tickets) +4. Creates payout record with gross amount, fees, and net amount +5. Updates event payout status to "requested" +6. Admin processes payout through Stripe +7. Payout status updated to "processing" then "completed" or "failed" + +### 6. Refund Management +1. Tickets can be marked as refunded +2. Refunded tickets are excluded from earnings calculations +3. Promoters do not receive payouts for refunded tickets + ## 🔧 Development Patterns ### Model Validations @@ -231,6 +338,8 @@ RAILS_MASTER_KEY=... CREATE INDEX idx_events_published_start_time ON events (state, start_time); CREATE INDEX idx_tickets_user_status ON tickets (user_id, status); CREATE INDEX idx_ticket_types_event ON ticket_types (event_id); +CREATE INDEX idx_orders_event_status ON orders (event_id, status); +CREATE INDEX idx_earnings_event_status ON earnings (event_id, status); ``` ### Security Considerations @@ -238,6 +347,7 @@ CREATE INDEX idx_ticket_types_event ON ticket_types (event_id); - **Strong Parameters**: All user inputs filtered - **Authentication**: Devise handles session security - **Payment Security**: Stripe handles sensitive payment data +- **Authorization**: Proper access controls for promoter vs customer actions ## 🧪 Testing Strategy @@ -247,6 +357,7 @@ CREATE INDEX idx_ticket_types_event ON ticket_types (event_id); 3. **Booking Process**: Cart validation, payment processing, ticket generation 4. **PDF Generation**: QR code uniqueness, ticket format 5. **Dashboard Metrics**: Query accuracy, performance +6. **Financial Workflows**: Fee calculations, payout processing, refund handling ### Seed Data Structure ```ruby @@ -279,7 +390,7 @@ ast-grep --pattern 'find_by_$FIELD($VALUE)' --rewrite 'find_by($FIELD: $VALUE)' ast-grep --pattern 'validates :$FIELD, presence: true' --lang ruby # Mass rename across multiple files -ast-grep --pattern 'old_method_name($$$ARGS)' --rewrite 'new_method_name($$$ARGS)' --lang ruby --update-all +ast-grep --pattern 'old_method_name($$ARGS)' --rewrite 'new_method_name($$ARGS)' --lang ruby --update-all ``` #### Best Practices: diff --git a/BACKLOG.md b/BACKLOG.md index 9d04cd8..db2d38f 100755 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -47,6 +47,7 @@ ## 🚧 Doing - [x] feat: Payout system for promoters (automated/manual payment processing) +- [ ] feat: Payout tracking for administrators - [ ] feat: Page to display all tickets for an event - [ ] feat: Add a link into notification email to order page that display all tickets diff --git a/app/assets/stylesheets/application.postcss.css b/app/assets/stylesheets/application.postcss.css index 0b0fcf5..f506f80 100755 --- a/app/assets/stylesheets/application.postcss.css +++ b/app/assets/stylesheets/application.postcss.css @@ -13,3 +13,4 @@ /* Import pages */ @import "pages/home"; +@import "pages/payouts"; diff --git a/app/assets/stylesheets/pages/payouts.css b/app/assets/stylesheets/pages/payouts.css new file mode 100644 index 0000000..9a72768 --- /dev/null +++ b/app/assets/stylesheets/pages/payouts.css @@ -0,0 +1,304 @@ +/* Payouts specific styles */ + +.payout-status-progress { + position: relative; + display: flex; + justify-content: space-between; + align-items: center; + margin: 2rem 0; +} + +.payout-status-progress::before { + content: ''; + position: absolute; + top: 50%; + left: 0; + right: 0; + height: 2px; + background-color: #e5e7eb; + transform: translateY(-50%); + z-index: 1; +} + +.payout-status-step { + position: relative; + z-index: 2; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; +} + +.payout-status-step-icon { + display: flex; + align-items: center; + justify-content: center; + width: 40px; + height: 40px; + border-radius: 50%; + margin-bottom: 0.5rem; + z-index: 2; +} + +.payout-status-step-icon.pending { + background-color: #f59e0b; + color: white; +} + +.payout-status-step-icon.processing { + background-color: #3b82f6; + color: white; +} + +.payout-status-step-icon.completed { + background-color: #10b981; + color: white; +} + +.payout-status-step-icon.failed { + background-color: #ef4444; + color: white; +} + +.payout-status-step-icon.incomplete { + background-color: #e5e7eb; + color: #9ca3af; +} + +.payout-status-step-label { + font-size: 0.75rem; + font-weight: 500; + color: #374151; +} + +.payout-status-step-date { + font-size: 0.625rem; + color: #9ca3af; + margin-top: 0.25rem; +} + +.payout-summary-card { + background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%); + border: 1px solid #bbf7d0; + border-radius: 0.75rem; + padding: 1.5rem; + box-shadow: 0 4px 6px -1px rgba(16, 185, 129, 0.1), 0 2px 4px -1px rgba(16, 185, 129, 0.06); +} + +.payout-summary-amount { + font-size: 2rem; + font-weight: 800; + color: #047857; + margin: 0.5rem 0; +} + +.payout-summary-label { + font-size: 0.875rem; + font-weight: 600; + color: #059669; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.payout-table-row:hover { + background-color: #f9fafb; +} + +.payout-status-badge { + display: inline-flex; + align-items: center; + padding: 0.25rem 0.75rem; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 500; +} + +.payout-status-badge.pending { + background-color: #fef3c7; + color: #92400e; +} + +.payout-status-badge.processing { + background-color: #dbeafe; + color: #1d4ed8; +} + +.payout-status-badge.completed { + background-color: #d1fae5; + color: #047857; +} + +.payout-status-badge.failed { + background-color: #fee2e2; + color: #b91c1c; +} + +.payout-empty-state { + text-align: center; + padding: 3rem 1rem; +} + +.payout-empty-state-icon { + margin: 0 auto 1rem; + width: 5rem; + height: 5rem; + display: flex; + align-items: center; + justify-content: center; + background-color: #f3f4f6; + border-radius: 50%; +} + +.payout-empty-state-title { + font-size: 1.25rem; + font-weight: 600; + color: #111827; + margin-bottom: 0.5rem; +} + +.payout-empty-state-description { + color: #6b7280; + margin-bottom: 1.5rem; +} + +.payout-detail-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; +} + +.payout-detail-title { + font-size: 1.5rem; + font-weight: 700; + color: #111827; +} + +.payout-event-card { + display: flex; + align-items: center; + padding: 1rem; + background-color: #f9fafb; + border-radius: 0.5rem; + margin-bottom: 1rem; +} + +.payout-event-icon { + flex-shrink: 0; + width: 2.5rem; + height: 2.5rem; + border-radius: 0.5rem; + background: linear-gradient(135deg, #8b5cf6 0%, #ec4899 100%); + display: flex; + align-items: center; + justify-content: center; + color: white; + margin-right: 1rem; +} + +.payout-event-name { + font-weight: 600; + color: #111827; +} + +.payout-event-id { + font-size: 0.875rem; + color: #6b7280; +} + +.payout-detail-grid { + display: grid; + grid-template-columns: 1fr; + gap: 1rem; +} + +@media (min-width: 768px) { + .payout-detail-grid { + grid-template-columns: 1fr 1fr; + } +} + +.payout-detail-item { + display: grid; + grid-template-columns: 1fr 2fr; + gap: 1rem; + padding: 1rem 0; + border-bottom: 1px solid #e5e7eb; +} + +.payout-detail-item:last-child { + border-bottom: none; +} + +.payout-detail-label { + font-size: 0.875rem; + font-weight: 500; + color: #6b7280; +} + +.payout-detail-value { + font-size: 0.875rem; + font-weight: 500; + color: #111827; +} + +.payout-detail-value.amount { + font-size: 1.125rem; + font-weight: 700; +} + +.payout-detail-value.net-amount { + font-size: 1.125rem; + font-weight: 700; + color: #059669; +} + +.payout-action-button { + width: 100%; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.75rem 1rem; + border-radius: 0.5rem; + font-weight: 600; + transition: all 0.2s; + box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); +} + +.payout-action-button.primary { + background-color: #10b981; + color: white; +} + +.payout-action-button.primary:hover { + background-color: #059669; + transform: translateY(-1px); + box-shadow: 0 4px 6px -1px rgba(16, 185, 129, 0.2), 0 2px 4px -1px rgba(16, 185, 129, 0.1); +} + +.payout-action-button.secondary { + background-color: #f3f4f6; + color: #374151; +} + +.payout-action-button.secondary:hover { + background-color: #e5e7eb; +} + +.payout-action-button.warning { + background-color: #fbbf24; + color: #713f12; +} + +.payout-action-button.warning:hover { + background-color: #f59e0b; +} + +.payout-action-button.danger { + background-color: #ef4444; + color: white; +} + +.payout-action-button.danger:hover { + background-color: #dc2626; +} \ No newline at end of file diff --git a/app/views/promoter/payouts/index.html.erb b/app/views/promoter/payouts/index.html.erb index 514347c..64dc98b 100644 --- a/app/views/promoter/payouts/index.html.erb +++ b/app/views/promoter/payouts/index.html.erb @@ -1,70 +1,142 @@ -
-
-

Payouts

+<% content_for(:title, "Payouts") %> + +
+ +
+
+

Payout History

+

View and track all your payout requests

+
+ <% if @payouts.any? %> -
- - - - - - - - - - - - <% @payouts.each do |payout| %> - - - - - - - - <% end %> - -
EventAmountStatusDateActions
-
<%= 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" %> -
-
- - <% if @payouts.respond_to?(:total_pages) %> -
- <%= paginate @payouts %> +
+ +
+
+
+ +
+
+

Total Payouts

+

<%= @payouts.count %>

+
+
- <% end %> + + +
+
+
+ +
+
+

Total Earned

+

€<%= @payouts.sum(&:net_amount_cents) / 100.0 %>

+
+
+
+ + +
+
+
+ +
+
+

Pending

+

<%= @payouts.pending.count %>

+
+
+
+
+ <% end %> + + + <% if @payouts.any? %> +
+
+ + + + + + + + + + + + <% @payouts.each do |payout| %> + + + + + + + + <% end %> + +
EventAmountStatusDateActions
+
+
+ +
+
+
<%= payout.event&.name || "Event not found" %>
+
#<%= payout.id %>
+
+
+
+
€<%= payout.net_amount_euros %>
+
Gross: €<%= payout.amount_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 Details", promoter_payout_path(payout), class: "text-indigo-600 hover:text-indigo-900 font-medium" %> +
+
+ + + <% if @payouts.respond_to?(:total_pages) %> +
+ <%= paginate @payouts %> +
+ <% end %> +
<% else %> -
-

No payouts found.

+ +
+
+ +
+

No payouts yet

+

You haven't requested any payouts yet. When your events end, you'll be able to request payouts here.

+ <%= 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 b9ba275..417afa6 100644 --- a/app/views/promoter/payouts/show.html.erb +++ b/app/views/promoter/payouts/show.html.erb @@ -1,74 +1,165 @@ -
-
-

Payout Details

- <%= link_to "Back to Payouts", promoter_payouts_path, class: "inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" %> +<% content_for(:title, "Payout Details") %> + +
+ +
+
+

Payout Details

+

Payout request for <%= @payout.event&.name || "Unknown Event" %>

+
+ <%= link_to "← Back to Payouts", promoter_payouts_path, class: "inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" %>
-
-
-

Payout Information

-

Details about this payout request.

+ +
+

Payout Status

+
+ +
+
+ <% if @payout.status == 'pending' %> + + <% else %> + + <% end %> +
+

Requested

+

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

+
+ +
+
+ <% if @payout.status == 'processing' %> + + <% elsif @payout.status == 'completed' || @payout.status == 'failed' %> + + <% else %> + + <% end %> +
+

Processing

+
+ +
+
+ <% if @payout.status == 'completed' %> + + <% elsif @payout.status == 'failed' %> + + <% else %> + + <% end %> +
+

Completed

+
-
-
-
-
Event
-
<%= @payout.event&.name || "Event not found" %>
-
-
-
Gross Amount
-
€<%= @payout.amount_euros %>
-
-
-
Platform Fees
-
€<%= @payout.fee_euros %>
-
-
-
Net Amount
-
€<%= @payout.net_amount_euros %>
-
-
-
Status
-
- <% case @payout.status %> - <% when 'pending' %> - - Pending - - <% when 'processing' %> - - Processing - - <% when 'completed' %> - - Completed - - <% when 'failed' %> - - Failed - - <% end %> -
-
-
-
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
-
<%= @payout.stripe_payout_id %>
+
+ + +
+
+

Gross Amount

+

€<%= @payout.amount_euros %>

+
+ +
+

Platform Fees

+

-€<%= @payout.fee_euros %>

+
+ +
+

Net Amount

+

€<%= @payout.net_amount_euros %>

+
+
+ + +
+
+

Payout Information

+

Details about this payout request

+
+ +
+
+
Event
+
+
+
+ +
+
+
<%= @payout.event&.name || "Event not found" %>
+
Event #<%= @payout.event&.id %>
+
- <% end %> -
+ +
+ +
+
Status
+
+ <% case @payout.status %> + <% when 'pending' %> + + + Pending + + <% when 'processing' %> + + + Processing + + <% when 'completed' %> + + + Completed + + <% when 'failed' %> + + + Failed + + <% 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
+
<%= @payout.stripe_payout_id %>
+
+ <% end %>
\ No newline at end of file diff --git a/db/migrate/20250916221454_create_payouts.rb b/db/migrate/20250916221454_create_payouts.rb index 7c8ea52..39542dc 100644 --- a/db/migrate/20250916221454_create_payouts.rb +++ b/db/migrate/20250916221454_create_payouts.rb @@ -1,8 +1,6 @@ class CreatePayouts < ActiveRecord::Migration[8.0] def change create_table :payouts do |t| - t.references :user, null: false, foreign_key: true - t.references :event, null: false, foreign_key: true t.integer :amount_cents, null: false t.integer :fee_cents, null: false, default: 0 t.integer :status, null: false, default: 0 @@ -10,6 +8,9 @@ class CreatePayouts < ActiveRecord::Migration[8.0] t.integer :total_orders_count, null: false, default: 0 t.integer :refunded_orders_count, null: false, default: 0 + t.references :user, null: false, foreign_key: false + t.references :event, null: false, foreign_key: false + t.timestamps end diff --git a/db/schema.rb b/db/schema.rb index 8f7144d..e51fc7b 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -71,14 +71,14 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_16_221454) do end create_table "payouts", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t| - t.bigint "user_id", null: false - t.bigint "event_id", null: false t.integer "amount_cents", null: false t.integer "fee_cents", default: 0, null: false t.integer "status", default: 0, null: false t.string "stripe_payout_id" t.integer "total_orders_count", default: 0, null: false t.integer "refunded_orders_count", default: 0, null: false + t.bigint "user_id", null: false + t.bigint "event_id", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.index ["event_id"], name: "index_payouts_on_event_id" @@ -142,7 +142,4 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_16_221454) do t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true t.index ["stripe_connected_account_id"], name: "index_users_on_stripe_connected_account_id", unique: true end - - add_foreign_key "payouts", "events" - add_foreign_key "payouts", "users" end diff --git a/db/seeds.rb b/db/seeds.rb index c1d254f..c9599a3 100755 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -122,6 +122,157 @@ promoter = User.find_or_create_by!(email: "kbataille@vivaldi.net") do |u| u.is_professionnal = true end +# Create a completed event with earnings for payout demonstration +completed_event_promoter = User.find_or_create_by!(email: "kbataille@vivaldi.net") do |u| + u.password = "password" + u.password_confirmation = "password" + u.first_name = "Event" + u.last_name = "Promoter" + u.is_professionnal = true + # Ensure the promoter has a Stripe account for payouts + u.stripe_connected_account_id = "acct_test_payout_account" unless u.stripe_connected_account_id.present? +end + +completed_event = Event.find_or_create_by!(name: "Completed Music Festival") do |e| + e.slug = "completed-music-festival" + e.state = :published + e.description = "An amazing music festival that has already taken place." + e.venue_name = "Central Park" + e.venue_address = "Central Park, New York, NY" + e.latitude = 40.7812 + e.longitude = -73.9665 + # Set the event to have ended 2 days ago + e.start_time = 2.days.ago + e.end_time = 2.days.ago + 8.hours + e.featured = false + e.image = "https://data.bizouk.com/cache1/events/images/10/78/87/b801a9a43266b4cc54bdda73bf34eec8_700_800_auto_97.jpg" + e.user = completed_event_promoter + # Ensure payout status is not_requested + e.payout_status = :not_requested +end + +# Create ticket types for the completed event +general_ticket_type = TicketType.find_or_create_by!(event: completed_event, name: "General Admission") do |tt| + tt.description = "General admission ticket for the Completed Music Festival" + tt.price_cents = 5000 # $50.00 + tt.quantity = 200 + tt.sale_start_at = 1.month.ago + tt.sale_end_at = completed_event.start_time - 1.hour + tt.minimum_age = 18 +end + +vip_ticket_type = TicketType.find_or_create_by!(event: completed_event, name: "VIP") do |tt| + tt.description = "VIP access ticket for the Completed Music Festival" + tt.price_cents = 15000 # $150.00 + tt.quantity = 50 + tt.sale_start_at = 1.month.ago + tt.sale_end_at = completed_event.start_time - 1.hour + tt.minimum_age = 21 +end + +# Create some orders and tickets for the completed event to generate earnings +buyer_user = User.find_or_create_by!(email: "buyer@example.com") do |u| + u.password = "password" + u.password_confirmation = "password" + u.first_name = "Ticket" + u.last_name = "Buyer" +end + +# Create multiple orders with different statuses to demonstrate the payout system +# Order 1: Paid order with general admission tickets +order1 = Order.find_or_create_by!(user: buyer_user, event: completed_event) do |o| + o.status = "paid" + o.total_amount_cents = 15000 # $150.00 for 3 general admission tickets ($50.00 each) +end + +# Create tickets for order 1 +3.times do |i| + Ticket.find_or_create_by!(order: order1, ticket_type: general_ticket_type) do |t| + t.qr_code = "ORDER1-TICKET#{i + 1}" + t.price_cents = 5000 # $50.00 + t.status = "active" + t.first_name = "Attendee" + t.last_name = "#{i + 1}" + end +end + +# Calculate platform fees using the actual model: €0.50 + 1.5% per ticket +# For 3 tickets at $50.00 each: +# Fixed fee: 3 tickets × $0.50 = $1.50 (150 cents) +# Percentage fee: 3 tickets × ($50.00 × 1.5%) = 3 × $0.75 = $2.25 (225 cents) +# Total platform fee: $1.50 + $2.25 = $3.75 (375 cents) +# Promoter payout: $150.00 - $3.75 = $146.25 (14625 cents) + +# Create earnings for this paid order (this would normally happen automatically) +Earning.find_or_create_by!(event: completed_event, user: completed_event_promoter, order: order1) do |e| + e.amount_cents = 14625 # $146.25 (promoter payout after fees) + e.fee_cents = 375 # $3.75 platform fee + e.status = "pending" +end + +# Order 2: Paid order with VIP tickets +order2 = Order.find_or_create_by!(user: buyer_user, event: completed_event) do |o| + o.status = "paid" + o.total_amount_cents = 30000 # $300.00 for 2 VIP tickets ($150.00 each) +end + +# Create tickets for order 2 +2.times do |i| + Ticket.find_or_create_by!(order: order2, ticket_type: vip_ticket_type) do |t| + t.qr_code = "ORDER2-TICKET#{i + 1}" + t.price_cents = 15000 # $150.00 + t.status = "active" + t.first_name = "VIP" + t.last_name = "Attendee #{i + 1}" + end +end + +# Calculate platform fees using the actual model: €0.50 + 1.5% per ticket +# For 2 tickets at $150.00 each: +# Fixed fee: 2 tickets × $0.50 = $1.00 (100 cents) +# Percentage fee: 2 tickets × ($150.00 × 1.5%) = 2 × $2.25 = $4.50 (450 cents) +# Total platform fee: $1.00 + $4.50 = $5.50 (550 cents) +# Promoter payout: $300.00 - $5.50 = $294.50 (29450 cents) + +# Create earnings for this paid order (this would normally happen automatically) +Earning.find_or_create_by!(event: completed_event, user: completed_event_promoter, order: order2) do |e| + e.amount_cents = 29450 # $294.50 (promoter payout after fees) + e.fee_cents = 550 # $5.50 platform fee + e.status = "pending" +end + +# Order 3: Refunded order to demonstrate that refunded tickets are excluded +order3 = Order.find_or_create_by!(user: buyer_user, event: completed_event) do |o| + o.status = "paid" + o.total_amount_cents = 5000 # $50.00 for 1 general admission ticket +end + +# Create ticket for order 3 (will be refunded) +refunded_ticket = Ticket.find_or_create_by!(order: order3, ticket_type: general_ticket_type) do |t| + t.qr_code = "ORDER3-TICKET1" + t.price_cents = 5000 # $50.00 + t.status = "refunded" # This ticket was refunded + t.first_name = "Refunded" + t.last_name = "Customer" +end + +# Calculate platform fees using the actual model: €0.50 + 1.5% per ticket +# For 1 ticket at $50.00: +# Fixed fee: 1 ticket × $0.50 = $0.50 (50 cents) +# Percentage fee: 1 ticket × ($50.00 × 1.5%) = $0.75 (75 cents) +# Total platform fee: $0.50 + $0.75 = $1.25 (125 cents) +# Promoter payout: $50.00 - $1.25 = $48.75 (4875 cents) + +# Create earnings for this refunded order (this would normally happen automatically) +Earning.find_or_create_by!(event: completed_event, user: completed_event_promoter, order: order3) do |e| + e.amount_cents = 4875 # $48.75 (promoter payout after fees) + e.fee_cents = 125 # $1.25 platform fee + e.status = "pending" +end + +puts "Created 1 completed event with sample orders and earnings for payout demonstration" + + belle_epoque_event = Event.find_or_create_by!(name: "LA BELLE ÉPOQUE PAR SISLEY ÉVENTS") do |e| e.slug = "la-belle-epoque-par-sisley-events" e.state = :draft