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 @@ -
View and track all your payout requests
+| Event | -Amount | -Status | -Date | -Actions | -
|---|---|---|---|---|
|
- <%= payout.event&.name || "Event not found" %>
- |
-
- €<%= payout.amount_euros %>
- Net: €<%= payout.net_amount_euros %> (Fee: €<%= payout.fee_euros %>)
- |
- - <% case payout.status %> - <% when 'pending' %> - - Pending - - <% when 'processing' %> - - Processing - - <% when 'completed' %> - - Completed - - <% when 'failed' %> - - Failed - - <% end %> - | -- <%= payout.created_at.strftime("%b %d, %Y") %> - | -- <%= link_to "View", promoter_payout_path(payout), class: "text-indigo-600 hover:text-indigo-900" %> - | -
Total Payouts
+<%= @payouts.count %>
+Total Earned
+€<%= @payouts.sum(&:net_amount_cents) / 100.0 %>
+Pending
+<%= @payouts.pending.count %>
+| Event | +Amount | +Status | +Date | +Actions | +
|---|---|---|---|---|
|
+
+
+
+
+
+
+
+ <%= 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" %> + | +
No payouts found.
+ +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" %>Payout request for <%= @payout.event&.name || "Unknown Event" %>
+Details about this payout request.
+ +Requested
+<%= @payout.created_at.strftime("%b %d, %Y") %>
+Processing
+Completed
+Gross Amount
+€<%= @payout.amount_euros %>
+Platform Fees
+-€<%= @payout.fee_euros %>
+Net Amount
+€<%= @payout.net_amount_euros %>
+Details about this payout request
+